From cde19704b352bd47910ca2049abf35497c368413 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 18 Jun 2026 02:01:27 +0200 Subject: [PATCH 01/39] chore(ci): fix ci.yml --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f584e87e..075ddcac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,7 +201,9 @@ jobs: - name: Fail if fixtures are stale (rebuild + commit them) run: git diff --exit-code tests/fixtures/plugins/ - name: Run plugin runtime tests - run: cargo test --features plugins plugins:: + # Quote: the trailing `::` confuses GitHub's YAML parser (mapping + # values not allowed) and aborts the whole workflow at load time. + run: 'cargo test --features plugins plugins::' rust-test: name: Server Unit and Functionnal Tests From e11e172769931a5489858f3c140339a016a3bf07 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 18 Jun 2026 02:03:04 +0200 Subject: [PATCH 02/39] feat(plugins): add missing icons in admin section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit % ./tools/check-icons.py Found 116 distinct FA icon(s) referenced in static/ Registry has 128 icon(s) in OxiIcons 4 missing icon(s): • clock-rotate-left used in: static/admin.html ✓ viewBox=0 0 576 512 • puzzle-piece used in: static/admin.html, static/admin.html, static/admin.html, static/admin.html ✓ viewBox=0 0 512 512 • sync used in: static/admin.html ✓ viewBox=0 0 512 512 • upload used in: static/admin.html, static/admin.html ✓ viewBox=0 0 448 512 ✓ Added 4 icon(s) to static/js/core/icons.js --- static/js/core/icons.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 13219e4c..5fcc41d1 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -524,7 +524,24 @@ const OxiIcons = { 'M384 96c0-35.3 28.7-64 64-64s64 28.7 64 64l0 32c0 17.7 14.3 32 32 32s32-14.3 32-32l0-32c0-70.7-57.3-128-128-128S320 25.3 320 96l0 64-160 0c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64l-32 0 0-64z' ], - adjust: [512, 'M448 256c0-106-86-192-192-192l0 384c106 0 192-86 192-192zM0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0z'] + adjust: [512, 'M448 256c0-106-86-192-192-192l0 384c106 0 192-86 192-192zM0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0z'], + + 'clock-rotate-left': [ + 576, + 'M288 64c106 0 192 86 192 192S394 448 288 448c-65.2 0-122.9-32.5-157.6-82.3-10.1-14.5-30.1-18-44.6-7.9s-18 30.1-7.9 44.6C124.1 468.6 201 512 288 512 429.4 512 544 397.4 544 256S429.4 0 288 0C202.3 0 126.5 42.1 80 106.7L80 80c0-17.7-14.3-32-32-32S16 62.3 16 80l0 112c0 17.7 14.3 32 32 32l24.6 0c.5 0 1 0 1.5 0l86 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-38.3 0C154.9 102.6 217 64 288 64zm24 88c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 104c0 6.4 2.5 12.5 7 17l72 72c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-65-65 0-94.1z' + ], + 'puzzle-piece': [ + 512, + 'M224 0c35.3 0 64 21.5 64 48 0 10.4-4.4 20-12 27.9-6.6 6.9-12 15.3-12 24.9 0 15 12.2 27.2 27.2 27.2l44.8 0c26.5 0 48 21.5 48 48l0 44.8c0 15 12.2 27.2 27.2 27.2 9.5 0 18-5.4 24.9-12 7.9-7.5 17.5-12 27.9-12 26.5 0 48 28.7 48 64s-21.5 64-48 64c-10.4 0-20.1-4.4-27.9-12-6.9-6.6-15.3-12-24.9-12-15 0-27.2 12.2-27.2 27.2L384 464c0 26.5-21.5 48-48 48l-56.8 0c-12.8 0-23.2-10.4-23.2-23.2 0-9.2 5.8-17.3 13.2-22.8 11.6-8.7 18.8-20.7 18.8-34 0-26.5-28.7-48-64-48s-64 21.5-64 48c0 13.3 7.2 25.3 18.8 34 7.4 5.5 13.2 13.5 13.2 22.8 0 12.8-10.4 23.2-23.2 23.2L48 512c-26.5 0-48-21.5-48-48L0 343.2c0-12.8 10.4-23.2 23.2-23.2 9.2 0 17.3 5.8 22.8 13.2 8.7 11.6 20.7 18.8 34 18.8 26.5 0 48-28.7 48-64s-21.5-64-48-64c-13.3 0-25.3 7.2-34 18.8-5.5 7.4-13.5 13.2-22.8 13.2-12.8 0-23.2-10.4-23.2-23.2L0 176c0-26.5 21.5-48 48-48l108.8 0c15 0 27.2-12.2 27.2-27.2 0-9.5-5.4-18-12-24.9-7.5-7.9-12-17.5-12-27.9 0-26.5 28.7-48 64-48z' + ], + sync: [ + 512, + 'M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z' + ], + upload: [ + 448, + 'M256 109.3L256 320c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-210.7-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l96-96c12.5-12.5 32.8-12.5 45.3 0l96 96c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L256 109.3zM224 400c44.2 0 80-35.8 80-80l80 0c35.3 0 64 28.7 64 64l0 32c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64l0-32c0-35.3 28.7-64 64-64l80 0c0 44.2 35.8 80 80 80zm144 24a24 24 0 1 0 0-48 24 24 0 1 0 0 48z' + ] }; // Icon CSS is now in /css/components/icons.css (loaded via main.css). From f095baf26b2e89afa7cc669c516df85552102dce Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 18 Jun 2026 02:32:11 +0200 Subject: [PATCH 03/39] chore(ci): translation and icons will warn only --- .github/workflows/ci.yml | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 075ddcac..f58fdef1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,20 +94,19 @@ jobs: - name: Check locale files are at parity with en.json # Python 3 stdlib only — no setup step needed. - # `--check-only` keeps the CI log terse; failures still surface - # via exit code (the script returns 1 when any non-English - # locale is missing a key present in en.json). Mirrors the - # `--check-only` flag on `tools/check-icons.py` below. - # Run locally without --check-only to see the missing keys. + # **Advisory** (non-blocking via `continue-on-error`): a failed + # exit code paints the step red but does not fail the job, so + # unrelated PRs still merge. Re-arm by removing + # `continue-on-error`. Run locally without `--check-only` to see + # the missing keys. + continue-on-error: true run: python3 tools/check-missing-translations.py --check-only - name: Check FA icons referenced in static/ are registered - # `--check-only` skips the Font-Awesome clone and the icons.js - # patch — it just scans `fas fa-` references and diffs - # them against OxiIcons. Exit 1 if any used icon is absent - # from the registry. Run locally without --check-only to - # auto-add missing entries from a checked-out Font-Awesome - # source. + # **Advisory** (non-blocking via `continue-on-error`). Run + # locally without `--check-only` to auto-add missing entries + # from a checked-out Font-Awesome source. + continue-on-error: true run: python3 tools/check-icons.py --check-only rust-fmt: From 536f1b81989eb2edf825ae306433e60bd7f702f7 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 16 Jun 2026 01:39:03 +0200 Subject: [PATCH 04/39] chore(drive): add plan of Drive impl. for review --- docs/plan/drive.md | 1393 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1393 insertions(+) create mode 100644 docs/plan/drive.md diff --git a/docs/plan/drive.md b/docs/plan/drive.md new file mode 100644 index 00000000..d94ec018 --- /dev/null +++ b/docs/plan/drive.md @@ -0,0 +1,1393 @@ +# Drives — design proposal + +> **Status**: design proposal, locked but not implemented. Reviewed Jun 2026 with +> Ed; all blocking decisions answered. Open items listed at the end of the file. + +## Context + +Today every resource (folder, file) in OxiCloud has a single user owner +recorded directly on the row (`storage.folders.user_id`, +`storage.files.user_id`). Sharing is layered on top via ReBAC grants in +`storage.access_grants`. The model is simple and has carried us this far, +but it has two structural problems: + +1. **Owner = user, full stop.** When a user leaves the company, their + files leave with them. The team's de-facto shared folders are + technically owned by a single person; transferring ownership requires + moving every resource one-by-one and re-issuing every grant. There is + no "this folder belongs to the Engineering team" concept. + +2. **Quota is per-user, full stop.** A user with personal storage who + also collaborates in a 1 TB team space currently has both counted + against their personal quota (or the team data costs are absorbed by + whoever owns the folder). There is no way to bill team storage to the + team. + +The proposal is to introduce **drives** as a Google-Workspace-style +container concept: + +- Every resource belongs to exactly one drive. +- A drive has owners (users **and/or** groups) with roles. +- Each user automatically gets a personal drive at registration. +- Shared drives can be owned by groups, so membership changes + automatically follow the group — "Bob left" no longer needs an admin + to chase down which folders to reassign. +- Quotas move from users to drives. +- Per-drive policies enable team-level rules (forbid public links, + forbid sharing, forbid cross-drive moves, future: timeboxed sessions, + end-to-end encryption). + +The shift is large but the model is well-understood — it's the same +pattern Google Workspace's Shared Drives and Microsoft SharePoint +Document Libraries use. OxiCloud users coming from those services will +recognise it instantly. + +## Prerequisite — PR D-Prep: `access_grants → role_grants` + +**Ratified 2026-06-16.** A separate PR lands BEFORE D0 that refactors +`storage.access_grants` into `storage.role_grants` with role-bundle +semantics: + +```sql +storage.role_grants + id uuid PRIMARY KEY DEFAULT gen_random_uuid() + subject_type text NOT NULL CHECK (subject_type IN ('user','group')) + subject_id uuid NOT NULL + resource_type text NOT NULL -- 'folder','file','drive','group',… + resource_id uuid NOT NULL + role text NOT NULL CHECK (role IN ('viewer','editor','owner', …)) + expires_at timestamptz NULL + granted_by uuid NOT NULL FK → auth.users(id) + granted_at timestamptz NOT NULL DEFAULT now() +``` + +Roles map to permission bundles via a single +server-side function `role_bundle(role) -> &[Permission]`: + +| Role | Bundle | +|---|---| +| `viewer` | `Read` | +| `editor` | `Read, Create, Update, Comment` | +| `owner` | `Read, Create, Update, Comment, Delete, Share, Manage` | + +`Manage` is a new Permission introduced in this refactor — covers +"configure this resource's settings, add/remove members, change role +assignments". Future-friendly for Group-as-Resource. + +**Why first**: +1. Refactor has standalone value beyond Drive (cleaner API, audit + log, UI surface). +2. Doing it AFTER Drive would force migrating both `access_grants` + AND `drive_members` — solving the unification first means D0 + migrates one table, not two. +3. **The `drive_members` table planned by earlier drafts goes away.** + Drive membership becomes rows in `role_grants` with + `resource_type='drive'`. One table, one engine, one cache. + +**The sections below describe the Drive model assuming D-Prep has +landed.** References to "drive membership" mean rows in +`role_grants` with `resource_type='drive'`, not a separate table. + +**Gating policy**: D-Prep must ship, bake in production, and pass +validation across the API surface (grant endpoints, audit log +events) and the UI surface (My Shares dialog, share modals, admin +group/role views) before any of the Drive PRs (D0–D7) begin. The +goal is to surface and absorb refactor-related issues against the +existing single-resource model — not to discover them mid-flight +while the Drive migration is also in motion. If D-Prep needs +follow-up patches after deploy, they land as standalone fixes +before D0 starts. + +## Locked design (decisions ratified in design discussion) + +### 1. Ownership pivot + +- A new table `storage.drives` becomes the ownership anchor. +- `storage.folders` and `storage.files` lose their `user_id` column; + they gain `drive_id NOT NULL`. +- The owner of a resource is computed via `resource.drive_id → + drive.drive_members WHERE role='owner'` — a JOIN, not a denormalised + column. The drives table will be tiny (one row per internal user + + handful of shared drives), so the join is cheap with a proper index. +- **Single source of truth** confirmed. `user_id` is dropped from + resources after a migration phase (see Migration below). + +### 2. Drive membership + +Drive membership lives in `storage.role_grants` (introduced by PR +D-Prep — see prerequisite above) with `resource_type='drive'` and +`resource_id=`. There is **no** separate +`drive_members` table. One row per `(subject, drive)` pair carries +the role; the role expands to a permission bundle via the same +`role_bundle()` function used everywhere else. + +Concretely, a drive membership is: + +```sql +INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) +VALUES ('user', $user_id, 'drive', $drive_id, 'editor', $admin_id); +``` + +The `PgAclEngine` reads this row no differently than any other +grant; the drive-membership concept exists only at the application +layer (`DriveService` enforces the personal-drive invariants, etc.) +— there's no separate engine path or cache keyspace. + +- A **shared** drive (`kind='shared'`) can have **0..N user + owners + 0..N group owners** (mixed freely) plus any number of + editors/viewers. +- A **personal** drive (`kind='personal'`) has exactly **one** + member row in `drive_members`, fixed to `(subject_type='user', + subject_id=, role='owner')`. The rule applies to + every `kind='personal'` row, whether it carries + `default_for_user IS NOT NULL` (the user's default drive) or + `default_for_user IS NULL` (a secondary personal silo, e.g. a + SQL-created sibling root folder promoted by migration §10). + Secondary does **not** loosen the single-user constraint. + - Personal drives **cannot** be added to, role-changed, or have + their sole owner removed. They cannot be co-owned. They cannot + be transferred to another user. This matches Google Drive's + "My Drive" and Microsoft's "OneDrive" semantics: a personal + drive is a single-user namespace; collaboration happens through + per-resource grants, by moving content into a shared drive, + or by promoting a secondary personal drive to `kind='shared'` + (capability matrix in §3). + - Enforcement is at the application layer: + `DriveService::add_member` refuses when `kind='personal'`; + `remove_member` refuses when the drive is personal. User + deletion cleans up the user's **default** personal drive via + the `default_for_user` FK cascade, and the user's + **secondary** personal drives via an application-layer pass + (those have no FK to cascade through — see §6 lifecycle). +- **Shared-drive last-owner protection**: removing the final + `role='owner'` member of a shared drive is refused at the + application layer. The check counts remaining owner-role members + (expanding group owners to their members) and rejects the delete + if the count would become zero. +- A shared drive can have **0 viewers** and **0 editors** — only + the ≥1-owner invariant matters. + +### 3. Drive entity + +```sql +storage.drives + id uuid PRIMARY KEY DEFAULT gen_random_uuid() + name text NOT NULL -- "Personal" or user-chosen + kind text NOT NULL CHECK (kind IN ('personal','shared')) + default_for_user uuid NULL FK → auth.users(id) ON DELETE CASCADE + quota_bytes bigint NULL -- NULL = unlimited + used_bytes bigint NOT NULL DEFAULT 0 + policies jsonb NOT NULL DEFAULT '{}' + created_at timestamptz NOT NULL DEFAULT now() + updated_at timestamptz NOT NULL DEFAULT now() + -- `default_for_user` may ONLY be set on personal drives. + -- Shared drives have it NULL by definition. + CONSTRAINT drives_default_marker_personal_only + CHECK (default_for_user IS NULL OR kind = 'personal') + +CREATE UNIQUE INDEX drives_default_for_user_idx + ON storage.drives (default_for_user) + WHERE default_for_user IS NOT NULL; -- one DEFAULT personal drive per user +``` + +#### Two orthogonal properties: `kind` and `default_for_user` + +- **`kind`** = drive capability shape (see §2 for the rules): + - `'personal'` = single-user single-owner; cannot have members + added; sharing happens only through per-resource grants. + - `'shared'` = multi-owner / multi-member; supports user and + group members at every role. +- **`default_for_user`** = "this is the default drive for user + X". Set on exactly one `kind='personal'` drive per user (the + partial unique index enforces it). Used by: + - UI default-drive redirect (`/` → `/drive/`). + - NC default-drive resolution when the credential doesn't pin + a specific drive. + - The `ON DELETE CASCADE` on this column cleans up the user's + default drive in one step when the user is deleted. + - Canonical lookup: `SELECT id FROM storage.drives WHERE + default_for_user = $1` — single-row, index-backed. + +A user can have **multiple `kind='personal'` drives** — at most +one of them carries `default_for_user`, the others sit as +"secondary personal" silos (e.g. SQL-created root folders +promoted by migration §10). Secondary personals follow the same +single-owner rules as the default; they just aren't the default +landing target. + +External users **never** get a personal drive (lifecycle §6). +There is no constraint to write — externals simply have no row +in `storage.drives` with `default_for_user = `, and +nothing tries to create one. + +#### Drive naming — `name` is a label, identity lives in `kind` + `default_for_user` + +`name` is owner-editable for every drive (personal or shared). A +user who renames their drive from "Personal" → "Ed's space" does +**not** stop having a personal drive, and does not stop having a +default. The `kind` flag + `default_for_user` pointer carry the +identity; the name is purely a display label. + +Why this matters: +- UI and NC default-drive resolution MUST query on `kind` / + `default_for_user`, never on `name = 'Personal'`. The latter + would silently break the moment the user renames. +- The initial migration sets `name = 'Personal'` on the default + personal drive for back-compat with the label users see today; + further renames go through the normal drive-rename endpoint + and persist on the same row. Secondary personal drives carry + whatever name the sibling root folder had (e.g. `Archive`, + `2024 Projects`). + +#### Capabilities matrix + +| Capability | `kind='personal'`, default (`default_for_user` set) | `kind='personal'`, secondary (`default_for_user IS NULL`) | `kind='shared'` | +|---|---|---|---| +| Membership shape | exactly 1 user-owner row | exactly 1 user-owner row | 0..N users + 0..N groups at any role | +| `add_member` | refused | refused | allowed | +| `remove_member` | refused (sole owner is fixed) | refused (sole owner is fixed) | allowed except sole owner | +| Rename | allowed (by the owner) | allowed (by the owner) | allowed (by any owner) | +| Delete via API | **refused** — deleting this loses all the user's files; the only path is user-delete cascade | allowed (it's just a silo) | allowed (by an owner; CASCADEs the drive's contents) | +| Default-drive lookup result | this drive | never | never | +| On user-delete | `ON DELETE CASCADE` via `default_for_user` FK (free) | application-layer cleanup: enumerate via drive_members and delete | member rows referencing the user are dropped; refuse user-delete if any shared drive would lose its last owner | +| Group ownership | no | no | yes | +| Per-resource grant outward | yes (subject to drive policies) | yes | yes | +| Cross-drive move | yes (subject to `forbid_cross_drive_move`) | yes | yes | +| Kind conversion | no — always default-personal | yes → may be promoted to `kind='shared'` later (drops the single-user restriction, picks up members) | no | + +### 4. Roles → permission bundles + +Drive-level roles map to existing ReBAC `Permission` values via union +expansion: + +| Role | Permissions implied on every resource inside the drive | +|---|---| +| `viewer` | `Read` | +| `editor` | `Read`, `Create`, `Update`, `Comment` | +| `owner` | `Read`, `Create`, `Update`, `Comment`, `Delete`, `Share`, *and* drive-level admin (rename, edit policies, manage members, change quota) | + +### 5. Permission resolution — additive over `role_grants` + +A user has permission `P` on resource `R` if any of the +`role_grants` rows reachable from them (directly, via group +membership, or via drive membership) carries a role whose bundle +includes `P`. Specifically: + +- A direct grant: `(subject_type='user', subject_id=$user, resource_id=$R, role=…)` +- A group-mediated grant: any `role_grants` row whose + `subject_type='group'` points at a group the user is a transitive + member of. +- A drive-mediated grant: any `role_grants` row whose + `resource_type='drive'` and `resource_id=$R.drive_id`, with + the same per-row role bundle. + +Permissions are **additive**. Drive role is the baseline floor for +every resource in the drive; explicit per-resource grants only add +on top. There is no "revoke for this file even though you're a +drive editor" concept, matching Google Workspace. + +The auth engine reads `role_grants` as a single source of truth. +Cache invalidation has one keyspace (rows in `role_grants`) instead +of two. The drive-membership lookup ("what drives can this caller +read?") is a `WHERE subject_id=$1 AND resource_type='drive'` scan +against the same table. + +### 6. Lifecycle rules + +| Event | Behaviour | +|---|---| +| New internal user registers | Auto-create a default personal drive (`kind='personal'`, `name='Personal'`, `default_for_user=`, `quota_bytes=`), insert the single `drive_members (drive_id, user, , owner)` row. | +| External user invited (magic-link only) | **No personal drive created.** External users are grant-only recipients with no storage. | +| External user converts to internal (future flow) | Default personal drive created at conversion time. | +| User deleted | **Default** personal drive cascade-deletes via `ON DELETE CASCADE` on `default_for_user`. **Secondary** personal drives (`kind='personal' AND default_for_user IS NULL` and whose sole `drive_members` row points at the user) are deleted by an application-layer pass in the same transaction. Member rows referencing the deleted user are removed from all shared drives. If a removal would leave a shared drive with zero owners, deletion is refused — admin must transfer first. | +| Group deleted | Refuse if the group is a member of any shared drive that would lose its last owner. Admin must transfer or remove the group's role from those drives first. (Groups can't be members of personal drives.) | +| Add member to personal drive | Refuse. Personal drives are single-user — collaborate via per-resource grants or by moving content into a shared drive. | +| Remove sole owner of personal drive | Refuse. The only deletion path for a personal drive is user-deletion via cascade. | +| Delete personal drive | Refuse from the API. Only ON DELETE CASCADE (user deletion) drops it. | +| Rename personal or shared drive | Allowed for any owner-role caller. `name` is a label only. | +| Remove last owner of shared drive | Refuse — drive must always have ≥1 owner. App-layer check on `DELETE FROM drive_members`. | + +### 7. Quota model + +The per-user `auth.users.storage_quota_bytes` field is **migrated to +the user's personal drive's `quota_bytes`** in one step, then the +column is deprecated (kept for one release cycle as a no-op, dropped +in a later migration). + +After the cutover: +- Every drive owns its quota. Files inside a drive count against that + drive's `used_bytes` only. +- A user who collaborates in a 1 TB shared drive sees their personal + drive's quota as "their" quota; the shared drive's quota is owned + by the team. +- New drives default to a tenant-configured `OXICLOUD_DEFAULT_DRIVE_QUOTA_BYTES` + setting (separate env var, replacing today's per-user equivalent). + +`used_bytes` is maintained incrementally on every file insert/delete +(plus a periodic reconciliation job to fix drift, similar to the +existing per-user accounting). + +**Chunk dedup vs per-drive quota.** With the CDC chunk store landed +in v0.7.0 (see `delta_upload_service`, `upload_ingest`, instant +upload by hash), a single chunk can be referenced by files in +multiple drives. The accounting decision: **each drive counts the +file's logical size in full against its own `used_bytes`** — dedup +savings are server-side only and never visible in the per-drive +quota number. This matches the existing per-user blob-dedup model +and avoids the alternative "pro-rated quota" trap (which makes +quota math depend on cross-drive content and breaks the user's +mental model of "I have 1 TB free"). Reconciliation job sums file +sizes per drive, not chunk allocations. + +### 8. Policies (JSONB, extensible) + +Each drive carries a `policies` JSON object. Five known keys for v1: + +```jsonc +{ + "forbid_sharing": false, // disables per-resource grants on this drive + "forbid_external_sharing": false, // blocks grants to is_external=true subjects + "forbid_public_links": false, // blocks token-share (anonymous link) creation + "forbid_cross_drive_move": false // blocks MOVE when src.drive_id != dst.drive_id +} +``` + +Enforcement points (one place per policy — single grep target): + +| Policy | Enforcement callsite | +|---|---| +| `forbid_sharing` | `grant_handler::create_grant` — checks `resource.drive_id`'s policy before insertion | +| `forbid_external_sharing` | `magic_link_invite_service::resolve_or_create_recipient` and `grant_handler::create_grant` (when subject is `is_external=true`) | +| `forbid_public_links` | `share_handler::create_shared_link` | +| `forbid_cross_drive_move` | `file_handler::move_file` and `folder_handler::move_folder` — refuse when `src.drive_id != dst.drive_id` | + +Default to `false` (everything allowed) — opt-in by drive owner via +the drive settings UI. + +#### Policy semantics — subtleties to remember + +- **`forbid_sharing`** disables **per-resource** grants on resources + in the drive. Drive owners can still add **drive-level** members + (otherwise the drive becomes uneditable except by the original + owner). The policy means "no fine-grained sharing of individual + files; access happens through drive membership only". +- **`forbid_cross_drive_move`** protects against exfiltration via UI + move. It does **not** stop download + re-upload (that's a different + category of policy — file-egress, future). UI surface should make + this explicit so users don't read it as data-leak protection. + +#### Future policy keys (out of scope for v1 — but the JSONB shape +accommodates them without schema migration) + +- `timeboxed_session` — drive contents require re-auth after N + minutes. Significant UX/middleware lift; defer. +- `end_to_end_encrypted` — client-side encryption; massive scope. + +### 9. URL surface + +#### Frontend + +| URL | Resolves to | +|---|---| +| `/` | Redirect to the caller's personal drive UUID | +| `/drive/` | Drive root view | +| `/drive//` | Folder inside the drive | + +#### Native WebDAV (`/webdav/...`) + +| URL | Resolves to | +|---|---| +| `/webdav/` | Caller's personal drive root + `` (back-compat with today's behaviour) | +| `/webdav/drives//` | Specific drive root + `` | + +Today's `/webdav/` handler implicitly looks up the caller's +home folder and prepends it. Post-drives, the same handler looks up +the caller's personal drive and resolves paths inside it. **Zero +breakage** for existing native WebDAV clients. + +The `drives` path segment is **reserved**: a folder literally named +`drives` cannot exist at the top level of any drive. Migration +pre-check refuses to start if existing data violates this — operator +must rename before upgrading. (Conservative estimate: zero existing +folders are named exactly `drives`. The migration script reports any +collisions for manual fix-up.) + +#### NextCloud-compat WebDAV (`/remote.php/dav/...`) + +> **The path-segment `/drives//` form is NOT used on the NC +> surface.** It is reserved for the native WebDAV surface (see +> table above). The NC surface keeps the URL shape +> `/remote.php/dav/files//` and carries the drive +> selector in the **credential**, not the URL. +> +> NC desktop / mobile clients store credentials per +> `(host, username)` and offer a single sync root per saved +> account. A path-segment scheme would require NC clients to grow +> multi-root awareness, which they don't have. Two +> credential-side mechanisms are valid here: +> +> 1. **Username discriminator (`{user}~{drive-uuid}`)** — the +> chroot POC on `feat/nextcloud-drive` (commit `137169b7`). +> The Basic Auth username carries the drive UUID after a `~` +> separator; the URL stays under `/remote.php/dav/files/{user}~{uuid}/`. +> Explicit on the wire, no per-credential server state needed +> beyond the existing app-password row. +> +> 2. **App-password ↔ drive binding** — store the chosen drive +> UUID directly on the `auth.app_passwords` row at issuance +> time. The Basic Auth username stays as `{user}` (clean +> NextCloud UX, no `~` to explain). The auth middleware looks +> up the app-password row, reads its `drive_id` binding, and +> uses that as the drive context. Each drive a user wants to +> sync gets its own app-password. +> +> Both are workable; option (2) is the cleaner UX (username +> matches what users type, no extra character to explain) but +> requires a schema add on `auth.app_passwords` and one extra +> JOIN in the hot auth path. Option (1) is the smallest possible +> change but exposes the `~` to the user. They're not mutually +> exclusive — the issuance flow can produce credentials in either +> shape. Decide before D1 ships which is the **default** the +> Login Flow v2 picker produces. + +| URL | Resolves to | +|---|---| +| `/remote.php/dav/files//` | That user's personal drive — unchanged. (App-password drive-binding NULL ⇒ personal.) | +| `/remote.php/dav/files/~/` | Option 1: explicit drive in the URL. | +| `/remote.php/dav/files//` *(with `auth.app_passwords.drive_id` set)* | Option 2: drive resolved from the credential row. URL is indistinguishable from the unchanged personal case to the client. | + +In either case, the auth middleware asserts the caller is a member +of the resolved drive before serving any DAV verb. Pre-existing NC +clients pointed at `/remote.php/dav/files//` continue +syncing the user's personal drive without reconfiguration — +regardless of which option is chosen as the default. + +#### Username/UUID collision — defused + +The `/remote.php/dav/files//` vs `/remote.php/dav/drives//` +split solves the worry about username/UUID ambiguity. The +discriminator is the literal segment (`files` vs `drives`), never +the value of ``. A user happening to have a UUID-shaped username +is no longer a problem. + +### 10. Storage paths — wrapper folder retired + +Today `storage.folders.path` is e.g. `My Folder - admin/Docs`. The +"My Folder - admin" wrapper is the user's home folder, created at +registration via `format!("My Folder - {}", username)`. + +Post-drives, **the wrapper goes away**. The drive itself is the +root; folders and files that used to live inside the wrapper sit +directly under the drive with no intermediate folder: + +``` +Drive "Personal" (uuid=…, kind=personal, owner=admin) ← was the "My Folder - admin" wrapper +├── Docs/ +└── aa.pdf +``` + +Same for shared drives — they already had no wrapper: + +``` +Drive "Engineering" (uuid=…, kind=shared, owners=group:engineering) +├── Specs/ +├── Roadmap.md +└── archive/ +``` + +The two surfaces share one rule: **drive root = `parent_id IS NULL` +within the drive's `drive_id`**. The "personal vs shared" branch +disappears from path resolution — both kinds resolve the same way. + +#### Why this is client-safe + +The wrapper was already invisible to WebDAV / NC clients pre-drive: + +- NC clients hit `/remote.php/dav/files//` and + `nc_to_internal_path` prepended `My Folder - /` internally + before talking to the storage layer. The client never saw the + wrapper segment in its URL. +- Native `/webdav/` was implicitly chrooted to the user's + home by `resolve_webdav_path`. Same story. + +So URL-level back-compat is preserved trivially — clients keep +asking for `/remote.php/dav/files/admin/Docs/foo.pdf`, the +resolver no longer prepends the wrapper, and the storage row's +path is now `Docs/foo.pdf` instead of `My Folder - admin/Docs/foo.pdf`. +Net effect on the wire: zero. + +#### Why this is a better model + +The original plan kept the wrapper "for back-compat" but it has +no value beyond the storage layer (clients never see it, the +filesystem mirror is happy either way). Keeping it forced path +resolution to always know whether the caller is in a personal or +shared drive and conditionally prepend a segment. Dropping it +collapses that branch and makes the personal-vs-shared distinction +purely a metadata concern (kind, quota source, member shape) — +**not** a path-shape concern. + +#### Sibling root folders also become drives + +Today the only way to get a `parent_id IS NULL` folder is via the +home-folder creation path, which produces exactly one per user. +However, manual SQL has already been used (and may still be used +by ops) to create additional top-level folders — folders that sit +beside `My Folder - ` at `parent_id IS NULL`. These are +**not** addressable today through any handler (the UI assumes +exactly one root); they exist as data only. + +Post-migration, the model has to absorb them. Rule: + +- For each user, find every row with `parent_id IS NULL AND user_id + = `. +- The one named `My Folder - ` becomes the **default + personal drive**: `kind='personal'`, + `default_for_user=`, sole member = the user. +- Every **other** sibling becomes a fresh **secondary personal + drive**: `kind='personal'`, `default_for_user=NULL`, sole + member = the user, name carried over from the folder's `name` + column, quota initialised from the user's quota + (`auth.users.storage_quota_bytes`) — same default as the + user's primary personal drive. Membership rules from §2 apply: + the user cannot invite co-owners while the drive remains + personal. To open the silo up, the user can later **convert** + the secondary personal to `kind='shared'` (an + application-layer operation that flips the kind and lifts the + single-user restriction so the membership API can add other + users / groups). +- The folder row is deleted (the drive replaces it as the root). + Its children get their `parent_id` set to `NULL` *within their + new `drive_id`*. + +The chroot POC's "pick a drive at login" picker on +`feat/nextcloud-drive` already produces the right shape for this: +users with one drive auto-select Personal silently, users with +N drives get a real picker. No POC change needed — it just sees +real drive rows instead of folder UUIDs. + +### 11. Content search index — drive-aware filtering + +v0.7.0 added an embedded Tantivy full-text content index (see +`infrastructure/services/search_index/tantivy_content_index.rs` +and migration `20260701000000_content_search_index.sql`). Today +every indexed document carries the owning user as a filter field +and queries restrict by that field at query time. + +When ownership pivots to `drive_id`, the index has to follow — +otherwise search leaks content across drives the moment D7 drops +`user_id`: + +1. **Schema update**: every indexed document gains a `drive_id` + field stamped at ingest time. Existing documents need a + one-shot reindex pass during the D0 migration (read each row, + look up its new `drive_id`, update the index entry). Cheap on + small instances; the migration script should report a progress + count for larger ones. +2. **Query path**: instead of filtering by `user_id = caller`, + expand `caller → set of drive_ids the caller can read` + (personal + every shared-drive membership) and filter by + `drive_id ∈ that set`. Expansion reuses the drive-role check + already required by `PgAclEngine`. +3. **Treat as a blocking step of D0**, not a D4/D5-era polish + item — otherwise the index is the silent leak path during the + dual-write window. + +Filtering on a low-cardinality `drive_id` is something Tantivy +handles natively; this is bookkeeping, not a query-plan risk. + +### 12. Trash — per-drive scoped, owner-actioned + +Today trash is per-user: `storage.files` / `storage.folders` +carry `is_trashed BOOLEAN` + `trashed_at` + `original_parent_id` +(soft-delete in place), and `storage.trash_items` is a VIEW that +UNIONs them. The listing endpoint (`GET /api/trash/resources`) +filters by `user_id = caller`. + +Post-drives, trash becomes **per-drive**: + +- **Storage shape is unchanged.** The `drive_id` column added to + `storage.files` / `storage.folders` in Phase A already + identifies which drive a trashed row belongs to. No new + trash table, no schema work beyond updating the + `storage.trash_items` VIEW to surface `drive_id` alongside + (or replacing) `user_id`. +- **Trash listing query** filters by drive(s) the caller can + read. Default listing returns trash from every drive the + caller has membership on; a `?drive_id=` parameter + scopes to one drive. UI shows a drive picker above the trash + list, same as the main file view. +- **Trash mutations are owner-only.** Per the §4 role-bundle, + `Delete` is in the owner bundle only — so today's "anyone + who can delete the original can act on its trash entry" is + already drive-owner-scoped. Specifically: + - **Send to trash** — any drive owner (carries `Delete`). + Personal drive: the user themselves. + - **Restore** — any drive owner. Operation reverses + `is_trashed`, sets `parent_id` back to `original_parent_id` + when that ancestor is still in the same drive (otherwise to + the drive root with a name conflict resolver). + - **Permanent delete** — any drive owner. Clears the row and + decrements drive `used_bytes`. + - **View trash** — any drive member (viewer / editor / + owner). Viewers can see what was deleted from a drive they + have access to; only owners can act on it. Mirrors Google + Drive's per-shared-drive trash UX. +- **Cross-drive moves carry their trash home with them**: + when a file moves from drive A → drive B and is later + trashed, the row's `drive_id` is B's, so trash for B sees + it (not A's, which is the natural and expected answer). +- **Cascade on drive deletion**: when a shared drive is + deleted (D3 will land the delete-drive flow), every + `storage.files` / `storage.folders` row with that + `drive_id` cascades, trashed or not. There's no need to + "drain the trash first" — the whole drive disappears in + one CASCADE. +- **Personal-drive trash** follows the same model: bound to + the personal drive, sole owner (= the user) does + everything. No new UX divergence between personal and + shared. + +The orphan/aborted-upload sweep introduced in v0.7.0 +(`944c8337`, periodic trash job) must become drive-aware so +it doesn't accidentally sweep across drives the caller +shouldn't see. It already keys off ownership; the rewrite is +a per-drive pass instead of per-user. + +### 13. Upload paths and quota timing + +Four upload protocols, each with different "when do we know the +size" and "when do we know the destination" answers. The Drive +migration pivots quota from user-scoped to drive-scoped without +changing protocol shapes — but one path (NC chunked) carries a +pre-existing wart that the chroot POC's `~` username (or the +`app_passwords.drive_id` binding) lets us finally fix. + +| Protocol | Size known | Quota check fires | Destination / drive known | +|---|---|---|---| +| Default multipart (`POST /api/files/upload`) | At request start (Content-Length / multipart `size`). | `file_upload_service.rs:185` — `check_storage_quota(caller_id, metadata.size)` before any bytes are stored. | At request start (form field `folder_id`). Drive derives from `folder.drive_id`. | +| Native chunked (`POST /api/uploads`) | At session create — client declares `total_size` in JSON. | `chunked_upload_handler.rs:213` — at session creation against declared `total_size`. | At session creation (`folder_id` in JSON). Drive derives from `folder.drive_id`. | +| **NextCloud chunked** (`/remote.php/dav/uploads/{user}/{session}/...`) | Never declared. MKCOL creates empty session, PUT chunks arrive one at a time, client decides "done". | **Today: only at the final MOVE (assemble)** — `handle_assemble` → `file_upload_service::ingest_stream_to_cas` → quota check on the assembled size. Wasted-bandwidth wart: a client over quota can upload GB before the server can refuse. | **Today**: only at MOVE (parsed from the `Destination:` header). **With the chroot POC** (`{user}~{drive-uuid}` username, see §9): known at MKCOL — the auth middleware already split the username. **With `app_passwords.drive_id` binding**: known at MKCOL — the credential row pins the drive. | +| Delta protocol (`/api/files/delta/*`) | At `negotiate` (client provides manifest with `total_size`). | `delta_upload_service.rs:331` — at commit against `total_size`. | At negotiate (target file_id or `folder_id`). Drive derives accordingly. | + +#### Decision — per-chunk incremental quota check on the NC chunked path + +The three non-NC paths trivially pivot to drive-scoped quota: +replace `check_storage_quota(caller_id, size)` with +`check_drive_quota(drive_id, size)`. Destination is known at +handler entry; drive falls out of the destination's `drive_id` +column. No protocol change. + +For NC chunked, the Drive migration **also closes the +wasted-bandwidth wart** because the drive identity is now known +at MKCOL (via `~` username or app-password binding — either NC +credential-side scheme from §9 surfaces it). Approach: + +1. **MKCOL guard** — if `drive.used_bytes >= drive.quota_bytes`, + refuse the session creation with `507 Insufficient Storage`. + No point letting the client even start. +2. **Per-chunk PUT check** — track cumulative bytes received in + the session (sum of on-disk chunk sizes, maintained by the + chunked-uploads service). On each PUT, before writing the + chunk: + ```text + if drive.used_bytes + session.bytes_so_far + chunk.size > drive.quota_bytes: + refuse with 507 Insufficient Storage + ``` + The first chunk that would push us over is refused; client + sees the error within one chunk's worth of wasted upload + (typically a few MB) instead of after the whole multi-GB file. +3. **Assemble-time re-check** stays as a defence-in-depth (in + case two concurrent sessions on the same drive each got past + the per-chunk check but their sum exceeds quota at MOVE). This + matches today's structure. +4. **Unlimited quota** (`quota_bytes IS NULL`) short-circuits all + three checks — no work. + +The per-chunk check is O(1) amortised: each session tracks its +cumulative size as it goes. The drive's `used_bytes` is read from +the row once per chunk; with the v0.7.0 incremental-update +pattern (`b5b80549`, `d6987329`) that's a single primary-key +lookup, not an aggregate query. + +Net effect on NC clients: nothing changes for in-quota uploads; +over-quota clients get a clear 507 within seconds instead of +after the whole upload finishes. + +#### Editor-role delete and trash — call out the UX tension + +§4's role bundle gives `Delete` only to `owner`. That means +in a shared drive, an editor who uploads a typo file CANNOT +send it to trash themselves — they have to ask an owner. This +is already flagged as Open Question 4 (revisit before D2) and +the answer there determines the trash UX for editors too. If +editors get a `Delete` capability (or a dedicated "trash own +content" capability), the trash mutation rules become "trash +your own files" + "drive owners can act on anyone's trashed +files". Until that's decided, the conservative answer above +(owner-only mutations) holds. + +### 14. File provenance — `created_by` / `updated_by` + +Today `storage.files.user_id` (and `storage.folders.user_id`) +quietly doubles as **both** the ownership pointer (whose files are +these?) AND the provenance signal (who created this?). The Drive +migration cleanly separates ownership onto `drive_id`, but +provenance has to come with us — in a shared drive where Ed, +Alice, and Bob all upload files, "who put this here?" remains a +load-bearing question for UI, audit, and account-cleanup workflows. + +The split: + +```sql +-- on both storage.folders and storage.files: +drive_id uuid NOT NULL -- ownership +created_by uuid NULL FK → auth.users(id) ON DELETE SET NULL +created_at timestamptz NOT NULL DEFAULT now() -- existing +updated_by uuid NULL FK → auth.users(id) ON DELETE SET NULL +updated_at timestamptz NOT NULL DEFAULT now() -- existing +``` + +- **`created_by`** is set once at row insert (every upload path: + multipart, native chunked, NC chunked, streaming CDC, delta, + instant-upload-by-hash) — the caller_id stamps it. Never + changes after. +- **`updated_by`** is set whenever `updated_at` is touched — + rename, overwrite, move, restore from trash, PROPPATCH on + favorites. Same write-path discipline as `updated_at` today. +- **`ON DELETE SET NULL`** on both FKs. When a user is deleted, + files they created or edited in shared drives stay (they belong + to the drive, not the deleter); the FK nulls out, the UI renders + "Unknown user" (or "Deleted user" with a small tombstone table — + future polish, see Open Question 12). + +#### Why this lands in D0, not D7 + +If we waited until D7 to introduce these columns, every existing +file/folder row would have NULL `created_by` after the migration — +provenance permanently lost for pre-D7 content. Adding the columns +in D0 and **backfilling from `user_id`** during the dual-write +phase gives every existing row a real `created_by` value (the user +we know created it, because that's what `user_id` meant). New +writes during the dual-write window populate both `user_id` AND +`created_by` / `updated_by`. By D7 the columns are self-sufficient +and dropping `user_id` loses nothing. + +#### UI display semantics + +- File details panel: "Created by Alice on 2026-01-15", "Last + edited by Bob 3 hours ago" — both fields drive a normal user + avatar + name lookup. NULL → "Unknown user" placeholder. +- Activity log on a shared drive: aggregates `updated_by` over + recent rows to show "who's been active here lately". +- Account-cleanup workflow: when an admin deletes a user, show a + pre-flight summary "Bob authored 47 files and last edited 12 + more across drives X, Y, Z — proceeding will mark those entries + as authored by 'Deleted user'." Lets the admin choose to + reassign or simply confirm. + +#### Engine touch-point + +The "who touched updated_at" rule applies uniformly across all +mutation paths. Centralise it: every service that bumps +`updated_at` must also set `updated_by = caller_id` in the same +SQL statement. The `FileRepository::update_*` and +`FolderRepository::update_*` methods are the natural choke points +— a single audit reveals every callsite to verify. + +The async `tree_etag_queue` (v0.7.0) propagates an etag up the +ancestry but **does NOT** propagate `updated_by` — ETags are +fingerprints of structure, not authorship. Only the direct +mutation site updates `updated_by`. + +## Migration strategy + +A drive-id column on every resource is a database surgery touching +every storage query. We phase it for safety: + +### Phase A — additive (PR D0) + +1. Create `storage.drives` and `storage.drive_members`. +2. Add `drive_id uuid NULL` to `storage.folders` and `storage.files`. +3. **Per-user root-folder sweep**: for each internal user, list + every `storage.folders` row where `parent_id IS NULL AND + user_id = `. Exactly one is expected to be + `My Folder - `; any extras are SQL-created siblings + (see §10). + - The `My Folder - ` row → becomes the **default + personal drive**: `INSERT INTO storage.drives (name='Personal', + kind='personal', default_for_user=, quota_bytes=)` + and insert one `(drive_id, user=, role='owner')` + member row. + - Every other sibling row → becomes a fresh **secondary + personal drive**: `kind='personal'`, `default_for_user=NULL`, + name carried over from the folder's `name`, + `quota_bytes=`, and one + `(drive_id, user=, role='owner')` member row. + Membership rules from §2 apply (single-owner, no `add_member`); + the user can later promote one to `kind='shared'` to invite + collaborators. +4. **Promote children, drop the wrapper**: for every folder/file + row that has `parent_id = `, set + `drive_id = ` and `parent_id = NULL` + (the drive itself is the new root, not a folder). Then DELETE + the root folder rows from step 3 — they no longer exist as + folders, the drive replaces them. +5. **Cascade `drive_id` down the tree** — for every remaining + folder/file row, set `drive_id` by walking the ancestry to + whichever root the row descends from. After this step every + row has the same `drive_id` as its `parent_id`'s row, which + chains up to a drive set in step 3/4. +6. **Full path-metadata reconstruction**. The `path` column on + **every** row in `storage.folders` and `storage.files` gets + rewritten. For rows that descended from `My Folder - `, + strip that prefix; for rows that descended from a sibling root, + strip that sibling's `name`. The path column now contains only + the in-drive path (e.g. `Docs/foo.pdf`, never + `My Folder - admin/Docs/foo.pdf`). + - **This is the bulk of D0's runtime cost.** Personal-drive + scope = every folder/file the user owns. A 100k-file user + gets 100k UPDATEs. Use a single `UPDATE … WHERE drive_id = + ` per drive, not a row-at-a-time loop. The ltree-path + change is what every downstream subsystem keys off, so doing + this in one transaction per drive (not per row) is also a + correctness boundary. + - **Downstream caches and indexes** — audit each for path or + path-derived keys: + - **Tantivy content index (§11)** — the index does NOT + store paths (see `tantivy_content_index.rs`: indexed + fields are `file_id`, `user_id`, `name` (basename only), + `content`. No `path` field, the wrapper folder name was + never a term). So the wrapper removal alone requires no + reindex. The reindex §11 calls for is driven by the + schema gaining `drive_id` and the query filter pivoting + from `user_id` to `drive_id` — NOT by the path rewrite. + Same migration window, but for a different reason. + - **Thumbnail cache** — if keyed by path rather than + file_id, invalidate; preferably switch to file_id-keyed + during this migration so the issue doesn't recur. Audit + before D0 starts. + - **Folder ETag queue (`async_tree_etag_queue`,** see Open + Question 8) — flush or recompute; ETags derived from old + paths are stale. + - **Recent-items / favorites** — referenced by file_id, not + path; probably fine. Verify. + - **On-disk storage mirror** — see Open Question 10. If the + filesystem layout is path-mirrored, every file moves on + disk too; if content-addressable, the FS is untouched. + Audit before D0 starts. +7. Verify: every row has `drive_id IS NOT NULL`, no row has + `parent_id` pointing at a non-existent folder, no `path` value + contains the legacy `My Folder - ` prefix. +8. Add `NOT NULL` constraint on `drive_id`. + +**Keep `user_id`** on resources alongside `drive_id` for the entire +Phase A release cycle. Code is updated to read `drive_id` everywhere; +`user_id` is dual-written for one release as a safety net. If +something breaks we can roll back without data loss. + +Pre-flight checks the migration script runs before any writes: +- Report how many sibling-root folders exist per user. Don't + refuse — extras become drives — but surface the count so an + operator can sanity-check ("Ed has 4 root folders, expected + ≤1; verify those are real and intended before promoting them + to drives"). +- Refuse if any sibling root folder is literally named `drives` + (would collide with the reserved URL segment on the native + `/webdav/drives//...` surface). Operator renames first. +- Refuse if any user has `storage_used_bytes > storage_quota_bytes` + by an amount that wouldn't fit the destination drive's quota + semantics (sanity check). + +### Phase B — cleanup (PR D7, one release later) + +1. Drop `user_id` from `storage.folders` and `storage.files`. +2. Drop dual-write code paths. +3. Deprecate `auth.users.storage_quota_bytes` (or drop — quotas live + on drives now). + +Phase B is the point of no return; deferring it by one release gives +us a real rollback window while the new model bakes in production. + +## PR sequencing + +| PR | Scope | Risk | +|---|---|---| +| **D-Prep — role_grants refactor** | `access_grants → role_grants` schema migration with role-bundle semantics. `Manage` Permission added to the enum + role bundle. Engine reads role_grants only; `access_grants` removed (after one dual-write release if compat is needed). API gains `role` parameter on grant endpoints; audit log emits one `role_grant.*` event per role assignment instead of N permission events. **No Drive concept yet.** Sets the foundation that all subsequent PRs build on. **Data shape confirmed**: empirical audit shows >99% of existing `access_grants` rows already cluster into the standard bundles (viewer/editor/owner) — the migration is mechanical for the vast majority of data; the <1% edge cases get absorbed by shipping `commenter` and `contributor` roles on day one or get an explicit per-row migration decision logged. | **Medium** — touches the load-bearing authorisation table, but the data shape removes the main migration risk | +| **D0 — foundation** | `storage.drives` schema (no `drive_members` — uses `role_grants` from D-Prep); `Drive` domain entity; migration creating personal drives + backfilling `drive_id` on every resource; read-only `GET /api/drives` listing the caller's drives (single query: `SELECT … FROM role_grants WHERE subject_id=$caller AND resource_type='drive'`). Dual-write `user_id` alongside `drive_id` for safety. **No new UI.** **Every upload path stamps `drive_id` at insert**: classic multipart (`file_handler::upload`), chunked NC (`uploads_handler`), streaming CDC (`upload_ingest`), delta upload (`delta_upload_service`), instant upload by hash. Tantivy reindex (see §11) is part of this PR. **Provenance columns added** (see §14): `created_by` and `updated_by` on both `storage.folders` and `storage.files`, FK to `auth.users` with `ON DELETE SET NULL`; backfilled from `user_id` so pre-Drive content has provenance from day one; every mutation path that touches `updated_at` also sets `updated_by`. | **High** — every storage query touches, all upload paths touched | +| **D1 — UI switcher + URL routing** | Sidebar drive picker, `/drive//` frontend routes, default-drive redirect from `/`. WebDAV path dispatcher recognising `drives/` as the drive-explicit prefix on both `/webdav/` and `/remote.php/dav/`. | Medium | +| **D2 — drive membership API + per-drive trash auth** | `POST /api/drives/{id}/members`, `DELETE`, `PUT` for role changes — thin handlers that translate to `role_grants` INSERT/DELETE/UPDATE with `resource_type='drive'`. `Resource::Drive(Uuid)` (added in D-Prep at the enum level) gets its specialised handler surface here. Shared-drive last-owner protection. Group-as-subject support reuses the existing `subject_groups` machinery. **Personal-drive guards** (`add_member`, `remove_member`, `delete_drive` refuse on `kind='personal'` — see §2). **Per-drive trash authorisation** (§12): trash listing filters by drive(s) the caller can read; trash mutations (send/restore/permanent-delete) require `role='owner'` on the drive; `storage.trash_items` VIEW updated to surface `drive_id`; orphan/aborted-upload sweep becomes per-drive. | Medium | +| **D3 — group-owned shared drives** | "Create shared drive" flow — admin or group owner triggers, drive created with `kind='shared'`, initial owner row is the group. Group-deletion guard refuses if the group is the last owner of any drive. Drive-rename, drive-delete. | Low | +| **D4 — per-drive quota** | Move storage accounting off `auth.users.storage_used_bytes` onto `storage.drives.used_bytes`. **Re-point the existing per-user incremental CTE** (introduced in v0.7.0 — see `b5b80549`, `d6987329`) at drive rows; don't reinvent the counting logic. Upload paths check `drive.quota_bytes` instead of (or in addition to) the user's quota for the dual-write window. **Per-chunk incremental quota check on the NC chunked path** (see §13): MKCOL refuses when the drive is already over quota; each PUT chunk runs an O(1) `used + session_so_far + chunk_size > quota` test and refuses with 507 within one chunk of wasted upload. Closes a pre-existing wart where NC clients could upload GB before learning they were over quota. Reconciliation job runs once per day to fix drift. | Medium | +| **D5 — policies** | JSONB policies column + enforcement at the four known callsites. Owner-only UI in drive settings. Ship policies one at a time if you want fine-grained rollout — `forbid_public_links` first (lowest risk), then `forbid_external_sharing`, then `forbid_sharing`, then `forbid_cross_drive_move`. | Low | +| **D6 — cross-drive move + audit** | Move folder/file between drives (allowed by default; gated by `forbid_cross_drive_move` policy on the source drive). Audit events for every drive lifecycle event (`drive.created`, `drive.member_added`, `drive.member_removed`, `drive.policy_changed`, `drive.deleted`, `resource.moved_between_drives`). | Low | +| **D7 — back-compat sweep** | Drop `user_id` from `storage.folders` / `storage.files`. Drop dual-write code. Drop or deprecate `auth.users.storage_quota_bytes`. **Provenance columns (`created_by`, `updated_by`) stay** — they were populated from D0 and are now the sole source of authorship signal. | Low — but the point of no return | + +Approximate total: 4–6 weeks of focused work, depending on test +coverage depth. + +## Out of scope for v1 (worth noting so we don't accidentally invite scope creep) + +- **Timeboxed session policy** — drives that auto-lock after N + minutes. Big middleware lift. +- **End-to-end encryption policy** — client-side encryption with + server holding only ciphertext. Massive scope. +- **Per-drive sync targets** — sync client design ("1 drive = 1 + target like 'family-computer'"). Schema-friendly today (drive has + stable UUID), implementation is its own design. +- **Cross-drive search UI** — start with per-drive scoping. "Search + everywhere" comes later. +- **Drive templates** — "create a drive pre-populated with these + folders" is nice but not foundation work. + + + +## Open questions to revisit before D0 lands + +1. **Naming of the default personal drive**. + - Locked: "Personal" (i18n key, neutral, doesn't break on username + rename). + - But: should it be renameable by the user? Default-rename allowed? + I'd say yes — drive name is just a label. + +2. **`auth.users.storage_quota_bytes` — drop or keep?** + - Drop entirely (cleanest). + - Keep as the **default initial quota** for newly-created personal + drives (so admins still have a single tunable). + - I lean toward keep-as-default-initial. + +3. **Can a user be a member of their own personal drive in more than one + way?** (e.g. directly AND via a group they belong to) + - Should the membership rows allow that? Or dedup? + - Easy answer: allow; permission resolution naturally unions, so + duplicate paths to the same role are idempotent. + +4. **What happens when you DELETE a folder/file in a shared drive + you have only editor role on?** + - Today: ReBAC checks Delete permission on the resource. + - With drives: editor role implies Create + Update + Comment but + **not** Delete (per the role-bundle table above). + - So editor cannot delete by default. Should they? Some teams want + "editor can do anything except change drive settings". Worth a + dedicated decision before D2 — possibly add a separate "can + delete" toggle, or split editor into `editor` / `contributor`. + +5. **Drive icons / colour customisation** — visual differentiator + between drives in the sidebar. Out of scope for v1 but worth + noting; the `drives` table can carry a small `display` JSONB column + that accumulates this kind of cosmetic config without schema + churn. + +6. **WebDAV path resolution edge case**: when a user is a member of a + shared drive and someone shares a single file inside it explicitly + with them via a per-resource grant, what path do they see in their + client? + - The drive's path (they have access via membership), full stop. + - Per-resource grants are additive; they don't create a separate + "shared with me" listing for files that already live in a drive + the user can access. + - Confirm during D2 that this is what users expect. + +7. **Search-everywhere from the file picker**. When a user goes to + share a file, today the picker lists their own files. With drives, + should the picker default to "current drive" or "all drives I have + access to"? UX call, not a foundation decision. + +8. **`async_tree_etag_queue` — drive-pinning assumption audit**. + v0.7.0 introduced `migrations/20260626000000_tree_etag_statement_triggers.sql` + + `20260627000000_async_tree_etag_queue.sql` for ETag + propagation up folder ancestry. The queue likely keys off + folder path / owner; before D0 starts, confirm it doesn't + embed assumptions that fold `drive_id` away (e.g. computing + ancestry across a path that crosses a drive boundary). + 5-minute audit to lock the question down. + +9. **NC credential ↔ drive binding default**. Section 9 leaves + open whether the Login Flow v2 picker defaults to issuing + `{user}~{uuid}` Basic Auth usernames (option 1) or + `auth.app_passwords.drive_id`-bound app-passwords (option 2) + for non-personal drives. Decide before D1 ships; the answer + determines whether `auth.app_passwords` gets a new column or + not. + +10. **On-disk storage mirror — does the file path under + `OXICLOUD_STORAGE_PATH` change too?** Phase A step 6 strips + the `My Folder - /` prefix from `storage.folders.path` + / `storage.files.path` columns. If the on-disk layout mirrors + these paths (`//My Folder - admin/Docs/foo.pdf`), + the migration also has to `mv` every file on disk. If on-disk + is content-addressable (BLAKE3-keyed), the columns can be + rewritten without touching the filesystem. **Audit the + storage adapter before starting D0** and decide whether the + migration script: + - just renames the path columns (CAS layout — cheap), or + - renames the path columns AND issues a `mv` per file + (path-mirrored layout — expensive on big instances). + The blob store is content-addressable as of v0.7.0 so most + file content lives under `.blobs//` and is + already wrapper-agnostic; the concern is only the + metadata-projection / thumbnail-cache trees if they're + path-shaped. + +11. **Promoting sibling root folders — UX after migration**. + Users whose home was a single `My Folder - ` end + up with one drive (Personal). Users with SQL-added siblings + end up with multiple drives, one named per the original + folder name. The drive name carries over verbatim — should + the migration log who got more than one drive, so an + operator can DM them and explain the new picker? Operational + nicety, not a correctness concern. + +12. **Deleted-user tombstone for provenance**. §14 sets + `created_by` / `updated_by` to NULL when the referenced user + is deleted (`ON DELETE SET NULL`). The UI then shows "Unknown + user", which is correct but information-poor — for compliance + / audit / "who left this?" purposes, a small `deleted_users` + tombstone table (`id, last_known_username, deleted_at`) would + let the UI render "Bob (deleted 2026-04-12)" instead of just + "Unknown user". Out of scope for v1 but easy to add later + without schema rework (NULL `created_by` stays NULL; a + parallel lookup against the tombstone table on display). + +## Existing code to reuse + +- **ReBAC `Resource` enum** at `src/domain/services/authorization.rs:74` + already has a tagged-union shape; adding `Drive(Uuid)` is one variant + + `from_parts` arm + `type_str` arm. +- **ReBAC `Permission` enum** already has the bundle we need + (`Read`, `Create`, `Update`, `Delete`, `Share`, `Comment`). The + role-to-bundle mapping for drives lives in a new `drive_role.rs` + helper. +- **`PgAclEngine` Moka cache** at + `src/infrastructure/services/pg_acl_engine.rs:88` handles ReBAC + grant caching. The drive-role check piggybacks on the same cache + with a separate keyspace (`drive_perm:::`). +- **`SubjectGroupService::list_transitive_users`** at + `src/application/services/subject_group_service.rs:385` already + expands a group to its transitive user members. The drive-membership + check uses this to resolve group-owner subjects. +- **`folder_service::create_home_folder`** at + `src/application/services/folder_service.rs:644` is where the + per-user wrapper folder is created today. Post-migration this + function **goes away** — there is no wrapper folder anymore. The + user-create lifecycle hook now creates a Drive row directly and + inserts the owner-role member row. The lifecycle path is the same; + the work it does shrinks. +- **NC path resolver `nc_to_internal_path`** at + `src/interfaces/nextcloud/webdav_handler.rs:51` and the native + resolver `resolve_webdav_path` at + `src/interfaces/api/handlers/webdav_handler.rs:188` are the two + callsites that learn about drives. Both gain a "drive context" + parameter resolved from the URL prefix (`/files//` or + `{user}~{uuid}` for NC; `/webdav/` or `/webdav/drives//` + for native). **Neither resolver prepends `My Folder - /` + anymore** — the storage path IS the in-drive path. Both + functions also get simpler, not more complex, despite gaining + the drive parameter (the personal-vs-shared branch is now a + metadata lookup, not a path-shape decision). +- **`MagicLinkInviteService`** and the share-notification pipeline + (`RecipientNotificationService`) need the new policy checks + (`forbid_external_sharing`, `forbid_sharing`) wired in at their + respective callsites. +- **Migration timestamp convention**: as of v0.7.0 the head + migration is `20260702000000_drop_dead_file_indexes.sql`, and + `20260701000000_content_search_index.sql` already exists (the + Tantivy index). D0's migration becomes `20260801000000_drives.sql` + (or whatever date D0 actually starts) — the original + `20260701000000_drives.sql` slot is taken. + +## Verification + +Each PR carries its own verification block. The bar across all of +them: **(a)** new behaviour proven by a focused test, **(b)** the +existing Hurl + Playwright baselines still pass green (`bash +tests/api/run.sh && bash tests/webdav/run.sh && cd tests/e2e && npm +test`), **(c)** `cargo fmt && cargo clippy --all-features +--all-targets -- -D warnings` clean. + +### D-Prep +- **Unit**: `role_bundle(role)` returns the expected permission set + for each role; `roles_implying(permission)` returns the expected + role list; round-trip a grant insert→read→compare for every role. +- **Data audit**: query `access_grants` and confirm that >99% of + rows cluster into the standard bundles (the empirical figure that + unlocked Sequence A). Log the <1% edge cases per row for review. +- **Migration round-trip**: roll forward against a populated DB → + every prior `access_grants` row is represented by exactly one + `role_grants` row → roll back → original `access_grants` rows + recovered byte-identically. +- **API**: new Hurl test `tests/api/role_grants.hurl` — + - `POST /api/grants` with `role='editor'` creates a single row + with the expected bundle when expanded. + - `PUT /api/grants/{id}` with `role='viewer'` is atomic (no race + window where the user has zero permissions). + - Compat shim: `POST /api/grants` with the legacy `permission` + field still works for one release and maps to the closest role. + - Audit log emits `role_grant.created`, `role_grant.role_changed`, + `role_grant.revoked` events with the role name carried through. +- **UI smoke (Playwright)**: My Shares dialog renders roles instead + of permission checkboxes; share modal preset buttons map to roles; + changing a member's role from editor → viewer fires exactly one + PATCH and the UI reflects the change without a race-window blank + state. +- **All existing Hurl + WebDAV + Playwright tests still pass** — the + refactor must be invisible to every non-grant-handling test. + +### D0 +- **Unit**: `Drive` entity (kind + `default_for_user` CHECK + constraints), `default_for_user` partial unique index. Personal- + drive `add_member` / `remove_member` / `delete_drive` all refuse + cleanly with the expected `DomainError`. +- **Migration round-trip**: roll forward against a populated DB → + every existing folder/file row has `drive_id` set (no NULLs); + every user has exactly one drive with `default_for_user` set; + sibling root folders became secondary `kind='personal'` drives; + every `storage.folders.path` and `storage.files.path` value has + the `My Folder - /` prefix stripped → roll back via + `sqlx migrate revert` → `drive_id` column gone, `user_id` intact + thanks to dual-write, original paths recovered. +- **Storage check**: post-migration `bash tests/api/storage_cleanup_check.sh` + still reports a clean tree (no orphans). +- **Tantivy reindex**: every indexed doc carries a `drive_id`; + search query filtered by `drive_id ∈ caller's drives` returns the + expected hits; cross-drive isolation confirmed (search as Alice, + Bob's content never appears). +- **API**: `tests/api/drives_foundation.hurl` — admin lists + `/api/drives`, sees their default personal drive with the correct + quota, `kind='personal'`, `default_for_user` matching their own + uuid. Creating a folder still works; the folder's `drive_id` + matches the personal drive. +- **All upload paths set `drive_id`**: targeted Hurl tests for + multipart, native chunked, NC chunked, delta upload, instant + upload by hash. Each verifies the resulting file row has the + expected `drive_id`. + +### D1 +- **Routing**: `cargo build` clean; WebDAV dispatcher routes + `/webdav/drives//...` correctly; `/webdav/` still + resolves to the caller's default drive (back-compat). +- **NC client back-compat**: a real NC sync client pointed at + `/remote.php/dav/files/admin/` continues syncing the user's default + personal drive without reconfiguration. The chroot POC's `~` + username (or app-password binding) lands a sync into the chosen + drive transparently. +- **Manual smoke**: open `/`, get redirected to + `/drive/`. Click sidebar drive switcher → URL + updates, listing reloads. Drive picker shows all of the caller's + drives (default first), each with its quota usage. +- **Playwright**: a new `tests/e2e/drive-switching.spec.ts` exercises + sidebar → URL → listing → cross-drive isolation (folders in + drive A don't appear in drive B's listing). + +### D2 +- **Membership API**: `POST /api/drives/{id}/members` with user, with + group, and with role changes; refuses on personal drives; + shared-drive last-owner protection. Group expansion (transitive + members count toward the owner total). +- **Trash per drive**: trash listing scopes correctly to drive(s) + the caller can read; mutations refuse without owner role; the + `storage.trash_items` VIEW surfaces `drive_id`. +- **Updated NC + WebDAV regression baselines** — the 105+ scenarios + in `BASELINE_TESTS_NC_WEBDAV.md` still pass green; per-drive + trash and membership changes don't regress existing protocol + behaviour. + +### D3–D7 +- Per-PR verification authored when the PR is drafted. Each PR adds + at least one new Hurl/Playwright test for the headline capability + and proves zero regression on the previous baselines. + +### Cross-cutting regression — runs on every Drive PR + +A standing checklist independent of the headline capability of each +PR: + +1. `bash tests/api/run.sh` green (>100 scenarios). +2. `bash tests/webdav/run.sh` green (NC + native WebDAV baselines). +3. `cd tests/e2e && npm test` green (Playwright). +4. `tests/api/storage_cleanup_check.sh` clean. +5. No new `cargo clippy` warnings. +6. Tantivy index returns no cross-drive results for any caller. +7. `/api/dedup/stats` shows blob ref-counts consistent with the + number of files referencing each blob across all drives. + +## UI design — outline for D1 and D3 + +The Drive concept reshapes three load-bearing UI elements. Each +gets a small design pass alongside the relevant PR — the bullets +here lock the **intended shape**; the pixel work happens when D1 / +D3 draft. + +### Sidebar — drives as children of a "Drive" section + +Today the sidebar shows a single root view (the user's home +folder). Post-Drive it gains a top-level **Drive** section whose +children are the drives the caller has access to: + +``` +📁 Drive + ⭐ Personal ← kind='personal', default_for_user=caller (★ marks default) + Family Archive ← kind='personal', secondary (no star) + Engineering ← kind='shared', caller is owner/editor/viewer + 2025 Marketing Sprint ← kind='shared' + Recent + Favorites + Shared with me + Trash +``` + +- The user's **default personal drive** is marked with a star (or + bolded — UI choice). Clicking it lands on the default landing + view (today's "Files" experience). +- Other drives — secondary personals, shared drives — appear below + with the same visual weight. Clicking switches the context (URL + changes to `/drive//...`, listing reloads). +- The sidebar is collapsible per-drive; users with many drives can + hide the drives they aren't actively in. +- **No drive icons / colours in v1** (deferred to a future polish + PR — see "Out of scope" item 5). +- The "Drive" section header itself is non-interactive (just a + grouping label); the action affordance is "Create shared drive" + via the `+` button at the end of the list (admin / group-owner + scoped; lands in D3). + +### Breadcrumb — drive-rooted + +Today's breadcrumb is path-rooted: `Home / Docs / Q3 / Report.pdf`. +Post-Drive the breadcrumb starts at the selected drive: + +``` +[Personal ▾] / Docs / Q3 / Report.pdf +``` + +- The drive name is the **first** element. Clicking it returns to + that drive's root listing. +- The `▾` chevron next to the drive name opens a quick-switcher + picker (same list as the sidebar). Lets users jump between drives + without using the sidebar. +- For paths inside shared drives, the drive name still leads: + `[Engineering ▾] / Specs / Q3 OKRs.md`. +- The breadcrumb's overflow / truncation behaviour for deep paths is + unchanged from today — only the root element is new. +- **Drive name follows server-side rename** — the breadcrumb queries + the drive's current `name`; renaming the drive updates the + breadcrumb on next reload without any client-side cache work. + +### "Owners" section — review and redesign + +Today's UI has an "Owner" surface in several places (file details +sidebar, share dialog, folder properties). It currently shows "the +user who owns this file" — a single name. + +Post-Drive, ownership has multiple shapes: + +- For a file in a personal drive: still one owner (the drive's sole + user-owner). Display stays the same — "Owner: Ed". +- For a file in a shared drive: ownership is the drive's **owner- + role members** (possibly multiple users + groups). Display + becomes "Owner: Engineering team (3 members + 2 groups)". +- For a drive itself (when displayed in a settings view): same as + above, but in a list form: "Owners: Ed, Alice, Engineering + group". + +Open questions for the D3 PR to settle: +- **Show the drive name as the effective owner?** "Owner: + Engineering" reads cleanly but conflates "the drive" with "the + drive's owners" — clearer for end users, less precise. +- **Should the file details sidebar expand owners on click?** + Click "Engineering team" → see the owner roster. Useful for + large drives where the owner list doesn't fit inline. +- **Audit/My Shares dialog**: the "Shared with me" view currently + groups by sharer. Post-Drive it can group by drive instead + (your shared-drive access shows once per drive, not once per + file). UX win — decide which grouping is default and whether + both are togglable. + +These three UI surfaces are independent enough that they can ship +in separate PRs (sidebar in D1, breadcrumb in D1, owners review +in D3 alongside the create-shared-drive flow). The sidebar and +breadcrumb are essentially mechanical given the new model; the +owners review is the only one with genuine design questions. + +## File map (anticipated, not yet created) + +``` +migrations/ + 20260730000000_role_grants.sql ← D-Prep (rename of access_grants + to role_grants with role bundles) + 20260801000000_drives.sql ← D0 (drives table only; + membership is rows in role_grants) + 20260901000000_drop_user_id_on_resources.sql ← D7 + +src/domain/entities/ + drive.rs ← D0 + role.rs ← D-Prep (role enum + bundle map) + +src/domain/services/ + authorization.rs (modify) ← D-Prep adds `Manage` Permission + + `Resource::Drive(Uuid)` variant + +src/application/services/ + drive_service.rs ← D0–D3 (CRUD + policy enforcement, + membership operations translate + to role_grants writes) + +src/infrastructure/repositories/pg/ + drive_pg_repository.rs ← D0 + role_grant_pg_repository.rs ← D-Prep (replaces + access_grant_pg_repository) + +src/infrastructure/services/pg_acl_engine.rs (modify) + reads role_grants only ← D-Prep + Resource::Drive routing ← D0 + +src/interfaces/api/handlers/ + grant_handler.rs (modify) ← D-Prep (accepts role parameter) + drive_handler.rs ← D0 (list), D2 (members), D3 (create/delete shared) + +src/interfaces/api/handlers/webdav_handler.rs (modify) + drive-aware path resolution ← D1 + +src/interfaces/nextcloud/webdav_handler.rs (modify) + drive-aware path resolution ← D1 + +static/js/views/drive/ ← D1 (sidebar switcher, drive view) +static/js/model/drives.js ← D1 (REST client) +static/css/components/driveSwitcher.css ← D1 +``` + +## Glossary (for the next reader) + +- **Drive** — a top-level container that owns folders and files. +- **Personal drive** — the auto-created drive a user gets at + registration. One per internal user. `kind='personal'`. +- **Shared drive** — a drive whose membership includes at least one + group (or multiple users). `kind='shared'`. +- **Drive member** — a row in `storage.role_grants` with + `resource_type='drive'`, carrying a subject (user or group) + and a role. There is **no** separate `drive_members` table — see + the D-Prep prerequisite at the top of this plan for the storage + pivot. +- **Owner / editor / viewer** — drive roles. Map to ReBAC + permission bundles for resources inside the drive. +- **Drive policy** — JSONB key on the drive that toggles a sharing / + movement restriction. +- **Drive context** in WebDAV — the drive whose root the request is + rooted at. Native WebDAV resolves it from the URL prefix + (`/webdav/` → caller's personal drive, + `/webdav/drives//` → explicit drive). NC resolves + it from the credential (Basic Auth `{user}~{uuid}` username, or + the `auth.app_passwords.drive_id` binding — see §9). +- **Wrapper folder** — historical name for + `My Folder - `, the folder created at registration + via `format!("My Folder - {}", username)`. **Retired** in the + Drive migration: drive root replaces it. Every reference to + "wrapper" in older comments / docs is by definition pre-Drive. From f168c4578f7acda8a316a8153e97c561268a511a Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 17 Jun 2026 23:14:25 +0200 Subject: [PATCH 05/39] feat(roles): prepare migration ReBAC to roles prepare migration of permission to roles this simplify drastically database (permission are now simply defined in code) and will permit reuse of the same ReBAC engine to define owners of drives mapping: ``` Role::Viewer => &[Permission::Read], Role::Commenter => &[Permission::Read, Permission::Comment], Role::Contributor => &[Permission::Read, Permission::Create], Role::Editor => &[ Permission::Read, Permission::Comment, Permission::Create, Permission::Update, ], Role::Owner => &[ Permission::Read, Permission::Comment, Permission::Create, Permission::Update, Permission::Share, Permission::Delete, Permission::Manage, ], ``` --- migrations/20260730000000_role_grants.sql | 244 ++++++++++++ src/application/dtos/grant_dto.rs | 196 ++++++++-- src/application/ports/authorization_ports.rs | 36 ++ src/domain/services/authorization.rs | 15 +- src/infrastructure/services/pg_acl_engine.rs | 119 +++++- src/interfaces/api/handlers/grant_handler.rs | 161 ++++++-- static/js/components/mySharesList.js | 2 +- static/js/components/roleChip.js | 11 +- static/js/components/shareModal.js | 24 +- static/js/core/types.js | 9 +- tests/api/role_grants.hurl | 388 +++++++++++++++++++ tests/api/run.sh | 1 + 12 files changed, 1132 insertions(+), 74 deletions(-) create mode 100644 migrations/20260730000000_role_grants.sql create mode 100644 tests/api/role_grants.hurl diff --git a/migrations/20260730000000_role_grants.sql b/migrations/20260730000000_role_grants.sql new file mode 100644 index 00000000..afdc1921 --- /dev/null +++ b/migrations/20260730000000_role_grants.sql @@ -0,0 +1,244 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- D-Prep: storage.role_grants — role-bundle replacement for access_grants +-- ════════════════════════════════════════════════════════════════════════════ +-- Refactor #1 of the Drive sequence (see `docs/plan/drive.md` § Prerequisite). +-- +-- Today every role assignment is stored as N rows in `storage.access_grants` +-- (one row per Permission in the role's bundle — editor = 4 rows, owner = 6). +-- This migration introduces `storage.role_grants` where each role assignment +-- is ONE row carrying the role name; permission expansion happens at engine +-- read time via the in-code `role_bundle()` function. +-- +-- The five roles shipped on day one: +-- viewer = {read} +-- commenter = {comment, read} ← new +-- contributor = {create, read} ← new +-- editor = {comment, create, read, update} +-- owner = {comment, create, delete, read, share, update} +-- (post-Drive: + manage, when Group-as-Resource lands) +-- +-- This migration is **additive**: `storage.access_grants` stays populated as +-- a dual-write safety net until a follow-up cleanup PR drops it after the +-- new model has baked in production. The down migration just drops +-- role_grants — access_grants is untouched, so rollback is trivial. +-- +-- Pre-flight: the migration REFUSES to run if `access_grants` contains any +-- non-bundle clusters (permission sets that don't match one of the five +-- roles above). Run `tools/audit-grants-bundle-shape.sql` first to confirm +-- the data is clean — Ed's audit on 2026-06-17 returned 100% bundle-shaped. + + +-- ── 1. Pre-flight assertion ───────────────────────────────────────────────── +-- Refuse to migrate if there are any non-bundle clusters. The five known +-- bundles are listed here verbatim; keep them in sync with the in-code +-- `role_bundle()` function. + +DO $BODY$ +DECLARE + bad_count BIGINT; +BEGIN + WITH cluster AS ( + SELECT subject_type, subject_id, resource_type, resource_id, + array_agg(permission ORDER BY permission) AS perms + FROM storage.access_grants + GROUP BY 1, 2, 3, 4 + ) + SELECT count(*) INTO bad_count + FROM cluster + WHERE perms NOT IN ( + ARRAY['read']::text[], + ARRAY['comment','read']::text[], + ARRAY['create','read']::text[], + ARRAY['comment','create','read','update']::text[], + ARRAY['comment','create','delete','read','share','update']::text[] + ); + + IF bad_count > 0 THEN + RAISE EXCEPTION + 'D-Prep migration refused: % (subject,resource) clusters in ' + 'storage.access_grants have non-bundle permission sets. Run ' + 'tools/audit-grants-bundle-shape.sql section 3 to inspect them, ' + 'then either resolve manually or extend the bundle list above ' + 'with a new named role before retrying.', bad_count; + END IF; +END $BODY$; + + +-- ── 2. The role_grants table ──────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS storage.role_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Subject (who has the role) + -- 'user' → auth.users.id + -- 'group' → storage.subject_groups.id + -- 'token' → storage.shares.id (anonymous link — always 'viewer') + subject_type TEXT NOT NULL + CHECK (subject_type IN ('user', 'group', 'token')), + subject_id UUID NOT NULL, + + -- Resource (what the role is on) + -- 'drive' and 'group' join later as Drive + Group-as-Resource land. + resource_type TEXT NOT NULL + CHECK (resource_type IN ('folder', 'file')), + resource_id UUID NOT NULL, + + -- Role — expands to a permission bundle via the in-code `role_bundle()` + -- function. The CHECK lists the day-one role roster; adding a new + -- role is a single ALTER TABLE DROP CONSTRAINT / ADD CONSTRAINT pair + -- (or replace with a foreign key into a lookup table if instance- + -- defined roles ever land). + -- + -- Universal roster: ANY role can be granted on ANY resource_type. + -- Permission bundles include capabilities the resource type may not + -- check for (e.g. `Manage` on a folder, `Create` on a file); those + -- produce harmless no-ops at engine read time — no per-resource-type + -- validation needed at the DB layer. + -- + -- The UI exposes only Viewer/Editor/Owner in the share dialog today + -- (matches the existing 3-button UX). Commenter and Contributor stay + -- in the enum for server-side use + future UI exposure when a real + -- use case asks for them. + role TEXT NOT NULL + CHECK (role IN ('viewer', 'commenter', 'contributor', 'editor', 'owner')), + + -- Audit + lifecycle + granted_by UUID NOT NULL, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ, + + -- Exactly one role per (subject, resource). Atomic role changes become + -- a single UPDATE; no DELETE+INSERT race. + UNIQUE (subject_type, subject_id, resource_type, resource_id) +); + +COMMENT ON TABLE storage.role_grants IS + 'Role-based ReBAC grants. One row = one role assignment. Permission ' + 'bundle expansion is in-code; see role_bundle() in ' + 'src/application/dtos/grant_dto.rs. Replaces storage.access_grants; ' + 'both tables coexist during the D-Prep dual-write window.'; +COMMENT ON COLUMN storage.role_grants.role IS + 'One of viewer / commenter / contributor / editor / owner. Expanded to ' + 'a Permission bundle by the in-code role_bundle() function at engine ' + 'read time.'; + + +-- ── 3. Indexes — match the hot-path queries ───────────────────────────────── + +-- "What does this caller have access to?" — every WebDAV / NC request, +-- every UI default-drive resolution (post-Drive) hits this. +CREATE INDEX IF NOT EXISTS idx_role_grants_subject + ON storage.role_grants (subject_type, subject_id); + +-- "Who has access to this resource?" — share dialogs, audit views. +CREATE INDEX IF NOT EXISTS idx_role_grants_resource + ON storage.role_grants (resource_type, resource_id); + +-- Partial index on expiry — only rows that actually expire (mirrors the +-- access_grants index pattern, same rationale). +CREATE INDEX IF NOT EXISTS idx_role_grants_expires_at + ON storage.role_grants (expires_at) WHERE expires_at IS NOT NULL; + +-- For GET /api/grants/outgoing/resources (who granted what). +CREATE INDEX IF NOT EXISTS idx_role_grants_granted_by + ON storage.role_grants (granted_by); + + +-- ── 4. Backfill from access_grants ───────────────────────────────────────── +-- For each (subject, resource) cluster in access_grants, write one +-- role_grants row with the matching role. The CASE expression mirrors +-- `Role::expand()` exactly — when that function changes (new role added), +-- update both this CASE and the CHECK constraint above. +-- +-- expires_at: take MIN across the cluster (most conservative — the role +-- assignment expires at the earliest expiry of any of its constituent +-- grants). granted_at: MIN (when the role assignment started). granted_by: +-- the granter of the earliest row (preserves attribution to the admin who +-- initially set the role up). + +WITH cluster AS ( + SELECT subject_type, + subject_id, + resource_type, + resource_id, + array_agg(permission ORDER BY permission) AS perms, + MIN(granted_at) AS earliest_granted_at, + MIN(expires_at) AS earliest_expires_at + FROM storage.access_grants + GROUP BY 1, 2, 3, 4 +), +earliest_grantor AS ( + SELECT DISTINCT ON (subject_type, subject_id, resource_type, resource_id) + subject_type, + subject_id, + resource_type, + resource_id, + granted_by + FROM storage.access_grants + ORDER BY subject_type, subject_id, resource_type, resource_id, granted_at ASC +) +INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, + role, granted_by, granted_at, expires_at) +SELECT + c.subject_type, + c.subject_id, + c.resource_type, + c.resource_id, + CASE c.perms + WHEN ARRAY['read']::text[] + THEN 'viewer' + WHEN ARRAY['comment','read']::text[] + THEN 'commenter' + WHEN ARRAY['create','read']::text[] + THEN 'contributor' + WHEN ARRAY['comment','create','read','update']::text[] + THEN 'editor' + WHEN ARRAY['comment','create','delete','read','share','update']::text[] + THEN 'owner' + END AS role, + eg.granted_by, + c.earliest_granted_at, + c.earliest_expires_at +FROM cluster c +JOIN earliest_grantor eg USING (subject_type, subject_id, resource_type, resource_id) +ON CONFLICT (subject_type, subject_id, resource_type, resource_id) DO NOTHING; + + +-- ── 5. Post-flight consistency check ─────────────────────────────────────── +-- Assert that the backfill landed one role_grants row per (subject, +-- resource) cluster in access_grants. Any mismatch means a bundle pattern +-- silently failed to match — refuses to commit, surfacing the bug. + +DO $BODY$ +DECLARE + expected_clusters BIGINT; + actual_role_grants BIGINT; + null_roles BIGINT; +BEGIN + SELECT count(*) INTO expected_clusters + FROM ( + SELECT 1 FROM storage.access_grants + GROUP BY subject_type, subject_id, resource_type, resource_id + ) c; + + SELECT count(*) INTO actual_role_grants FROM storage.role_grants; + + IF expected_clusters != actual_role_grants THEN + RAISE EXCEPTION + 'D-Prep backfill consistency check failed: expected % role_grants ' + 'rows (one per distinct (subject, resource) cluster in access_grants), ' + 'got %. Investigate before declaring the migration successful.', + expected_clusters, actual_role_grants; + END IF; + + -- Defensive: NULL role would mean the CASE expression failed to match. + -- Pre-flight already refuses this, but double-check. + SELECT count(*) INTO null_roles FROM storage.role_grants WHERE role IS NULL; + IF null_roles > 0 THEN + RAISE EXCEPTION + 'D-Prep backfill produced % role_grants rows with NULL role — ' + 'a bundle pattern slipped past the pre-flight check. Investigate.', + null_roles; + END IF; +END $BODY$; diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index ac74a225..e7c0ec75 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -95,6 +95,7 @@ pub enum PermissionDto { Comment, Delete, Update, + Manage, } impl From for Permission { @@ -106,6 +107,7 @@ impl From for Permission { PermissionDto::Comment => Permission::Comment, PermissionDto::Delete => Permission::Delete, PermissionDto::Update => Permission::Update, + PermissionDto::Manage => Permission::Manage, } } } @@ -119,59 +121,176 @@ impl From for PermissionDto { Permission::Comment => PermissionDto::Comment, Permission::Delete => PermissionDto::Delete, Permission::Update => PermissionDto::Update, + Permission::Manage => PermissionDto::Manage, } } } // ════════════════════════════════════════════════════════════════════════════ -// Roles (DTO-layer sugar) +// Roles — the load-bearing model for ReBAC grants // ════════════════════════════════════════════════════════════════════════════ +// +// Today a role is "DTO-layer sugar" — every grant write expands a role into +// N rows in `storage.access_grants`. The D-Prep refactor (see +// `docs/plan/drive.md` §Prerequisite + migration `20260730000000_role_grants.sql`) +// pushes the role down to storage (`storage.role_grants.role TEXT`); the +// engine reads the role and expands the bundle at query time via this same +// `expand()` function. Adding a role is now schema-free — one variant + one +// match arm here. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum Role { + /// Read access only. The default "anyone can look but not touch". Viewer, - //Commenter, + /// Read + comment. Useful for review-only stakeholders. + Commenter, + /// Read + create. The "drop-zone" role — uploads allowed, existing + /// content untouchable. Common for support-ticket attachments and + /// photo-submission folders. + Contributor, + /// Read + create + update + comment. The standard collaboration role. Editor, - //Manager, - Admin, + /// Full bundle: read, create, update, comment, delete, share — and + /// `Manage` for resource types that support it (drives, groups). This + /// is the highest user-grantable role; renamed from the historical + /// `Admin` to disambiguate from `UserRole::Admin` (the user-account + /// privilege) and to match Drive plan terminology. + /// + /// **Wire-format compat shim** (one release): also deserialises from + /// the legacy `"admin"` string so cached frontend clients keep + /// working until they refresh. Serialisation always emits `"owner"`. + /// Drop the alias in the cleanup PR. + #[serde(alias = "admin")] + Owner, } impl Role { - /// Expands a role into its constituent raw permissions. Storage and - /// engine know nothing about roles — the server normalizes here before - /// writing rows. + /// Expand the role into its permission bundle. Single source of truth — + /// any code that needs "does this role include Permission X?" routes + /// through here (or its inverse, `roles_implying`). + /// + /// After D-Prep this is called at engine read time (1 row → bundle + /// expanded server-side). Pre-D-Prep it was called at API write time + /// (1 role → N rows fanned out). pub fn expand(self) -> &'static [Permission] { match self { Role::Viewer => &[Permission::Read], - /* reserved for future Role::Commenter => &[Permission::Read, Permission::Comment], - */ + Role::Contributor => &[Permission::Read, Permission::Create], Role::Editor => &[ Permission::Read, Permission::Comment, Permission::Create, Permission::Update, ], - /* reserved for future - Role::Manager => &[ - Permission::Read, - Permission::Comment, - Permission::Create, - Permission::Update, - Permission::Share, - ], - */ - Role::Admin => &[ + Role::Owner => &[ Permission::Read, Permission::Comment, Permission::Create, Permission::Update, Permission::Share, Permission::Delete, + Permission::Manage, ], } } + + /// Lowercase string discriminator — matches the SQL `role` column values + /// in `storage.role_grants` and the JSON wire format. + pub fn as_str(self) -> &'static str { + match self { + Role::Viewer => "viewer", + Role::Commenter => "commenter", + Role::Contributor => "contributor", + Role::Editor => "editor", + Role::Owner => "owner", + } + } + + /// Parse a role from its SQL / JSON string discriminator. Returns + /// `None` for unknown values. Accepts the legacy `"admin"` spelling + /// for one release of API compat (clients that cached the old name + /// keep working; new responses always emit `"owner"`). + pub fn parse(s: &str) -> Option { + match s { + "viewer" => Some(Role::Viewer), + "commenter" => Some(Role::Commenter), + "contributor" => Some(Role::Contributor), + "editor" => Some(Role::Editor), + "owner" => Some(Role::Owner), + // Legacy compat: drop after one release once all clients are + // updated. Emits a debug log so we can track stragglers. + "admin" => { + tracing::debug!( + target: "oxicloud::grants", + "Role::parse: accepted legacy 'admin' string as Role::Owner" + ); + Some(Role::Owner) + } + _ => None, + } + } + + /// Every role, in a stable order. Used by `roles_implying` and exposed + /// to the UI so the share modal can render the full picker without + /// hardcoding the list. + /// + /// **UI scope today**: the share dialog renders only `Viewer`, + /// `Editor`, and `Owner` (matches the existing 3-button UX). The + /// `Commenter` and `Contributor` variants are implemented server- + /// side and accepted on the API surface, reserved for future UI + /// exposure when a real use case asks for them. Until then they + /// stay invisible to end users — no picker option, no documentation + /// surface. + /// + /// Any role can be granted on any resource type. Permission bundles + /// that include capabilities the resource type doesn't check for + /// (e.g. `Manage` on a folder, `Create` on a file) simply produce + /// harmless no-ops — no separate validation layer is needed. + pub const ALL: [Role; 5] = [ + Role::Viewer, + Role::Commenter, + Role::Contributor, + Role::Editor, + Role::Owner, + ]; +} + +/// Inverse of [`Role::expand`]: returns every role whose bundle contains +/// the given permission. Used by the engine to build the SQL +/// `WHERE role IN (...)` filter on hot-path queries like "what drives can +/// this caller read?": +/// +/// ```ignore +/// SELECT resource_id FROM role_grants +/// WHERE subject_id = $1 +/// AND resource_type = 'drive' +/// AND role IN (roles_implying(Permission::Read)); +/// ``` +/// +/// Precomputed in code rather than stored in the DB — `Role` and +/// `Permission` are both small fixed enums, the table can never grow +/// beyond a handful of rows, and keeping it in-code makes "what changes +/// when I add a Permission?" a single grep target. +pub fn roles_implying(permission: Permission) -> &'static [Role] { + use Permission::*; + match permission { + // Every role grants Read — viewer is the floor. + Read => &[ + Role::Viewer, + Role::Commenter, + Role::Contributor, + Role::Editor, + Role::Owner, + ], + Comment => &[Role::Commenter, Role::Editor, Role::Owner], + Create => &[Role::Contributor, Role::Editor, Role::Owner], + Update => &[Role::Editor, Role::Owner], + Delete => &[Role::Owner], + Share => &[Role::Owner], + Manage => &[Role::Owner], + } } // ════════════════════════════════════════════════════════════════════════════ @@ -430,12 +549,34 @@ pub struct SharedWithMeItemDto { } /// Derive the closest-matching role label from a set of permissions. -/// Maps the permission set to `"admin"`, `"editor"`, or `"viewer"`. +/// +/// **Legacy helper for the dual-write window.** Once D-Prep ships and the +/// engine reads `role_grants.role` directly, this function becomes unused +/// and is dropped in the cleanup PR. Kept here so callers that still hit +/// `access_grants` and reconstruct a role for display can stay working +/// during the transition. +/// +/// Emits the new five-role roster on output (`"viewer"` / `"commenter"` / +/// `"contributor"` / `"editor"` / `"owner"`). Note this is **lossy** for +/// permission sets that don't match a bundle exactly — but D-Prep's +/// pre-flight refuses to migrate any such cluster, so post-migration data +/// only contains bundle-shaped sets. pub fn role_from_permissions(perms: &[Permission]) -> &'static str { - if perms.contains(&Permission::Delete) && perms.contains(&Permission::Share) { - "admin" - } else if perms.contains(&Permission::Create) || perms.contains(&Permission::Update) { + let has_read = perms.contains(&Permission::Read); + let has_comment = perms.contains(&Permission::Comment); + let has_create = perms.contains(&Permission::Create); + let has_update = perms.contains(&Permission::Update); + let has_delete = perms.contains(&Permission::Delete); + let has_share = perms.contains(&Permission::Share); + + if has_delete && has_share { + "owner" + } else if has_create && has_update { "editor" + } else if has_read && has_create && !has_update { + "contributor" + } else if has_read && has_comment && !has_create && !has_update { + "commenter" } else { "viewer" } @@ -457,7 +598,12 @@ pub struct OutgoingResourceGrantDto { pub subject_id: Uuid, /// Human-readable label (username for users, share name for tokens). pub subject_display: String, - /// Derived role label: `"viewer"` | `"editor"` | `"admin"`. + /// Role label: `"viewer"` | `"commenter"` | `"contributor"` | `"editor"` + /// | `"owner"`. Emitted by `role_from_permissions()` during the dual-write + /// window; once D-Prep cleanup lands this is read directly from + /// `storage.role_grants.role`. The legacy `"admin"` spelling is no longer + /// emitted — clients that cached it must accept `"owner"` too (the API + /// `Role::parse` still accepts `"admin"` on input for one release). pub role: String, pub granted_at: chrono::DateTime, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index e310c3e1..b0b9b1c7 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -181,4 +181,40 @@ pub trait AuthorizationEngine: Send + Sync + 'static { /// Removes every grant whose `subject` matches. Called when a user/token /// /group is deleted. Returns the count of rows removed. async fn revoke_all_for_subject(&self, subject: Subject) -> Result; + + // ── Role-keyed grant operations (D-Prep dual-write) ──────────────────── + // These manage `storage.role_grants`, the role-keyed table introduced + // by the D-Prep refactor (see `docs/plan/drive.md` §Prerequisite). + // + // During the dual-write window both tables stay populated; the engine + // reads from `access_grants` until the read-path pivot lands. After + // the cleanup PR (which drops `access_grants`), these two methods + // become the ONLY grant write path — the per-permission `grant` / + // `revoke` above are removed at that point. + // + // The handler layer drives these (it knows the Role); lifecycle hook + // bulk-deletes (`revoke_all_for_*` above) wipe role_grants in lockstep + // inside their own implementation, so callers using those paths don't + // need to invoke `clear_role` separately. + + /// Set the role for a `(subject, resource)` pair. Idempotent via the + /// UNIQUE `(subject_type, subject_id, resource_type, resource_id)` + /// constraint — `ON CONFLICT` updates the role + expires_at if they + /// changed, which is exactly the right semantics for an atomic role + /// change (e.g. promoting Viewer → Editor in one UPDATE with no race + /// window, no DELETE+INSERT). + async fn set_role( + &self, + granted_by: Uuid, + subject: Subject, + role: crate::application::dtos::grant_dto::Role, + resource: Resource, + expires_at: Option>, + ) -> Result<(), DomainError>; + + /// Remove the role for a `(subject, resource)` pair. Idempotent — + /// succeeds whether or not the row existed. Called after `revoke` + /// succeeds to keep the two tables in sync during dual-write; after + /// cleanup this is the canonical role-revocation entry point. + async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError>; } diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 2e095e59..7e8b5503 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -140,18 +140,29 @@ pub enum Permission { Delete, /// Modify the resource (rename, move, edit content). Update, + /// Configure the resource's settings, add/remove members, change role + /// assignments. Used by: + /// - Drive owners managing drive membership and policies. + /// - Group owners managing the group itself (Group-as-Resource, future). + /// + /// Folder and file resources do not currently surface a `Manage` check; + /// the permission lives in the enum because the role bundle (`Owner`) + /// includes it, and the resource types that DO check it (`Drive`, + /// `Group`) are added in subsequent PRs (see `docs/plan/drive.md`). + Manage, } impl Permission { /// Every permission, in a stable order. Used by `Role::expand()` and SQL /// `permission = ANY(...)` lookups. - pub const ALL: [Permission; 6] = [ + pub const ALL: [Permission; 7] = [ Permission::Read, Permission::Create, Permission::Share, Permission::Comment, Permission::Delete, Permission::Update, + Permission::Manage, ]; pub fn as_str(&self) -> &'static str { @@ -162,6 +173,7 @@ impl Permission { Permission::Comment => "comment", Permission::Delete => "delete", Permission::Update => "update", + Permission::Manage => "manage", } } @@ -175,6 +187,7 @@ impl Permission { "comment" => Some(Permission::Comment), "delete" => Some(Permission::Delete), "update" => Some(Permission::Update), + "manage" => Some(Permission::Manage), _ => None, } } diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index b60ba832..1d46db3b 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -37,6 +37,7 @@ use uuid::Uuid; use moka::future::Cache; use sqlx::PgPool; +use crate::application::dtos::grant_dto::roles_implying; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::common::errors::DomainError; use crate::domain::entities::subject_group::INTERNAL_GROUP_ID; @@ -237,6 +238,22 @@ impl PgAclEngine { } } + /// Convert a `Permission` into the array of role strings whose bundle + /// includes it — used to bind the `g.role = ANY($N::text[])` filter on + /// every cascade / lookup query that reads `storage.role_grants`. + /// + /// This is the inverse of `Role::expand()`, precomputed via + /// `grant_dto::roles_implying()`. The mapping is small and static (≤5 + /// roles per permission today); resolving it in code keeps the SQL + /// path simple and lets us add new roles without touching every + /// query site. + fn roles_implying_strings(permission: Permission) -> Vec<&'static str> { + roles_implying(permission) + .iter() + .map(|r| r.as_str()) + .collect() + } + /// Cascading check for folders: is there a grant on any ancestor folder /// (including the target itself) for any of the given subject IDs and /// any of the given subject types? @@ -247,6 +264,12 @@ impl PgAclEngine { /// `subject_ids` is the expanded set returned by `expand_user` (or a /// single-element vec for non-user callers). /// + /// **D-Prep**: now reads `storage.role_grants` (1 row per role + /// assignment) instead of `storage.access_grants` (N rows per role). + /// The permission filter `g.permission = $3` becomes + /// `g.role = ANY($3::text[])` where the array is the set of roles whose + /// bundle includes the requested permission — see `roles_implying()`. + /// /// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade. async fn folder_cascade_grant_exists( &self, @@ -257,14 +280,15 @@ impl PgAclEngine { counters: &QueryCounters, ) -> Result { counters.sql_queries.fetch_add(1, Ordering::Relaxed); + let roles = Self::roles_implying_strings(permission); let exists: Option = sqlx::query_scalar( r#" SELECT 1 - FROM storage.access_grants g + FROM storage.role_grants g JOIN storage.folders gf ON gf.id = g.resource_id WHERE g.subject_type = ANY($1) AND g.subject_id = ANY($2) - AND g.permission = $3 + AND g.role = ANY($3::text[]) AND g.resource_type = 'folder' AND (g.expires_at IS NULL OR g.expires_at > NOW()) AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4) @@ -273,7 +297,7 @@ impl PgAclEngine { ) .bind(subject_types) .bind(subject_ids) - .bind(permission.as_str()) + .bind(&roles) .bind(folder_id) .fetch_optional(self.pool.as_ref()) .await @@ -285,7 +309,7 @@ impl PgAclEngine { /// Cascading check for files: either a direct file grant OR a grant on /// any ancestor folder of the file's containing folder. See /// `folder_cascade_grant_exists` for the meaning of `subject_types` / - /// `subject_ids`. + /// `subject_ids` and the D-Prep role-array migration. async fn file_cascade_grant_exists( &self, subject_types: &[&str], @@ -295,27 +319,28 @@ impl PgAclEngine { counters: &QueryCounters, ) -> Result { counters.sql_queries.fetch_add(1, Ordering::Relaxed); + let roles = Self::roles_implying_strings(permission); let exists: Option = sqlx::query_scalar( r#" SELECT 1 FROM ( -- direct file grant SELECT 1 - FROM storage.access_grants + FROM storage.role_grants WHERE subject_type = ANY($1) AND subject_id = ANY($2) - AND permission = $3 + AND role = ANY($3::text[]) AND resource_type = 'file' AND resource_id = $4 AND (expires_at IS NULL OR expires_at > NOW()) UNION ALL -- cascading from any ancestor folder of the file's containing folder SELECT 1 - FROM storage.access_grants g + FROM storage.role_grants g JOIN storage.folders gf ON gf.id = g.resource_id JOIN storage.files target_f ON target_f.id = $4 WHERE g.subject_type = ANY($1) AND g.subject_id = ANY($2) - AND g.permission = $3 + AND g.role = ANY($3::text[]) AND g.resource_type = 'folder' AND (g.expires_at IS NULL OR g.expires_at > NOW()) AND target_f.folder_id IS NOT NULL @@ -327,7 +352,7 @@ impl PgAclEngine { ) .bind(subject_types) .bind(subject_ids) - .bind(permission.as_str()) + .bind(&roles) .bind(file_id) .fetch_optional(self.pool.as_ref()) .await @@ -1680,6 +1705,19 @@ impl AuthorizationEngine for PgAclEngine { } async fn revoke_all_for_resource(&self, resource: Resource) -> Result { + // D-Prep dual-write: wipe role_grants for this resource too. + // Idempotent — succeeds whether or not any row existed. + sqlx::query( + "DELETE FROM storage.role_grants WHERE resource_type = $1 AND resource_id = $2", + ) + .bind(resource.type_str()) + .bind(resource.id()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("PgAcl", format!("revoke role_grants for resource: {e}")) + })?; + let result = sqlx::query( "DELETE FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2", ) @@ -1693,6 +1731,16 @@ impl AuthorizationEngine for PgAclEngine { } async fn revoke_all_for_subject(&self, subject: Subject) -> Result { + // D-Prep dual-write: wipe role_grants for this subject too. + sqlx::query("DELETE FROM storage.role_grants WHERE subject_type = $1 AND subject_id = $2") + .bind(subject.type_str()) + .bind(subject.id()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("PgAcl", format!("revoke role_grants for subject: {e}")) + })?; + let result = sqlx::query( "DELETE FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2", ) @@ -1704,6 +1752,59 @@ impl AuthorizationEngine for PgAclEngine { Ok(result.rows_affected() as usize) } + + // ── D-Prep role_grants writes ────────────────────────────────────────── + + async fn set_role( + &self, + granted_by: Uuid, + subject: Subject, + role: crate::application::dtos::grant_dto::Role, + resource: Resource, + expires_at: Option>, + ) -> Result<(), DomainError> { + sqlx::query( + r#" + INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, + role, granted_by, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (subject_type, subject_id, resource_type, resource_id) + DO UPDATE SET role = EXCLUDED.role, + expires_at = EXCLUDED.expires_at, + granted_by = EXCLUDED.granted_by + "#, + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(resource.type_str()) + .bind(resource.id()) + .bind(role.as_str()) + .bind(granted_by) + .bind(expires_at) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("set_role: {e}")))?; + + Ok(()) + } + + async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError> { + sqlx::query( + "DELETE FROM storage.role_grants \ + WHERE subject_type = $1 AND subject_id = $2 \ + AND resource_type = $3 AND resource_id = $4", + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(resource.type_str()) + .bind(resource.id()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("clear_role: {e}")))?; + + Ok(()) + } } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index adc8c5c3..21617cb5 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -14,7 +14,7 @@ use axum::{ use futures::future::join_all; use serde::Deserialize; use std::sync::Arc; -use tracing::{error, info, warn}; +use tracing::{error, warn}; use utoipa::IntoParams; use uuid::Uuid; @@ -22,7 +22,7 @@ use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::grant_dto::{ CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto, OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto, - ResourceDto, ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, + ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions, }; use crate::application::ports::authorization_ports::AuthorizationEngine; @@ -66,10 +66,23 @@ pub async fn create_grant( let authz = &state.authorization; let caller_id = auth_user.id; - // Validate: exactly one of permissions/role - let permissions: Vec = match (dto.permissions, dto.role) { - (Some(perms), None) if !perms.is_empty() => perms.into_iter().map(Into::into).collect(), - (None, Some(role)) => role.expand().to_vec(), + // Validate: exactly one of permissions/role. Capture BOTH the + // permission list (for the per-permission access_grants writes that + // keep the old engine read path working) AND the role (for the new + // role_grants `set_role` dual-write that lands after the per- + // permission loop). + let (permissions, role): (Vec, Role) = match (dto.permissions, dto.role) { + (Some(perms), None) if !perms.is_empty() => { + let perms: Vec = perms.into_iter().map(Into::into).collect(); + // Derive the closest matching role from the raw permission set + // so we have ONE role to mirror into role_grants. `Role::parse` + // always succeeds here because `role_from_permissions` only + // emits known role strings. + let role = Role::parse(role_from_permissions(&perms)) + .expect("role_from_permissions returns a known role string"); + (perms, role) + } + (None, Some(role)) => (role.expand().to_vec(), role), (Some(_), Some(_)) => { return AppError::new( StatusCode::BAD_REQUEST, @@ -154,6 +167,16 @@ pub async fn create_grant( let mut results: Vec = Vec::with_capacity(permissions.len()); for perm in permissions { + // `storage.access_grants.permission` CHECK constraint predates + // `Permission::Manage`; it accepts only the original 6 values. + // Manage exists in the Owner bundle for engine read-path use + // (via `roles_implying`) and gets persisted via the role_grants + // dual-write below. Skipping it here keeps the access_grants + // safety net populated without tripping the CHECK; the cleanup + // PR that drops access_grants also drops this skip. + if perm == Permission::Manage { + continue; + } match authz .grant(caller_id, subject, perm, resource, expires_at) .await @@ -165,12 +188,31 @@ pub async fn create_grant( } } } - info!( - "Created {} grant(s) for subject={:?} on resource={:?} by user {}", - results.len(), - subject, - resource, - caller_id + + // D-Prep dual-write: mirror the role assignment into storage.role_grants. + // ON CONFLICT UPDATE makes this idempotent — repeated POSTs with the + // same (subject, resource) update the role in place, matching the + // PATCH-style semantics callers will get after the engine read pivot. + if let Err(err) = authz + .set_role(caller_id, subject, role, resource, expires_at) + .await + { + error!("set_role dual-write failed: {err}"); + return AppError::from(err).into_response(); + } + + tracing::info!( + target: "audit", + event = "role_grant.created", + caller_id = %caller_id, + subject_type = subject.type_str(), + subject_id = %subject.id(), + resource_type = resource.type_str(), + resource_id = %resource.id(), + role = role.as_str(), + permission_count = results.len(), + expires_at = ?expires_at, + "🤝 grant created with role '{}'", role.as_str(), ); // PR N1 — route the post-grant notification through the unified @@ -274,17 +316,20 @@ pub async fn revoke_grant( Err(_) => return AppError::not_found(format!("Grant {id} not found")).into_response(), }; - // Look up the grant to find the underlying resource (and granter). - let on_resource = match authz.find_grant_by_id(grant_id).await { - Ok(Some((res, granter))) => (res, granter), + // Look up the grant to find the subject, resource, and granter. + // `find_grant_full_by_id` returns the subject too — needed for the + // `clear_role` dual-write below (role_grants is keyed by (subject, + // resource), not by access_grants id). + let (subject, resource, granter) = match authz.find_grant_full_by_id(grant_id).await { + Ok(Some(triple)) => triple, Ok(None) => return StatusCode::NO_CONTENT.into_response(), // idempotent Err(e) => return AppError::from(e).into_response(), }; // Caller is authorized if they are the granter OR have Share on the resource. - if on_resource.1 != caller_id + if granter != caller_id && let Err(e) = authz - .require(Subject::User(caller_id), Permission::Share, on_resource.0) + .require(Subject::User(caller_id), Permission::Share, resource) .await { return AppError::from(e).into_response(); @@ -293,7 +338,36 @@ pub async fn revoke_grant( if let Err(e) = authz.revoke(grant_id).await { return AppError::from(e).into_response(); } - info!("Revoked grant {grant_id} (caller {caller_id})"); + + // D-Prep dual-write: clear the role_grants row for this (subject, + // resource). Idempotent — succeeds whether or not the row existed. + // + // Today's API revokes one access_grants row by id; the role_grants + // row models the WHOLE (subject, resource) cluster. Calling clear_role + // here effectively revokes the WHOLE role assignment in role_grants, + // even if other per-permission access_grants rows remain. This is the + // correct semantics for the eventual cleanup-PR model (role_grants is + // role-keyed; once access_grants goes away, "revoke" means "drop the + // role"). During the dual-write window the two tables can drift + // briefly if a caller revokes only some permissions of a role, but + // the engine still reads access_grants so behaviour is unchanged. + if let Err(e) = authz.clear_role(subject, resource).await { + return AppError::from(e).into_response(); + } + + tracing::info!( + target: "audit", + event = "role_grant.revoked", + caller_id = %caller_id, + grant_id = %grant_id, + subject_type = subject.type_str(), + subject_id = %subject.id(), + resource_type = resource.type_str(), + resource_id = %resource.id(), + granter_id = %granter, + self_revoke = (granter == caller_id), + "🗑️ grant revoked", + ); StatusCode::NO_CONTENT.into_response() } @@ -509,9 +583,22 @@ pub async fn set_role( .map(|g| g.permission) .collect(); - // Diff and apply. - let to_add: Vec = target_perms.difference(¤t_perms).copied().collect(); - let to_remove: Vec = current_perms.difference(&target_perms).copied().collect(); + // Diff and apply. `Permission::Manage` is excluded from both sides + // because the historical `access_grants.permission` CHECK doesn't + // accept it — see the matching skip in `create_grant`. The role + // assignment captures Manage via the role_grants `set_role` call + // further down; the engine's read-path uses `roles_implying(Manage)` + // → `[Owner]` and never goes through per-permission rows. + let to_add: Vec = target_perms + .difference(¤t_perms) + .copied() + .filter(|p| *p != Permission::Manage) + .collect(); + let to_remove: Vec = current_perms + .difference(&target_perms) + .copied() + .filter(|p| *p != Permission::Manage) + .collect(); for perm in &to_remove { if let Some(g) = current @@ -542,6 +629,19 @@ pub async fn set_role( return AppError::from(e).into_response(); } + // D-Prep dual-write: mirror the resulting role into storage.role_grants. + // The per-permission diff above keeps access_grants converged; this + // single UPSERT keeps role_grants in sync with the OVERALL outcome + // (one row carrying the role + expiry). After the engine read pivot + // and the access_grants drop, the per-permission diff above goes + // away and this call becomes the only mutation the handler performs. + if let Err(e) = authz + .set_role(caller_id, subject, dto.role, resource, expires_at) + .await + { + return AppError::from(e).into_response(); + } + // Return the new full set. let after = match authz.list_grants_on_resource(resource).await { Ok(g) => g, @@ -553,9 +653,22 @@ pub async fn set_role( .map(Into::into) .collect(); - info!( - "Role applied: caller={} subject={:?} resource={:?} added={:?} removed={:?}", - caller_id, subject, resource, to_add, to_remove + tracing::info!( + target: "audit", + event = "role_grant.role_set", + caller_id = %caller_id, + subject_type = subject.type_str(), + subject_id = %subject.id(), + resource_type = resource.type_str(), + resource_id = %resource.id(), + role = dto.role.as_str(), + permissions_added = to_add.len(), + permissions_removed = to_remove.len(), + expires_at = ?expires_at, + "🔁 role set to '{}' (+{} -{})", + dto.role.as_str(), + to_add.len(), + to_remove.len(), ); (StatusCode::OK, Json(mine)).into_response() } diff --git a/static/js/components/mySharesList.js b/static/js/components/mySharesList.js index 6c374406..1db6b02f 100644 --- a/static/js/components/mySharesList.js +++ b/static/js/components/mySharesList.js @@ -454,7 +454,7 @@ class MySharesList { ); menu.appendChild(this._menuSeparator()); - for (const role of /** @type {('admin'|'editor'|'viewer')[]} */ (['admin', 'editor', 'viewer'])) { + for (const role of /** @type {('owner'|'editor'|'viewer')[]} */ (['owner', 'editor', 'viewer'])) { const isCurrent = grant.role === role; const mi = this._menuItem(isCurrent ? 'fas fa-check' : '', roleLabel(role), false, async () => { menu.remove(); diff --git a/static/js/components/roleChip.js b/static/js/components/roleChip.js index de37dac9..c2eea359 100644 --- a/static/js/components/roleChip.js +++ b/static/js/components/roleChip.js @@ -23,7 +23,7 @@ import { i18n } from '../core/i18n.js'; * @returns {'manage'|'edit'|'view'} */ function roleMod(role) { - if (role === 'admin') return 'manage'; + if (role === 'owner') return 'manage'; if (role === 'editor') return 'edit'; return 'view'; } @@ -31,14 +31,17 @@ function roleMod(role) { /** * Translate a role identifier into a localized human-readable label. * Exported so callers that just want the label (e.g. context-menu rows) - * can reuse the same wording the chip uses. + * can reuse the same wording the chip uses. Unknown roles fall back to + * the raw role string — `commenter` and `contributor` exist server-side + * but aren't surfaced in the UI today, so they'll display as-is until a + * future UI exposure adds proper labels. * @param {string} role * @returns {string} */ export function roleLabel(role) { /** @type {Record} */ const m = { - admin: i18n.t('share.role.canManage', 'Can manage'), + owner: i18n.t('share.role.canManage', 'Can manage'), editor: i18n.t('share.role.canEdit', 'Can edit'), viewer: i18n.t('share.role.canView', 'Can view') }; @@ -51,7 +54,7 @@ export function roleLabel(role) { * @returns {string} */ function roleIcon(role) { - if (role === 'admin') return 'fa-crown'; + if (role === 'owner') return 'fa-crown'; if (role === 'editor') return 'fa-pencil-alt'; return 'fa-eye'; } diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js index 2b3febdd..dc5b246a 100644 --- a/static/js/components/shareModal.js +++ b/static/js/components/shareModal.js @@ -73,11 +73,21 @@ function _looksLikeEmail(q) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q); } -/** Permissions that belong to each role (must mirror the Rust DTO). */ +/** + * Permissions that belong to each role (mirrors `Role::expand()` in + * `src/application/dtos/grant_dto.rs`). The share modal only renders + * Viewer/Editor/Owner as picker buttons today; `commenter` and + * `contributor` are kept here for fidelity with the server-side enum so + * a future UI exposure doesn't need a mirror-table update. The `manage` + * permission in the owner bundle is reserved for Drive- and Group-level + * admin actions (no-op on file/folder resources). + */ const ROLE_PERMISSIONS = { viewer: ['read'], + commenter: ['read', 'comment'], + contributor: ['read', 'create'], editor: ['read', 'comment', 'create', 'update'], - admin: ['read', 'comment', 'create', 'update', 'share', 'delete'] + owner: ['read', 'comment', 'create', 'update', 'share', 'delete', 'manage'] }; /** @@ -113,7 +123,7 @@ async function _searchGroups(q) { */ function _roleFromGrants(subjectGrants) { const perms = new Set(subjectGrants.map((g) => g.permission)); - if (perms.has('delete') || perms.has('share')) return 'admin'; + if (perms.has('delete') || perms.has('share')) return 'owner'; if (perms.has('create') || perms.has('update')) return 'editor'; return 'viewer'; } @@ -363,7 +373,7 @@ const shareModal = { for (const [val, label] of [ ['viewer', i18n.t('share.role.canView', 'Can view')], ['editor', i18n.t('share.role.canEdit', 'Can edit')], - ['admin', i18n.t('share.role.canManage', 'Can manage')] + ['owner', i18n.t('share.role.canManage', 'Can manage')] ]) { const opt = document.createElement('option'); opt.value = val; @@ -649,7 +659,7 @@ const shareModal = { // matching the UX contract and the kebab-menu / role-select dropdown // order. Renaming the labels from "Manager"/"Editor"/"Viewer" to // "Can manage"/"Can edit"/"Can view" left this iteration order stale. - const groups = /** @type {ShareRoleEnum[]} */ (['admin', 'editor', 'viewer']); + const groups = /** @type {ShareRoleEnum[]} */ (['owner', 'editor', 'viewer']); let memberIndex = 0; for (const role of groups) { @@ -663,7 +673,7 @@ const shareModal = { header.className = 'smd-group-header'; const labelMap = { - admin: i18n.t('share.role.canManage', 'Can manage'), + owner: i18n.t('share.role.canManage', 'Can manage'), editor: i18n.t('share.role.canEdit', 'Can edit'), viewer: i18n.t('share.role.canView', 'Can view') }; @@ -711,7 +721,7 @@ const shareModal = { for (const [val, label] of [ ['viewer', i18n.t('share.role.canView', 'Can view')], ['editor', i18n.t('share.role.canEdit', 'Can edit')], - ['admin', i18n.t('share.role.canManage', 'Can manage')] + ['owner', i18n.t('share.role.canManage', 'Can manage')] ]) { const opt = document.createElement('option'); opt.value = val; diff --git a/static/js/core/types.js b/static/js/core/types.js index 594919c0..3a719a2b 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -367,7 +367,7 @@ * @property {'user'|'group'|'token'|'external'} subject_type * @property {string} subject_id * @property {string} subject_display - Username (users) or share name (tokens). - * @property {'viewer'|'editor'|'admin'} role + * @property {'viewer'|'commenter'|'contributor'|'editor'|'owner'} role - Server-emitted role string. `commenter` and `contributor` are reserved for future UI exposure; today the share modal only renders `viewer`/`editor`/`owner` (see `ShareRoleEnum`). * @property {string} granted_at - ISO-8601 * @property {string|null} [expires_at] - ISO-8601 or absent. * @property {boolean} has_password - True when a token subject has a password set. @@ -453,8 +453,11 @@ // ------------------- share modal /** - * Share roles (DTO-layer sugar for the ReBAC permission sets). - * @typedef {'viewer'|'editor'|'admin'} ShareRoleEnum + * Share-modal-exposed roles. The server's `Role` enum also includes + * `commenter` and `contributor` (see `OutgoingResourceGrant.role`); those + * are reserved for future UI exposure and are not offered as picker options + * today. The "Can manage" UI label maps to `owner`. + * @typedef {'viewer'|'editor'|'owner'} ShareRoleEnum */ /** diff --git a/tests/api/role_grants.hurl b/tests/api/role_grants.hurl new file mode 100644 index 00000000..4a0f4ede --- /dev/null +++ b/tests/api/role_grants.hurl @@ -0,0 +1,388 @@ +# ============================================================= +# OxiCloud — D-Prep: role_grants dual-write + new wire format +# ============================================================= +# Pins the D-Prep refactor behaviours that don't fit naturally into +# the existing `grants.hurl` (which is API-shape-focused). Specifically: +# +# 1. New wire-format role names: +# - "owner" is accepted on POST and emitted on response +# - "admin" still accepted on POST for one release (compat shim +# in `Role::parse`); the server normalises it to Owner +# +# 2. Dual-write proof: granting a role and then exercising a +# permission from its bundle works → proves the row landed in +# `storage.role_grants` because the engine now reads from there +# for authz decisions (see `folder_cascade_grant_exists` post- +# D-Prep). If dual-write failed, the engine would see no row and +# reject the check. +# +# 3. Atomic role updates via PUT /api/grants/role — the role flips +# in a single SQL update (no DELETE+INSERT race window). +# +# 4. Clean revoke: DELETE /api/grants/{id} clears role_grants too. +# +# Self-contained: creates its own users, folders, and files so it +# can run in any position relative to other test files. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — admin login + home folder lookup +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" +admin_user_id: jsonpath "$.user.id" + +GET {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Captures] +admin_home_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Create two fresh test users (renee, sam) so this file +# doesn't depend on cross-file fixtures. Use the +# legacy-compat path that POSTs to /api/admin/users. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "username": "renee", "password": "ReneePassword1!", "email": "renee@example.com", "role": "user" } + +HTTP 201 +[Captures] +renee_user_id: jsonpath "$.id" + + +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "username": "sam", "password": "SamPassword1!", "email": "sam@example.com", "role": "user" } + +HTTP 201 +[Captures] +sam_user_id: jsonpath "$.id" + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "renee", "password": "ReneePassword1!" } + +HTTP 200 +[Captures] +renee_token: jsonpath "$.access_token" + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "sam", "password": "SamPassword1!" } + +HTTP 200 +[Captures] +sam_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — admin creates a folder "role-grants-test" + a file +# inside it to use as the authz target throughout the file. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "role-grants-test", "parent_id": "{{admin_home_id}}" } + +HTTP 201 +[Captures] +test_folder_id: jsonpath "$.id" + + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{test_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +test_file_id: jsonpath "$.id" + +# Rename immediately so subsequent throwaway uploads of `hello.txt` +# to the same folder don't 409. Each throwaway upload below applies +# the same pattern (upload → rename → use) to keep the namespace +# clean for the next one. +PUT {{base_url}}/api/files/{{test_file_id}}/rename +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "step3-anchor.txt" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Grant renee role="owner" on the folder. New wire format. +# Response should echo back the canonical name. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{renee_user_id}}" }, + "resource": { "type": "folder", "id": "{{test_folder_id}}" }, + "role": "owner" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Dual-write proof: renee (now Owner of the folder) can +# DELETE a file inside it. Owner's bundle includes Delete; +# the engine reads from role_grants → if dual-write didn't +# land the row, the cascade query returns empty and the +# delete is refused. +# +# We upload + delete a throwaway file to avoid removing the +# test_file we'll need for later steps. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{test_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +throwaway_file_id: jsonpath "$.id" + +# Rename so subsequent uploads in this folder don't 409 on "hello.txt". +PUT {{base_url}}/api/files/{{throwaway_file_id}}/rename +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "renee-throwaway.txt" } + +HTTP 200 + + +DELETE {{base_url}}/api/files/{{throwaway_file_id}} +Authorization: Bearer {{renee_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Legacy "admin" string compat. The server's Role::parse +# accepts "admin" and normalises to Owner during the D-Prep +# dual-write window (one release). Verify by granting sam +# with role="admin" — sam should then be able to Delete too. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{sam_user_id}}" }, + "resource": { "type": "folder", "id": "{{test_folder_id}}" }, + "role": "admin" +} + +HTTP 201 +[Captures] +# Capture the first per-permission grant id from the response so +# Step 10's revoke doesn't have to query the My Shares endpoint +# with awkward JSONPath filtering. Any single grant_id works: +# the revoke handler calls `clear_role` which wipes the entire +# role_grants row for (sam, test_folder), so the engine reads +# return false for sam afterwards regardless of how many +# access_grants rows still exist. +sam_grant_id: jsonpath "$.grants[0].id" + + +# Sam should now have Owner-equivalent access — Delete works. +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{test_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +sam_throwaway_id: jsonpath "$.id" + +PUT {{base_url}}/api/files/{{sam_throwaway_id}}/rename +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "sam-throwaway.txt" } + +HTTP 200 + + +DELETE {{base_url}}/api/files/{{sam_throwaway_id}} +Authorization: Bearer {{sam_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Atomic role update: demote renee from Owner to Viewer +# via PUT /api/grants/role. Single SQL UPDATE on +# role_grants — no DELETE+INSERT race. +# +# After: renee can still Read but should be refused Delete. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/grants/role +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{renee_user_id}}" }, + "resource": { "type": "folder", "id": "{{test_folder_id}}" }, + "role": "viewer" +} + +HTTP 200 + + +# Renee can still read the file (Viewer's bundle includes Read). +GET {{base_url}}/api/files/{{test_file_id}} +Authorization: Bearer {{renee_token}} + +HTTP 200 + + +# Renee CANNOT delete — Viewer's bundle excludes Delete; the +# folder_cascade_grant_exists query for Delete returns empty. +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{test_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +post_demote_file_id: jsonpath "$.id" + +PUT {{base_url}}/api/files/{{post_demote_file_id}}/rename +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "post-demote.txt" } + +HTTP 200 + + +DELETE {{base_url}}/api/files/{{post_demote_file_id}} +Authorization: Bearer {{renee_token}} + +HTTP * +[Asserts] +status >= 400 +status < 500 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Promote renee back to Editor (one role change, atomic) +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/grants/role +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{renee_user_id}}" }, + "resource": { "type": "folder", "id": "{{test_folder_id}}" }, + "role": "editor" +} + +HTTP 200 + + +# Editor's bundle includes Update — renaming a file should work. +PUT {{base_url}}/api/files/{{post_demote_file_id}}/rename +Authorization: Bearer {{renee_token}} +Content-Type: application/json +{ "name": "renamed-by-renee.txt" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — My Shares response shape: the outgoing-resources endpoint +# must emit role strings from the new roster ("viewer" / +# "editor" / "owner"), never the legacy "admin". +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants/outgoing/resources +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +# The response body MUST contain "owner" (sam's role) and "editor" +# (renee's current role after the promote). It MUST NOT contain the +# legacy "admin" role string for any grant emitted by the server. +body contains "\"role\":\"owner\"" +body contains "\"role\":\"editor\"" +body not contains "\"role\":\"admin\"" + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Revoke: removing sam's grant. The handler clears the +# access_grants rows AND calls clear_role to wipe the +# role_grants row in the same flow. +# +# After: sam's Delete attempt should be refused (proof +# the role_grants row is gone — the cascade query for +# Delete returns empty because sam has no row pointing +# at this folder). +# ───────────────────────────────────────────────────────────── + +# sam_grant_id was captured at Step 6 from the create response. +DELETE {{base_url}}/api/grants/{{sam_grant_id}} +Authorization: Bearer {{admin_token}} + +HTTP 204 + + +# Post-revoke: sam can no longer Delete in this folder. +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{test_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +post_revoke_file_id: jsonpath "$.id" + +PUT {{base_url}}/api/files/{{post_revoke_file_id}}/rename +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "post-revoke.txt" } + +HTTP 200 + + +DELETE {{base_url}}/api/files/{{post_revoke_file_id}} +Authorization: Bearer {{sam_token}} + +HTTP * +[Asserts] +status >= 400 +status < 500 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Teardown: clean up users + folder so this file +# leaves no residue for the storage_cleanup_check. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{test_folder_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/admin/users/{{renee_user_id}} +Authorization: Bearer {{admin_token}} +HTTP 200 + +DELETE {{base_url}}/api/admin/users/{{sam_user_id}} +Authorization: Bearer {{admin_token}} +HTTP 200 diff --git a/tests/api/run.sh b/tests/api/run.sh index 1524b63e..d07cfbb9 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -146,6 +146,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/public_shares.hurl" \ "$API_DIR/permissions.hurl" \ "$API_DIR/grants.hurl" \ + "$API_DIR/role_grants.hurl" \ "$API_DIR/subject_groups.hurl" \ "$API_DIR/groups_effective_members.hurl" \ "$API_DIR/grants_nested_groups.hurl" \ From ae219b81fcb1217c7e952726a057e56f7c25e2a3 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 17 Jun 2026 23:26:17 +0200 Subject: [PATCH 06/39] chore(roles): add validation script for migration script on Ed's sandbox, adapt values accordingly your environment --- tools/audit-grants-bundle-shape.sql | 215 ++++++++++++++++++ tools/d-prep-real-db-smoke.sh | 328 ++++++++++++++++++++++++++++ 2 files changed, 543 insertions(+) create mode 100644 tools/audit-grants-bundle-shape.sql create mode 100755 tools/d-prep-real-db-smoke.sh diff --git a/tools/audit-grants-bundle-shape.sql b/tools/audit-grants-bundle-shape.sql new file mode 100644 index 00000000..fb3d24de --- /dev/null +++ b/tools/audit-grants-bundle-shape.sql @@ -0,0 +1,215 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- audit-grants-bundle-shape.sql — Pre-D-Prep diagnostic +-- ════════════════════════════════════════════════════════════════════════════ +-- Purpose: confirm the assumption that >99% of existing `storage.access_grants` +-- rows already cluster into the standard role bundles +-- (viewer / commenter / contributor / editor / owner). The answer determines +-- whether the access_grants → role_grants migration is fully mechanical (just +-- run the backfill) or whether per-row decisions are needed for some edge +-- cases. +-- +-- READ-ONLY. Safe to run against any environment including production. +-- +-- Usage: +-- psql "$DATABASE_URL" -f tools/audit-grants-bundle-shape.sql +-- +-- Reference role bundles (mirror `Role::expand()` in +-- `src/application/dtos/grant_dto.rs`): +-- viewer = {read} +-- commenter = {read, comment} ← reserved variant +-- contributor = {read, create} ← new in D-Prep +-- editor = {read, comment, create, update} +-- owner = {read, comment, create, update, share, delete} +-- (post-D-Prep: + manage, when Group-as-Resource lands) +-- ════════════════════════════════════════════════════════════════════════════ + + +\echo '' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo ' 0. Total population' +\echo '─────────────────────────────────────────────────────────────────────────' + +SELECT count(*) AS total_grant_rows, + count(DISTINCT (subject_type, subject_id, resource_type, resource_id)) + AS distinct_clusters, + ROUND( + count(*)::numeric + / NULLIF(count(DISTINCT (subject_type, subject_id, resource_type, resource_id)), 0), + 2 + ) AS avg_rows_per_cluster +FROM storage.access_grants; + + +\echo '' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo ' 1. Bundle distribution — which permission sets exist, how popular?' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo ' Each row = a unique permission set; cluster_count = how many (subject,' +\echo ' resource) pairs have exactly that set.' + +WITH cluster AS ( + SELECT subject_type, + subject_id, + resource_type, + resource_id, + array_agg(permission ORDER BY permission) AS perms + FROM storage.access_grants + GROUP BY 1, 2, 3, 4 +), +known_bundles AS ( + SELECT ARRAY['read']::text[] AS perms, 'viewer' AS role + UNION ALL SELECT ARRAY['comment','read']::text[], 'commenter' + UNION ALL SELECT ARRAY['create','read']::text[], 'contributor' + UNION ALL SELECT ARRAY['comment','create','read','update']::text[], 'editor' + UNION ALL SELECT ARRAY['comment','create','delete','read','share','update']::text[], 'owner' +) +SELECT cluster.perms, + count(*) AS cluster_count, + ROUND(100.0 * count(*) / SUM(count(*)) OVER (), 2) AS pct, + COALESCE(known_bundles.role, '(non-bundle)') AS maps_to_role +FROM cluster +LEFT JOIN known_bundles ON known_bundles.perms = cluster.perms +GROUP BY cluster.perms, known_bundles.role +ORDER BY cluster_count DESC; + + +\echo '' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo ' 2. Bundle-shaped vs not — the headline number' +\echo '─────────────────────────────────────────────────────────────────────────' + +WITH cluster AS ( + SELECT subject_type, + subject_id, + resource_type, + resource_id, + array_agg(permission ORDER BY permission) AS perms + FROM storage.access_grants + GROUP BY 1, 2, 3, 4 +), +known_bundles AS ( + SELECT ARRAY['read']::text[] AS perms + UNION ALL SELECT ARRAY['comment','read']::text[] + UNION ALL SELECT ARRAY['create','read']::text[] + UNION ALL SELECT ARRAY['comment','create','read','update']::text[] + UNION ALL SELECT ARRAY['comment','create','delete','read','share','update']::text[] +), +shape AS ( + SELECT CASE WHEN cluster.perms = ANY(SELECT kb.perms FROM known_bundles kb) + THEN 'bundle-shaped' + ELSE 'NON-bundle (needs per-row decision)' + END AS shape + FROM cluster +) +SELECT shape, + count(*) AS clusters, + ROUND(100.0 * count(*) / SUM(count(*)) OVER (), 2) AS pct +FROM shape +GROUP BY shape +ORDER BY clusters DESC; + + +\echo '' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo ' 3. Non-bundle clusters in detail (the <1% — investigate each)' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo ' If this returns 0 rows, the migration is FULLY mechanical. Otherwise' +\echo ' eyeball each row and decide: closest role + audit log entry, or' +\echo ' refuse-to-migrate (rare).' + +WITH cluster AS ( + SELECT subject_type, + subject_id, + resource_type, + resource_id, + array_agg(permission ORDER BY permission) AS perms + FROM storage.access_grants + GROUP BY 1, 2, 3, 4 +), +known_bundles AS ( + SELECT ARRAY['read']::text[] AS perms + UNION ALL SELECT ARRAY['comment','read']::text[] + UNION ALL SELECT ARRAY['create','read']::text[] + UNION ALL SELECT ARRAY['comment','create','read','update']::text[] + UNION ALL SELECT ARRAY['comment','create','delete','read','share','update']::text[] +) +SELECT cluster.subject_type, + cluster.subject_id, + cluster.resource_type, + cluster.resource_id, + cluster.perms +FROM cluster +WHERE NOT (cluster.perms = ANY(SELECT kb.perms FROM known_bundles kb)) +ORDER BY cluster.resource_type, cluster.resource_id +LIMIT 200; + + +\echo '' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo ' 4. Sanity checks — invariants that should be true today' +\echo '─────────────────────────────────────────────────────────────────────────' + +\echo '' +\echo ' 4a. Clusters with no Read permission (broken? if >0, investigate)' +WITH cluster AS ( + SELECT subject_type, subject_id, resource_type, resource_id, + array_agg(permission ORDER BY permission) AS perms + FROM storage.access_grants + GROUP BY 1, 2, 3, 4 +) +SELECT count(*) AS clusters_without_read +FROM cluster +WHERE NOT ('read' = ANY(perms)); + +\echo '' +\echo ' 4b. Subject-type distribution (sanity check on token / external counts)' +SELECT subject_type, + count(*) AS rows, + count(DISTINCT subject_id) AS distinct_subjects, + count(DISTINCT (resource_type, resource_id)) AS distinct_resources +FROM storage.access_grants +GROUP BY subject_type +ORDER BY subject_type; + +\echo '' +\echo ' 4c. Resource-type distribution' +SELECT resource_type, count(*) AS rows +FROM storage.access_grants +GROUP BY resource_type +ORDER BY rows DESC; + +\echo '' +\echo ' 4d. Permission distribution (total rows per permission value)' +SELECT permission, count(*) AS rows +FROM storage.access_grants +GROUP BY permission +ORDER BY rows DESC; + + +\echo '' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo ' 5. Migration cost estimate' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo ' role_grants will have ~ (distinct clusters) rows, replacing ~ (total)' +\echo ' rows in access_grants. The ratio is the per-row reduction factor.' + +WITH cluster AS ( + SELECT subject_type, subject_id, resource_type, resource_id + FROM storage.access_grants + GROUP BY 1, 2, 3, 4 +) +SELECT (SELECT count(*) FROM storage.access_grants) AS access_grants_rows_today, + (SELECT count(*) FROM cluster) AS role_grants_rows_after, + ROUND( + (SELECT count(*) FROM storage.access_grants)::numeric + / NULLIF((SELECT count(*) FROM cluster), 0), + 2 + ) AS reduction_factor; + + +\echo '' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo ' DONE — share section 2 (headline) and section 3 (non-bundle detail)' +\echo ' to make the D-Prep PR scope decision.' +\echo '─────────────────────────────────────────────────────────────────────────' +\echo '' diff --git a/tools/d-prep-real-db-smoke.sh b/tools/d-prep-real-db-smoke.sh new file mode 100755 index 00000000..6ded56ac --- /dev/null +++ b/tools/d-prep-real-db-smoke.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env bash +# ════════════════════════════════════════════════════════════════════════════ +# d-prep-real-db-smoke.sh — Validate D-Prep migration against sandbox data +# ════════════════════════════════════════════════════════════════════════════ +# Reads from your sandbox DB (real data — but it's a sandbox, not prod), dumps +# it, restores into an isolated parallel DB, applies the D-Prep migration +# (`20260730000000_role_grants.sql`), and verifies the backfill against the +# pre-recorded audit numbers from `tools/audit-grants-bundle-shape.sql`. +# +# The sandbox DB itself is NEVER MODIFIED — everything happens in the parallel +# `oxicloud_dprep_smoke` DB which is dropped at the start of each run. +# +# Usage: +# tools/d-prep-real-db-smoke.sh # uses $DATABASE_URL +# tools/d-prep-real-db-smoke.sh --keep # leave smoke DB for poking after +# tools/d-prep-real-db-smoke.sh --help +# +# Requirements: +# - pg_dump / pg_restore / psql (PostgreSQL 14+ should be fine; sandbox version-matched) +# - DATABASE_URL pointing at the sandbox DB +# +# PATH gotcha (Mac brew): `brew install postgresql@18` is keg-only; if pg_dump +# isn't on PATH, run: +# export PATH="$(brew --prefix postgresql@18)/bin:$PATH" +# +# Expected audit numbers (from your audit run on 2026-06-17): +# - 73 access_grants rows clustering into 38 (subject, resource) pairs +# - 29 viewer / 5 editor / 4 owner +# These are encoded as assertions below. If your sandbox data has shifted +# since the audit, update EXPECTED_* variables OR re-run the audit script +# first. +# +# Memory cross-refs: +# - bug_pg_dump_folders_circular_fk.md — circular FK on `folders` forces +# `pg_restore --disable-triggers` +# - project_drive_sequence_a.md — D-Prep scope + audit data +# ════════════════════════════════════════════════════════════════════════════ + +set -euo pipefail + + +# ── Config ────────────────────────────────────────────────────────────────── + +SMOKE_DB_NAME="oxicloud_dprep_smoke" +DUMP_FILE="${TMPDIR:-/tmp}/oxicloud-sandbox-${SMOKE_DB_NAME}.dump" +MIGRATION_FILE="migrations/20260730000000_role_grants.sql" + +# Expected values from the audit (tools/audit-grants-bundle-shape.sql run +# on 2026-06-17). Bump these if you re-run the audit and see different +# numbers — they are intentionally HARDCODED so a silent drift gets caught. +EXPECTED_DISTINCT_CLUSTERS=38 +EXPECTED_VIEWER=29 +EXPECTED_EDITOR=5 +EXPECTED_OWNER=4 + +KEEP_DB=0 + + +# ── Arg parsing ───────────────────────────────────────────────────────────── + +usage() { + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +for arg in "$@"; do + case "$arg" in + --keep|--keep-db) KEEP_DB=1 ;; + -h|--help) usage 0 ;; + *) echo "Unknown arg: $arg" >&2 ; usage 2 ;; + esac +done + + +# ── Pre-flight ────────────────────────────────────────────────────────────── + +if [[ -z "${DATABASE_URL:-}" ]]; then + echo "ERROR: \$DATABASE_URL must be set and point at the sandbox DB." >&2 + exit 2 +fi + +for bin in pg_dump pg_restore psql; do + if ! command -v "$bin" >/dev/null 2>&1; then + echo "ERROR: '$bin' not found on PATH." >&2 + echo "Hint: brew-installed postgresql is keg-only. Run:" >&2 + echo " export PATH=\"\$(brew --prefix postgresql@18)/bin:\$PATH\"" >&2 + exit 2 + fi +done + +if [[ ! -f "$MIGRATION_FILE" ]]; then + echo "ERROR: migration file not found: $MIGRATION_FILE" >&2 + echo "Run this script from the repo root." >&2 + exit 2 +fi + +# Derive connection URLs for the maintenance DB (where we'll issue +# DROP/CREATE DATABASE) and the smoke DB itself, by splicing +# $SMOKE_DB_NAME into $DATABASE_URL. Avoids relying on libpq's +# default local Unix socket — `dropdb`/`createdb`/`psql` without an +# explicit -d won't read $DATABASE_URL and try /tmp/.s.PGSQL.5432 +# (which fails on Mac brew where the server runs on a non-default +# socket / port). +URL_NO_QUERY="${DATABASE_URL%%\?*}" +URL_QUERY="${DATABASE_URL:${#URL_NO_QUERY}}" # "" or "?…" +SOURCE_DB_NAME="${URL_NO_QUERY##*/}" +URL_BASE="${URL_NO_QUERY%/"$SOURCE_DB_NAME"}" +MAINTENANCE_URL="${URL_BASE}/postgres${URL_QUERY}" +SMOKE_URL="${URL_BASE}/${SMOKE_DB_NAME}${URL_QUERY}" + +if [[ "$SOURCE_DB_NAME" == "$SMOKE_DB_NAME" ]]; then + echo "ERROR: DATABASE_URL appears to point at '$SMOKE_DB_NAME' — refusing" >&2 + echo "to dump-and-restore on top of itself. Set DATABASE_URL to the sandbox." >&2 + exit 2 +fi + + +# ── Helpers ───────────────────────────────────────────────────────────────── + +log() { echo "[smoke] $*"; } +fail() { echo "[smoke] FAIL: $*" >&2 ; exit 1; } +pass() { echo "[smoke] PASS: $*"; } + +# Run a query against the smoke DB and capture a single scalar. +scalar() { + psql -tAX -d "$SMOKE_URL" -c "$1" +} + +# Run a query and check the result equals an expected scalar. +expect_scalar() { + local query="$1" expected="$2" label="$3" + local actual + actual=$(scalar "$query") + if [[ "$actual" == "$expected" ]]; then + pass "$label: $actual" + else + fail "$label: expected $expected, got '$actual'" + fi +} + + +# ── 1. Snapshot the sandbox ───────────────────────────────────────────────── + +log "Dumping sandbox DB → $DUMP_FILE" +log "(custom format, so pg_restore --disable-triggers can side-step the" +log " circular FK on storage.folders.parent_id — see bug memory.)" +pg_dump "$DATABASE_URL" --format=custom --no-owner --no-privileges \ + --file="$DUMP_FILE" +log "Dumped $(du -h "$DUMP_FILE" | awk '{print $1}')" + + +# ── 2. Recreate the smoke DB ──────────────────────────────────────────────── + +log "Dropping smoke DB '$SMOKE_DB_NAME' if it exists" +psql -d "$MAINTENANCE_URL" -c "DROP DATABASE IF EXISTS \"$SMOKE_DB_NAME\"" + +log "Creating fresh smoke DB '$SMOKE_DB_NAME'" +psql -d "$MAINTENANCE_URL" -c "CREATE DATABASE \"$SMOKE_DB_NAME\"" + + +# ── 3. Restore sandbox into smoke DB ──────────────────────────────────────── + +log "Restoring sandbox dump into smoke DB" +# --disable-triggers handles the storage.folders parent_id circular FK without +# wrapping every COPY in SET CONSTRAINTS ALL DEFERRED. +# --no-owner --no-privileges already on dump side; --single-transaction makes +# the restore atomic so a mid-flight failure leaves no half-state. +pg_restore --dbname="$SMOKE_URL" --disable-triggers \ + --single-transaction --no-owner --no-privileges \ + "$DUMP_FILE" + + +# ── 4. Wipe any prior role_grants state from the smoke DB ────────────────── +# The sandbox may already have role_grants if the server has been booted +# with the D-Prep migration applied (auto-migrate on startup). For the +# smoke we want to exercise the migration FRESHLY — same as a brand-new +# install — so drop both the migration's tables and let it recreate them +# from scratch against the access_grants snapshot. + +log "Wiping any prior role_grants state from smoke DB (so we test the migration freshly)" +psql -d "$SMOKE_URL" --set ON_ERROR_STOP=1 -c " +DROP TABLE IF EXISTS storage.role_grants_migration_log CASCADE; +DROP TABLE IF EXISTS storage.role_grants CASCADE; +" + +# ── 5. Pre-migration sanity ──────────────────────────────────────────────── + +log "Pre-migration sanity checks" +expect_scalar "SELECT count(*) FROM storage.access_grants" \ + "73" "access_grants row count matches audit" + +if [[ $(scalar "SELECT to_regclass('storage.role_grants') IS NULL") != "t" ]]; then + fail "storage.role_grants survived the drop — something is wrong." +fi +pass "storage.role_grants does not exist (clean slate)" + + +# ── 5. Apply the D-Prep migration ─────────────────────────────────────────── + +log "Applying D-Prep migration: $MIGRATION_FILE" +# Wrap in a transaction so a constraint failure rolls back cleanly; the +# migration itself has BEGIN/COMMIT semantics via -1 to psql. +psql -d "$SMOKE_URL" --set ON_ERROR_STOP=1 -1 -f "$MIGRATION_FILE" +log "Migration applied" + + +# ── 6. Post-migration assertions ──────────────────────────────────────────── + +log "Post-migration verification" + +# Row count: should match `distinct_clusters` from the audit +expect_scalar "SELECT count(*) FROM storage.role_grants" \ + "$EXPECTED_DISTINCT_CLUSTERS" "role_grants row count" + +# Role distribution +expect_scalar "SELECT count(*) FROM storage.role_grants WHERE role = 'viewer'" \ + "$EXPECTED_VIEWER" "viewer count" +expect_scalar "SELECT count(*) FROM storage.role_grants WHERE role = 'editor'" \ + "$EXPECTED_EDITOR" "editor count" +expect_scalar "SELECT count(*) FROM storage.role_grants WHERE role = 'owner'" \ + "$EXPECTED_OWNER" "owner count" + +# Zero NULL roles, zero non-bundle roles (the CHECK constraint should +# already enforce this, but proving it here too) +expect_scalar "SELECT count(*) FROM storage.role_grants WHERE role IS NULL" \ + "0" "no NULL roles" +expect_scalar "SELECT count(*) FROM storage.role_grants WHERE role NOT IN ('viewer','commenter','contributor','editor','owner')" \ + "0" "no unknown roles" + +# access_grants is untouched +expect_scalar "SELECT count(*) FROM storage.access_grants" \ + "73" "access_grants row count unchanged (dual-write safety net intact)" + + +# ── 7. Equivalence check (strongest assertion) ────────────────────────────── +# For every role_grants row, the corresponding (subject, resource) cluster +# in access_grants must have exactly the role's bundle as its permission +# set. If any row's bundle doesn't match what `Role::expand()` says, the +# backfill mis-mapped and the equivalence count goes non-zero. + +log "Bundle equivalence check: role_grants ↔ access_grants" + +EQUIVALENCE_MISMATCHES=$(scalar " +WITH expected AS ( + SELECT 'viewer'::text AS role, ARRAY['read']::text[] AS perms + UNION ALL SELECT 'commenter', ARRAY['comment','read']::text[] + UNION ALL SELECT 'contributor', ARRAY['create','read']::text[] + UNION ALL SELECT 'editor', ARRAY['comment','create','read','update']::text[] + UNION ALL SELECT 'owner', ARRAY['comment','create','delete','read','share','update']::text[] +), +actual AS ( + SELECT subject_type, subject_id, resource_type, resource_id, + array_agg(permission ORDER BY permission) AS perms + FROM storage.access_grants + GROUP BY 1, 2, 3, 4 +) +SELECT count(*) +FROM storage.role_grants rg +JOIN actual a USING (subject_type, subject_id, resource_type, resource_id) +JOIN expected e ON e.role = rg.role +WHERE a.perms <> e.perms +") + +if [[ "$EQUIVALENCE_MISMATCHES" == "0" ]]; then + pass "every role_grants row's bundle matches access_grants exactly" +else + log "" + log "MISMATCH DETAIL (first 10):" + psql -d "$SMOKE_URL" -c " +WITH expected AS ( + SELECT 'viewer'::text AS role, ARRAY['read']::text[] AS perms + UNION ALL SELECT 'commenter', ARRAY['comment','read']::text[] + UNION ALL SELECT 'contributor', ARRAY['create','read']::text[] + UNION ALL SELECT 'editor', ARRAY['comment','create','read','update']::text[] + UNION ALL SELECT 'owner', ARRAY['comment','create','delete','read','share','update']::text[] +), +actual AS ( + SELECT subject_type, subject_id, resource_type, resource_id, + array_agg(permission ORDER BY permission) AS perms + FROM storage.access_grants + GROUP BY 1, 2, 3, 4 +) +SELECT rg.subject_type, rg.subject_id, rg.resource_type, rg.resource_id, + rg.role, + a.perms AS access_grants_perms, + e.perms AS expected_perms +FROM storage.role_grants rg +JOIN actual a USING (subject_type, subject_id, resource_type, resource_id) +JOIN expected e ON e.role = rg.role +WHERE a.perms <> e.perms +LIMIT 10 +" + fail "$EQUIVALENCE_MISMATCHES role_grants rows have a bundle that diverges from their access_grants cluster" +fi + + +# ── 8. Migration audit log shape ──────────────────────────────────────────── +# `role_grants_migration_log` is the one-shot table created by the migration +# to record any demotions. With 100%-bundle-shaped data (the audit says so), +# it should be EMPTY — nothing was demoted. + +if [[ $(scalar "SELECT to_regclass('storage.role_grants_migration_log') IS NOT NULL") == "t" ]]; then + expect_scalar "SELECT count(*) FROM storage.role_grants_migration_log" \ + "0" "migration audit log empty (no demotions, as expected for 100% bundle-shaped data)" +fi + + +# ── 9. Cleanup ────────────────────────────────────────────────────────────── + +log "" +log "─────────────────────────────────────────────────────────" +log " ALL ASSERTIONS PASSED" +log "─────────────────────────────────────────────────────────" +log "" + +if [[ "$KEEP_DB" == "1" ]]; then + log "Leaving smoke DB '$SMOKE_DB_NAME' for inspection." + log "Poke at it with:" + log " psql \"$SMOKE_URL\"" + log "" + log "Drop when done:" + log " psql -d \"$MAINTENANCE_URL\" -c 'DROP DATABASE \"$SMOKE_DB_NAME\"'" +else + log "Dropping smoke DB '$SMOKE_DB_NAME'" + psql -d "$MAINTENANCE_URL" -c "DROP DATABASE \"$SMOKE_DB_NAME\"" +fi + +log "Dump file kept at $DUMP_FILE (delete with: rm '$DUMP_FILE')" From cc6f53528b97bc5a4f79ea7ae61bb4622588fcef Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 17 Jun 2026 23:35:58 +0200 Subject: [PATCH 07/39] feat(role): reflect ReBAC doc with changes --- docs/architecture/rebac-authorization.md | 12 ++++++++- docs/plan/drive.md | 8 ++++++ ...plan-ReBAC-Permissions-Grants-Cascading.md | 27 ++++++++++++------- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/architecture/rebac-authorization.md b/docs/architecture/rebac-authorization.md index ca5c927e..2a1d70d9 100644 --- a/docs/architecture/rebac-authorization.md +++ b/docs/architecture/rebac-authorization.md @@ -107,9 +107,19 @@ storage.access_grants expires_at TIMESTAMPTZ NULL ``` -One row per `(subject, permission, resource)` triple. An "admin role on folder +One row per `(subject, permission, resource)` triple. An "owner role on folder X for user Y" is 6 rows; a "viewer role" is 1 row. +> **Note (D-Prep, 2026-06-17):** the role assignment has since pivoted into +> a separate `storage.role_grants` table that stores **one row per role +> assignment** rather than one per permission. `access_grants` stays +> populated via dual-write during the transition; the engine reads the +> role-keyed table for authz decisions. The cleanup PR drops +> `access_grants` after the dual-write window. The historical role name +> `Admin` was renamed to `Owner` at the same time, to disambiguate from +> `UserRole::Admin` (user-account privilege) and match Drive plan +> terminology. + Cleanup is trigger-driven (`trg_cleanup_grants_folder`, …): when a resource or subject is deleted, all referencing grants disappear in the same transaction. diff --git a/docs/plan/drive.md b/docs/plan/drive.md index d94ec018..e567c00f 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -44,6 +44,14 @@ recognise it instantly. ## Prerequisite — PR D-Prep: `access_grants → role_grants` +**Status (2026-06-17): scope complete, ready to PR.** The schema migration +runs cleanly against real sandbox data (38 role_grants rows produced, +matching the audit's 29 viewer / 5 editor / 4 owner distribution, zero +bundle mismatches). End-to-end Hurl test (`tests/api/role_grants.hurl`) +covers create / atomic role update / revoke with both the canonical +`"owner"` and the legacy `"admin"` compat wire format. Engine reads +pivot to `role_grants` for authz decisions; `access_grants` stays +populated via dual-write as the safety net for one release cycle. **Ratified 2026-06-16.** A separate PR lands BEFORE D0 that refactors `storage.access_grants` into `storage.role_grants` with role-bundle semantics: diff --git a/docs/plan/plan-ReBAC-Permissions-Grants-Cascading.md b/docs/plan/plan-ReBAC-Permissions-Grants-Cascading.md index 5a0a6506..9a55d1cb 100644 --- a/docs/plan/plan-ReBAC-Permissions-Grants-Cascading.md +++ b/docs/plan/plan-ReBAC-Permissions-Grants-Cascading.md @@ -602,20 +602,29 @@ impl Role { pub fn expand(self) -> &'static [Permission] { match self { Role::Viewer => &[Permission::Read], - Role::Commenter => &[Permission::Read, Permission::Comment], - Role::Editor => &[Permission::Read, Permission::Comment, - Permission::Create, Permission::Update], - Role::Manager => &[Permission::Read, Permission::Comment, - Permission::Create, Permission::Update, - Permission::Share], - Role::Admin => &[Permission::Read, Permission::Comment, - Permission::Create, Permission::Update, - Permission::Share, Permission::Delete], + Role::Commenter => &[Permission::Read, Permission::Comment], + Role::Contributor => &[Permission::Read, Permission::Create], + Role::Editor => &[Permission::Read, Permission::Comment, + Permission::Create, Permission::Update], + Role::Owner => &[Permission::Read, Permission::Comment, + Permission::Create, Permission::Update, + Permission::Share, Permission::Delete, + Permission::Manage], } } } ``` +> **Note (D-Prep, 2026-06-17):** the `Manager` role was retired before shipping +> (its bundle was a strict subset of `Owner`); the historical `Admin` role was +> renamed to `Owner` to disambiguate from `UserRole::Admin` (the user-account +> privilege) and match Drive plan terminology. `Contributor` is the new +> drop-zone role. The actual on-the-wire enum lives in +> `src/application/dtos/grant_dto.rs`; that file is the canonical source of +> truth for bundle expansion. The pivot to role-keyed storage (`role_grants` +> table) also happened in D-Prep — see +> `docs/architecture/rebac-authorization.md` for the dual-write timeline. + ### `POST /api/grants` accepts either shape ```json From 72129af0bd9a0fe527bd1943d5130c108f3c4a32 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 18 Jun 2026 01:42:32 +0200 Subject: [PATCH 08/39] refactor(role): use grant only - remove permission centric mode - finalize migration drop all tables with permissions - ensure roles are ENUM (owner is always displayed first) --- .../20260801000000_role_grants_enum.sql | 72 +++ ...801000001_role_grants_cascade_triggers.sql | 122 +++++ .../20260801000002_drop_access_grants.sql | 63 +++ src/application/dtos/grant_dto.rs | 202 ++------ src/application/ports/authorization_ports.rs | 64 +-- .../services/auth_application_service.rs | 4 +- src/application/services/share_service.rs | 12 +- .../services/subject_group_service.rs | 26 +- src/domain/entities/share.rs | 2 +- src/domain/entities/subject_group.rs | 2 +- .../repositories/subject_group_repository.rs | 4 +- src/domain/services/authorization.rs | 130 ++++- .../repositories/pg/share_pg_repository.rs | 16 +- .../pg/subject_group_pg_repository.rs | 4 +- src/infrastructure/services/pg_acl_engine.rs | 446 ++++++++---------- src/integration_test_support.rs | 4 +- src/interfaces/api/handlers/grant_handler.rs | 200 ++------ src/interfaces/api/mod.rs | 4 +- static/js/components/shareModal.js | 33 +- static/js/core/types.js | 24 +- static/js/model/grants.js | 4 +- static/sw.js | 2 +- tests/api/grants.hurl | 41 +- tests/api/grants_nested_groups.hurl | 8 +- tests/api/role_grants.hurl | 53 ++- tests/api/subject_groups.hurl | 2 +- 26 files changed, 800 insertions(+), 744 deletions(-) create mode 100644 migrations/20260801000000_role_grants_enum.sql create mode 100644 migrations/20260801000001_role_grants_cascade_triggers.sql create mode 100644 migrations/20260801000002_drop_access_grants.sql diff --git a/migrations/20260801000000_role_grants_enum.sql b/migrations/20260801000000_role_grants_enum.sql new file mode 100644 index 00000000..81ac0f16 --- /dev/null +++ b/migrations/20260801000000_role_grants_enum.sql @@ -0,0 +1,72 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- Cleanup #1: storage.role_grants.role — TEXT → storage.grant_role ENUM +-- ════════════════════════════════════════════════════════════════════════════ +-- D-Prep shipped `role_grants.role` as TEXT + CHECK constraint. Promoting it +-- to a native PostgreSQL ENUM gives us three things at once: +-- +-- 1. Index-driven sort by role strength. The ENUM values are declared in +-- strength order — owner first, viewer last. `ORDER BY role ASC` then +-- yields the UX-mandated "strongest first" ordering (Owner → Editor → +-- Contributor → Commenter → Viewer) without a CASE expression. The +-- `idx_role_grants_subject` / `idx_role_grants_resource` indexes can be +-- extended (or composite-augmented) with the role column for index-only +-- ordered scans. +-- +-- 2. Type-level safety. The CHECK constraint goes away; invalid roles fail +-- at the column type, not at row insertion. One contract instead of two +-- (column type AND check constraint). +-- +-- 3. Cleaner query shape. Every listing query that used the strength CASE +-- becomes a plain `ORDER BY role` after this migration. +-- +-- Trade-off accepted: PostgreSQL ENUMs allow ADD VALUE (with BEFORE / AFTER +-- positional anchors) and RENAME VALUE, but not DROP VALUE or arbitrary +-- reorder. The OxiCloud role roster is intentionally stable — new roles get +-- appended, none get reordered or removed. Confirmed with Ed. +-- +-- This migration must run BEFORE the access_grants drop, since it's purely +-- about role_grants.role. + +-- ── 1. Create the ENUM type ──────────────────────────────────────────────── +-- Declaration order = sort order. Strongest first so `ORDER BY role ASC` +-- matches the UX requirement (max permission → least permission). + +CREATE TYPE storage.grant_role AS ENUM ( + 'owner', -- ordinal 0, sorts first + 'editor', -- ordinal 1 + 'contributor', -- ordinal 2 + 'commenter', -- ordinal 3 + 'viewer' -- ordinal 4, sorts last +); + +COMMENT ON TYPE storage.grant_role IS + 'Role-keyed grant strength. Declaration order is sort order: ORDER BY ' + 'role ASC yields owner → viewer (strongest → weakest), matching the ' + 'share-dialog and shared-with-me UX. Adding a new role is ALTER TYPE ' + 'ADD VALUE; renaming is ALTER TYPE RENAME VALUE. Dropping or reordering ' + 'is not supported — adjust the roster only by append.'; + + +-- ── 2. Drop the redundant CHECK constraint ───────────────────────────────── +-- The inline CHECK on role_grants.role was auto-named +-- `role_grants_role_check` by PostgreSQL. Drop it before the type swap — +-- the ENUM now enforces the same invariant at the column level. + +ALTER TABLE storage.role_grants + DROP CONSTRAINT IF EXISTS role_grants_role_check; + + +-- ── 3. Convert role TEXT → storage.grant_role ────────────────────────────── +-- USING cast: text values are guaranteed to be one of the five valid labels +-- (the dropped CHECK enforced this; the D-Prep backfill only produced these +-- five values). If a stray value slipped through, the cast errors out and +-- the migration aborts — preferable to silently coercing. + +ALTER TABLE storage.role_grants + ALTER COLUMN role TYPE storage.grant_role + USING role::storage.grant_role; + +COMMENT ON COLUMN storage.role_grants.role IS + 'One of owner / editor / contributor / commenter / viewer. Expanded to ' + 'a Permission bundle by the in-code role_bundle() function at engine ' + 'read time. Sort order matches declaration order in storage.grant_role.'; diff --git a/migrations/20260801000001_role_grants_cascade_triggers.sql b/migrations/20260801000001_role_grants_cascade_triggers.sql new file mode 100644 index 00000000..1fca5043 --- /dev/null +++ b/migrations/20260801000001_role_grants_cascade_triggers.sql @@ -0,0 +1,122 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- Cleanup #2: cascade triggers for storage.role_grants +-- ════════════════════════════════════════════════════════════════════════════ +-- The D-Prep migration created `storage.role_grants` but no cascade triggers. +-- Until now, role_grants stayed consistent because the application-layer +-- lifecycle hooks (`engine.revoke_all_for_resource` / `_subject`) wiped rows +-- on the canonical delete paths, AND the existing `trg_cleanup_grants_*` +-- triggers kept `storage.access_grants` clean as a defence-in-depth net. +-- +-- The follow-up cleanup PR drops `access_grants` (and its triggers) entirely. +-- Without this migration that drop would leave `role_grants` without any +-- DB-level safety net — direct SQL, future codepaths that forget to call the +-- engine hooks, and any other bypass route could orphan rows whose subject +-- or resource has already been deleted. +-- +-- This migration mirrors the four forward + one reverse triggers from +-- `20260520000000_rebac_access_grants.sql` and `20260612000001_share_grant_ +-- reverse_cascade.sql`, retargeted at `storage.role_grants`. Same shape, same +-- AFTER-DELETE semantics, same idempotent CREATE OR REPLACE patterns. +-- +-- During the transition window (this migration applied; `access_grants` not +-- yet dropped) both sets of triggers coexist — they target different tables +-- and don't conflict. Once `access_grants` is dropped, the old triggers and +-- their helper functions vanish in the same migration. + +-- ── 1. Forward cascade: resource delete → cleanup role_grants ────────────── +-- Fires AFTER DELETE on storage.folders / storage.files; deletes every +-- role_grants row referencing that resource. TG_ARGV[0] discriminates which +-- resource_type the trigger is wired for. + +CREATE OR REPLACE FUNCTION storage.cleanup_role_grants_on_resource_delete() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM storage.role_grants + WHERE resource_type = TG_ARGV[0] + AND resource_id = OLD.id; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_cleanup_role_grants_folder ON storage.folders; +CREATE TRIGGER trg_cleanup_role_grants_folder + AFTER DELETE ON storage.folders + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_role_grants_on_resource_delete('folder'); + +DROP TRIGGER IF EXISTS trg_cleanup_role_grants_file ON storage.files; +CREATE TRIGGER trg_cleanup_role_grants_file + AFTER DELETE ON storage.files + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_role_grants_on_resource_delete('file'); + + +-- ── 2. Forward cascade: subject delete → cleanup role_grants ─────────────── +-- Fires AFTER DELETE on auth.users / storage.shares; deletes every +-- role_grants row referencing that subject. Groups are NOT wired here — +-- `subject_group_service::delete()` performs that cascade transactionally +-- in application code, mirroring the historical access_grants behaviour. + +CREATE OR REPLACE FUNCTION storage.cleanup_role_grants_on_subject_delete() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM storage.role_grants + WHERE subject_type = TG_ARGV[0] + AND subject_id = OLD.id; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_cleanup_role_grants_user ON auth.users; +CREATE TRIGGER trg_cleanup_role_grants_user + AFTER DELETE ON auth.users + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_role_grants_on_subject_delete('user'); + +DROP TRIGGER IF EXISTS trg_cleanup_role_grants_token ON storage.shares; +CREATE TRIGGER trg_cleanup_role_grants_token + AFTER DELETE ON storage.shares + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_role_grants_on_subject_delete('token'); + + +-- ── 3. Reverse cascade: last-token-grant delete → cleanup storage.shares ─── +-- A caller hitting DELETE /api/grants/{id} on a token's role grant would +-- otherwise leave the storage.shares row stranded — the token still +-- resolves to "no access" (cascade query finds no rows), but the metadata +-- row accumulates forever. +-- +-- With role_grants the UNIQUE (subject, resource) constraint guarantees a +-- token has at most ONE role grant per resource, so "the last grant for a +-- token" collapses to "the only grant for that token". The NOT EXISTS +-- guard still works correctly — it just always evaluates the same way for +-- token subjects. +-- +-- The DELETE on storage.shares is a no-op when the share row is already +-- gone (the forward cascade `trg_cleanup_role_grants_token` is in flight +-- and already removed it). Idempotent in both directions. + +CREATE OR REPLACE FUNCTION storage.cleanup_share_on_last_role_grant_delete() +RETURNS trigger AS $$ +BEGIN + IF OLD.subject_type = 'token' THEN + DELETE FROM storage.shares s + WHERE s.id = OLD.subject_id + AND NOT EXISTS ( + SELECT 1 FROM storage.role_grants rg + WHERE rg.subject_type = 'token' + AND rg.subject_id = OLD.subject_id + ); + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_cleanup_share_on_role_grant_delete ON storage.role_grants; +CREATE TRIGGER trg_cleanup_share_on_role_grant_delete + AFTER DELETE ON storage.role_grants + FOR EACH ROW + EXECUTE FUNCTION storage.cleanup_share_on_last_role_grant_delete(); + +COMMENT ON FUNCTION storage.cleanup_share_on_last_role_grant_delete() IS + 'Reverse cascade: deletes storage.shares row when its last token role grant is removed. Pairs with trg_cleanup_role_grants_token (forward direction).'; diff --git a/migrations/20260801000002_drop_access_grants.sql b/migrations/20260801000002_drop_access_grants.sql new file mode 100644 index 00000000..4162b626 --- /dev/null +++ b/migrations/20260801000002_drop_access_grants.sql @@ -0,0 +1,63 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- Cleanup #3: drop storage.access_grants (and everything attached to it) +-- ════════════════════════════════════════════════════════════════════════════ +-- The final step of the role-keyed ReBAC cleanup. By the time this migration +-- runs: +-- +-- * Every read path goes through `storage.role_grants` (cleanup #1 / #2). +-- * The engine no longer has a `grant()` method; `set_role()` / +-- `clear_role()` are the only writes. +-- * The HTTP surface (`POST /api/grants`, `PUT /api/grants/role`) only +-- accepts role-keyed shapes. +-- * `share_service`, `subject_group_service`, `auth_application_service`, +-- `share_pg_repository`, and `integration_test_support` all read +-- `role_grants` exclusively. +-- * `storage.role_grants` has its own cascade triggers +-- (`trg_cleanup_role_grants_*`) and reverse-cascade +-- (`trg_cleanup_share_on_role_grant_delete`), added in cleanup #2. +-- +-- So `access_grants` is fully unreferenced — we can drop it together with +-- the helper triggers + functions defined in +-- `20260520000000_rebac_access_grants.sql` and +-- `20260612000001_share_grant_reverse_cascade.sql`. +-- +-- Roll-back posture: this is destructive. There is no down migration. The +-- D-Prep backfill is one-way (role-keyed rows are derived from +-- permission-keyed clusters; the reverse reconstruction would need a fixed +-- bundle mapping that may have shifted between releases). Recovering +-- requires restoring from a backup taken before this migration runs. + +-- ── 1. Drop the access_grants triggers FROM their source tables ──────────── +-- These triggers live on storage.folders / storage.files / auth.users / +-- storage.shares. Dropping access_grants doesn't implicitly remove them +-- (the trigger row points at the source table; the body references the +-- target table, and that body is what breaks once access_grants is gone). +-- Drop them explicitly so subsequent DELETEs on those source tables don't +-- error out. + +DROP TRIGGER IF EXISTS trg_cleanup_grants_folder ON storage.folders; +DROP TRIGGER IF EXISTS trg_cleanup_grants_file ON storage.files; +DROP TRIGGER IF EXISTS trg_cleanup_grants_user ON auth.users; +DROP TRIGGER IF EXISTS trg_cleanup_grants_token ON storage.shares; + +-- The reverse-cascade trigger is ON access_grants and goes away with the +-- table — but the IF EXISTS makes this safe regardless of drop order. +DROP TRIGGER IF EXISTS trg_cleanup_share_on_grant_delete ON storage.access_grants; + + +-- ── 2. Drop the trigger helper functions ──────────────────────────────────── +-- No other code references these — the `cleanup_role_grants_*` equivalents +-- defined in cleanup #2 carry the same behaviour against role_grants. + +DROP FUNCTION IF EXISTS storage.cleanup_grants_on_resource_delete(); +DROP FUNCTION IF EXISTS storage.cleanup_grants_on_subject_delete(); +DROP FUNCTION IF EXISTS storage.cleanup_share_on_last_token_grant_delete(); + + +-- ── 3. Drop the table ────────────────────────────────────────────────────── +-- CASCADE removes any remaining dependent objects (indexes, comments, and +-- the reverse-cascade trigger if it survived step 1). With every Rust code +-- path already routed through role_grants, nothing in the application +-- layer will notice. + +DROP TABLE IF EXISTS storage.access_grants CASCADE; diff --git a/src/application/dtos/grant_dto.rs b/src/application/dtos/grant_dto.rs index e7c0ec75..62c42bf5 100644 --- a/src/application/dtos/grant_dto.rs +++ b/src/application/dtos/grant_dto.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::application::dtos::cursor::{CursorListResponse, CursorQuery, PageCursor}; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; -use crate::domain::services::authorization::{Grant, Permission, Resource, Subject}; +use crate::domain::services::authorization::{Grant, Permission, Resource, Role, Subject}; // ════════════════════════════════════════════════════════════════════════════ // Subject / Resource / Permission DTOs @@ -130,166 +130,50 @@ impl From for PermissionDto { // Roles — the load-bearing model for ReBAC grants // ════════════════════════════════════════════════════════════════════════════ // -// Today a role is "DTO-layer sugar" — every grant write expands a role into -// N rows in `storage.access_grants`. The D-Prep refactor (see -// `docs/plan/drive.md` §Prerequisite + migration `20260730000000_role_grants.sql`) -// pushes the role down to storage (`storage.role_grants.role TEXT`); the -// engine reads the role and expands the bundle at query time via this same -// `expand()` function. Adding a role is now schema-free — one variant + one -// match arm here. +// One row per role assignment in `storage.role_grants.role` (a +// `storage.grant_role` ENUM). The engine expands the bundle at query time +// via `Role::expand()` on the domain enum. Adding a role is two edits: +// the variant + match arm on `Role`, and an `ALTER TYPE +// storage.grant_role ADD VALUE 'name'` migration. +/// Wire-format wrapper around the domain `Role` enum. Carries the +/// serde/utoipa derives. Maps 1:1 to/from `Role` via `From`. +/// +/// The historical `"admin"` alias for `Owner` (used during the D-Prep +/// dual-write window for cached clients) has been retired in the cleanup +/// PR — the OxiCloud UI emits `"owner"` exclusively. Stragglers receive +/// a 422 on POST/PUT, which surfaces the upgrade cleanly. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] -pub enum Role { - /// Read access only. The default "anyone can look but not touch". +pub enum RoleDto { Viewer, - /// Read + comment. Useful for review-only stakeholders. Commenter, - /// Read + create. The "drop-zone" role — uploads allowed, existing - /// content untouchable. Common for support-ticket attachments and - /// photo-submission folders. Contributor, - /// Read + create + update + comment. The standard collaboration role. Editor, - /// Full bundle: read, create, update, comment, delete, share — and - /// `Manage` for resource types that support it (drives, groups). This - /// is the highest user-grantable role; renamed from the historical - /// `Admin` to disambiguate from `UserRole::Admin` (the user-account - /// privilege) and to match Drive plan terminology. - /// - /// **Wire-format compat shim** (one release): also deserialises from - /// the legacy `"admin"` string so cached frontend clients keep - /// working until they refresh. Serialisation always emits `"owner"`. - /// Drop the alias in the cleanup PR. - #[serde(alias = "admin")] Owner, } -impl Role { - /// Expand the role into its permission bundle. Single source of truth — - /// any code that needs "does this role include Permission X?" routes - /// through here (or its inverse, `roles_implying`). - /// - /// After D-Prep this is called at engine read time (1 row → bundle - /// expanded server-side). Pre-D-Prep it was called at API write time - /// (1 role → N rows fanned out). - pub fn expand(self) -> &'static [Permission] { - match self { - Role::Viewer => &[Permission::Read], - Role::Commenter => &[Permission::Read, Permission::Comment], - Role::Contributor => &[Permission::Read, Permission::Create], - Role::Editor => &[ - Permission::Read, - Permission::Comment, - Permission::Create, - Permission::Update, - ], - Role::Owner => &[ - Permission::Read, - Permission::Comment, - Permission::Create, - Permission::Update, - Permission::Share, - Permission::Delete, - Permission::Manage, - ], +impl From for Role { + fn from(r: RoleDto) -> Self { + match r { + RoleDto::Viewer => Role::Viewer, + RoleDto::Commenter => Role::Commenter, + RoleDto::Contributor => Role::Contributor, + RoleDto::Editor => Role::Editor, + RoleDto::Owner => Role::Owner, } } - - /// Lowercase string discriminator — matches the SQL `role` column values - /// in `storage.role_grants` and the JSON wire format. - pub fn as_str(self) -> &'static str { - match self { - Role::Viewer => "viewer", - Role::Commenter => "commenter", - Role::Contributor => "contributor", - Role::Editor => "editor", - Role::Owner => "owner", - } - } - - /// Parse a role from its SQL / JSON string discriminator. Returns - /// `None` for unknown values. Accepts the legacy `"admin"` spelling - /// for one release of API compat (clients that cached the old name - /// keep working; new responses always emit `"owner"`). - pub fn parse(s: &str) -> Option { - match s { - "viewer" => Some(Role::Viewer), - "commenter" => Some(Role::Commenter), - "contributor" => Some(Role::Contributor), - "editor" => Some(Role::Editor), - "owner" => Some(Role::Owner), - // Legacy compat: drop after one release once all clients are - // updated. Emits a debug log so we can track stragglers. - "admin" => { - tracing::debug!( - target: "oxicloud::grants", - "Role::parse: accepted legacy 'admin' string as Role::Owner" - ); - Some(Role::Owner) - } - _ => None, - } - } - - /// Every role, in a stable order. Used by `roles_implying` and exposed - /// to the UI so the share modal can render the full picker without - /// hardcoding the list. - /// - /// **UI scope today**: the share dialog renders only `Viewer`, - /// `Editor`, and `Owner` (matches the existing 3-button UX). The - /// `Commenter` and `Contributor` variants are implemented server- - /// side and accepted on the API surface, reserved for future UI - /// exposure when a real use case asks for them. Until then they - /// stay invisible to end users — no picker option, no documentation - /// surface. - /// - /// Any role can be granted on any resource type. Permission bundles - /// that include capabilities the resource type doesn't check for - /// (e.g. `Manage` on a folder, `Create` on a file) simply produce - /// harmless no-ops — no separate validation layer is needed. - pub const ALL: [Role; 5] = [ - Role::Viewer, - Role::Commenter, - Role::Contributor, - Role::Editor, - Role::Owner, - ]; } -/// Inverse of [`Role::expand`]: returns every role whose bundle contains -/// the given permission. Used by the engine to build the SQL -/// `WHERE role IN (...)` filter on hot-path queries like "what drives can -/// this caller read?": -/// -/// ```ignore -/// SELECT resource_id FROM role_grants -/// WHERE subject_id = $1 -/// AND resource_type = 'drive' -/// AND role IN (roles_implying(Permission::Read)); -/// ``` -/// -/// Precomputed in code rather than stored in the DB — `Role` and -/// `Permission` are both small fixed enums, the table can never grow -/// beyond a handful of rows, and keeping it in-code makes "what changes -/// when I add a Permission?" a single grep target. -pub fn roles_implying(permission: Permission) -> &'static [Role] { - use Permission::*; - match permission { - // Every role grants Read — viewer is the floor. - Read => &[ - Role::Viewer, - Role::Commenter, - Role::Contributor, - Role::Editor, - Role::Owner, - ], - Comment => &[Role::Commenter, Role::Editor, Role::Owner], - Create => &[Role::Contributor, Role::Editor, Role::Owner], - Update => &[Role::Editor, Role::Owner], - Delete => &[Role::Owner], - Share => &[Role::Owner], - Manage => &[Role::Owner], +impl From for RoleDto { + fn from(r: Role) -> Self { + match r { + Role::Viewer => RoleDto::Viewer, + Role::Commenter => RoleDto::Commenter, + Role::Contributor => RoleDto::Contributor, + Role::Editor => RoleDto::Editor, + Role::Owner => RoleDto::Owner, + } } } @@ -325,17 +209,18 @@ pub enum SubjectInputDto { }, } -/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`. -/// Server-side validation requires exactly one of the two to be present. +/// `POST /api/grants` — create or refresh a role assignment. +/// +/// Strictly role-keyed since the cleanup PR: callers send exactly one +/// role; the engine writes a single row in `storage.role_grants`. The +/// historical per-permission shape (`permissions: [...]`) was dropped — +/// the OxiCloud UI is the only known caller and it already sends `role`. #[derive(Debug, Deserialize, ToSchema)] pub struct CreateGrantDto { pub subject: SubjectInputDto, pub resource: ResourceDto, - #[serde(default)] - pub permissions: Option>, - #[serde(default)] - pub role: Option, - /// Optional expiry for every grant in this request. RFC 3339 / ISO 8601. + pub role: RoleDto, + /// Optional expiry for the grant. RFC 3339 / ISO 8601. #[serde(default)] pub expires_at: Option>, } @@ -345,7 +230,7 @@ pub struct CreateGrantDto { pub struct UpdateRoleDto { pub subject: SubjectDto, pub resource: ResourceDto, - pub role: Role, + pub role: RoleDto, /// Optional expiry applied to every grant written or updated by this call. #[serde(default)] pub expires_at: Option>, @@ -360,7 +245,10 @@ pub struct GrantDto { pub id: Uuid, pub subject: SubjectDto, pub resource: ResourceDto, - pub permission: PermissionDto, + /// Role-keyed since D-Prep cleanup — one row in `storage.role_grants` + /// is one `GrantDto`. The bundle of underlying permissions is implied + /// by the role and recomputed client-side from the same lookup table. + pub role: RoleDto, pub granted_by: Uuid, pub granted_at: chrono::DateTime, #[serde(skip_serializing_if = "Option::is_none")] @@ -373,7 +261,7 @@ impl From for GrantDto { id: g.id, subject: g.subject.into(), resource: g.resource.into(), - permission: g.permission.into(), + role: g.role.into(), granted_by: g.granted_by, granted_at: g.granted_at, expires_at: g.expires_at, diff --git a/src/application/ports/authorization_ports.rs b/src/application/ports/authorization_ports.rs index b0b9b1c7..494267af 100644 --- a/src/application/ports/authorization_ports.rs +++ b/src/application/ports/authorization_ports.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use crate::common::errors::DomainError; use crate::domain::services::authorization::{ Grant, GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, - ResourceKind, Subject, + ResourceKind, Role, Subject, }; pub trait AuthorizationEngine: Send + Sync + 'static { @@ -90,11 +90,7 @@ pub trait AuthorizationEngine: Send + Sync + 'static { /// Resources explicitly granted to `subject`. Direct grants only — no /// cascade expansion. Used by `GET /api/grants/incoming`. - async fn list_incoming_grants( - &self, - subject: Subject, - permission_filter: Option, - ) -> Result, DomainError>; + async fn list_incoming_grants(&self, subject: Subject) -> Result, DomainError>; /// Cursor-paginated list of resources explicitly granted to `subject`, /// optionally filtered by resource kind. Multiple permission rows for the @@ -139,38 +135,20 @@ pub trait AuthorizationEngine: Send + Sync + 'static { reverse: bool, ) -> Result<(Vec, Option), DomainError>; - /// Create a grant. Idempotent — duplicates are absorbed by the UNIQUE - /// constraint; if the row already exists its `expires_at` is updated. - async fn grant( - &self, - granted_by: Uuid, - subject: Subject, - permission: Permission, - resource: Resource, - expires_at: Option>, - ) -> Result; - - /// Update `expires_at` on every grant row for the given subject. - /// Used when a share's expiry is changed — one call updates all - /// permission rows for that token in a single UPDATE. + /// Update `expires_at` for every role grant belonging to `subject`. + /// Used by `share_service` when a token-share's expiry is refreshed — + /// the subject (token) maps to a small fixed set of role grants, so a + /// single UPDATE covers them. Resource-scoped expiry changes go through + /// `set_role` (which carries `expires_at` as part of its UPSERT). async fn set_expiry_for_subject( &self, subject: Subject, expires_at: Option>, ) -> Result<(), DomainError>; - /// Update `expires_at` on every grant row for the given `(subject, resource)` - /// pair. Used by `set_role` to sync the expiry of retained grants when the - /// caller changes expiry without changing permissions. - async fn set_expiry_on_resource( - &self, - subject: Subject, - resource: Resource, - expires_at: Option>, - ) -> Result<(), DomainError>; - - /// Revoke a specific grant by its UUID. Returns `Ok(())` whether or not - /// the row existed (idempotent revoke). + /// Revoke a single role grant by its UUID. Idempotent — returns `Ok(())` + /// whether or not the row existed. The id comes from a prior listing + /// or `find_grant_full_by_id` lookup. async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError>; /// Removes every grant whose `resource` matches. Called by lifecycle @@ -182,20 +160,10 @@ pub trait AuthorizationEngine: Send + Sync + 'static { /// /group is deleted. Returns the count of rows removed. async fn revoke_all_for_subject(&self, subject: Subject) -> Result; - // ── Role-keyed grant operations (D-Prep dual-write) ──────────────────── - // These manage `storage.role_grants`, the role-keyed table introduced - // by the D-Prep refactor (see `docs/plan/drive.md` §Prerequisite). - // - // During the dual-write window both tables stay populated; the engine - // reads from `access_grants` until the read-path pivot lands. After - // the cleanup PR (which drops `access_grants`), these two methods - // become the ONLY grant write path — the per-permission `grant` / - // `revoke` above are removed at that point. - // - // The handler layer drives these (it knows the Role); lifecycle hook - // bulk-deletes (`revoke_all_for_*` above) wipe role_grants in lockstep - // inside their own implementation, so callers using those paths don't - // need to invoke `clear_role` separately. + // ── Role-keyed grant operations ──────────────────────────────────────── + // These are the only grant write path. Lifecycle hook bulk-deletes + // (`revoke_all_for_*` above) wipe matching rows directly, so callers + // using those paths don't need to invoke `clear_role` separately. /// Set the role for a `(subject, resource)` pair. Idempotent via the /// UNIQUE `(subject_type, subject_id, resource_type, resource_id)` @@ -207,10 +175,10 @@ pub trait AuthorizationEngine: Send + Sync + 'static { &self, granted_by: Uuid, subject: Subject, - role: crate::application::dtos::grant_dto::Role, + role: Role, resource: Resource, expires_at: Option>, - ) -> Result<(), DomainError>; + ) -> Result; /// Remove the role for a `(subject, resource)` pair. Idempotent — /// succeeds whether or not the row existed. Called after `revoke` diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 36584601..a77e7125 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1388,7 +1388,7 @@ impl AuthApplicationService { /// Visibility rule, evaluated top-to-bottom: /// 1. **Self lookup** — `caller_id == target_id` always succeeds. /// 2. **Shared-grant relationship** — caller and target appear - /// together on at least one row of `storage.access_grants`, + /// together on at least one row of `storage.role_grants`, /// either direction (caller-as-granter / target-as-subject, /// or target-as-granter / caller-as-subject). Applies to both /// internal and external callers. This is what lets an @@ -1454,7 +1454,7 @@ impl AuthApplicationService { let related: Option = sqlx::query_scalar( r#" SELECT 1 - FROM storage.access_grants + FROM storage.role_grants WHERE (granted_by = $1 AND subject_type = 'user' AND subject_id = $2) OR (granted_by = $2 AND subject_type = 'user' AND subject_id = $1) LIMIT 1 diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 3250c7fe..da411815 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -5,7 +5,7 @@ use tokio::sync::Semaphore; use uuid::Uuid; use crate::domain::repositories::folder_repository::FolderRepository; -use crate::domain::services::authorization::{Permission, Resource, Subject}; +use crate::domain::services::authorization::{Resource, Role, Subject}; use crate::infrastructure::repositories::pg::SharePgRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository; @@ -254,9 +254,9 @@ impl ShareUseCase for ShareService { .await .map_err(|e| ShareServiceError::Repository(e.to_string()))?; - // Create one Read-only grant for the token subject, carrying expires_at. - // Tokens are always read-only. The DELETE trigger `trg_cleanup_grants_token` - // cleans up this grant when the share is later deleted. + // Anonymous link tokens always get the Viewer role (read-only). + // The `trg_cleanup_grants_token` trigger cleans up this grant when + // the share row is later deleted. let item_id_uuid = Uuid::parse_str(saved_share.item_id()) .map_err(|_| ShareServiceError::Validation("Invalid item UUID".to_string()))?; let resource = match saved_share.item_type() { @@ -267,10 +267,10 @@ impl ShareUseCase for ShareService { .expires_at .and_then(|ts| chrono::DateTime::from_timestamp(ts as i64, 0)); self.authorization - .grant( + .set_role( user_id, Subject::Token(saved_share.id()), - Permission::Read, + Role::Viewer, resource, expires_dt, ) diff --git a/src/application/services/subject_group_service.rs b/src/application/services/subject_group_service.rs index 89576334..41e0d7c3 100644 --- a/src/application/services/subject_group_service.rs +++ b/src/application/services/subject_group_service.rs @@ -5,7 +5,7 @@ //! - Name validation runs (defence-in-depth alongside the DB CHECK). //! - Virtual groups (e.g. `Internal`) are protected from mutation. //! - Audit events are emitted via `tracing::info!(target = "audit", ...)`. -//! - Cascading delete of `storage.access_grants` rows referencing this +//! - Cascading delete of `storage.role_grants` rows referencing this //! group runs in the same transaction as the group delete. //! //! See `migrations/20260612000000_subject_groups.sql` for the schema. @@ -172,9 +172,9 @@ impl SubjectGroupService { /// Delete the group; cascades to: /// - `auth.subject_group_members` rows (FK CASCADE). - /// - `storage.access_grants` rows where `subject_type='group'` and + /// - `storage.role_grants` rows where `subject_type='group'` and /// `subject_id = id` (handled here, no FK exists between - /// `access_grants` and `subject_groups`). + /// `role_grants` and `subject_groups`). pub async fn delete(&self, id: Uuid, caller_id: Uuid) -> Result<(), DomainError> { let existing = self.get_by_id(id).await?; if existing.is_virtual { @@ -196,7 +196,7 @@ impl SubjectGroupService { })?; let grants_deleted = sqlx::query( - "DELETE FROM storage.access_grants + "DELETE FROM storage.role_grants WHERE subject_type = 'group' AND subject_id = $1", ) .bind(id) @@ -519,9 +519,9 @@ mod integration_tests { // ── 13. Grants are revoked atomically when a group is deleted ────────── // - // The plan said "FK CASCADE", but there's no FK between `access_grants` - // and `subject_groups` (different schemas; the cascade is handled by the - // service's transactional DELETE). This test pins that behaviour. + // There is no FK between `storage.role_grants` and `auth.subject_groups` + // (different schemas); the cascade is handled by the service's + // transactional DELETE. This test pins that behaviour. #[tokio::test] async fn test_grants_revoked_when_group_deleted() { let svc = make_service().await; @@ -534,21 +534,21 @@ mod integration_tests { .unwrap(); let resource_id = Uuid::new_v4(); sqlx::query( - "INSERT INTO storage.access_grants \ + "INSERT INTO storage.role_grants \ (subject_type, subject_id, resource_type, resource_id, \ - permission, granted_by) \ - VALUES ('group', $1, 'folder', $2, 'read', $3)", + role, granted_by) \ + VALUES ('group', $1, 'folder', $2, 'viewer', $3)", ) .bind(group.id) .bind(resource_id) .bind(admin) .execute(svc.pool.as_ref()) .await - .expect("insert grant row"); + .expect("insert role_grants row"); // Sanity: the grant exists. let pre: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM storage.access_grants \ + "SELECT COUNT(*) FROM storage.role_grants \ WHERE subject_type = 'group' AND subject_id = $1", ) .bind(group.id) @@ -561,7 +561,7 @@ mod integration_tests { svc.delete(group.id, admin).await.unwrap(); let post: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM storage.access_grants \ + "SELECT COUNT(*) FROM storage.role_grants \ WHERE subject_type = 'group' AND subject_id = $1", ) .bind(group.id) diff --git a/src/domain/entities/share.rs b/src/domain/entities/share.rs index 24a09f5c..93d965e4 100644 --- a/src/domain/entities/share.rs +++ b/src/domain/entities/share.rs @@ -12,7 +12,7 @@ pub struct Share { item_type: ShareItemType, token: String, password_hash: Option, - /// Derived from `storage.access_grants.expires_at` — not stored on the share row. + /// Derived from `storage.role_grants.expires_at` — not stored on the share row. expires_at: Option, created_at: u64, created_by: Uuid, diff --git a/src/domain/entities/subject_group.rs b/src/domain/entities/subject_group.rs index 2ada7cf7..8845eb5b 100644 --- a/src/domain/entities/subject_group.rs +++ b/src/domain/entities/subject_group.rs @@ -2,7 +2,7 @@ //! //! Subject groups are root-owned (no `owner_id`), globally named with an //! RFC 5321 local-part shape, and able to contain users *or* other groups. -//! Grants in `storage.access_grants` with `subject_type = 'group'` reference +//! Grants in `storage.role_grants` with `subject_type = 'group'` reference //! a row in `auth.subject_groups`. //! //! Cycle prevention and depth-cap (`MAX_GROUP_DEPTH`) are enforced at the diff --git a/src/domain/repositories/subject_group_repository.rs b/src/domain/repositories/subject_group_repository.rs index 96212b07..cc799954 100644 --- a/src/domain/repositories/subject_group_repository.rs +++ b/src/domain/repositories/subject_group_repository.rs @@ -93,8 +93,8 @@ pub trait SubjectGroupRepository: Send + Sync + 'static { ) -> Result; /// Delete the group. Cascades to `subject_group_members` and to - /// `storage.access_grants` rows referencing this group as subject (via - /// the application service — there is no FK between `access_grants` and + /// `storage.role_grants` rows referencing this group as subject (via + /// the application service — there is no FK between `role_grants` and /// `subject_groups`, so the service performs the cascade explicitly in /// the same transaction). async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError>; diff --git a/src/domain/services/authorization.rs b/src/domain/services/authorization.rs index 7e8b5503..06cf0607 100644 --- a/src/domain/services/authorization.rs +++ b/src/domain/services/authorization.rs @@ -3,7 +3,7 @@ //! These types are storage-agnostic — they describe the relationship between //! a subject (who), a resource (what), and a permission (action). The //! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation -//! maps them to / from `storage.access_grants` rows. +//! maps them to / from `storage.role_grants` rows. use crate::application::dtos::cursor::PageCursor; use std::fmt; @@ -49,7 +49,7 @@ impl Subject { /// `"external"` is no longer accepted: PR-2 of the external-users /// work folded the federated-identity case into `Subject::User(uuid)` /// with `auth.users.is_external = TRUE`. The DB CHECK constraint - /// on `storage.access_grants.subject_type` was narrowed to match. + /// on `storage.role_grants.subject_type` was narrowed to match. pub fn from_parts(subject_type: &str, id: Uuid) -> Option { match subject_type { "user" => Some(Subject::User(id)), @@ -200,7 +200,7 @@ impl fmt::Display for Permission { } // ════════════════════════════════════════════════════════════════════════════ -// Grant — a row in storage.access_grants +// Grant — a row in storage.role_grants // ════════════════════════════════════════════════════════════════════════════ #[derive(Clone, Debug)] @@ -208,12 +208,132 @@ pub struct Grant { pub id: Uuid, pub subject: Subject, pub resource: Resource, - pub permission: Permission, + /// Role-keyed since D-Prep cleanup: one `Grant` represents the role + /// row in `storage.role_grants` rather than a single permission. The + /// engine and HTTP surface no longer carry per-permission rows; + /// callers that need permissions use `role.expand()`. + pub role: Role, pub granted_by: Uuid, pub granted_at: chrono::DateTime, pub expires_at: Option>, } +// ════════════════════════════════════════════════════════════════════════════ +// Role — a named bundle of permissions +// ════════════════════════════════════════════════════════════════════════════ +// +// Roles are the load-bearing model for ReBAC grants since D-Prep. Each +// `storage.role_grants` row stores one role; the engine expands the bundle +// at read time via `Role::expand()`. Adding a role is two edits: +// 1. a variant here + match arm in `expand()` / `as_str()` / `parse()` +// 2. an `ALTER TYPE storage.grant_role ADD VALUE 'name'` migration +// +// `RoleDto` (DTO layer) carries the wire-format derives + the legacy +// `"admin"` alias for backwards compat. + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum Role { + Viewer, + Commenter, + Contributor, + Editor, + Owner, +} + +impl Role { + /// Expand the role into its permission bundle. Single source of truth — + /// any code that needs "does this role include Permission X?" routes + /// through here (or its inverse, `roles_implying`). + pub fn expand(self) -> &'static [Permission] { + match self { + Role::Viewer => &[Permission::Read], + Role::Commenter => &[Permission::Read, Permission::Comment], + Role::Contributor => &[Permission::Read, Permission::Create], + Role::Editor => &[ + Permission::Read, + Permission::Comment, + Permission::Create, + Permission::Update, + ], + Role::Owner => &[ + Permission::Read, + Permission::Comment, + Permission::Create, + Permission::Update, + Permission::Share, + Permission::Delete, + Permission::Manage, + ], + } + } + + /// Lowercase discriminator — matches the SQL `role` ENUM values in + /// `storage.role_grants` (after the `::text` cast). + pub fn as_str(self) -> &'static str { + match self { + Role::Viewer => "viewer", + Role::Commenter => "commenter", + Role::Contributor => "contributor", + Role::Editor => "editor", + Role::Owner => "owner", + } + } + + /// Parse a role from its SQL discriminator. Returns `None` for unknown + /// values. The `"admin"` legacy alias is handled by `RoleDto` at the + /// wire boundary — the database only ever stores the canonical names. + pub fn parse(s: &str) -> Option { + match s { + "viewer" => Some(Role::Viewer), + "commenter" => Some(Role::Commenter), + "contributor" => Some(Role::Contributor), + "editor" => Some(Role::Editor), + "owner" => Some(Role::Owner), + _ => None, + } + } + + /// Every role, in declaration order. Mirrors the `storage.grant_role` + /// ENUM order in PG, which is weakest-to-strongest as written here for + /// historical reasons (`storage.grant_role` declares strongest first). + pub const ALL: [Role; 5] = [ + Role::Viewer, + Role::Commenter, + Role::Contributor, + Role::Editor, + Role::Owner, + ]; +} + +impl fmt::Display for Role { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Inverse of [`Role::expand`]: returns every role whose bundle contains +/// the given permission. Used by the engine to build the SQL +/// `WHERE role IN (...)` filter on hot-path queries like "what drives can +/// this caller read?". +pub fn roles_implying(permission: Permission) -> &'static [Role] { + use Permission::*; + match permission { + Read => &[ + Role::Viewer, + Role::Commenter, + Role::Contributor, + Role::Editor, + Role::Owner, + ], + Comment => &[Role::Commenter, Role::Editor, Role::Owner], + Create => &[Role::Contributor, Role::Editor, Role::Owner], + Update => &[Role::Editor, Role::Owner], + Delete => &[Role::Owner], + Share => &[Role::Owner], + Manage => &[Role::Owner], + } +} + impl Grant { pub fn is_expired(&self) -> bool { self.expires_at.is_some_and(|exp| exp < chrono::Utc::now()) @@ -225,7 +345,7 @@ impl Grant { // ════════════════════════════════════════════════════════════════════════════ /// Resource type without an id — used to filter paginated grant queries by -/// type. Mirrors the `resource_type` column values in `storage.access_grants`. +/// type. Mirrors the `resource_type` column values in `storage.role_grants`. /// Add new variants here when new resource types are supported. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ResourceKind { diff --git a/src/infrastructure/repositories/pg/share_pg_repository.rs b/src/infrastructure/repositories/pg/share_pg_repository.rs index d5c30183..8660324e 100644 --- a/src/infrastructure/repositories/pg/share_pg_repository.rs +++ b/src/infrastructure/repositories/pg/share_pg_repository.rs @@ -38,7 +38,7 @@ impl SharePgRepository { /// Maps a [`sqlx::postgres::PgRow`] to the domain [`Share`] entity. /// Expects columns: id, item_id, item_name, item_type, token, password_hash, - /// expires_at (derived from access_grants subquery), created_at, created_by, access_count. + /// expires_at (derived from role_grants subquery), created_at, created_by, access_count. fn row_to_entity(row: &sqlx::postgres::PgRow) -> Result { let id: Uuid = row .try_get("id") @@ -54,7 +54,7 @@ impl SharePgRepository { DomainError::internal_error("Share", format!("Failed to read token: {e}")) })?; let password_hash: Option = row.try_get("password_hash").unwrap_or(None); - // expires_at derived from access_grants subquery (unix seconds as i64) + // expires_at derived from role_grants subquery (unix seconds as i64) let expires_at: Option = row.try_get("expires_at").unwrap_or(None); let created_at: i64 = row.try_get("created_at").map_err(|e| { DomainError::internal_error("Share", format!("Failed to read created_at: {e}")) @@ -98,7 +98,7 @@ impl ShareStoragePort for SharePgRepository { RETURNING id, item_id, item_name, item_type, token, password_hash, (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) - FROM storage.access_grants ag + FROM storage.role_grants ag WHERE ag.subject_type = 'token' AND ag.subject_id = id) AS expires_at, created_at, created_by, access_count "#, @@ -127,7 +127,7 @@ impl ShareStoragePort for SharePgRepository { r#" SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash, (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) - FROM storage.access_grants ag + FROM storage.role_grants ag WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at, s.created_at, s.created_by, s.access_count FROM storage.shares s @@ -160,7 +160,7 @@ impl ShareStoragePort for SharePgRepository { r#" SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash, (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) - FROM storage.access_grants ag + FROM storage.role_grants ag WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at, s.created_at, s.created_by, s.access_count FROM storage.shares s @@ -218,7 +218,7 @@ impl ShareStoragePort for SharePgRepository { r#" SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash, (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) - FROM storage.access_grants ag + FROM storage.role_grants ag WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at, s.created_at, s.created_by, s.access_count FROM storage.shares s @@ -250,7 +250,7 @@ impl ShareStoragePort for SharePgRepository { RETURNING id, item_id, item_name, item_type, token, password_hash, (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) - FROM storage.access_grants ag + FROM storage.role_grants ag WHERE ag.subject_type = 'token' AND ag.subject_id = storage.shares.id) AS expires_at, created_at, created_by, access_count "#, @@ -286,7 +286,7 @@ impl ShareStoragePort for SharePgRepository { r#" SELECT s.id, s.item_id, s.item_name, s.item_type, s.token, s.password_hash, (SELECT MIN(EXTRACT(EPOCH FROM ag.expires_at)::BIGINT) - FROM storage.access_grants ag + FROM storage.role_grants ag WHERE ag.subject_type = 'token' AND ag.subject_id = s.id) AS expires_at, s.created_at, s.created_by, s.access_count, COUNT(*) OVER() AS total_count diff --git a/src/infrastructure/repositories/pg/subject_group_pg_repository.rs b/src/infrastructure/repositories/pg/subject_group_pg_repository.rs index 7484a155..6f3d0f60 100644 --- a/src/infrastructure/repositories/pg/subject_group_pg_repository.rs +++ b/src/infrastructure/repositories/pg/subject_group_pg_repository.rs @@ -272,8 +272,8 @@ impl SubjectGroupRepository for SubjectGroupPgRepository { async fn delete(&self, id: Uuid) -> Result<(), SubjectGroupRepositoryError> { // The application service is responsible for clearing related - // `storage.access_grants` rows in the same transaction (there's no - // FK between access_grants and subject_groups). The subject_group_members + // `storage.role_grants` rows in the same transaction (there's no + // FK between role_grants and subject_groups). The subject_group_members // rows cascade automatically via FK. let result = sqlx::query("DELETE FROM auth.subject_groups WHERE id = $1") .bind(id) diff --git a/src/infrastructure/services/pg_acl_engine.rs b/src/infrastructure/services/pg_acl_engine.rs index 1d46db3b..652d54e3 100644 --- a/src/infrastructure/services/pg_acl_engine.rs +++ b/src/infrastructure/services/pg_acl_engine.rs @@ -1,13 +1,14 @@ //! PostgreSQL-backed implementation of `AuthorizationEngine`. //! -//! Stores grants in `storage.access_grants` (see migration -//! `20260520000000_rebac_access_grants.sql`). Cascading is resolved at check -//! time via PostgreSQL `ltree` `@>` (ancestor-of) on `storage.folders.lpath`, -//! using the existing GiST index for O(log N) traversal. +//! Stores grants in `storage.role_grants` (one role per (subject, resource) +//! pair; the role's permission bundle is expanded in code via +//! `Role::expand()`). Cascading is resolved at check time via PostgreSQL +//! `ltree` `@>` (ancestor-of) on `storage.folders.lpath`, using the +//! existing GiST index for O(log N) traversal. //! //! Owner is implicit — `storage.folders.user_id` / `storage.files.user_id` //! are checked first via dedicated helpers; if the caller is the owner, no -//! SQL against `access_grants` happens. +//! SQL against `role_grants` happens. //! //! ## Lifecycle cleanup //! @@ -37,14 +38,13 @@ use uuid::Uuid; use moka::future::Cache; use sqlx::PgPool; -use crate::application::dtos::grant_dto::roles_implying; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::common::errors::DomainError; use crate::domain::entities::subject_group::INTERNAL_GROUP_ID; use crate::domain::repositories::subject_group_repository::SubjectGroupRepository; use crate::domain::services::authorization::{ Grant, GrantCursor, IncomingGrantSummary, OutgoingGrantEntry, OutgoingResourceSummary, - Permission, Resource, ResourceKind, Subject, + Permission, Resource, ResourceKind, Role, Subject, roles_implying, }; use crate::infrastructure::repositories::pg::SubjectGroupPgRepository; use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository; @@ -206,7 +206,7 @@ impl PgAclEngine { } /// Expand a caller's `Subject` into the `(subject_types, subject_ids)` - /// pair that should be matched in `storage.access_grants`. For User + /// pair that should be matched in `storage.role_grants`. For User /// callers this is `(["user","group"], [uid, …transitive groups, INTERNAL])`; /// for any non-user subject (Token / External / Group as direct caller) /// it's a single-element pair with no cascade. @@ -239,8 +239,8 @@ impl PgAclEngine { } /// Convert a `Permission` into the array of role strings whose bundle - /// includes it — used to bind the `g.role = ANY($N::text[])` filter on - /// every cascade / lookup query that reads `storage.role_grants`. + /// includes it — bound as `ANY($N::storage.grant_role[])` so the + /// ENUM-typed `role` column compares without an implicit text cast. /// /// This is the inverse of `Role::expand()`, precomputed via /// `grant_dto::roles_implying()`. The mapping is small and static (≤5 @@ -264,11 +264,10 @@ impl PgAclEngine { /// `subject_ids` is the expanded set returned by `expand_user` (or a /// single-element vec for non-user callers). /// - /// **D-Prep**: now reads `storage.role_grants` (1 row per role - /// assignment) instead of `storage.access_grants` (N rows per role). - /// The permission filter `g.permission = $3` becomes - /// `g.role = ANY($3::text[])` where the array is the set of roles whose - /// bundle includes the requested permission — see `roles_implying()`. + /// Reads `storage.role_grants` (1 row per role assignment); a permission + /// filter `g.permission = $3` becomes `g.role = ANY($3::storage.grant_role[])` where + /// the array is the set of roles whose bundle includes the requested + /// permission — see `roles_implying()`. /// /// Uses the GiST index on `storage.folders.lpath` for O(log N) cascade. async fn folder_cascade_grant_exists( @@ -288,7 +287,7 @@ impl PgAclEngine { JOIN storage.folders gf ON gf.id = g.resource_id WHERE g.subject_type = ANY($1) AND g.subject_id = ANY($2) - AND g.role = ANY($3::text[]) + AND g.role = ANY($3::storage.grant_role[]) AND g.resource_type = 'folder' AND (g.expires_at IS NULL OR g.expires_at > NOW()) AND gf.lpath @> (SELECT lpath FROM storage.folders WHERE id = $4) @@ -329,7 +328,7 @@ impl PgAclEngine { FROM storage.role_grants WHERE subject_type = ANY($1) AND subject_id = ANY($2) - AND role = ANY($3::text[]) + AND role = ANY($3::storage.grant_role[]) AND resource_type = 'file' AND resource_id = $4 AND (expires_at IS NULL OR expires_at > NOW()) UNION ALL @@ -340,7 +339,7 @@ impl PgAclEngine { JOIN storage.files target_f ON target_f.id = $4 WHERE g.subject_type = ANY($1) AND g.subject_id = ANY($2) - AND g.role = ANY($3::text[]) + AND g.role = ANY($3::storage.grant_role[]) AND g.resource_type = 'folder' AND (g.expires_at IS NULL OR g.expires_at > NOW()) AND target_f.folder_id IS NOT NULL @@ -361,39 +360,16 @@ impl PgAclEngine { Ok(exists.is_some()) } - /// Look up a single grant by id. Returns `(resource, granted_by)` so - /// the REST `DELETE /api/grants/{id}` handler can decide authorization - /// without a second round-trip. Returns `Ok(None)` if no such grant. - pub async fn find_grant_by_id( - &self, - grant_id: Uuid, - ) -> Result, DomainError> { - let row: Option<(String, Uuid, Uuid)> = sqlx::query_as( - "SELECT resource_type, resource_id, granted_by FROM storage.access_grants WHERE id = $1", - ) - .bind(grant_id) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("PgAcl", format!("find_grant_by_id: {e}")))?; - - let Some((rt, rid, granter)) = row else { - return Ok(None); - }; - let res = Resource::from_parts(&rt, rid) - .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?; - Ok(Some((res, granter))) - } - - /// Variant of `find_grant_by_id` that also returns the subject — - /// needed by `POST /api/grants/{id}/notify` to resolve who to email. - /// Returns `(subject, resource, granted_by)` or `None`. + /// Look up a single role grant by id, returning the actors a revoke / + /// notify handler needs to make a decision without a second round-trip. + /// Returns `(subject, resource, granted_by)` or `None` if no such row. pub async fn find_grant_full_by_id( &self, grant_id: Uuid, ) -> Result, DomainError> { let row: Option<(String, Uuid, String, Uuid, Uuid)> = sqlx::query_as( "SELECT subject_type, subject_id, resource_type, resource_id, granted_by \ - FROM storage.access_grants WHERE id = $1", + FROM storage.role_grants WHERE id = $1", ) .bind(grant_id) .fetch_optional(self.pool.as_ref()) @@ -410,8 +386,14 @@ impl PgAclEngine { Ok(Some((subject, resource, granter))) } - /// Row type for all full-grant SELECT queries: - /// (id, subject_type, subject_id, resource_type, resource_id, permission, granted_by, granted_at, expires_at) + /// Row type for `storage.role_grants` SELECTs: + /// (id, subject_type, subject_id, resource_type, resource_id, role, granted_by, granted_at, expires_at). + /// + /// Builds a single role-keyed `Grant` per row. `Grant` is role-keyed + /// since the D-Prep cleanup PR — every listing method returns role + /// rows directly; bundle expansion to per-permission Grants no longer + /// happens here. Callers that need the permission set use + /// `grant.role.expand()` at the call site. #[allow(clippy::type_complexity)] fn row_to_grant( row: ( @@ -430,13 +412,13 @@ impl PgAclEngine { .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown subject_type"))?; let resource = Resource::from_parts(&row.3, row.4) .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown resource_type"))?; - let permission = Permission::parse(&row.5) - .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown permission"))?; + let role = Role::parse(&row.5) + .ok_or_else(|| DomainError::internal_error("PgAcl", "unknown role"))?; Ok(Grant { id: row.0, subject, resource, - permission, + role, granted_by: row.6, granted_at: row.7, expires_at: row.8, @@ -554,15 +536,14 @@ impl AuthorizationEngine for PgAclEngine { result } - async fn list_incoming_grants( - &self, - subject: Subject, - permission_filter: Option, - ) -> Result, DomainError> { - let perm_str = permission_filter.map(|p| p.as_str().to_string()); + async fn list_incoming_grants(&self, subject: Subject) -> Result, DomainError> { let counters = QueryCounters::default(); let (subject_types, subject_ids) = self.subject_match_set(subject, &counters).await?; + // `ORDER BY role ASC` exploits the `storage.grant_role` ENUM + // declared as `(owner, editor, contributor, commenter, viewer)`, + // so the sort order matches the UX requirement ("Owner > Editor + // > Contributor > Commenter > Viewer") without a per-row CASE. let rows = sqlx::query_as::< _, ( @@ -579,18 +560,16 @@ impl AuthorizationEngine for PgAclEngine { >( r#" SELECT id, subject_type, subject_id, resource_type, resource_id, - permission, granted_by, granted_at, expires_at - FROM storage.access_grants + role::text, granted_by, granted_at, expires_at + FROM storage.role_grants WHERE subject_type = ANY($1) AND subject_id = ANY($2) - AND ($3::text IS NULL OR permission = $3) - ORDER BY granted_at DESC - LIMIT $4 + ORDER BY role ASC, granted_at DESC + LIMIT $3 "#, ) .bind(&subject_types) .bind(&subject_ids) - .bind(perm_str) .bind(MAX_GRANT_ROWS + 1) .fetch_all(self.pool.as_ref()) .await @@ -621,7 +600,12 @@ impl AuthorizationEngine for PgAclEngine { // NULL otherwise. This lets every sort mode share a single query_as call. // 0 resource_type String // 1 resource_id Uuid - // 2 permissions Vec + // 2 roles Vec — every distinct role granting access to this + // resource (post-D-Prep). Expanded to permissions + // in `IncomingGrantSummary` via `Role::expand()`. + // Multiple entries possible when a user has both + // a direct grant and a group-mediated grant on + // the same resource. // 3 granted_at DateTime // 4 granted_by Uuid // 5 sort_str Option — resource_name (name/type) or owner_name (granted_by) @@ -653,14 +637,20 @@ impl AuthorizationEngine for PgAclEngine { // is `(["user","group"], [uid, …transitive groups, INTERNAL])` so the // listing includes every resource the user can reach via a group // grant (matching what `check()` allows). See `subject_match_set`. + // + // Post-D-Prep this reads `storage.role_grants` and aggregates the + // ENUM-typed `role` column into a text array. Multiple roles can + // appear per resource when the caller reaches it via both a direct + // grant and a group-mediated grant — the union of role bundles + // produces the displayed permission set in Rust below. const AGG: &str = r#"agg AS ( SELECT resource_type, resource_id, - array_agg(DISTINCT permission ORDER BY permission) AS permissions, + array_agg(DISTINCT role::text ORDER BY role::text) AS roles, MIN(granted_at) AS granted_at, (array_agg(granted_by ORDER BY granted_at))[1] AS granted_by - FROM storage.access_grants + FROM storage.role_grants WHERE subject_type = ANY($1) AND subject_id = ANY($2) AND ($3::text[] IS NULL OR resource_type = ANY($3)) @@ -725,7 +715,7 @@ impl AuthorizationEngine for PgAclEngine { LEFT JOIN storage.folders f ON f.id = agg.resource_id AND agg.resource_type = 'folder' LEFT JOIN storage.files fi ON fi.id = agg.resource_id AND agg.resource_type = 'file' ) - SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int + SELECT resource_type, resource_id, roles, granted_at, granted_by, sort_str, sort_int FROM named WHERE {where_clause} ORDER BY {order_clause} @@ -765,7 +755,7 @@ impl AuthorizationEngine for PgAclEngine { FROM agg LEFT JOIN auth.users u ON u.id = agg.granted_by ) - SELECT resource_type, resource_id, permissions, granted_at, granted_by, sort_str, sort_int + SELECT resource_type, resource_id, roles, granted_at, granted_by, sort_str, sort_int FROM owner_named WHERE {where_clause} ORDER BY {order_clause} @@ -793,7 +783,7 @@ impl AuthorizationEngine for PgAclEngine { }; format!( r#"WITH {AGG} - SELECT resource_type, resource_id, permissions, granted_at, granted_by, + SELECT resource_type, resource_id, roles, granted_at, granted_by, NULL::text AS sort_str, NULL::bigint AS sort_int FROM agg @@ -875,14 +865,21 @@ impl AuthorizationEngine for PgAclEngine { }; // ── Convert rows to domain summaries ────────────────────────────────── + // Post-D-Prep: the SQL aggregate produces a `roles` text array. We + // expand each role's bundle and union them — direct grants and + // group-mediated grants on the same resource collapse to a single + // deduplicated permission set, matching the pre-pivot behaviour. let summaries = rows .into_iter() - .filter_map(|(rt, rid, perms_str, granted_at, granted_by, _, _)| { + .filter_map(|(rt, rid, roles_str, granted_at, granted_by, _, _)| { let resource_type = ResourceKind::parse(&rt)?; - let permissions = perms_str + let mut permissions: Vec = roles_str .into_iter() - .filter_map(|s| Permission::parse(&s)) + .filter_map(|s| Role::parse(&s)) + .flat_map(|r| r.expand().iter().copied()) .collect(); + permissions.sort_by_key(|p| p.as_str()); + permissions.dedup(); Some(IncomingGrantSummary { resource_type, resource_id: rid, @@ -897,6 +894,14 @@ impl AuthorizationEngine for PgAclEngine { } async fn list_grants_on_resource(&self, resource: Resource) -> Result, DomainError> { + // Pivoted to `storage.role_grants` (see `list_incoming_grants`). + // Each role row expands to N permission-keyed `Grant` rows via + // `role_row_to_grants` until the public `Grant` shape becomes + // role-keyed. + // + // `ORDER BY role ASC` exploits the `storage.grant_role` ENUM's + // declaration order (owner first → viewer last) so the share + // dialog's "who has access" list shows strongest grants on top. let rows = sqlx::query_as::< _, ( @@ -913,11 +918,11 @@ impl AuthorizationEngine for PgAclEngine { >( r#" SELECT id, subject_type, subject_id, resource_type, resource_id, - permission, granted_by, granted_at, expires_at - FROM storage.access_grants + role::text, granted_by, granted_at, expires_at + FROM storage.role_grants WHERE resource_type = $1 AND resource_id = $2 - ORDER BY granted_at DESC + ORDER BY role ASC, granted_at DESC LIMIT $3 "#, ) @@ -942,7 +947,10 @@ impl AuthorizationEngine for PgAclEngine { ) -> Result<(Vec, Option), DomainError> { let fetch_limit = (limit as i64) + 1; - // Row shape — one row per (resource, subject, permission). + // Row shape — post-D-Prep, one row per (resource, subject) since + // `storage.role_grants` carries exactly one role per pair (UNIQUE + // constraint). Permission bundles are expanded in the row consumer + // via `Role::expand()`. // Columns: // 0 resource_type String // 1 resource_id Uuid @@ -951,9 +959,9 @@ impl AuthorizationEngine for PgAclEngine { // 4 subject_id Uuid // 5 subject_display String — username or share item_name // 6 grant_id Uuid - // 7 granted_at DateTime — this (subject, perm) row + // 7 granted_at DateTime — this (subject, role) row // 8 expires_at Option> - // 9 permission String + // 9 role String — `grant_role` ENUM as text // 10 sort_str Option // 11 sort_int Option // 12 has_password bool — token: shares.password_hash IS NOT NULL @@ -1040,7 +1048,7 @@ impl AuthorizationEngine for PgAclEngine { CASE WHEN ag.resource_type = 'file' THEN fi.name END ) AS sort_str, {sort_int_expr} AS sort_int - FROM storage.access_grants ag + FROM storage.role_grants ag LEFT JOIN storage.folders f ON f.id = ag.resource_id AND ag.resource_type = 'folder' LEFT JOIN storage.files fi ON fi.id = ag.resource_id AND ag.resource_type = 'file' WHERE ag.granted_by = $1 @@ -1055,12 +1063,12 @@ impl AuthorizationEngine for PgAclEngine { SELECT ag.resource_type, ag.resource_id, rp.first_shared_at, ag.subject_type, ag.subject_id, COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, - ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, + ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.role::text AS role, rp.sort_str, rp.sort_int, (sh.password_hash IS NOT NULL) AS has_password, COALESCE(u.is_external, FALSE) AS is_external FROM rp - JOIN storage.access_grants ag + JOIN storage.role_grants ag ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id AND ag.granted_by = $1 LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id @@ -1129,7 +1137,7 @@ impl AuthorizationEngine for PgAclEngine { ELSE 3 END)::bigint AS sort_int, MIN(ag.granted_at) AS first_granted_at - FROM storage.access_grants ag + FROM storage.role_grants ag LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id LEFT JOIN auth.subject_groups sg @@ -1160,13 +1168,13 @@ impl AuthorizationEngine for PgAclEngine { ag.id AS grant_id, ag.granted_at, ag.expires_at, - ag.permission, + ag.role::text AS role, LOWER(rp.subject_display) AS sort_str, rp.sort_int, rp.has_password, rp.is_external FROM rp - JOIN storage.access_grants ag + JOIN storage.role_grants ag ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id AND ag.subject_type = rp.subject_type @@ -1180,7 +1188,12 @@ impl AuthorizationEngine for PgAclEngine { // Page on (role_order, subject_display, resource_id) triples so that all // of one person's grants within a role are contiguous — enabling aggregation // ("Bob on Folder A, Folder B") to work correctly across cursor pages. - // role_order: 0 = admin (has delete+share), 1 = editor (has create or update), 2 = viewer + // + // role_order matches the `storage.grant_role` ENUM declaration + // order (strongest first) via `array_position`, so + // `sort_int ASC` matches the UX requirement: 1 = owner, + // 2 = editor, 3 = contributor, 4 = commenter, 5 = viewer. + // 1-based because `array_position` is. // Cursor: sort_int=role_order, resource_name=LOWER(subject_display), resource_id let (page_where, page_order) = if reverse { ( @@ -1209,15 +1222,20 @@ impl AuthorizationEngine for PgAclEngine { MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display, BOOL_OR(sh.password_hash IS NOT NULL) AS has_password, COALESCE(BOOL_OR(u.is_external), FALSE) AS is_external, - CASE - WHEN BOOL_OR(ag.permission = 'delete') - AND BOOL_OR(ag.permission = 'share') THEN 0 - WHEN BOOL_OR(ag.permission = 'create') - OR BOOL_OR(ag.permission = 'update') THEN 1 - ELSE 2 - END::bigint AS sort_int, + -- One role per (resource, subject) post-D-Prep + -- (UNIQUE constraint on role_grants), so MAX + -- returns that single row's role. `array_position` + -- against the ENUM's declaration order produces a + -- 1-based rank: owner=1 → viewer=5. Strength + -- ordering tracks the ENUM declaration — adding + -- a new role between owner and viewer doesn't + -- need a parallel CASE update here. + array_position( + enum_range(NULL::storage.grant_role), + MAX(ag.role) + )::bigint AS sort_int, MIN(ag.granted_at) AS first_granted_at - FROM storage.access_grants ag + FROM storage.role_grants ag LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id LEFT JOIN storage.shares sh @@ -1246,13 +1264,13 @@ impl AuthorizationEngine for PgAclEngine { ag.id AS grant_id, ag.granted_at, ag.expires_at, - ag.permission, + ag.role::text AS role, LOWER(rp.subject_display) AS sort_str, rp.sort_int, rp.has_password, rp.is_external FROM rp - JOIN storage.access_grants ag + JOIN storage.role_grants ag ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id AND ag.subject_type = rp.subject_type @@ -1284,7 +1302,7 @@ impl AuthorizationEngine for PgAclEngine { SELECT resource_type, resource_id, MIN(granted_at) AS first_shared_at, NULL::text AS sort_str, NULL::bigint AS sort_int - FROM storage.access_grants + FROM storage.role_grants WHERE granted_by = $1 GROUP BY resource_type, resource_id ), @@ -1297,12 +1315,12 @@ impl AuthorizationEngine for PgAclEngine { SELECT ag.resource_type, ag.resource_id, rp.first_shared_at, ag.subject_type, ag.subject_id, COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display, - ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission, + ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.role::text AS role, NULL::text AS sort_str, NULL::bigint AS sort_int, (sh.password_hash IS NOT NULL) AS has_password, COALESCE(u.is_external, FALSE) AS is_external FROM rp - JOIN storage.access_grants ag + JOIN storage.role_grants ag ON ag.resource_type = rp.resource_type AND ag.resource_id = rp.resource_id AND ag.granted_by = $1 LEFT JOIN auth.users u ON ag.subject_type = 'user' AND u.id = ag.subject_id @@ -1380,7 +1398,7 @@ impl AuthorizationEngine for PgAclEngine { grant_id, granted_at, expires_at, - perm_str, + role_str, _, _, has_password, @@ -1389,7 +1407,7 @@ impl AuthorizationEngine for PgAclEngine { let Some(resource_type) = ResourceKind::parse(&rt_str) else { continue; }; - let Some(perm) = Permission::parse(&perm_str) else { + let Some(role) = Role::parse(&role_str) else { continue; }; let key = (resource_id, subj_id); @@ -1409,8 +1427,10 @@ impl AuthorizationEngine for PgAclEngine { }, ) }); - if !entry.permissions.contains(&perm) { - entry.permissions.push(perm); + for &perm in role.expand() { + if !entry.permissions.contains(&perm) { + entry.permissions.push(perm); + } } } @@ -1498,7 +1518,7 @@ impl AuthorizationEngine for PgAclEngine { grant_id, granted_at, expires_at, - perm_str, + role_str, _, _, has_password, @@ -1507,7 +1527,7 @@ impl AuthorizationEngine for PgAclEngine { let Some(resource_type) = ResourceKind::parse(&rt_str) else { continue; }; - let Some(perm) = Permission::parse(&perm_str) else { + let Some(role) = Role::parse(&role_str) else { continue; }; @@ -1531,8 +1551,10 @@ impl AuthorizationEngine for PgAclEngine { has_password, is_external, }); - if !entry.permissions.contains(&perm) { - entry.permissions.push(perm); + for &perm in role.expand() { + if !entry.permissions.contains(&perm) { + entry.permissions.push(perm); + } } } @@ -1578,6 +1600,11 @@ impl AuthorizationEngine for PgAclEngine { } async fn list_outgoing_grants(&self, granted_by: Uuid) -> Result, DomainError> { + // Pivoted to `storage.role_grants` (see `list_incoming_grants`). + // Group membership doesn't apply on the outgoing side — we + // filter by `granted_by` directly. Bundle expansion still + // happens at read time via `role_row_to_grants` until the + // public `Grant` shape becomes role-keyed. let rows = sqlx::query_as::< _, ( @@ -1594,10 +1621,10 @@ impl AuthorizationEngine for PgAclEngine { >( r#" SELECT id, subject_type, subject_id, resource_type, resource_id, - permission, granted_by, granted_at, expires_at - FROM storage.access_grants + role::text, granted_by, granted_at, expires_at + FROM storage.role_grants WHERE granted_by = $1 - ORDER BY granted_at DESC + ORDER BY role ASC, granted_at DESC "#, ) .bind(granted_by) @@ -1608,11 +1635,68 @@ impl AuthorizationEngine for PgAclEngine { rows.into_iter().map(Self::row_to_grant).collect() } - async fn grant( + async fn set_expiry_for_subject( + &self, + subject: Subject, + expires_at: Option>, + ) -> Result<(), DomainError> { + sqlx::query( + "UPDATE storage.role_grants SET expires_at = $3 \ + WHERE subject_type = $1 AND subject_id = $2", + ) + .bind(subject.type_str()) + .bind(subject.id()) + .bind(expires_at) + .execute(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}")) + })?; + Ok(()) + } + + async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> { + sqlx::query("DELETE FROM storage.role_grants WHERE id = $1") + .bind(grant_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?; + Ok(()) + } + + async fn revoke_all_for_resource(&self, resource: Resource) -> Result { + let result = sqlx::query( + "DELETE FROM storage.role_grants WHERE resource_type = $1 AND resource_id = $2", + ) + .bind(resource.type_str()) + .bind(resource.id()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?; + + Ok(result.rows_affected() as usize) + } + + async fn revoke_all_for_subject(&self, subject: Subject) -> Result { + let result = sqlx::query( + "DELETE FROM storage.role_grants WHERE subject_type = $1 AND subject_id = $2", + ) + .bind(subject.type_str()) + .bind(subject.id()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?; + + Ok(result.rows_affected() as usize) + } + + // ── D-Prep role_grants writes ────────────────────────────────────────── + + async fn set_role( &self, granted_by: Uuid, subject: Subject, - permission: Permission, + role: Role, resource: Resource, expires_at: Option>, ) -> Result { @@ -1630,149 +1714,17 @@ impl AuthorizationEngine for PgAclEngine { Option>, ), >( - r#" - INSERT INTO storage.access_grants - (subject_type, subject_id, resource_type, resource_id, permission, granted_by, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission) - DO UPDATE SET expires_at = EXCLUDED.expires_at - RETURNING id, subject_type, subject_id, resource_type, resource_id, - permission, granted_by, granted_at, expires_at - "#, - ) - .bind(subject.type_str()) - .bind(subject.id()) - .bind(resource.type_str()) - .bind(resource.id()) - .bind(permission.as_str()) - .bind(granted_by) - .bind(expires_at) - .fetch_one(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("PgAcl", format!("insert grant: {e}")))?; - - Self::row_to_grant(row) - } - - async fn set_expiry_for_subject( - &self, - subject: Subject, - expires_at: Option>, - ) -> Result<(), DomainError> { - sqlx::query( - "UPDATE storage.access_grants SET expires_at = $3 WHERE subject_type = $1 AND subject_id = $2", - ) - .bind(subject.type_str()) - .bind(subject.id()) - .bind(expires_at) - .execute(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("PgAcl", format!("set_expiry_for_subject: {e}")))?; - Ok(()) - } - - async fn set_expiry_on_resource( - &self, - subject: Subject, - resource: Resource, - expires_at: Option>, - ) -> Result<(), DomainError> { - sqlx::query( - "UPDATE storage.access_grants SET expires_at = $3 \ - WHERE subject_type = $1 AND subject_id = $2 \ - AND resource_type = $4 AND resource_id = $5", - ) - .bind(subject.type_str()) - .bind(subject.id()) - .bind(expires_at) - .bind(resource.type_str()) - .bind(resource.id()) - .execute(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("PgAcl", format!("set_expiry_on_resource: {e}")) - })?; - Ok(()) - } - - async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> { - sqlx::query("DELETE FROM storage.access_grants WHERE id = $1") - .bind(grant_id) - .execute(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke: {e}")))?; - Ok(()) - } - - async fn revoke_all_for_resource(&self, resource: Resource) -> Result { - // D-Prep dual-write: wipe role_grants for this resource too. - // Idempotent — succeeds whether or not any row existed. - sqlx::query( - "DELETE FROM storage.role_grants WHERE resource_type = $1 AND resource_id = $2", - ) - .bind(resource.type_str()) - .bind(resource.id()) - .execute(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("PgAcl", format!("revoke role_grants for resource: {e}")) - })?; - - let result = sqlx::query( - "DELETE FROM storage.access_grants WHERE resource_type = $1 AND resource_id = $2", - ) - .bind(resource.type_str()) - .bind(resource.id()) - .execute(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for resource: {e}")))?; - - Ok(result.rows_affected() as usize) - } - - async fn revoke_all_for_subject(&self, subject: Subject) -> Result { - // D-Prep dual-write: wipe role_grants for this subject too. - sqlx::query("DELETE FROM storage.role_grants WHERE subject_type = $1 AND subject_id = $2") - .bind(subject.type_str()) - .bind(subject.id()) - .execute(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("PgAcl", format!("revoke role_grants for subject: {e}")) - })?; - - let result = sqlx::query( - "DELETE FROM storage.access_grants WHERE subject_type = $1 AND subject_id = $2", - ) - .bind(subject.type_str()) - .bind(subject.id()) - .execute(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("PgAcl", format!("revoke for subject: {e}")))?; - - Ok(result.rows_affected() as usize) - } - - // ── D-Prep role_grants writes ────────────────────────────────────────── - - async fn set_role( - &self, - granted_by: Uuid, - subject: Subject, - role: crate::application::dtos::grant_dto::Role, - resource: Resource, - expires_at: Option>, - ) -> Result<(), DomainError> { - sqlx::query( r#" INSERT INTO storage.role_grants (subject_type, subject_id, resource_type, resource_id, role, granted_by, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + VALUES ($1, $2, $3, $4, $5::storage.grant_role, $6, $7) ON CONFLICT (subject_type, subject_id, resource_type, resource_id) DO UPDATE SET role = EXCLUDED.role, expires_at = EXCLUDED.expires_at, granted_by = EXCLUDED.granted_by + RETURNING id, subject_type, subject_id, resource_type, resource_id, + role::text, granted_by, granted_at, expires_at "#, ) .bind(subject.type_str()) @@ -1782,11 +1734,11 @@ impl AuthorizationEngine for PgAclEngine { .bind(role.as_str()) .bind(granted_by) .bind(expires_at) - .execute(self.pool.as_ref()) + .fetch_one(self.pool.as_ref()) .await .map_err(|e| DomainError::internal_error("PgAcl", format!("set_role: {e}")))?; - Ok(()) + Self::row_to_grant(row) } async fn clear_role(&self, subject: Subject, resource: Resource) -> Result<(), DomainError> { diff --git a/src/integration_test_support.rs b/src/integration_test_support.rs index 8e2e2e3a..8b4cf043 100644 --- a/src/integration_test_support.rs +++ b/src/integration_test_support.rs @@ -42,7 +42,7 @@ pub fn test_db_url() -> String { /// OnceCell so concurrent test threads block until the first caller /// finishes; subsequent calls are zero-cost. /// -/// Order matters: `storage.access_grants` rows go first because there's +/// Order matters: `storage.role_grants` rows go first because there's /// no FK from there to `auth.subject_groups` (the service's `delete` /// path does this transactionally; here we bypass the service). static CLEANUP_ONCE: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); @@ -51,7 +51,7 @@ pub async fn ensure_clean_test_db(pool: &PgPool) { CLEANUP_ONCE .get_or_init(|| async { let _ = sqlx::query( - "DELETE FROM storage.access_grants + "DELETE FROM storage.role_grants WHERE subject_type = 'group' AND subject_id IN ( SELECT id FROM auth.subject_groups WHERE name LIKE 'rust-test-%' diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 21617cb5..7fc39ab3 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -21,9 +21,9 @@ use uuid::Uuid; use crate::application::dtos::cursor::PageCursor; use crate::application::dtos::grant_dto::{ CreateGrantDto, CreateGrantResponseDto, GrantDto, MySharesDto, NotifyOutcomeSetDto, - OutgoingResourceGrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto, - ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, - SubjectDto, SubjectInputDto, UpdateRoleDto, role_from_permissions, + OutgoingResourceGrantDto, OutgoingResourceItemDto, ResourceContentDto, ResourceDto, + ResourceTypeDto, SharedWithMeDto, SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, + SubjectInputDto, UpdateRoleDto, role_from_permissions, }; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_ports::FileRetrievalUseCase; @@ -35,7 +35,7 @@ use crate::common::errors::DomainError; use crate::domain::errors::ErrorKind; use crate::domain::services::authorization::{ GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind, - Subject, + Role, Subject, }; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; @@ -66,41 +66,7 @@ pub async fn create_grant( let authz = &state.authorization; let caller_id = auth_user.id; - // Validate: exactly one of permissions/role. Capture BOTH the - // permission list (for the per-permission access_grants writes that - // keep the old engine read path working) AND the role (for the new - // role_grants `set_role` dual-write that lands after the per- - // permission loop). - let (permissions, role): (Vec, Role) = match (dto.permissions, dto.role) { - (Some(perms), None) if !perms.is_empty() => { - let perms: Vec = perms.into_iter().map(Into::into).collect(); - // Derive the closest matching role from the raw permission set - // so we have ONE role to mirror into role_grants. `Role::parse` - // always succeeds here because `role_from_permissions` only - // emits known role strings. - let role = Role::parse(role_from_permissions(&perms)) - .expect("role_from_permissions returns a known role string"); - (perms, role) - } - (None, Some(role)) => (role.expand().to_vec(), role), - (Some(_), Some(_)) => { - return AppError::new( - StatusCode::BAD_REQUEST, - "Provide either 'permissions' or 'role', not both", - "InvalidInput", - ) - .into_response(); - } - _ => { - return AppError::new( - StatusCode::BAD_REQUEST, - "Either 'permissions' (non-empty) or 'role' is required", - "InvalidInput", - ) - .into_response(); - } - }; - + let role: Role = dto.role.into(); let resource: Resource = dto.resource.into(); let expires_at = dto.expires_at; @@ -165,41 +131,20 @@ pub async fn create_grant( } }; - let mut results: Vec = Vec::with_capacity(permissions.len()); - for perm in permissions { - // `storage.access_grants.permission` CHECK constraint predates - // `Permission::Manage`; it accepts only the original 6 values. - // Manage exists in the Owner bundle for engine read-path use - // (via `roles_implying`) and gets persisted via the role_grants - // dual-write below. Skipping it here keeps the access_grants - // safety net populated without tripping the CHECK; the cleanup - // PR that drops access_grants also drops this skip. - if perm == Permission::Manage { - continue; - } - match authz - .grant(caller_id, subject, perm, resource, expires_at) - .await - { - Ok(grant) => results.push(grant.into()), - Err(err) => { - error!("grant insert failed for {perm:?}: {err}"); - return AppError::from(err).into_response(); - } - } - } - - // D-Prep dual-write: mirror the role assignment into storage.role_grants. - // ON CONFLICT UPDATE makes this idempotent — repeated POSTs with the - // same (subject, resource) update the role in place, matching the - // PATCH-style semantics callers will get after the engine read pivot. - if let Err(err) = authz + // Single role row in `storage.role_grants`. `ON CONFLICT UPDATE` in + // the engine makes repeated POSTs with the same (subject, resource) + // a role refresh, matching the PATCH-style semantics callers expect. + let grant = match authz .set_role(caller_id, subject, role, resource, expires_at) .await { - error!("set_role dual-write failed: {err}"); - return AppError::from(err).into_response(); - } + Ok(g) => g, + Err(err) => { + error!("set_role write failed: {err}"); + return AppError::from(err).into_response(); + } + }; + let grants = vec![GrantDto::from(grant)]; tracing::info!( target: "audit", @@ -210,7 +155,6 @@ pub async fn create_grant( resource_type = resource.type_str(), resource_id = %resource.id(), role = role.as_str(), - permission_count = results.len(), expires_at = ?expires_at, "🤝 grant created with role '{}'", role.as_str(), ); @@ -282,7 +226,7 @@ pub async fn create_grant( ( StatusCode::CREATED, Json(CreateGrantResponseDto { - grants: results, + grants, notification, }), ) @@ -560,9 +504,8 @@ pub async fn set_role( let caller_id = auth_user.id; let subject: Subject = dto.subject.into(); let resource: Resource = dto.resource.into(); + let role: Role = dto.role.into(); let expires_at = dto.expires_at; - let target_perms: std::collections::HashSet = - dto.role.expand().iter().copied().collect(); // Caller must have Share on the resource. if let Err(e) = authz @@ -572,86 +515,16 @@ pub async fn set_role( return AppError::from(e).into_response(); } - // Fetch current grants on the resource for this subject. - let current = match authz.list_grants_on_resource(resource).await { + // Atomic role refresh. UNIQUE on (subject, resource) + ON CONFLICT + // UPDATE in `set_role` turns this into a single UPSERT — no diff, + // no race window. Returns the resulting role row. + let grant = match authz + .set_role(caller_id, subject, role, resource, expires_at) + .await + { Ok(g) => g, Err(e) => return AppError::from(e).into_response(), }; - let current_perms: std::collections::HashSet = current - .iter() - .filter(|g| g.subject == subject) - .map(|g| g.permission) - .collect(); - - // Diff and apply. `Permission::Manage` is excluded from both sides - // because the historical `access_grants.permission` CHECK doesn't - // accept it — see the matching skip in `create_grant`. The role - // assignment captures Manage via the role_grants `set_role` call - // further down; the engine's read-path uses `roles_implying(Manage)` - // → `[Owner]` and never goes through per-permission rows. - let to_add: Vec = target_perms - .difference(¤t_perms) - .copied() - .filter(|p| *p != Permission::Manage) - .collect(); - let to_remove: Vec = current_perms - .difference(&target_perms) - .copied() - .filter(|p| *p != Permission::Manage) - .collect(); - - for perm in &to_remove { - if let Some(g) = current - .iter() - .find(|g| g.subject == subject && g.permission == *perm) - && let Err(e) = authz.revoke(g.id).await - { - return AppError::from(e).into_response(); - } - } - for perm in &to_add { - if let Err(e) = authz - .grant(caller_id, subject, *perm, resource, expires_at) - .await - { - return AppError::from(e).into_response(); - } - } - - // Sync expiry on all remaining grants for this (subject, resource) pair — - // includes newly added ones and any that were already present (retained). - // Callers that omit expires_at will clear any existing expiry; this is - // intentional: it keeps all permission rows for the pair consistent. - if let Err(e) = authz - .set_expiry_on_resource(subject, resource, expires_at) - .await - { - return AppError::from(e).into_response(); - } - - // D-Prep dual-write: mirror the resulting role into storage.role_grants. - // The per-permission diff above keeps access_grants converged; this - // single UPSERT keeps role_grants in sync with the OVERALL outcome - // (one row carrying the role + expiry). After the engine read pivot - // and the access_grants drop, the per-permission diff above goes - // away and this call becomes the only mutation the handler performs. - if let Err(e) = authz - .set_role(caller_id, subject, dto.role, resource, expires_at) - .await - { - return AppError::from(e).into_response(); - } - - // Return the new full set. - let after = match authz.list_grants_on_resource(resource).await { - Ok(g) => g, - Err(e) => return AppError::from(e).into_response(), - }; - let mine: Vec = after - .into_iter() - .filter(|g| g.subject == subject) - .map(Into::into) - .collect(); tracing::info!( target: "audit", @@ -661,34 +534,22 @@ pub async fn set_role( subject_id = %subject.id(), resource_type = resource.type_str(), resource_id = %resource.id(), - role = dto.role.as_str(), - permissions_added = to_add.len(), - permissions_removed = to_remove.len(), + role = role.as_str(), expires_at = ?expires_at, - "🔁 role set to '{}' (+{} -{})", - dto.role.as_str(), - to_add.len(), - to_remove.len(), + "🔁 role set to '{}'", role.as_str(), ); - (StatusCode::OK, Json(mine)).into_response() + (StatusCode::OK, Json(vec![GrantDto::from(grant)])).into_response() } // ════════════════════════════════════════════════════════════════════════════ // GET /api/grants/incoming // ════════════════════════════════════════════════════════════════════════════ -#[derive(Debug, Deserialize, IntoParams)] -pub struct IncomingQuery { - #[serde(default)] - pub permission: Option, -} - #[utoipa::path( get, path = "/api/grants/incoming", - params(IncomingQuery), responses( - (status = 200, description = "Direct grants targeting the caller", body = Vec), + (status = 200, description = "Direct role grants targeting the caller", body = Vec), ), security(("bearerAuth" = [])), tag = "grants" @@ -696,12 +557,11 @@ pub struct IncomingQuery { pub async fn list_incoming( State(state): State, auth_user: AuthUser, - Query(q): Query, ) -> impl IntoResponse { let caller_id = auth_user.id; match state .authorization - .list_incoming_grants(Subject::User(caller_id), q.permission.map(Into::into)) + .list_incoming_grants(Subject::User(caller_id)) .await { Ok(grants) => { diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index e30113f3..2c07d61f 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -23,7 +23,7 @@ use crate::application::dtos::folder_dto::{ use crate::application::dtos::folder_listing_dto::FolderListingDto; use crate::application::dtos::grant_dto::{ CreateGrantDto, GrantDto, OutgoingResourceItemDto, PermissionDto, ResourceContentDto, - ResourceDto, ResourceTypeDto, Role, SharedWithMeDto, SharedWithMeItemDto, SubjectDto, + ResourceDto, ResourceTypeDto, RoleDto, SharedWithMeDto, SharedWithMeItemDto, SubjectDto, SubjectTypeDto, UpdateRoleDto, }; use crate::application::dtos::i18n_dto::{ @@ -351,7 +351,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; ResourceTypeDto, ResourceDto, PermissionDto, - Role, + RoleDto, CreateGrantDto, UpdateRoleDto, GrantDto, diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js index dc5b246a..6c876301 100644 --- a/static/js/components/shareModal.js +++ b/static/js/components/shareModal.js @@ -73,23 +73,6 @@ function _looksLikeEmail(q) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q); } -/** - * Permissions that belong to each role (mirrors `Role::expand()` in - * `src/application/dtos/grant_dto.rs`). The share modal only renders - * Viewer/Editor/Owner as picker buttons today; `commenter` and - * `contributor` are kept here for fidelity with the server-side enum so - * a future UI exposure doesn't need a mirror-table update. The `manage` - * permission in the owner bundle is reserved for Drive- and Group-level - * admin actions (no-op on file/folder resources). - */ -const ROLE_PERMISSIONS = { - viewer: ['read'], - commenter: ['read', 'comment'], - contributor: ['read', 'create'], - editor: ['read', 'comment', 'create', 'update'], - owner: ['read', 'comment', 'create', 'update', 'share', 'delete', 'manage'] -}; - /** * Fetch up to ~8 ReBAC subject groups whose name matches `q`. Authenticated * endpoint; returns `[]` on any failure so the autocomplete degrades to @@ -117,14 +100,20 @@ async function _searchGroups(q) { } /** - * Derive the highest role a set of grants represents for one subject. + * Pick the displayed role for a member row. Server-side every Grant + * carries an explicit role since the cleanup PR, so this just reads it. + * The server may emit `commenter` or `contributor` (full enum), but the + * picker only exposes Viewer/Editor/Owner — collapse the two unexposed + * roles to the closest neighbour so the UI never renders an unknown + * option. * @param {Grant[]} subjectGrants * @returns {ShareRoleEnum} */ function _roleFromGrants(subjectGrants) { - const perms = new Set(subjectGrants.map((g) => g.permission)); - if (perms.has('delete') || perms.has('share')) return 'owner'; - if (perms.has('create') || perms.has('update')) return 'editor'; + const role = subjectGrants[0]?.role; + if (role === 'owner' || role === 'editor' || role === 'viewer') return role; + if (role === 'commenter') return 'viewer'; + if (role === 'contributor') return 'editor'; return 'viewer'; } @@ -616,7 +605,7 @@ const shareModal = { granted_at: '', granted_by: '', subject: { type: subjectType, id: contact.id }, - permission: /** @type {import('../core/types.js').PermissionTypeEnum} */ (ROLE_PERMISSIONS[this._stagedRole][0]), + role: this._stagedRole, resource: { type: this._itemType, id: this._item?.id ?? '' } }; this._localMembers.push({ diff --git a/static/js/core/types.js b/static/js/core/types.js index 3a719a2b..0c12efb2 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -302,21 +302,27 @@ * @property {String} id */ +/** + * Server-side role enum — every grantable role the backend recognises. + * The share modal's UI picker only exposes a subset (see `ShareRoleEnum`); + * the wire format may carry any of these values on a Grant. + * @typedef {'viewer'|'commenter'|'contributor'|'editor'|'owner'} GrantRoleEnum + */ + /** * @typedef {Object} Grant * @property {string} id * @property {string} granted_at - ISO-8601 datetime string. * @property {string} granted_by * @property {Subject} subject - * @property {PermissionTypeEnum} permission + * @property {GrantRoleEnum} role - Role-keyed grant. One Grant = one role + * assignment in `storage.role_grants`. The implied permission bundle + * is derived client-side from the same lookup table used by + * `Role::expand()` on the server (see `ROLE_PERMISSIONS` in shareModal). * @property {Resource} resource * @property {string|null} [expires_at] - ISO-8601 datetime string, or absent/null for no expiry. */ -/** - * Roles: `viewer`, `commenter`, `editor`, `manager`, `admin` - */ - /** * Configuration for `ResourceListComponent`. * @typedef {Object} ResourceListConfig @@ -367,7 +373,7 @@ * @property {'user'|'group'|'token'|'external'} subject_type * @property {string} subject_id * @property {string} subject_display - Username (users) or share name (tokens). - * @property {'viewer'|'commenter'|'contributor'|'editor'|'owner'} role - Server-emitted role string. `commenter` and `contributor` are reserved for future UI exposure; today the share modal only renders `viewer`/`editor`/`owner` (see `ShareRoleEnum`). + * @property {GrantRoleEnum} role - Server-emitted role string. `commenter` and `contributor` are reserved for future UI exposure; today the share modal only renders `viewer`/`editor`/`owner` (see `ShareRoleEnum`). * @property {string} granted_at - ISO-8601 * @property {string|null} [expires_at] - ISO-8601 or absent. * @property {boolean} has_password - True when a token subject has a password set. @@ -464,7 +470,11 @@ * One collaborator row in the share modal's People section. * @typedef {Object} MemberEntry * @property {Grant} grant - Representative grant (used for subject/resource info). - * @property {Grant[]} _grants - All grants for this subject on the resource (may be > 1). + * @property {Grant[]} _grants - All grants for this subject on the resource. Post-pivot + * this is at most one entry (`storage.role_grants` UNIQUE on + * `(subject, resource)`); the array shape is preserved so the existing + * "revoke every grant on remove" loop in `_applyAll` still works + * without a special-case for empty / new entries. * @property {ShareRoleEnum} role - Derived role label shown in the UI. * @property {'keep'|'remove'|'change'|'new'} _op - Pending local operation. * @property {string|null} [expires_at] - YYYY-MM-DD expiry date string, or null for no expiry. diff --git a/static/js/model/grants.js b/static/js/model/grants.js index a6890ff0..68dd8bb3 100644 --- a/static/js/model/grants.js +++ b/static/js/model/grants.js @@ -164,7 +164,9 @@ const grants = { /** * Create a new grant. - * Body mirrors `CreateGrantDto`: `{ subject, resource, role }` OR `{ subject, resource, permissions }`. + * Body mirrors `CreateGrantDto`: `{ subject, resource, role, expires_at? }`. + * Strictly role-keyed since the cleanup PR — the per-permission shape + * is no longer accepted. * * Response shape (PR N1 — `CreateGrantResponseDto`): * diff --git a/static/sw.js b/static/sw.js index 17197a43..d6915295 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,6 +1,6 @@ // OxiCloud Service Worker // FIXME: generate cache name according build ? -const CACHE_NAME = 'oxicloud-cache-v27'; +const CACHE_NAME = 'oxicloud-cache-v28'; // Only cache static assets — NOT HTML files. // HTML files are served network-first so browsers always get the latest diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index 3c5214ac..6c503eb6 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -122,11 +122,11 @@ Content-Type: application/json } HTTP 201 -# PR N1: POST /api/grants now wraps results in -# `CreateGrantResponseDto { grants, notification }`. +# Cleanup PR: one role row per (subject, resource). `CreateGrantResponseDto` +# wraps a single role-keyed Grant in `.grants[0]`. [Asserts] jsonpath "$.grants" count == 1 -jsonpath "$.grants[0].permission" == "read" +jsonpath "$.grants[0].role" == "viewer" # ───────────────────────────────────────────────────────────── @@ -148,12 +148,13 @@ Authorization: Bearer {{dave_token}} HTTP 200 [Asserts] -jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].permission" == "read" +jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].role" == "viewer" # ───────────────────────────────────────────────────────────── -# Step 9 — Promote Bob to Admin (adds comment, create, update, share, delete). -# PUT /api/grants/role reconciles the row set in one call. +# Step 9 — Promote Bob to Owner (covers comment, create, update, share, +# delete, manage). PUT /api/grants/role replaces the role in one +# UPSERT against `storage.role_grants`. # ───────────────────────────────────────────────────────────── PUT {{base_url}}/api/grants/role Authorization: Bearer {{alice_token}} @@ -161,16 +162,17 @@ Content-Type: application/json { "subject": { "type": "user", "id": "{{dave_user_id}}" }, "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, - "role": "admin" + "role": "owner" } HTTP 200 [Asserts] -jsonpath "$" count == 6 +jsonpath "$" count == 1 +jsonpath "$[0].role" == "owner" # ───────────────────────────────────────────────────────────── -# Step 10 — Bob can now rename (Manager includes update). +# Step 10 — Bob can now rename (Owner includes update). # ───────────────────────────────────────────────────────────── PUT {{base_url}}/api/folders/{{shared_folder_id}}/rename Authorization: Bearer {{dave_token}} @@ -194,7 +196,7 @@ HTTP 200 # ───────────────────────────────────────────────────────────── -# Step 12 — Bob re-shares to Carol (he has Share via Admin). +# Step 12 — Bob re-shares to Carol (he has Share via Owner). # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/grants Authorization: Bearer {{dave_token}} @@ -218,7 +220,7 @@ Authorization: Bearer {{eve_token}} HTTP 200 [Asserts] -jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].permission" == "read" +jsonpath "$[?(@.resource.id=='{{shared_folder_id}}')].role" == "viewer" # ───────────────────────────────────────────────────────────── @@ -247,7 +249,7 @@ Content-Type: application/json HTTP 200 [Asserts] jsonpath "$" count == 1 -jsonpath "$[0].permission" == "read" +jsonpath "$[0].role" == "viewer" # ───────────────────────────────────────────────────────────── @@ -263,8 +265,9 @@ HTTP 404 # ───────────────────────────────────────────────────────────── # Step 17 — Lifecycle: Alice deletes the folder. The DB trigger -# trg_cleanup_grants_folder removes both bob's and carol's -# grants automatically (also for the cascade-deleted child). +# trg_cleanup_role_grants_folder removes both bob's and +# carol's grants automatically (also for the cascade-deleted +# child). # ───────────────────────────────────────────────────────────── DELETE {{base_url}}/api/folders/{{child_folder_id}} Authorization: Bearer {{alice_token}} @@ -767,7 +770,7 @@ HTTP 404 # ════════════════════════════════════════════════════════════════════ -# Phase 2D — Promote adam to Admin (all 6 permissions). Delete OK. +# Phase 2D — Promote adam to Owner (full bundle, incl. delete). Delete OK. # ════════════════════════════════════════════════════════════════════ PUT {{base_url}}/api/grants/role Authorization: Bearer {{alice_token}} @@ -775,7 +778,7 @@ Content-Type: application/json { "subject": { "type": "user", "id": "{{adam_user_id}}" }, "resource": { "type": "folder", "id": "{{perm_folder_id}}" }, - "role": "admin" + "role": "owner" } HTTP 200 @@ -788,7 +791,7 @@ HTTP 204 # ════════════════════════════════════════════════════════════════════ # Phase 2E — Lifecycle cleanup. Alice (still the owner) trashes & -# empties; the trigger removes all access_grants rows. +# empties; the trigger removes all role_grants rows. # ════════════════════════════════════════════════════════════════════ DELETE {{base_url}}/api/folders/{{perm_folder_id}} Authorization: Bearer {{alice_token}} @@ -1176,12 +1179,12 @@ Content-Type: application/json { "subject": { "type": "user", "id": "{{frank_user_id}}" }, "resource": { "type": "folder", "id": "{{batch_root_id}}" }, - "role": "admin" + "role": "owner" } HTTP 200 -# Frank (Admin grant = Delete) trashes batch_file_2 — item goes to +# Frank (Owner role includes Delete) trashes batch_file_2 — item goes to # Alice's trash because file.user_id is unchanged (Alice is still owner). POST {{base_url}}/api/batch/trash Authorization: Bearer {{frank_token}} diff --git a/tests/api/grants_nested_groups.hurl b/tests/api/grants_nested_groups.hurl index e11c6282..83a8b585 100644 --- a/tests/api/grants_nested_groups.hurl +++ b/tests/api/grants_nested_groups.hurl @@ -285,7 +285,7 @@ HTTP 201 # `CreateGrantResponseDto { grants, notification }`. [Asserts] jsonpath "$.grants" count == 1 -jsonpath "$.grants[0].permission" == "read" +jsonpath "$.grants[0].role" == "viewer" jsonpath "$.grants[0].subject.type" == "group" jsonpath "$.grants[0].subject.id" == "{{group_a_id}}" @@ -355,7 +355,7 @@ Authorization: Bearer {{henry_token}} HTTP 200 [Asserts] -jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].permission" == "read" +jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].role" == "viewer" jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].subject.type" == "group" jsonpath "$[?(@.resource.id=='{{perm_folder_id}}')].subject.id" == "{{group_a_id}}" @@ -533,7 +533,7 @@ HTTP 404 # ════════════════════════════════════════════════════════════════════ -# Phase D — Promote group A's grant to Admin (all 6 permissions). +# Phase D — Promote group A's grant to Owner (full bundle). # Delete now succeeds for henry, still flowing through B → A. # ════════════════════════════════════════════════════════════════════ PUT {{base_url}}/api/grants/role @@ -542,7 +542,7 @@ Content-Type: application/json { "subject": { "type": "group", "id": "{{group_a_id}}" }, "resource": { "type": "folder", "id": "{{perm_folder_id}}" }, - "role": "admin" + "role": "owner" } HTTP 200 diff --git a/tests/api/role_grants.hurl b/tests/api/role_grants.hurl index 4a0f4ede..e5a218e1 100644 --- a/tests/api/role_grants.hurl +++ b/tests/api/role_grants.hurl @@ -4,17 +4,15 @@ # Pins the D-Prep refactor behaviours that don't fit naturally into # the existing `grants.hurl` (which is API-shape-focused). Specifically: # -# 1. New wire-format role names: +# 1. Wire-format role names: # - "owner" is accepted on POST and emitted on response -# - "admin" still accepted on POST for one release (compat shim -# in `Role::parse`); the server normalises it to Owner +# - "admin" is REJECTED with 422 (compat alias retired in the +# cleanup PR — see Step 6a) # -# 2. Dual-write proof: granting a role and then exercising a +# 2. Role-keyed write proof: granting a role and then exercising a # permission from its bundle works → proves the row landed in -# `storage.role_grants` because the engine now reads from there -# for authz decisions (see `folder_cascade_grant_exists` post- -# D-Prep). If dual-write failed, the engine would see no row and -# reject the check. +# `storage.role_grants` and the engine read path expands the +# bundle correctly (see `folder_cascade_grant_exists`). # # 3. Atomic role updates via PUT /api/grants/role — the role flips # in a single SQL update (no DELETE+INSERT race window). @@ -177,10 +175,9 @@ HTTP 204 # ───────────────────────────────────────────────────────────── -# Step 6 — Legacy "admin" string compat. The server's Role::parse -# accepts "admin" and normalises to Owner during the D-Prep -# dual-write window (one release). Verify by granting sam -# with role="admin" — sam should then be able to Delete too. +# Step 6a — Reject the legacy "admin" string. The cleanup PR +# retired the `#[serde(alias = "admin")]` compat shim on +# `RoleDto::Owner`; the deserialiser now refuses it with 422. # ───────────────────────────────────────────────────────────── POST {{base_url}}/api/grants Authorization: Bearer {{admin_token}} @@ -191,15 +188,26 @@ Content-Type: application/json "role": "admin" } +HTTP 422 + + +# ───────────────────────────────────────────────────────────── +# Step 6b — Grant sam Owner with the canonical role string. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{sam_user_id}}" }, + "resource": { "type": "folder", "id": "{{test_folder_id}}" }, + "role": "owner" +} + HTTP 201 [Captures] -# Capture the first per-permission grant id from the response so -# Step 10's revoke doesn't have to query the My Shares endpoint -# with awkward JSONPath filtering. Any single grant_id works: -# the revoke handler calls `clear_role` which wipes the entire -# role_grants row for (sam, test_folder), so the engine reads -# return false for sam afterwards regardless of how many -# access_grants rows still exist. +# The single role-keyed Grant returned in `.grants[0]` is the +# `storage.role_grants` row id. Step 10's revoke uses it to +# `clear_role` and wipe the row. sam_grant_id: jsonpath "$.grants[0].id" @@ -326,9 +334,8 @@ body not contains "\"role\":\"admin\"" # ───────────────────────────────────────────────────────────── -# Step 10 — Revoke: removing sam's grant. The handler clears the -# access_grants rows AND calls clear_role to wipe the -# role_grants row in the same flow. +# Step 10 — Revoke: removing sam's grant. `engine.revoke()` DELETEs +# the single `storage.role_grants` row by id. # # After: sam's Delete attempt should be refused (proof # the role_grants row is gone — the cascade query for @@ -336,7 +343,7 @@ body not contains "\"role\":\"admin\"" # at this folder). # ───────────────────────────────────────────────────────────── -# sam_grant_id was captured at Step 6 from the create response. +# sam_grant_id was captured at Step 6b from the create response. DELETE {{base_url}}/api/grants/{{sam_grant_id}} Authorization: Bearer {{admin_token}} diff --git a/tests/api/subject_groups.hurl b/tests/api/subject_groups.hurl index aa958bb0..93fe1680 100644 --- a/tests/api/subject_groups.hurl +++ b/tests/api/subject_groups.hurl @@ -221,7 +221,7 @@ Content-Type: application/json { "subject": { "type": "group", "id": "{{engineers_id}}" }, "resource": { "type": "folder", "id": "{{shared_folder_id}}" }, - "permissions": ["read"] + "role": "viewer" } HTTP 201 From c893f9ae2908fdcf8ca712614e3b366c3181e851 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 18 Jun 2026 01:52:47 +0200 Subject: [PATCH 09/39] chore(roles): remove deprecated tools this tools are were heere to be tracked, no more necessary once migration is done --- tools/audit-grants-bundle-shape.sql | 215 ------------------ tools/d-prep-real-db-smoke.sh | 328 ---------------------------- 2 files changed, 543 deletions(-) delete mode 100644 tools/audit-grants-bundle-shape.sql delete mode 100755 tools/d-prep-real-db-smoke.sh diff --git a/tools/audit-grants-bundle-shape.sql b/tools/audit-grants-bundle-shape.sql deleted file mode 100644 index fb3d24de..00000000 --- a/tools/audit-grants-bundle-shape.sql +++ /dev/null @@ -1,215 +0,0 @@ --- ════════════════════════════════════════════════════════════════════════════ --- audit-grants-bundle-shape.sql — Pre-D-Prep diagnostic --- ════════════════════════════════════════════════════════════════════════════ --- Purpose: confirm the assumption that >99% of existing `storage.access_grants` --- rows already cluster into the standard role bundles --- (viewer / commenter / contributor / editor / owner). The answer determines --- whether the access_grants → role_grants migration is fully mechanical (just --- run the backfill) or whether per-row decisions are needed for some edge --- cases. --- --- READ-ONLY. Safe to run against any environment including production. --- --- Usage: --- psql "$DATABASE_URL" -f tools/audit-grants-bundle-shape.sql --- --- Reference role bundles (mirror `Role::expand()` in --- `src/application/dtos/grant_dto.rs`): --- viewer = {read} --- commenter = {read, comment} ← reserved variant --- contributor = {read, create} ← new in D-Prep --- editor = {read, comment, create, update} --- owner = {read, comment, create, update, share, delete} --- (post-D-Prep: + manage, when Group-as-Resource lands) --- ════════════════════════════════════════════════════════════════════════════ - - -\echo '' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo ' 0. Total population' -\echo '─────────────────────────────────────────────────────────────────────────' - -SELECT count(*) AS total_grant_rows, - count(DISTINCT (subject_type, subject_id, resource_type, resource_id)) - AS distinct_clusters, - ROUND( - count(*)::numeric - / NULLIF(count(DISTINCT (subject_type, subject_id, resource_type, resource_id)), 0), - 2 - ) AS avg_rows_per_cluster -FROM storage.access_grants; - - -\echo '' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo ' 1. Bundle distribution — which permission sets exist, how popular?' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo ' Each row = a unique permission set; cluster_count = how many (subject,' -\echo ' resource) pairs have exactly that set.' - -WITH cluster AS ( - SELECT subject_type, - subject_id, - resource_type, - resource_id, - array_agg(permission ORDER BY permission) AS perms - FROM storage.access_grants - GROUP BY 1, 2, 3, 4 -), -known_bundles AS ( - SELECT ARRAY['read']::text[] AS perms, 'viewer' AS role - UNION ALL SELECT ARRAY['comment','read']::text[], 'commenter' - UNION ALL SELECT ARRAY['create','read']::text[], 'contributor' - UNION ALL SELECT ARRAY['comment','create','read','update']::text[], 'editor' - UNION ALL SELECT ARRAY['comment','create','delete','read','share','update']::text[], 'owner' -) -SELECT cluster.perms, - count(*) AS cluster_count, - ROUND(100.0 * count(*) / SUM(count(*)) OVER (), 2) AS pct, - COALESCE(known_bundles.role, '(non-bundle)') AS maps_to_role -FROM cluster -LEFT JOIN known_bundles ON known_bundles.perms = cluster.perms -GROUP BY cluster.perms, known_bundles.role -ORDER BY cluster_count DESC; - - -\echo '' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo ' 2. Bundle-shaped vs not — the headline number' -\echo '─────────────────────────────────────────────────────────────────────────' - -WITH cluster AS ( - SELECT subject_type, - subject_id, - resource_type, - resource_id, - array_agg(permission ORDER BY permission) AS perms - FROM storage.access_grants - GROUP BY 1, 2, 3, 4 -), -known_bundles AS ( - SELECT ARRAY['read']::text[] AS perms - UNION ALL SELECT ARRAY['comment','read']::text[] - UNION ALL SELECT ARRAY['create','read']::text[] - UNION ALL SELECT ARRAY['comment','create','read','update']::text[] - UNION ALL SELECT ARRAY['comment','create','delete','read','share','update']::text[] -), -shape AS ( - SELECT CASE WHEN cluster.perms = ANY(SELECT kb.perms FROM known_bundles kb) - THEN 'bundle-shaped' - ELSE 'NON-bundle (needs per-row decision)' - END AS shape - FROM cluster -) -SELECT shape, - count(*) AS clusters, - ROUND(100.0 * count(*) / SUM(count(*)) OVER (), 2) AS pct -FROM shape -GROUP BY shape -ORDER BY clusters DESC; - - -\echo '' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo ' 3. Non-bundle clusters in detail (the <1% — investigate each)' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo ' If this returns 0 rows, the migration is FULLY mechanical. Otherwise' -\echo ' eyeball each row and decide: closest role + audit log entry, or' -\echo ' refuse-to-migrate (rare).' - -WITH cluster AS ( - SELECT subject_type, - subject_id, - resource_type, - resource_id, - array_agg(permission ORDER BY permission) AS perms - FROM storage.access_grants - GROUP BY 1, 2, 3, 4 -), -known_bundles AS ( - SELECT ARRAY['read']::text[] AS perms - UNION ALL SELECT ARRAY['comment','read']::text[] - UNION ALL SELECT ARRAY['create','read']::text[] - UNION ALL SELECT ARRAY['comment','create','read','update']::text[] - UNION ALL SELECT ARRAY['comment','create','delete','read','share','update']::text[] -) -SELECT cluster.subject_type, - cluster.subject_id, - cluster.resource_type, - cluster.resource_id, - cluster.perms -FROM cluster -WHERE NOT (cluster.perms = ANY(SELECT kb.perms FROM known_bundles kb)) -ORDER BY cluster.resource_type, cluster.resource_id -LIMIT 200; - - -\echo '' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo ' 4. Sanity checks — invariants that should be true today' -\echo '─────────────────────────────────────────────────────────────────────────' - -\echo '' -\echo ' 4a. Clusters with no Read permission (broken? if >0, investigate)' -WITH cluster AS ( - SELECT subject_type, subject_id, resource_type, resource_id, - array_agg(permission ORDER BY permission) AS perms - FROM storage.access_grants - GROUP BY 1, 2, 3, 4 -) -SELECT count(*) AS clusters_without_read -FROM cluster -WHERE NOT ('read' = ANY(perms)); - -\echo '' -\echo ' 4b. Subject-type distribution (sanity check on token / external counts)' -SELECT subject_type, - count(*) AS rows, - count(DISTINCT subject_id) AS distinct_subjects, - count(DISTINCT (resource_type, resource_id)) AS distinct_resources -FROM storage.access_grants -GROUP BY subject_type -ORDER BY subject_type; - -\echo '' -\echo ' 4c. Resource-type distribution' -SELECT resource_type, count(*) AS rows -FROM storage.access_grants -GROUP BY resource_type -ORDER BY rows DESC; - -\echo '' -\echo ' 4d. Permission distribution (total rows per permission value)' -SELECT permission, count(*) AS rows -FROM storage.access_grants -GROUP BY permission -ORDER BY rows DESC; - - -\echo '' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo ' 5. Migration cost estimate' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo ' role_grants will have ~ (distinct clusters) rows, replacing ~ (total)' -\echo ' rows in access_grants. The ratio is the per-row reduction factor.' - -WITH cluster AS ( - SELECT subject_type, subject_id, resource_type, resource_id - FROM storage.access_grants - GROUP BY 1, 2, 3, 4 -) -SELECT (SELECT count(*) FROM storage.access_grants) AS access_grants_rows_today, - (SELECT count(*) FROM cluster) AS role_grants_rows_after, - ROUND( - (SELECT count(*) FROM storage.access_grants)::numeric - / NULLIF((SELECT count(*) FROM cluster), 0), - 2 - ) AS reduction_factor; - - -\echo '' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo ' DONE — share section 2 (headline) and section 3 (non-bundle detail)' -\echo ' to make the D-Prep PR scope decision.' -\echo '─────────────────────────────────────────────────────────────────────────' -\echo '' diff --git a/tools/d-prep-real-db-smoke.sh b/tools/d-prep-real-db-smoke.sh deleted file mode 100755 index 6ded56ac..00000000 --- a/tools/d-prep-real-db-smoke.sh +++ /dev/null @@ -1,328 +0,0 @@ -#!/usr/bin/env bash -# ════════════════════════════════════════════════════════════════════════════ -# d-prep-real-db-smoke.sh — Validate D-Prep migration against sandbox data -# ════════════════════════════════════════════════════════════════════════════ -# Reads from your sandbox DB (real data — but it's a sandbox, not prod), dumps -# it, restores into an isolated parallel DB, applies the D-Prep migration -# (`20260730000000_role_grants.sql`), and verifies the backfill against the -# pre-recorded audit numbers from `tools/audit-grants-bundle-shape.sql`. -# -# The sandbox DB itself is NEVER MODIFIED — everything happens in the parallel -# `oxicloud_dprep_smoke` DB which is dropped at the start of each run. -# -# Usage: -# tools/d-prep-real-db-smoke.sh # uses $DATABASE_URL -# tools/d-prep-real-db-smoke.sh --keep # leave smoke DB for poking after -# tools/d-prep-real-db-smoke.sh --help -# -# Requirements: -# - pg_dump / pg_restore / psql (PostgreSQL 14+ should be fine; sandbox version-matched) -# - DATABASE_URL pointing at the sandbox DB -# -# PATH gotcha (Mac brew): `brew install postgresql@18` is keg-only; if pg_dump -# isn't on PATH, run: -# export PATH="$(brew --prefix postgresql@18)/bin:$PATH" -# -# Expected audit numbers (from your audit run on 2026-06-17): -# - 73 access_grants rows clustering into 38 (subject, resource) pairs -# - 29 viewer / 5 editor / 4 owner -# These are encoded as assertions below. If your sandbox data has shifted -# since the audit, update EXPECTED_* variables OR re-run the audit script -# first. -# -# Memory cross-refs: -# - bug_pg_dump_folders_circular_fk.md — circular FK on `folders` forces -# `pg_restore --disable-triggers` -# - project_drive_sequence_a.md — D-Prep scope + audit data -# ════════════════════════════════════════════════════════════════════════════ - -set -euo pipefail - - -# ── Config ────────────────────────────────────────────────────────────────── - -SMOKE_DB_NAME="oxicloud_dprep_smoke" -DUMP_FILE="${TMPDIR:-/tmp}/oxicloud-sandbox-${SMOKE_DB_NAME}.dump" -MIGRATION_FILE="migrations/20260730000000_role_grants.sql" - -# Expected values from the audit (tools/audit-grants-bundle-shape.sql run -# on 2026-06-17). Bump these if you re-run the audit and see different -# numbers — they are intentionally HARDCODED so a silent drift gets caught. -EXPECTED_DISTINCT_CLUSTERS=38 -EXPECTED_VIEWER=29 -EXPECTED_EDITOR=5 -EXPECTED_OWNER=4 - -KEEP_DB=0 - - -# ── Arg parsing ───────────────────────────────────────────────────────────── - -usage() { - sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' - exit "${1:-0}" -} - -for arg in "$@"; do - case "$arg" in - --keep|--keep-db) KEEP_DB=1 ;; - -h|--help) usage 0 ;; - *) echo "Unknown arg: $arg" >&2 ; usage 2 ;; - esac -done - - -# ── Pre-flight ────────────────────────────────────────────────────────────── - -if [[ -z "${DATABASE_URL:-}" ]]; then - echo "ERROR: \$DATABASE_URL must be set and point at the sandbox DB." >&2 - exit 2 -fi - -for bin in pg_dump pg_restore psql; do - if ! command -v "$bin" >/dev/null 2>&1; then - echo "ERROR: '$bin' not found on PATH." >&2 - echo "Hint: brew-installed postgresql is keg-only. Run:" >&2 - echo " export PATH=\"\$(brew --prefix postgresql@18)/bin:\$PATH\"" >&2 - exit 2 - fi -done - -if [[ ! -f "$MIGRATION_FILE" ]]; then - echo "ERROR: migration file not found: $MIGRATION_FILE" >&2 - echo "Run this script from the repo root." >&2 - exit 2 -fi - -# Derive connection URLs for the maintenance DB (where we'll issue -# DROP/CREATE DATABASE) and the smoke DB itself, by splicing -# $SMOKE_DB_NAME into $DATABASE_URL. Avoids relying on libpq's -# default local Unix socket — `dropdb`/`createdb`/`psql` without an -# explicit -d won't read $DATABASE_URL and try /tmp/.s.PGSQL.5432 -# (which fails on Mac brew where the server runs on a non-default -# socket / port). -URL_NO_QUERY="${DATABASE_URL%%\?*}" -URL_QUERY="${DATABASE_URL:${#URL_NO_QUERY}}" # "" or "?…" -SOURCE_DB_NAME="${URL_NO_QUERY##*/}" -URL_BASE="${URL_NO_QUERY%/"$SOURCE_DB_NAME"}" -MAINTENANCE_URL="${URL_BASE}/postgres${URL_QUERY}" -SMOKE_URL="${URL_BASE}/${SMOKE_DB_NAME}${URL_QUERY}" - -if [[ "$SOURCE_DB_NAME" == "$SMOKE_DB_NAME" ]]; then - echo "ERROR: DATABASE_URL appears to point at '$SMOKE_DB_NAME' — refusing" >&2 - echo "to dump-and-restore on top of itself. Set DATABASE_URL to the sandbox." >&2 - exit 2 -fi - - -# ── Helpers ───────────────────────────────────────────────────────────────── - -log() { echo "[smoke] $*"; } -fail() { echo "[smoke] FAIL: $*" >&2 ; exit 1; } -pass() { echo "[smoke] PASS: $*"; } - -# Run a query against the smoke DB and capture a single scalar. -scalar() { - psql -tAX -d "$SMOKE_URL" -c "$1" -} - -# Run a query and check the result equals an expected scalar. -expect_scalar() { - local query="$1" expected="$2" label="$3" - local actual - actual=$(scalar "$query") - if [[ "$actual" == "$expected" ]]; then - pass "$label: $actual" - else - fail "$label: expected $expected, got '$actual'" - fi -} - - -# ── 1. Snapshot the sandbox ───────────────────────────────────────────────── - -log "Dumping sandbox DB → $DUMP_FILE" -log "(custom format, so pg_restore --disable-triggers can side-step the" -log " circular FK on storage.folders.parent_id — see bug memory.)" -pg_dump "$DATABASE_URL" --format=custom --no-owner --no-privileges \ - --file="$DUMP_FILE" -log "Dumped $(du -h "$DUMP_FILE" | awk '{print $1}')" - - -# ── 2. Recreate the smoke DB ──────────────────────────────────────────────── - -log "Dropping smoke DB '$SMOKE_DB_NAME' if it exists" -psql -d "$MAINTENANCE_URL" -c "DROP DATABASE IF EXISTS \"$SMOKE_DB_NAME\"" - -log "Creating fresh smoke DB '$SMOKE_DB_NAME'" -psql -d "$MAINTENANCE_URL" -c "CREATE DATABASE \"$SMOKE_DB_NAME\"" - - -# ── 3. Restore sandbox into smoke DB ──────────────────────────────────────── - -log "Restoring sandbox dump into smoke DB" -# --disable-triggers handles the storage.folders parent_id circular FK without -# wrapping every COPY in SET CONSTRAINTS ALL DEFERRED. -# --no-owner --no-privileges already on dump side; --single-transaction makes -# the restore atomic so a mid-flight failure leaves no half-state. -pg_restore --dbname="$SMOKE_URL" --disable-triggers \ - --single-transaction --no-owner --no-privileges \ - "$DUMP_FILE" - - -# ── 4. Wipe any prior role_grants state from the smoke DB ────────────────── -# The sandbox may already have role_grants if the server has been booted -# with the D-Prep migration applied (auto-migrate on startup). For the -# smoke we want to exercise the migration FRESHLY — same as a brand-new -# install — so drop both the migration's tables and let it recreate them -# from scratch against the access_grants snapshot. - -log "Wiping any prior role_grants state from smoke DB (so we test the migration freshly)" -psql -d "$SMOKE_URL" --set ON_ERROR_STOP=1 -c " -DROP TABLE IF EXISTS storage.role_grants_migration_log CASCADE; -DROP TABLE IF EXISTS storage.role_grants CASCADE; -" - -# ── 5. Pre-migration sanity ──────────────────────────────────────────────── - -log "Pre-migration sanity checks" -expect_scalar "SELECT count(*) FROM storage.access_grants" \ - "73" "access_grants row count matches audit" - -if [[ $(scalar "SELECT to_regclass('storage.role_grants') IS NULL") != "t" ]]; then - fail "storage.role_grants survived the drop — something is wrong." -fi -pass "storage.role_grants does not exist (clean slate)" - - -# ── 5. Apply the D-Prep migration ─────────────────────────────────────────── - -log "Applying D-Prep migration: $MIGRATION_FILE" -# Wrap in a transaction so a constraint failure rolls back cleanly; the -# migration itself has BEGIN/COMMIT semantics via -1 to psql. -psql -d "$SMOKE_URL" --set ON_ERROR_STOP=1 -1 -f "$MIGRATION_FILE" -log "Migration applied" - - -# ── 6. Post-migration assertions ──────────────────────────────────────────── - -log "Post-migration verification" - -# Row count: should match `distinct_clusters` from the audit -expect_scalar "SELECT count(*) FROM storage.role_grants" \ - "$EXPECTED_DISTINCT_CLUSTERS" "role_grants row count" - -# Role distribution -expect_scalar "SELECT count(*) FROM storage.role_grants WHERE role = 'viewer'" \ - "$EXPECTED_VIEWER" "viewer count" -expect_scalar "SELECT count(*) FROM storage.role_grants WHERE role = 'editor'" \ - "$EXPECTED_EDITOR" "editor count" -expect_scalar "SELECT count(*) FROM storage.role_grants WHERE role = 'owner'" \ - "$EXPECTED_OWNER" "owner count" - -# Zero NULL roles, zero non-bundle roles (the CHECK constraint should -# already enforce this, but proving it here too) -expect_scalar "SELECT count(*) FROM storage.role_grants WHERE role IS NULL" \ - "0" "no NULL roles" -expect_scalar "SELECT count(*) FROM storage.role_grants WHERE role NOT IN ('viewer','commenter','contributor','editor','owner')" \ - "0" "no unknown roles" - -# access_grants is untouched -expect_scalar "SELECT count(*) FROM storage.access_grants" \ - "73" "access_grants row count unchanged (dual-write safety net intact)" - - -# ── 7. Equivalence check (strongest assertion) ────────────────────────────── -# For every role_grants row, the corresponding (subject, resource) cluster -# in access_grants must have exactly the role's bundle as its permission -# set. If any row's bundle doesn't match what `Role::expand()` says, the -# backfill mis-mapped and the equivalence count goes non-zero. - -log "Bundle equivalence check: role_grants ↔ access_grants" - -EQUIVALENCE_MISMATCHES=$(scalar " -WITH expected AS ( - SELECT 'viewer'::text AS role, ARRAY['read']::text[] AS perms - UNION ALL SELECT 'commenter', ARRAY['comment','read']::text[] - UNION ALL SELECT 'contributor', ARRAY['create','read']::text[] - UNION ALL SELECT 'editor', ARRAY['comment','create','read','update']::text[] - UNION ALL SELECT 'owner', ARRAY['comment','create','delete','read','share','update']::text[] -), -actual AS ( - SELECT subject_type, subject_id, resource_type, resource_id, - array_agg(permission ORDER BY permission) AS perms - FROM storage.access_grants - GROUP BY 1, 2, 3, 4 -) -SELECT count(*) -FROM storage.role_grants rg -JOIN actual a USING (subject_type, subject_id, resource_type, resource_id) -JOIN expected e ON e.role = rg.role -WHERE a.perms <> e.perms -") - -if [[ "$EQUIVALENCE_MISMATCHES" == "0" ]]; then - pass "every role_grants row's bundle matches access_grants exactly" -else - log "" - log "MISMATCH DETAIL (first 10):" - psql -d "$SMOKE_URL" -c " -WITH expected AS ( - SELECT 'viewer'::text AS role, ARRAY['read']::text[] AS perms - UNION ALL SELECT 'commenter', ARRAY['comment','read']::text[] - UNION ALL SELECT 'contributor', ARRAY['create','read']::text[] - UNION ALL SELECT 'editor', ARRAY['comment','create','read','update']::text[] - UNION ALL SELECT 'owner', ARRAY['comment','create','delete','read','share','update']::text[] -), -actual AS ( - SELECT subject_type, subject_id, resource_type, resource_id, - array_agg(permission ORDER BY permission) AS perms - FROM storage.access_grants - GROUP BY 1, 2, 3, 4 -) -SELECT rg.subject_type, rg.subject_id, rg.resource_type, rg.resource_id, - rg.role, - a.perms AS access_grants_perms, - e.perms AS expected_perms -FROM storage.role_grants rg -JOIN actual a USING (subject_type, subject_id, resource_type, resource_id) -JOIN expected e ON e.role = rg.role -WHERE a.perms <> e.perms -LIMIT 10 -" - fail "$EQUIVALENCE_MISMATCHES role_grants rows have a bundle that diverges from their access_grants cluster" -fi - - -# ── 8. Migration audit log shape ──────────────────────────────────────────── -# `role_grants_migration_log` is the one-shot table created by the migration -# to record any demotions. With 100%-bundle-shaped data (the audit says so), -# it should be EMPTY — nothing was demoted. - -if [[ $(scalar "SELECT to_regclass('storage.role_grants_migration_log') IS NOT NULL") == "t" ]]; then - expect_scalar "SELECT count(*) FROM storage.role_grants_migration_log" \ - "0" "migration audit log empty (no demotions, as expected for 100% bundle-shaped data)" -fi - - -# ── 9. Cleanup ────────────────────────────────────────────────────────────── - -log "" -log "─────────────────────────────────────────────────────────" -log " ALL ASSERTIONS PASSED" -log "─────────────────────────────────────────────────────────" -log "" - -if [[ "$KEEP_DB" == "1" ]]; then - log "Leaving smoke DB '$SMOKE_DB_NAME' for inspection." - log "Poke at it with:" - log " psql \"$SMOKE_URL\"" - log "" - log "Drop when done:" - log " psql -d \"$MAINTENANCE_URL\" -c 'DROP DATABASE \"$SMOKE_DB_NAME\"'" -else - log "Dropping smoke DB '$SMOKE_DB_NAME'" - psql -d "$MAINTENANCE_URL" -c "DROP DATABASE \"$SMOKE_DB_NAME\"" -fi - -log "Dump file kept at $DUMP_FILE (delete with: rm '$DUMP_FILE')" From 0ef4c624c5a6434f178063ebe38a0962486f71e9 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 14 Jun 2026 16:42:58 +0200 Subject: [PATCH 10/39] chore(load): start implementation of load tests initial test from Ed's nuc: metric pctl baseline current delta status ----------------------------------------------------------------------------------------------- folder_cascade.list_depth1 p50 0.3ms 0.3ms -6.3% ok folder_cascade.list_depth1 p95 2.3ms 0.5ms -75.7% ok folder_cascade.list_depth1 p99 4.7ms 2.5ms -48.1% ok folder_cascade.list_depth4 p50 0.4ms 0.3ms -10.0% ok folder_cascade.list_depth4 p95 0.9ms 0.6ms -31.2% ok folder_cascade.list_depth4 p99 2.4ms 1.0ms -56.7% ok folder_cascade.list_depth8 p50 0.3ms 0.3ms -8.8% ok folder_cascade.list_depth8 p95 0.6ms 0.5ms -22.2% ok folder_cascade.list_depth8 p99 1.9ms 0.5ms -71.5% ok folder_cascade.list_depth_deep p50 0.3ms 0.3ms -5.0% ok folder_cascade.list_depth_deep p95 0.6ms 0.5ms -18.6% ok folder_cascade.list_depth_deep p99 2.0ms 0.7ms -67.2% ok share_cascade_rebac.list_grants p50 0.4ms 0.3ms -27.6% ok share_cascade_rebac.list_grants p95 1.2ms 0.5ms -57.0% ok share_cascade_rebac.list_grants p99 1.7ms 1.1ms -36.7% ok share_cascade_rebac.fetch_as_grantee_depth1 p50 0.5ms 0.5ms -11.1% ok share_cascade_rebac.fetch_as_grantee_depth1 p95 1.1ms 0.7ms -41.2% ok share_cascade_rebac.fetch_as_grantee_depth1 p99 3.0ms 1.3ms -58.3% ok share_cascade_rebac.fetch_as_grantee_depth4 p50 0.5ms 0.5ms -13.7% ok share_cascade_rebac.fetch_as_grantee_depth4 p95 1.4ms 0.7ms -50.5% ok share_cascade_rebac.fetch_as_grantee_depth4 p99 2.2ms 1.1ms -49.3% ok share_cascade_rebac.fetch_as_grantee_depth8 p50 0.5ms 0.4ms -16.0% ok share_cascade_rebac.fetch_as_grantee_depth8 p95 0.9ms 0.7ms -26.1% ok share_cascade_rebac.fetch_as_grantee_depth8 p99 1.5ms 0.9ms -40.1% ok share_cascade_rebac.fetch_as_grantee_depth_deep p50 0.5ms 0.4ms -17.3% ok share_cascade_rebac.fetch_as_grantee_depth_deep p95 1.0ms 0.7ms -31.2% ok share_cascade_rebac.fetch_as_grantee_depth_deep p99 1.6ms 0.8ms -49.4% ok subject_group_nested.fetch_as_member_depth1 p50 0.5ms 0.4ms -8.4% ok subject_group_nested.fetch_as_member_depth1 p95 0.6ms 0.6ms -10.2% ok subject_group_nested.fetch_as_member_depth1 p99 1.4ms 0.6ms -55.2% ok subject_group_nested.fetch_as_member_depth4 p50 0.5ms 0.5ms -7.9% ok subject_group_nested.fetch_as_member_depth4 p95 0.6ms 0.6ms -4.0% ok subject_group_nested.fetch_as_member_depth4 p99 0.7ms 0.6ms -2.2% ok subject_group_nested.fetch_as_member_depth8 p50 0.5ms 0.4ms -8.9% ok subject_group_nested.fetch_as_member_depth8 p95 0.5ms 0.6ms +7.1% ok subject_group_nested.fetch_as_member_depth8 p99 0.6ms 0.7ms +10.3% ok subject_group_nested.fetch_as_member_depth_deep p50 0.5ms 0.4ms -7.6% ok subject_group_nested.fetch_as_member_depth_deep p95 0.6ms 0.5ms -11.9% ok subject_group_nested.fetch_as_member_depth_deep p99 0.6ms 0.7ms +8.9% ok --- .github/workflows/load-nightly.yml | 111 +++ .github/workflows/load-smoke.yml | 45 ++ .gitignore | 4 + Cargo.toml | 14 + Dockerfile | 7 +- .../benchmake-and-performance-tracking.md | 187 +++++ justfile | 22 + src/bin/load-seed.rs | 746 ++++++++++++++++++ tests/common/spawn-db.sh | 23 +- tests/load/README.md | 99 ++- tests/load/bake-baseline.mjs | 72 ++ tests/load/baseline/load.json | 81 ++ tests/load/baseline/smoke.json | 33 + tests/load/compare.mjs | 136 ++++ tests/load/lib/auth.js | 38 + tests/load/lib/http.js | 40 + tests/load/lib/metrics.js | 76 ++ tests/load/merge-summaries.mjs | 40 + tests/load/results/.gitkeep | 0 tests/load/run.sh | 166 ++++ tests/load/scenarios/folder_cascade.js | 60 ++ tests/load/scenarios/share_cascade_rebac.js | 84 ++ tests/load/scenarios/smoke.js | 64 ++ tests/load/scenarios/subject_group_nested.js | 73 ++ tests/load/smoke.sh | 108 +++ tests/load/test.env | 31 + 26 files changed, 2345 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/load-nightly.yml create mode 100644 .github/workflows/load-smoke.yml create mode 100644 docs/plan/benchmake-and-performance-tracking.md create mode 100644 src/bin/load-seed.rs create mode 100644 tests/load/bake-baseline.mjs create mode 100644 tests/load/baseline/load.json create mode 100644 tests/load/baseline/smoke.json create mode 100644 tests/load/compare.mjs create mode 100644 tests/load/lib/auth.js create mode 100644 tests/load/lib/http.js create mode 100644 tests/load/lib/metrics.js create mode 100644 tests/load/merge-summaries.mjs create mode 100644 tests/load/results/.gitkeep create mode 100755 tests/load/run.sh create mode 100644 tests/load/scenarios/folder_cascade.js create mode 100644 tests/load/scenarios/share_cascade_rebac.js create mode 100644 tests/load/scenarios/smoke.js create mode 100644 tests/load/scenarios/subject_group_nested.js create mode 100755 tests/load/smoke.sh create mode 100644 tests/load/test.env diff --git a/.github/workflows/load-nightly.yml b/.github/workflows/load-nightly.yml new file mode 100644 index 00000000..a799dffb --- /dev/null +++ b/.github/workflows/load-nightly.yml @@ -0,0 +1,111 @@ +name: Load Nightly + +# Nightly regression gate. Runs every k6 scenario, diffs against +# baseline/load.json, opens an issue if any metric regresses. +# +# IMPORTANT: shared GitHub-hosted runners produce noisy timings — +# regression signal is unreliable until this workflow is moved to a +# pinned self-hosted runner with consistent hardware. The cron run is +# informational until then; treat opened issues as "investigate" not +# "broken main." + +on: + schedule: + # 03:00 UTC daily — outside US/EU working hours, low contention. + - cron: '0 3 * * *' + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + load: + name: k6 load suite + baseline diff + # Skip the scheduled run on forks — nightly is only meaningful against the + # canonical baseline.json on the upstream repo. Forks can still hit it + # manually via `workflow_dispatch` if they want, and the guard is bypassed + # for that path (event != schedule). Additional fork repositories are + # opted-in explicitly below. + if: github.event_name != 'schedule' || github.repository == 'AtalayaLabs/OxiCloud' || github.repository == 'EdouardVanbelle/OxiCloud' + # tunning on Ed's nuc to ensure stable environment + # treating regressions here as merge-blocking. ubuntu-latest is too noisy + # for trustworthy p95/p99 deltas. + runs-on: [self-hosted, nuc-loadtest] + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + # The self-hosted runner bind-mounts target/ as a persistent volume + # for fast incremental rebuilds. actions/checkout's default cleanup + # tries to rmdir it and hits EBUSY on the mount point. Git still + # syncs the working tree to the target SHA — only non-git files + # (target/, tests/load/results/) persist, which is what we want. + clean: false + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: load + + - name: Install Node 20 + # The self-hosted runner image ships Node 12, which can't parse the + # ES-module `.mjs` helpers (compare.mjs / merge-summaries.mjs / + # bake-baseline.mjs). Pin to a current LTS for both runners. + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install k6 + uses: grafana/setup-k6-action@v1 + + - name: Build OxiCloud + load-seed (release) + # Single cargo invocation: load_seed_bin is an empty marker feature + # that gates the load-seed bin without changing oxicloud's dep + # graph, so cargo compiles oxicloud exactly once. + run: cargo build --release --features load_seed_bin --bin oxicloud --bin load-seed + + - name: Run full load suite + id: load + run: bash tests/load/run.sh + env: + BUILD_TARGET: release + continue-on-error: true + + - name: Upload raw k6 summaries + if: always() + uses: actions/upload-artifact@v4 + with: + name: load-results-${{ github.run_id }} + path: tests/load/results/*.json + retention-days: 30 + + - name: Prepare regression report + if: steps.load.outcome == 'failure' + run: | + { + echo "## Load nightly regression" + echo "" + echo "Run: \`${{ github.run_id }}\` · Commit: \`${{ github.sha }}\`" + echo "Runner: \`${{ runner.name }}\` (\`${{ runner.os }}/${{ runner.arch }}\`)" + echo "" + echo "Compare exited non-zero — see uploaded artifact \`load-results-${{ github.run_id }}\` for raw summaries." + echo "" + echo "Baseline is anchored to the runner that produced it; cross-runner" + echo "comparisons (e.g. an artifact baked on different hardware) will" + echo "show large \"regressions\" that are really hardware deltas." + } > regression-issue.md + + - name: Open issue on regression + # Skip on forks where Issues are disabled — the regression report is + # already in the artifact, and the workflow's exit code marks the run + # as failed regardless. Forks that want issue notifications can enable + # issues on their repo and remove this condition. + if: steps.load.outcome == 'failure' && github.repository == 'AtalayaLabs/OxiCloud' + uses: peter-evans/create-issue-from-file@v5 + with: + title: "Load nightly: regression on ${{ github.sha }}" + content-filepath: regression-issue.md + labels: | + load-test + regression diff --git a/.github/workflows/load-smoke.yml b/.github/workflows/load-smoke.yml new file mode 100644 index 00000000..330f5536 --- /dev/null +++ b/.github/workflows/load-smoke.yml @@ -0,0 +1,45 @@ +name: Load Smoke + +# PR-tier liveness check. Verifies the k6 load harness still builds and a +# single happy-path iteration runs against a freshly built server. +# NO regression gate — that's the nightly workflow's job. + +on: + pull_request: + branches: [ main, dev ] + paths: + - 'tests/load/**' + - 'src/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'migrations/**' + - '.github/workflows/load-smoke.yml' + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + smoke: + name: k6 smoke (load harness) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: load + + - name: Install k6 + uses: grafana/setup-k6-action@v1 + + - name: Build OxiCloud (debug) + run: cargo build --bin oxicloud + + - name: Run smoke scenario + run: bash tests/load/smoke.sh + env: + BUILD_TARGET: debug diff --git a/.gitignore b/.gitignore index 468d6c8f..71532900 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,7 @@ wasm/oxicloud-hash/target/ .devenv/ .direnv/ .devenv-state/ + +# K6 load-test raw run outputs and per-run storage (baseline is committed) +tests/load/results/*.json +tests/load/storage/ diff --git a/Cargo.toml b/Cargo.toml index 1d00c362..10b9e41a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,12 @@ integration_tests = [] # WASM plugin runtime (Extism). Opt-in: bundles wasmtime, a large engine most # deployments won't use. Activation also requires OXICLOUD_ENABLE_PLUGINS=true. plugins = ["dep:extism", "dep:toml", "dep:file-rotate"] +# Empty marker feature that gates the `load-seed` binary so it isn't built +# by default (and skipped in prod Docker builds). Kept separate from +# test_utils so enabling it doesn't change the oxicloud dependency graph — +# this lets one `cargo build` produce both `oxicloud` and `load-seed` +# without recompiling oxicloud with mockall in scope. +load_seed_bin = [] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } @@ -101,6 +107,14 @@ path = "src/bin/generate-openapi.rs" name = "migrate-nfc-filenames" path = "src/bin/migrate-nfc-filenames.rs" +[[bin]] +name = "load-seed" +path = "src/bin/load-seed.rs" +# Test fixture seeder. Gated behind the empty `load_seed_bin` feature so +# `cargo build --release` (and the prod Dockerfile) skip it. tests/load/run.sh +# and load-nightly.yml build it explicitly with --features load_seed_bin. +required-features = ["load_seed_bin"] + [build-dependencies] oxc_allocator = "0.125.0" oxc_parser = "0.125.0" diff --git a/Dockerfile b/Dockerfile index 117edafa..4049b31b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN mkdir -p src/bin && \ echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \ echo 'fn main() {}' > src/bin/generate-openapi.rs && \ echo 'fn main() {}' > src/bin/migrate-nfc-filenames.rs && \ - cargo build --release && \ + cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames && \ rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-* # ─── Stage 3: Build the application ────────────────────────────────────────── @@ -38,7 +38,10 @@ COPY migrations migrations COPY templates templates # Build with all optimizations (DATABASE_URL only needed at compile-time for sqlx) ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud" -RUN DATABASE_URL="${DATABASE_URL}" cargo build --release +# Explicit --bin list: defence-in-depth so the prod image never ships +# test-only bins (e.g. load-seed) even if `required-features` gating +# changes upstream. +RUN DATABASE_URL="${DATABASE_URL}" cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames # ─── Stage 4: Minimal runtime image ────────────────────────────────────────── FROM alpine:3.24.0 diff --git a/docs/plan/benchmake-and-performance-tracking.md b/docs/plan/benchmake-and-performance-tracking.md new file mode 100644 index 00000000..2038a2c5 --- /dev/null +++ b/docs/plan/benchmake-and-performance-tracking.md @@ -0,0 +1,187 @@ +# OxiCloud Load Test Scaffolding (K6) — Scenario A + +## Context + +OxiCloud needs a repeatable way to **detect performance regressions** as features land. The current `tests/load/` directory is empty except for a README asking K6-vs-drill. Scenario A focuses on a single user with many subfolders and cascading systems (folder ops, ReBAC permission groups, nested subject groups) — measuring p50/p95/p99 to identify inflections when new features are added. Scenario B (many concurrent users) is deferred. + +**Decisions already locked in via conversation:** +- K6 (Go-based, JS scripting in goja) over Rust testers — client overhead is dwarfed by server latency for these scenarios; regression deltas only require client *consistency*, not raw speed. +- **Two scenario tiers**: a **fast smoke** for PRs (~1 min, no regression gate — just verifies the harness still runs) and **long scenarios** for nightly + manual (regression-gated). +- **Hybrid seeding**: bulk fixtures via direct SQL (`cargo run --bin load-seed`); the resources each scenario actively touches go through REST so the measured path is realistic. +- **Result storage**: `baseline/baseline.json` committed in repo (p50/p95/p99 + tolerance per metric); per-run raw JSON kept locally / CI artifact, gitignored. Baseline updates are deliberate PRs. +- **No PR gating on long suite** — nightly on main + manual `workflow_dispatch` only. + +## Approach + +### 1. Directory layout + +``` +tests/load/ + README.md + test.env # base_url=http://localhost:8088, admin creds + run.sh # full suite: spawn-db → seed → server → k6 → compare → cleanup + smoke.sh # smoke only: same shape, runs scenarios/smoke.js, no gate + compare.mjs # diffs k6 summary.json vs baseline/baseline.json + lib/ + auth.js # login() → bearer token (POST /api/auth/login) + http.js # base URL + auth header helpers + metrics.js # custom k6 Trends, naming convention . + scenarios/ + smoke.js # 1 VU, 1 iter — login, create folder, upload, list, delete + folder_cascade.js # list/move/copy/trash on depth-8 fanout-5 tree + share_cascade_rebac.js # grant a folder, fetch N descendants as grantee + subject_group_nested.js # nested-group chain (depth 3), grant via group, fetch + baseline/ + baseline.json # committed; { ".": {p50,p95,p99,tolerance_pct} } + results/ + .gitkeep # raw runs, gitignored +``` + +Mirrors the `tests/api/` shell pattern (run.sh, test.env, separate server port). Uses port **8088** to avoid colliding with api-test on 8087. + +### 2. Rust bulk seeder — `src/bin/load-seed.rs` + +New binary registered in `Cargo.toml` alongside `generate-openapi` and `migrate-nfc-filenames`. + +**CLI:** +``` +cargo run --bin load-seed -- \ + --depth 8 --fanout 5 --files-per-leaf 10 \ + --extra-users 20 --group-depth 3 --group-fanout 5 +``` + +**Inserts via sqlx (one transaction per phase):** +- 1 admin + N extra users into `auth.users` (Argon2id with light params m=16384,t=1,p=1; same params as `src/infrastructure/services/password_hasher.rs` test path). +- One shared 0-byte blob row in `storage.blobs` with `ref_count = total_files`. +- Folder tree in `storage.folders`, **inserted level-by-level** so the `trg_folders_path` BEFORE-INSERT trigger can compute `path`/`lpath` from the parent chain. +- Files in `storage.files`, all referencing the shared blob hash for dedup. +- Subject groups in `auth.subject_groups` + membership rows in `auth.subject_group_members` (XOR `member_user_id`/`member_group_id`), forming a nested chain `G_root → G_mid → G_leaf → users`. +- ReBAC grants in `storage.access_grants` for a known set of test resources (used by `share_cascade_rebac.js`). + +Reuses existing connection config: reads `DATABASE_URL` from env, same shape as `tests/common/server.env`. + +**Critical gotchas surfaced during exploration:** +- Tree-ETag triggers (`folders_bump_tree_etag_*`) are STATEMENT-level (per commit `4200209d`), so bulk inserts fire them once per statement — safe. +- ReBAC cascade is computed in the application layer (`pg_acl_engine`), not at DB level. Seeder just inserts grant rows; the cascade is the *server* behavior we want to measure. +- `auth.subject_groups.name` is CITEXT, max 64, RFC-5321 local-part shape — generated names use `g_NNN` pattern to stay valid. + +### 3. K6 scenarios + +All scenarios load `baseline.json` at startup and set `thresholds` dynamically from it (`http_req_duration{op:x}: p(95).` → e.g. `folder_cascade.list_depth8`, `share_cascade_rebac.fetch_as_grantee`, `subject_group_nested.grant_via_chain3`. + +**Cold/warm split**: first iteration tagged `cold` (its metrics flow to `._cold`), rest are warm. Single first iter avoids polluting warm metrics with one-shot connection setup. + +### 4. Runner scripts + +**`run.sh`** (mirrors `tests/api/run.sh` shape): +1. `spawn-db.sh` (reused from `tests/common/`) +2. `init-test-schema.sh` (reused) +3. `cargo run --bin load-seed -- ` against the test DB +4. Start `target//oxicloud` on port 8088 with `OXICLOUD_STORAGE_PATH=tests/load/storage` +5. `wait_for_http http://localhost:8088/ready` +6. `k6 run --summary-export=results/$(date +%s).json scenarios/folder_cascade.js scenarios/share_cascade_rebac.js scenarios/subject_group_nested.js` +7. `node compare.mjs results/.json baseline/baseline.json` → exits non-zero on regression +8. `trap cleanup EXIT` tears down server + DB + +**`smoke.sh`**: same shape but only runs `scenarios/smoke.js`, **skips the seeder** (smoke creates its own minimal data), **skips `compare.mjs`**. Goal is harness liveness, not regression. + +### 5. `compare.mjs` + +Plain Node script (no deps, just `fs` + `process`). Reads two JSONs, walks every metric in baseline, computes: +``` +delta_pct = (current - baseline) / baseline * 100 +``` +Prints a human table: +``` +folder_cascade.list_depth8.p95: 142ms → 198ms (+39%) REGRESSION (tolerance 10%) +share_cascade_rebac.grant.p95: 60ms → 58ms (-3%) ok +``` +Exit 1 if any metric exceeds tolerance. Exit 0 otherwise. Missing metrics in current = exit 1 (suite drift); new metrics in current = warn only. + +### 6. Baseline format + +```json +{ + "folder_cascade.list_depth8": { "p50": 12.0, "p95": 45.0, "p99": 80.0, "tolerance_pct": 10 }, + "share_cascade_rebac.fetch_as_grantee": { "p50": 18.0, "p95": 55.0, "p99": 100.0, "tolerance_pct": 10 }, + ... +} +``` + +Values are placeholders — first run on a stable machine seeds real numbers. Updated only via the `load-baseline` recipe (which rewrites `baseline.json` from the latest run) and committed deliberately as `chore(load): accept new baseline for `. + +### 7. Justfile recipes (added to existing `justfile`) + +``` +load: # full suite — nightly + manual + bash tests/load/run.sh + +load-smoke: # fast harness check + bash tests/load/smoke.sh + +load-baseline: # rerun, overwrite baseline from latest result + bash tests/load/run.sh + cp tests/load/results/$(ls -t tests/load/results/*.json | head -1) tests/load/baseline/baseline.json + +load-seed: # standalone seeder for local poking + cargo run --bin load-seed -- --depth 8 --fanout 5 --files-per-leaf 10 +``` + +Matches existing recipe naming (`test-*`, `front-*`, `api-test`). + +### 8. CI workflows (stubs) + +- **`.github/workflows/load-nightly.yml`** — cron `0 3 * * *` UTC + `workflow_dispatch`; runs `just load` on `ubuntu-latest` initially with a `# TODO: replace with self-hosted runner — shared GH runners produce noisy results for regression detection` comment; uploads `tests/load/results/*.json` as artifact; on `compare.mjs` non-zero, opens an issue using `peter-evans/create-issue-from-file` with the diff report. +- **`.github/workflows/load-smoke.yml`** — on every PR to `main`; runs `just load-smoke`; ~1 min budget; no regression gate, just "harness builds & runs." + +### 9. Critical files to create / modify + +**Create:** +- `tests/load/README.md` +- `tests/load/test.env` +- `tests/load/run.sh`, `smoke.sh` +- `tests/load/compare.mjs` +- `tests/load/lib/auth.js`, `http.js`, `metrics.js` +- `tests/load/scenarios/smoke.js`, `folder_cascade.js`, `share_cascade_rebac.js`, `subject_group_nested.js` +- `tests/load/baseline/baseline.json` (placeholder values) +- `tests/load/results/.gitkeep` +- `src/bin/load-seed.rs` +- `.github/workflows/load-nightly.yml`, `load-smoke.yml` + +**Modify:** +- `Cargo.toml` — add `[[bin]] name = "load-seed" path = "src/bin/load-seed.rs"` after the `migrate-nfc-filenames` entry +- `justfile` — append four `load*` recipes +- `.gitignore` — add `tests/load/results/*.json` and `tests/load/storage/` + +### 10. Reused existing utilities + +- `tests/common/spawn-db.sh`, `stop-db.sh`, `init-test-schema.sh`, `server.env` — reused as-is by `run.sh` / `smoke.sh`. +- Argon2id parameters from `src/infrastructure/services/password_hasher.rs` test path — mirrored in `load-seed.rs` so test users can log in via the real auth flow. +- DB schema migrations (`migrations/`) — applied via the existing `init-test-schema.sh`; no schema changes needed. + +## Verification + +1. **Build**: `cargo build --bin load-seed` succeeds; `cargo clippy --all-features --all-targets -- -D warnings` stays green. +2. **Seeder works standalone**: `just db && just load-seed` populates `auth.users`, `storage.folders` (depth-8 tree visible via `SELECT count(*) FROM storage.folders WHERE user_id = …`), `auth.subject_groups`, `storage.access_grants`. +3. **Smoke runs locally**: `just load-smoke` — exits 0 in under ~90 s, leaves no orphan postgres/oxicloud processes. +4. **Full suite runs locally**: `just load` — k6 prints per-scenario p50/p95/p99; `compare.mjs` prints the diff table; since `baseline.json` is empty placeholders, expect a "REGRESSION" stub-report that the user reviews before running `just load-baseline` to accept the first real baseline. +5. **Baseline workflow**: after first `just load`, `just load-baseline` rewrites `baseline.json`; subsequent `just load` exits 0 (within tolerance). +6. **CI smoke**: trigger `load-smoke.yml` on a draft PR; confirm it completes under the time budget and uploads no artifacts on success. +7. **CI nightly**: manually trigger `load-nightly.yml` via `workflow_dispatch`; confirm artifact upload and (since no real baseline yet) confirm issue creation works. + +## Out of scope + +- Real baseline values (first run on user's hardware seeds them). +- Self-hosted runner setup (workflow stub uses `ubuntu-latest` with a TODO comment). +- Scenario B (many concurrent users) — deferred per user request. +- Goose / Rust-tester alternative — deferred until scenario B demonstrates K6 saturation issues. +- Additional scenarios (CalDAV / CardDAV / WebDAV / search depth) — the scaffolding makes them additive: drop a new file in `scenarios/`, add its metric names to `baseline.json`. diff --git a/justfile b/justfile index 29f47595..0b52f217 100644 --- a/justfile +++ b/justfile @@ -153,3 +153,25 @@ front-design: api-test: bash tests/api/run.sh bash tests/webdav/run.sh + +# k6 load suite — full scenarios + regression diff vs baseline/load.json. +# Used by the nightly workflow and on demand. Release build for fair timings. +load: + bash tests/load/run.sh + +# k6 smoke — single happy-path iteration. PR-tier liveness check, no gate. +load-smoke: + bash tests/load/smoke.sh + +# Re-run the full suite, then bake the latest summary into baseline/load.json. +# The leading `-` lets `run.sh`'s regression-exit not abort the bake step; +# review the diff and commit deliberately: +# chore(load): accept new baseline for +load-baseline: + -bash tests/load/run.sh + node tests/load/bake-baseline.mjs + +# Standalone seeder (poking around in psql). Needs the test DB up: +# just db +load-seed: + cargo run --bin load-seed -- --depth 5 --fanout 4 --files-per-leaf 3 diff --git a/src/bin/load-seed.rs b/src/bin/load-seed.rs new file mode 100644 index 00000000..34208b99 --- /dev/null +++ b/src/bin/load-seed.rs @@ -0,0 +1,746 @@ +//! `load-seed` — bulk-seeds OxiCloud's test database for K6 load scenarios. +//! +//! Inserts users, a deep folder tree, files (all sharing one dedup'd blob), +//! nested subject groups, and ReBAC grants directly via sqlx — bypassing the +//! REST API so the seed phase is fast and doesn't pollute measured metrics. +//! +//! Only the resources each k6 scenario actively touches (the grant being +//! created, the move target, etc.) go through the HTTP API at run time. +//! +//! Run (from repo root, against the test DB on port 5433): +//! +//! ```bash +//! DATABASE_URL='postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test' \ +//! cargo run --bin load-seed -- \ +//! --depth 8 --fanout 3 --files-per-leaf 3 \ +//! --extra-users 20 --group-depth 3 --group-fanout 5 \ +//! --manifest tests/load/results/seed-manifest.json +//! ``` +//! +//! Output: writes a JSON manifest at `--manifest` listing the IDs and +//! credentials each k6 scenario needs (admin login, grantee user, deep folder +//! IDs, group chain, etc.). K6 scenarios load this manifest at startup. + +use argon2::password_hash::SaltString; +use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version}; +use rand_core::OsRng; +use serde::Serialize; +use sqlx::PgPool; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::PathBuf; +use uuid::Uuid; + +// ── CLI ────────────────────────────────────────────────────────────────── + +struct Args { + depth: u32, + fanout: u32, + files_per_leaf: u32, + extra_users: u32, + group_depth: u32, + group_fanout: u32, + manifest: PathBuf, + password: String, +} + +impl Args { + fn parse() -> Self { + // Defaults match tests/load/test.env; both bound the exponential cost + // of fanout^depth. Override via flags or LOAD_* env vars. + let mut depth = 5; + let mut fanout = 4; + let mut files_per_leaf = 3; + let mut extra_users = 20; + let mut group_depth = 3; + let mut group_fanout = 5; + let mut manifest = PathBuf::from("tests/load/results/seed-manifest.json"); + let mut password = "TestPassword1!".to_string(); + + let argv: Vec = env::args().collect(); + let mut i = 1; + while i < argv.len() { + let key = argv[i].as_str(); + let val = || { + argv.get(i + 1) + .unwrap_or_else(|| panic!("missing value for {}", key)) + }; + match key { + "--depth" => depth = val().parse().expect("--depth must be u32"), + "--fanout" => fanout = val().parse().expect("--fanout must be u32"), + "--files-per-leaf" => { + files_per_leaf = val().parse().expect("--files-per-leaf must be u32"); + } + "--extra-users" => { + extra_users = val().parse().expect("--extra-users must be u32"); + } + "--group-depth" => { + group_depth = val().parse().expect("--group-depth must be u32"); + } + "--group-fanout" => { + group_fanout = val().parse().expect("--group-fanout must be u32"); + } + "--manifest" => manifest = PathBuf::from(val()), + "--password" => password = val().clone(), + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + other => panic!("unknown argument: {}", other), + } + i += 2; + } + + Self { + depth, + fanout, + files_per_leaf, + extra_users, + group_depth, + group_fanout, + manifest, + password, + } + } +} + +fn print_help() { + println!("load-seed — bulk-seed OxiCloud for k6 load tests"); + println!(); + println!("Reads DATABASE_URL from env. Inserts users, folders, files, groups, grants."); + println!("Writes a JSON manifest the k6 scenarios consume."); + println!(); + println!("Flags (with defaults):"); + println!(" --depth 5 folder tree depth (exponential: fanout^depth folders)"); + println!(" --fanout 4 children per non-leaf folder"); + println!(" --files-per-leaf 3 files inserted in each leaf folder"); + println!(" --extra-users 20 non-admin users created"); + println!(" --group-depth 3 nested subject-group chain length"); + println!(" --group-fanout 5 users added directly to each leaf group"); + println!(" --manifest output manifest JSON path"); + println!(" --password shared password for all seeded users"); +} + +// ── Manifest (consumed by k6 scenarios) ────────────────────────────────── + +#[derive(Serialize)] +struct Manifest { + admin: UserCreds, + grantee: UserCreds, + group_member: UserCreds, + shared_subtree: SubtreeIds, + group_subtree: SubtreeIds, + nested_groups: NestedGroupIds, + /// Total folders seeded (informational). + total_folders: u64, + /// Total files seeded (informational). + total_files: u64, +} + +#[derive(Serialize)] +struct UserCreds { + id: Uuid, + username: String, + password: String, +} + +#[derive(Serialize)] +struct SubtreeIds { + /// Root of the subtree the grant is attached to. + root: Uuid, + /// A folder at depth 4 inside the subtree (for mid-depth fetches). + depth4: Uuid, + /// A folder at depth 8 inside the subtree (for mid-depth fetches). + depth8: Uuid, + /// A folder at depth N (== `--depth`) inside the subtree (for deep fetches). + deepest: Uuid, +} + +#[derive(Serialize)] +struct NestedGroupIds { + /// Outermost group — the one referenced by the grant. Contains `mid` as a member. + root: Uuid, + /// Intermediate group — member of `root`, contains `leaf` as a member. + mid: Uuid, + /// Innermost group — member of `mid`, contains `group_member` user as a direct member. + leaf: Uuid, +} + +// ── Main ───────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Best-effort: load .env if present (mirrors the rest of the codebase). + let _ = dotenvy::dotenv(); + + let args = Args::parse(); + let database_url = env::var("DATABASE_URL").map_err(|_| { + "DATABASE_URL must be set (e.g. postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test)" + })?; + + println!( + "[load-seed] connecting to {}", + sanitize_url_for_log(&database_url) + ); + let pool = PgPool::connect(&database_url).await?; + + println!("[load-seed] wiping previous load-test data…"); + wipe(&pool).await?; + + println!("[load-seed] hashing shared password…"); + let password_hash = hash_password(&args.password); + + println!( + "[load-seed] inserting users (1 admin + {})…", + args.extra_users + ); + let admin = insert_user(&pool, "load_admin", "admin", &password_hash).await?; + let extra_users = insert_extra_users(&pool, args.extra_users, &password_hash).await?; + + // Pick a grantee (user[0]) and a group member (user[1]). + let grantee = extra_users + .first() + .ok_or("need at least one extra user for grantee")? + .clone(); + let group_member = extra_users + .get(1) + .ok_or("need at least two extra users")? + .clone(); + + println!("[load-seed] inserting shared blob…"); + let blob_hash = insert_shared_blob(&pool).await?; + + println!( + "[load-seed] building folder tree (depth={}, fanout={})…", + args.depth, args.fanout + ); + // Two parallel subtrees off root: one for the user-grant scenario, one for + // the group-grant scenario. Each has its own depth/fanout shape so a single + // grant cascades over a known fixed number of descendants. + let shared_subtree = + build_subtree(&pool, admin.id, "shared_root", args.depth, args.fanout).await?; + let group_subtree = + build_subtree(&pool, admin.id, "group_root", args.depth, args.fanout).await?; + + let total_folders = shared_subtree.all_ids.len() as u64 + group_subtree.all_ids.len() as u64; + let total_leaves = shared_subtree.leaves.len() + group_subtree.leaves.len(); + + println!( + "[load-seed] inserting files (files_per_leaf={}, leaves={})…", + args.files_per_leaf, total_leaves + ); + let total_files = insert_files( + &pool, + admin.id, + &blob_hash, + &shared_subtree.leaves, + &group_subtree.leaves, + args.files_per_leaf, + ) + .await?; + + println!( + "[load-seed] building nested group chain (depth={}, fanout={})…", + args.group_depth, args.group_fanout + ); + let nested_groups = build_group_chain( + &pool, + admin.id, + &extra_users, + &group_member.id, + args.group_depth, + args.group_fanout, + ) + .await?; + + println!("[load-seed] inserting grants…"); + // Grant the grantee read on shared_subtree.root. + insert_grant( + &pool, + "user", + grantee.id, + "folder", + shared_subtree.root, + "read", + admin.id, + ) + .await?; + // Grant the outermost group read on group_subtree.root. + insert_grant( + &pool, + "group", + nested_groups.root, + "folder", + group_subtree.root, + "read", + admin.id, + ) + .await?; + + let manifest = Manifest { + admin: UserCreds { + id: admin.id, + username: admin.username, + password: args.password.clone(), + }, + grantee: UserCreds { + id: grantee.id, + username: grantee.username, + password: args.password.clone(), + }, + group_member: UserCreds { + id: group_member.id, + username: group_member.username, + password: args.password.clone(), + }, + shared_subtree: SubtreeIds { + root: shared_subtree.root, + depth4: shared_subtree.depth4, + depth8: shared_subtree.depth8, + deepest: shared_subtree.deepest, + }, + group_subtree: SubtreeIds { + root: group_subtree.root, + depth4: group_subtree.depth4, + depth8: group_subtree.depth8, + deepest: group_subtree.deepest, + }, + nested_groups, + total_folders, + total_files, + }; + + if let Some(parent) = args.manifest.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(&manifest)?; + fs::write(&args.manifest, json)?; + println!( + "[load-seed] manifest written → {} ({} folders, {} files)", + args.manifest.display(), + total_folders, + total_files + ); + + Ok(()) +} + +// ── Wipe ───────────────────────────────────────────────────────────────── + +async fn wipe(pool: &PgPool) -> Result<(), sqlx::Error> { + // Users starting with `load_` are the only ones this seeder ever creates. + // CASCADE on auth.users → storage.folders / storage.files / grants / group + // memberships, so a single DELETE wipes everything load-test-related. + sqlx::query("DELETE FROM auth.users WHERE username LIKE 'load_%'") + .execute(pool) + .await?; + // Groups starting with `load_` aren't owned by users; clear them explicitly. + sqlx::query("DELETE FROM auth.subject_groups WHERE name LIKE 'load_%'") + .execute(pool) + .await?; + // The shared dedup blob is identified by its fixed hash sentinel below. + sqlx::query("DELETE FROM storage.blobs WHERE hash = $1") + .bind(SHARED_BLOB_HASH) + .execute(pool) + .await?; + Ok(()) +} + +// ── Password ───────────────────────────────────────────────────────────── + +/// Light Argon2id parameters — same shape as the password_hasher test path +/// (`src/infrastructure/services/password_hasher.rs::test_hasher`). +/// Production hashes verify regardless of m/t/p because Argon2 reads the +/// parameters back from the hash string itself. +fn hash_password(password: &str) -> String { + let params = Params::new(16384, 1, 1, None).expect("valid argon2 params"); + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let salt = SaltString::generate(&mut OsRng); + argon2 + .hash_password(password.as_bytes(), &salt) + .expect("argon2 hash") + .to_string() +} + +// ── Users ──────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct SeededUser { + id: Uuid, + username: String, +} + +async fn insert_user( + pool: &PgPool, + username: &str, + role: &str, + password_hash: &str, +) -> Result { + let email = format!("{}@load.test.invalid", username); + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO auth.users (username, email, password_hash, role) + VALUES ($1, $2, $3, $4::auth.userrole) + RETURNING id", + ) + .bind(username) + .bind(email) + .bind(password_hash) + .bind(role) + .fetch_one(pool) + .await?; + Ok(SeededUser { + id: row.0, + username: username.to_string(), + }) +} + +async fn insert_extra_users( + pool: &PgPool, + count: u32, + password_hash: &str, +) -> Result, sqlx::Error> { + if count == 0 { + return Ok(Vec::new()); + } + let usernames: Vec = (0..count).map(|i| format!("load_user_{:04}", i)).collect(); + let emails: Vec = usernames + .iter() + .map(|u| format!("{}@load.test.invalid", u)) + .collect(); + + // Bulk insert via UNNEST — one round-trip for all N users. + let rows: Vec<(Uuid, String)> = sqlx::query_as( + "INSERT INTO auth.users (username, email, password_hash, role) + SELECT u.username, u.email, $1::text, 'user'::auth.userrole + FROM UNNEST($2::text[], $3::text[]) AS u(username, email) + RETURNING id, username", + ) + .bind(password_hash) + .bind(&usernames) + .bind(&emails) + .fetch_all(pool) + .await?; + + // RETURNING order is implementation-defined — sort by username so the + // caller can rely on `extra_users[0]` being `load_user_0000`. + let mut users: Vec = rows + .into_iter() + .map(|(id, username)| SeededUser { id, username }) + .collect(); + users.sort_by(|a, b| a.username.cmp(&b.username)); + Ok(users) +} + +// ── Blob ───────────────────────────────────────────────────────────────── + +/// All seeded files share this single zero-byte blob. Its ref_count is bumped +/// in lockstep with file inserts; the file_metadata schema doesn't require a +/// real on-disk blob for the dedup index to be consistent. +const SHARED_BLOB_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +async fn insert_shared_blob(pool: &PgPool) -> Result { + sqlx::query( + "INSERT INTO storage.blobs (hash, size, ref_count, content_type) + VALUES ($1, 0, 0, 'application/octet-stream') + ON CONFLICT (hash) DO NOTHING", + ) + .bind(SHARED_BLOB_HASH) + .execute(pool) + .await?; + Ok(SHARED_BLOB_HASH.to_string()) +} + +// ── Folder tree ────────────────────────────────────────────────────────── + +struct Subtree { + root: Uuid, + depth4: Uuid, + depth8: Uuid, + deepest: Uuid, + all_ids: Vec, + leaves: Vec, +} + +/// Build a folder tree under `parent=NULL` rooted at `root_name`. +/// +/// Inserts level-by-level so `trg_folders_path` resolves `path`/`lpath` from +/// the already-committed parent rows. Returns the root, a depth-4 sample, the +/// deepest leaf, the full id set, and the leaf ids (used for file seeding). +async fn build_subtree( + pool: &PgPool, + user_id: Uuid, + root_name: &str, + depth: u32, + fanout: u32, +) -> Result { + // Per-row BEFORE INSERT trigger (compute_folder_path) makes each level cost + // O(parents × fanout); past depth 6 with fanout 5 the seed time grows fast. + // Print the count BEFORE each level so it's clear which level is in flight + // and how many rows are about to be inserted. + let predicted: u64 = (0..=depth).map(|l| (fanout as u64).pow(l)).sum(); + println!( + " subtree '{}': ~{} folders predicted", + root_name, predicted + ); + + // Level 0 — the root folder. + let root: (Uuid,) = sqlx::query_as( + "INSERT INTO storage.folders (name, parent_id, user_id) + VALUES ($1, NULL, $2) + RETURNING id", + ) + .bind(root_name) + .bind(user_id) + .fetch_one(pool) + .await?; + let root_id = root.0; + let mut all_ids = vec![root_id]; + + let mut current_level: Vec = vec![root_id]; + let mut depth4: Option = None; + let mut depth8: Option = None; + let mut deepest: Uuid = root_id; + + for level in 1..=depth { + // Build the (parent_id, name) pairs for this level. + let parents: Vec = current_level + .iter() + .flat_map(|p| std::iter::repeat_n(*p, fanout as usize)) + .collect(); + let names: Vec = current_level + .iter() + .enumerate() + .flat_map(|(pi, _)| (0..fanout).map(move |c| format!("l{}_{}_{}", level, pi, c))) + .collect(); + + let started = std::time::Instant::now(); + println!( + " level {}/{}: inserting {} folders…", + level, + depth, + parents.len() + ); + + let rows: Vec<(Uuid,)> = sqlx::query_as( + "INSERT INTO storage.folders (name, parent_id, user_id) + SELECT f.name, f.parent_id, $1 + FROM UNNEST($2::uuid[], $3::text[]) AS f(parent_id, name) + RETURNING id", + ) + .bind(user_id) + .bind(&parents) + .bind(&names) + .fetch_all(pool) + .await?; + let level_ids: Vec = rows.into_iter().map(|(id,)| id).collect(); + println!( + " level {}/{}: done in {:.1}s", + level, + depth, + started.elapsed().as_secs_f64() + ); + + if level == 4 { + depth4 = level_ids.first().copied(); + } + if level == 8 { + depth8 = level_ids.first().copied(); + } + if level == depth { + deepest = *level_ids.first().expect("non-empty deepest level"); + } + + all_ids.extend(&level_ids); + current_level = level_ids; + } + + let depth4 = depth4.unwrap_or(deepest); + let depth8 = depth8.unwrap_or(deepest); + let leaves = current_level; + + Ok(Subtree { + root: root_id, + depth4, + depth8, + deepest, + all_ids, + leaves, + }) +} + +// ── Files ──────────────────────────────────────────────────────────────── + +async fn insert_files( + pool: &PgPool, + user_id: Uuid, + blob_hash: &str, + shared_leaves: &[Uuid], + group_leaves: &[Uuid], + files_per_leaf: u32, +) -> Result { + if files_per_leaf == 0 { + return Ok(0); + } + let all_leaves: Vec = shared_leaves + .iter() + .chain(group_leaves.iter()) + .copied() + .collect(); + if all_leaves.is_empty() { + return Ok(0); + } + + // Build (folder_id, name) pairs. + let folder_ids: Vec = all_leaves + .iter() + .flat_map(|f| std::iter::repeat_n(*f, files_per_leaf as usize)) + .collect(); + let names: Vec = (0..all_leaves.len()) + .flat_map(|li| (0..files_per_leaf).map(move |fi| format!("file_{}_{}.txt", li, fi))) + .collect(); + + sqlx::query( + "INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) + SELECT f.name, f.folder_id, $1, $2, 0, 'text/plain' + FROM UNNEST($3::uuid[], $4::text[]) AS f(folder_id, name)", + ) + .bind(user_id) + .bind(blob_hash) + .bind(&folder_ids) + .bind(&names) + .execute(pool) + .await?; + + let total = folder_ids.len() as i64; + sqlx::query("UPDATE storage.blobs SET ref_count = ref_count + $1 WHERE hash = $2") + .bind(total) + .bind(blob_hash) + .execute(pool) + .await?; + + Ok(total as u64) +} + +// ── Subject groups ─────────────────────────────────────────────────────── + +async fn build_group_chain( + pool: &PgPool, + admin_id: Uuid, + extra_users: &[SeededUser], + group_member_user_id: &Uuid, + depth: u32, + fanout: u32, +) -> Result> { + if depth < 2 { + return Err(format!("--group-depth must be ≥ 2 (got {})", depth).into()); + } + + // Create `depth` groups: g_0 (outermost) → g_1 → … → g_{depth-1} (innermost). + // Insert one row per group; CITEXT name must match the RFC-5321 regex, + // and the `load_` prefix lets the wipe step find them again. + let salt: String = Uuid::new_v4().simple().to_string(); + let names: Vec = (0..depth) + .map(|i| format!("load_g_{}_{}", salt, i)) + .collect(); + + let mut group_ids: HashMap = HashMap::with_capacity(depth as usize); + for (i, name) in names.iter().enumerate() { + let row: (Uuid,) = + sqlx::query_as("INSERT INTO auth.subject_groups (name) VALUES ($1) RETURNING id") + .bind(name) + .fetch_one(pool) + .await?; + group_ids.insert(i as u32, row.0); + } + + // Wire the chain: g_i contains g_{i+1} as a member (XOR: member_group_id set). + for i in 0..(depth - 1) { + let parent = group_ids[&i]; + let child = group_ids[&(i + 1)]; + sqlx::query( + "INSERT INTO auth.subject_group_members (group_id, member_user_id, member_group_id, added_by) + VALUES ($1, NULL, $2, $3)", + ) + .bind(parent) + .bind(child) + .bind(admin_id) + .execute(pool) + .await?; + } + + // Innermost group gets `fanout` direct user members, ensuring the + // group_member sentinel user is always among them. + let leaf = group_ids[&(depth - 1)]; + let user_ids: Vec = { + let mut v = vec![*group_member_user_id]; + for u in extra_users.iter().filter(|u| u.id != *group_member_user_id) { + if v.len() as u32 >= fanout { + break; + } + v.push(u.id); + } + v + }; + + let leafs: Vec = std::iter::repeat_n(leaf, user_ids.len()).collect(); + let added_by_col: Vec = std::iter::repeat_n(admin_id, user_ids.len()).collect(); + sqlx::query( + "INSERT INTO auth.subject_group_members (group_id, member_user_id, member_group_id, added_by) + SELECT g.group_id, g.member_user_id, NULL, g.added_by + FROM UNNEST($1::uuid[], $2::uuid[], $3::uuid[]) AS g(group_id, member_user_id, added_by)", + ) + .bind(&leafs) + .bind(&user_ids) + .bind(&added_by_col) + .execute(pool) + .await?; + + Ok(NestedGroupIds { + root: group_ids[&0], + mid: group_ids[&(depth / 2)], + leaf, + }) +} + +// ── Grants ─────────────────────────────────────────────────────────────── + +async fn insert_grant( + pool: &PgPool, + subject_type: &str, + subject_id: Uuid, + resource_type: &str, + resource_id: Uuid, + permission: &str, + granted_by: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO storage.access_grants + (subject_type, subject_id, resource_type, resource_id, permission, granted_by) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission) DO NOTHING", + ) + .bind(subject_type) + .bind(subject_id) + .bind(resource_type) + .bind(resource_id) + .bind(permission) + .bind(granted_by) + .execute(pool) + .await?; + Ok(()) +} + +// ── Utilities ──────────────────────────────────────────────────────────── + +fn sanitize_url_for_log(url: &str) -> String { + // Strip "user:password@" from the URL so logs don't leak credentials. + if let Some(at) = url.find('@') + && let Some(scheme_end) = url.find("://") + { + let scheme = &url[..scheme_end + 3]; + let rest = &url[at..]; + return format!("{}***{}", scheme, rest); + } + url.to_string() +} diff --git a/tests/common/spawn-db.sh b/tests/common/spawn-db.sh index 8fd748eb..0db7cff3 100755 --- a/tests/common/spawn-db.sh +++ b/tests/common/spawn-db.sh @@ -32,11 +32,24 @@ wait_for_postgres_ready() { # Belt-and-braces: pg_isready returns 0 as soon as the server accepts # connections, but a query may still race the very first request. One # successful SELECT confirms the round-trip works end-to-end. - PGPASSWORD=oxicloud_test psql -h 127.0.0.1 -p 5433 -U oxicloud_test -d oxicloud_test \ - -v ON_ERROR_STOP=1 -c 'SELECT 1' >/dev/null 2>&1 || { - echo "Postgres reported ready but a sample query failed" >&2 - exit 1 - } + # + # Retry the probe a handful of times — under CPU pressure (e.g. a parallel + # cargo build hammering a self-hosted runner) the role/db init can complete + # a beat after pg_isready returns success. A single-shot probe in that + # window produces spurious "Postgres reported ready but a sample query + # failed" failures. Show the last error if every retry fails so operators + # see the actual psql diagnostic. + local last_err + for _ in 1 2 3 4 5 6 7 8 9 10; do + if last_err=$(PGPASSWORD=oxicloud_test psql -h 127.0.0.1 -p 5433 \ + -U oxicloud_test -d oxicloud_test \ + -v ON_ERROR_STOP=1 -c 'SELECT 1' 2>&1 >/dev/null); then + return 0 + fi + sleep 0.5 + done + echo "Postgres reported ready but a sample query failed after 10 retries: $last_err" >&2 + exit 1 } echo "[setup] Starting test postgres..." diff --git a/tests/load/README.md b/tests/load/README.md index 529be1b5..dfe0d289 100644 --- a/tests/load/README.md +++ b/tests/load/README.md @@ -1,11 +1,94 @@ -iPurpose of this directory: implement load test and identify response time under: -- heavy load -- many content -- many sub folders and sharing +# tests/load/ -Goal is to identify inflections and regression when a new feature is added +K6 load-test suite for OxiCloud. Detects performance regressions by comparing +each run's p50/p95/p99 against a committed baseline. -No accemtance criteria yet +## Suites -Load test via k6 ? -or drill (written in Rust) ? +- **smoke** — `just load-smoke`. Single VU, single iteration of one scenario. + Verifies the harness still builds and the server boots. ~1 minute. Run on + every PR. No regression gate. +- **full** — `just load`. Runs every scenario under `scenarios/` against a + seeded database. Compares results against `baseline/load.json`; exits + non-zero on regression beyond the per-metric tolerance. Run nightly on + `main` and manually. + +## Scenarios + +| File | What it measures | +| --------------------------------- | ------------------------------------------------------------------------------- | +| `scenarios/smoke.js` | Login + create folder + upload + list root + delete. Liveness only. | +| `scenarios/folder_cascade.js` | `GET /contents`, `PUT /move`, batch copy, `DELETE` on a depth-8 fanout-5 tree. | +| `scenarios/share_cascade_rebac.js`| `POST /grants` on a folder, then descendants fetched by the grantee. | +| `scenarios/subject_group_nested.js`| Grant via a 3-level nested group chain, then descendants fetched by a member. | + +Add new scenarios as `scenarios/.js`; register their metric names in +`baseline/load.json` (or `baseline/smoke.json` if you wire smoke gating). + +## Seeding + +`src/bin/load-seed.rs` (invoked by `run.sh`) bulk-inserts fixtures directly +via sqlx: users, deep folder tree, files (all sharing one dedup'd blob), +nested subject groups, ReBAC grants. Only the resources each scenario +actively touches (the grant being created, the move target, etc.) go +through the REST API at run time — that is the measured hot path. + +## Baseline & regression detection + +Baselines live under `baseline/`, split by which runner grades them: + +| File | Used by | Regression-gated? | +| --------------------- | ------------------------ | ----------------- | +| `baseline/load.json` | `just load` (`run.sh`) | Yes | +| `baseline/smoke.json` | `just load-smoke` | Not yet (see below) | + +Both have the same shape — one entry per `.`: + +```json +{ + "folder_cascade.list_depth1": { "p50": 0.97, "p95": 2.04, "p99": 4.82, "tolerance_pct": 10 } +} +``` + +K6 scenarios load the relevant file at startup and set `thresholds` from +it, so a regression fails the K6 run directly. `compare.mjs` also prints a +human-readable diff table after the run and exits non-zero if any metric +regresses beyond its tolerance. + +The smoke scenario is currently **not regression-gated** — `smoke.sh` runs +the scenario but doesn't call `compare.mjs`. When you decide it should be, +mirror the `run.sh` pattern and point `compare.mjs` at +`baseline/smoke.json`. + +**Updating a baseline is deliberate.** Run `just load-baseline` to rewrite +`baseline/load.json` from the latest run, then commit it as +`chore(load): accept new baseline for `. Never auto-update. For +`smoke.json`, pass explicit paths to `bake-baseline.mjs`. + +## Local workflow + +```bash +just db # start the test postgres (port 5433) +just load-seed # seed alone (poking around in psql) +just load-smoke # fast liveness check +just load # full suite + regression diff +just load-baseline # rerun, accept current numbers as the new bar +``` + +## CI + +- `.github/workflows/load-smoke.yml` — every PR. ~1 min. No regression gate. +- `.github/workflows/load-nightly.yml` — cron daily + `workflow_dispatch`. + Runs the full suite, uploads results as artifact, opens an issue on + regression. Currently runs on `ubuntu-latest`; replace with a stable + self-hosted runner for trustworthy regression signal (shared GitHub + runners produce noisy timings). + +## Why K6 and not Goose/drill + +K6 is Go-based (JS scripting in `goja`), not Node. For scenario A, the +client adds ~50–200µs per request — negligible vs. multi-ms server work, +and regression deltas only need *consistency*. K6 also gives us +thresholds-as-DSL, native InfluxDB/Prometheus output, and faster +scenario iteration than a Rust tester would. Reassess when scenario B +(many concurrent users) demonstrates K6 saturation issues. diff --git a/tests/load/bake-baseline.mjs b/tests/load/bake-baseline.mjs new file mode 100644 index 00000000..3330da37 --- /dev/null +++ b/tests/load/bake-baseline.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// bake-baseline.mjs — convert the latest k6 --summary-export into the +// baseline shape that compare.mjs reads. +// +// Tolerance values from the existing baseline are preserved; only the p50/ +// p95/p99 numbers are overwritten. Run this after `just load` to lock in a +// new accepted bar, then commit the result deliberately. +// +// Usage: +// node bake-baseline.mjs # auto-pick latest summary, target baseline/load.json +// node bake-baseline.mjs # explicit paths (e.g. baseline/smoke.json) + +import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { argv, exit } from 'node:process'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +function latestSummary() { + const dir = join(HERE, 'results'); + const candidates = readdirSync(dir) + .filter((f) => f.startsWith('run-') && f.endsWith('.json')) + .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + if (candidates.length === 0) { + console.error('bake-baseline: no run-*.json under tests/load/results/'); + exit(2); + } + return join(dir, candidates[0].f); +} + +const summaryPath = argv[2] ? resolve(argv[2]) : latestSummary(); +const baselinePath = argv[3] ? resolve(argv[3]) : join(HERE, 'baseline/load.json'); + +const summary = JSON.parse(readFileSync(summaryPath, 'utf8')); +const baseline = JSON.parse(readFileSync(baselinePath, 'utf8')); + +const metricKeyFor = (op) => `http_req_duration{op:${op}}`; +const PERCENTILES = [ + { baseline: 'p50', k6: 'med' }, + { baseline: 'p95', k6: 'p(95)' }, + { baseline: 'p99', k6: 'p(99)' }, +]; + +let updated = 0; +let missing = 0; + +for (const [op, entry] of Object.entries(baseline)) { + if (op.startsWith('_')) continue; + const metric = summary.metrics?.[metricKeyFor(op)]; + if (!metric) { + console.warn(` missing: ${op} (no data in ${summaryPath})`); + missing++; + continue; + } + // k6 --summary-export puts percentile values directly on the metric object + // (alongside `thresholds`), not inside a `.values` wrapper. + let touched = false; + for (const p of PERCENTILES) { + const cur = metric[p.k6]; + if (cur === undefined) continue; + entry[p.baseline] = Number(cur.toFixed(2)); + touched = true; + } + if (touched) updated++; +} + +writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`); +console.log(`bake-baseline: updated ${updated} metric(s), missing ${missing} in ${baselinePath}`); +console.log(`Source: ${summaryPath}`); +console.log('Commit deliberately: chore(load): accept new baseline for '); diff --git a/tests/load/baseline/load.json b/tests/load/baseline/load.json new file mode 100644 index 00000000..b377008b --- /dev/null +++ b/tests/load/baseline/load.json @@ -0,0 +1,81 @@ +{ + "_comment": "Baseline for `just load` — the long scenarios. Each entry: p50/p95/p99 (ms) + tolerance_pct (regression threshold). Updated by `just load-baseline`. Keys must match the `op` tag the matching scenario emits — adding a scenario means adding entries here in the same PR. Smoke metrics live in smoke.json.", + "folder_cascade.list_depth1": { + "p50": 0.35, + "p95": 2.25, + "p99": 4.73, + "tolerance_pct": 10 + }, + "folder_cascade.list_depth4": { + "p50": 0.36, + "p95": 0.92, + "p99": 2.39, + "tolerance_pct": 10 + }, + "folder_cascade.list_depth8": { + "p50": 0.34, + "p95": 0.63, + "p99": 1.9, + "tolerance_pct": 10 + }, + "folder_cascade.list_depth_deep": { + "p50": 0.3, + "p95": 0.61, + "p99": 2.03, + "tolerance_pct": 10 + }, + "share_cascade_rebac.list_grants": { + "p50": 0.4, + "p95": 1.16, + "p99": 1.68, + "tolerance_pct": 10 + }, + "share_cascade_rebac.fetch_as_grantee_depth1": { + "p50": 0.51, + "p95": 1.14, + "p99": 3.02, + "tolerance_pct": 10 + }, + "share_cascade_rebac.fetch_as_grantee_depth4": { + "p50": 0.53, + "p95": 1.43, + "p99": 2.22, + "tolerance_pct": 10 + }, + "share_cascade_rebac.fetch_as_grantee_depth8": { + "p50": 0.52, + "p95": 0.89, + "p99": 1.48, + "tolerance_pct": 10 + }, + "share_cascade_rebac.fetch_as_grantee_depth_deep": { + "p50": 0.51, + "p95": 1.04, + "p99": 1.64, + "tolerance_pct": 10 + }, + "subject_group_nested.fetch_as_member_depth1": { + "p50": 0.48, + "p95": 0.63, + "p99": 1.41, + "tolerance_pct": 10 + }, + "subject_group_nested.fetch_as_member_depth4": { + "p50": 0.5, + "p95": 0.58, + "p99": 0.65, + "tolerance_pct": 10 + }, + "subject_group_nested.fetch_as_member_depth8": { + "p50": 0.48, + "p95": 0.53, + "p99": 0.62, + "tolerance_pct": 10 + }, + "subject_group_nested.fetch_as_member_depth_deep": { + "p50": 0.45, + "p95": 0.62, + "p99": 0.63, + "tolerance_pct": 10 + } +} diff --git a/tests/load/baseline/smoke.json b/tests/load/baseline/smoke.json new file mode 100644 index 00000000..bf8f4b1f --- /dev/null +++ b/tests/load/baseline/smoke.json @@ -0,0 +1,33 @@ +{ + "_comment": "Baseline for `just load-smoke` — the smoke scenario. Placeholder values; smoke is not currently regression-gated by smoke.sh. When you decide it should be, point compare.mjs at this file from smoke.sh (mirror the run.sh pattern) and re-bake.", + "smoke.login": { + "p50": 50, + "p95": 200, + "p99": 400, + "tolerance_pct": 15 + }, + "smoke.create_folder": { + "p50": 20, + "p95": 80, + "p99": 150, + "tolerance_pct": 15 + }, + "smoke.upload_tiny": { + "p50": 30, + "p95": 120, + "p99": 250, + "tolerance_pct": 15 + }, + "smoke.list_root": { + "p50": 15, + "p95": 60, + "p99": 120, + "tolerance_pct": 15 + }, + "smoke.delete_folder": { + "p50": 20, + "p95": 80, + "p99": 150, + "tolerance_pct": 15 + } +} diff --git a/tests/load/compare.mjs b/tests/load/compare.mjs new file mode 100644 index 00000000..822e9f41 --- /dev/null +++ b/tests/load/compare.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// compare.mjs — regression diff for k6 load-test runs. +// +// Reads a k6 --summary-export JSON and the committed baseline, diffs the +// p50/p95/p99 of every baseline metric against the current run, prints a +// human-readable table, and exits non-zero if any metric regresses beyond +// its tolerance. +// +// Usage: +// node compare.mjs +// +// Exit codes: +// 0 — every metric within tolerance. +// 1 — one or more metrics regressed, or a baseline metric is absent from +// the current summary (suite drift — either a scenario was deleted +// or a tag was renamed without updating baseline.json). + +import { readFileSync } from 'node:fs'; +import { argv, exit } from 'node:process'; + +if (argv.length < 4) { + console.error('usage: compare.mjs '); + exit(2); +} + +const summaryPath = argv[2]; +const baselinePath = argv[3]; + +const summary = JSON.parse(readFileSync(summaryPath, 'utf8')); +const baseline = JSON.parse(readFileSync(baselinePath, 'utf8')); + +// k6 summary metrics are keyed verbatim with the tag: e.g. +// "http_req_duration{op:smoke.login}". +const metricKeyFor = (op) => `http_req_duration{op:${op}}`; + +// Map k6 percentile field names → baseline field names. +const PERCENTILES = [ + { baseline: 'p50', k6: 'med' }, + { baseline: 'p95', k6: 'p(95)' }, + { baseline: 'p99', k6: 'p(99)' }, +]; + +const rows = []; +let anyRegression = false; +let anyMissing = false; + +for (const [op, base] of Object.entries(baseline)) { + if (op.startsWith('_')) continue; // skip _comment etc. + const metric = summary.metrics?.[metricKeyFor(op)]; + if (!metric) { + rows.push({ op, status: 'MISSING', detail: 'no data for this tag in current summary' }); + anyMissing = true; + continue; + } + const tol = (base.tolerance_pct ?? 10) / 100; + // Noise-floor guard: at sub-millisecond percentiles, a 10% tolerance is + // smaller than a single context switch. Require the absolute delta to + // exceed `min_delta_ms` too — otherwise the swing is below the measurement + // floor and we don't flag it. Default 0.5ms is conservative for the load + // suite's typical 0.3–3ms range. Override per metric in baseline.json. + const minDelta = base.min_delta_ms ?? 0.5; + + for (const p of PERCENTILES) { + const baseVal = base[p.baseline]; + // k6 --summary-export puts percentile values directly on the metric, not + // inside a `.values` wrapper. + const curVal = metric[p.k6]; + if (baseVal === undefined || curVal === undefined) continue; + + const deltaPct = ((curVal - baseVal) / baseVal) * 100; + const deltaAbs = curVal - baseVal; + const limitPct = baseVal * (1 + tol); + // Regression only if BOTH the % rule AND the absolute floor are breached. + const regressed = curVal > limitPct && deltaAbs > minDelta; + if (regressed) anyRegression = true; + rows.push({ + op, + percentile: p.baseline, + base: baseVal, + cur: curVal, + deltaPct, + regressed, + tol: base.tolerance_pct ?? 10, + }); + } +} + +// ── Render table ────────────────────────────────────────────────────────── + +const ms = (v) => `${v.toFixed(1)}ms`; +const pct = (v) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`; + +function pad(s, n) { + if (s.length >= n) return s; + return s + ' '.repeat(n - s.length); +} + +const colOp = Math.max(20, ...rows.map((r) => r.op.length)); +const colP = 5; +const colVal = 11; + +console.log(); +console.log( + pad('metric', colOp + 1) + + pad('pctl', colP + 1) + + pad('baseline', colVal + 1) + + pad('current', colVal + 1) + + pad('delta', 9) + + 'status', +); +console.log('-'.repeat(colOp + colP + colVal * 2 + 9 + 12)); + +for (const r of rows) { + if (r.status === 'MISSING') { + console.log(`${pad(r.op, colOp + 1)}${pad('-', colP + 1)}${pad('-', colVal + 1)}${pad('-', colVal + 1)}${pad('-', 9)}MISSING — ${r.detail}`); + continue; + } + const status = r.regressed ? `REGRESSION (tol ±${r.tol}%)` : 'ok'; + console.log( + pad(r.op, colOp + 1) + + pad(r.percentile, colP + 1) + + pad(ms(r.base), colVal + 1) + + pad(ms(r.cur), colVal + 1) + + pad(pct(r.deltaPct), 9) + + status, + ); +} + +console.log(); +if (anyRegression || anyMissing) { + if (anyRegression) console.error('compare.mjs: one or more metrics regressed beyond tolerance.'); + if (anyMissing) console.error('compare.mjs: one or more baseline metrics are missing from the current summary.'); + exit(1); +} +console.log('compare.mjs: all metrics within tolerance.'); +exit(0); diff --git a/tests/load/lib/auth.js b/tests/load/lib/auth.js new file mode 100644 index 00000000..c35a36bd --- /dev/null +++ b/tests/load/lib/auth.js @@ -0,0 +1,38 @@ +// Login helper for OxiCloud k6 load tests. +// Returns the JWT access token from POST /api/auth/login. + +import { check, fail } from 'k6'; +import http from 'k6/http'; +import { BASE } from './http.js'; + +/** + * Log in via the public auth endpoint and return the bearer token. + * + * `body` shape matches `auth_handler.rs::login` — `{username, password}`. + * The response carries `access_token` plus refresh state we don't need here. + * + * The `op` tag should be scenario-qualified (e.g. 'smoke.login') so the + * recorded metric matches the corresponding key in baseline/baseline.json. + * Bare 'login' is fine for ad-hoc scripts that aren't regression-gated. + * + * @param {string} username + * @param {string} password + * @param {string} [op='login'] + * @returns {string} + */ +export function login(username, password, op = 'login') { + const res = http.post( + `${BASE}/api/auth/login`, + JSON.stringify({ username, password }), + { headers: { 'Content-Type': 'application/json' }, tags: { op } }, + ); + + const ok = check(res, { + 'login 200': (r) => r.status === 200, + 'login has access_token': (r) => !!r.json('access_token'), + }); + if (!ok) { + fail(`login failed for ${username}: status=${res.status}, body=${res.body}`); + } + return res.json('access_token'); +} diff --git a/tests/load/lib/http.js b/tests/load/lib/http.js new file mode 100644 index 00000000..82fdca25 --- /dev/null +++ b/tests/load/lib/http.js @@ -0,0 +1,40 @@ +// Shared HTTP helpers for OxiCloud k6 load tests. +// Centralises the base URL and adds the Bearer token + JSON Content-Type +// headers expected by every protected endpoint. + +import http from 'k6/http'; + +/** + * Base URL of the OxiCloud server under test. + * Read from K6_BASE_URL (set by run.sh), defaulting to the load-suite port. + */ +export const BASE = __ENV.K6_BASE_URL || 'http://localhost:8088'; + +/** + * Build a request params object with auth + JSON headers and a `tag` so + * the response's metrics are isolated under `.`. + * + * @param {string} token Bearer token from auth.login() + * @param {string} op Metric tag, e.g. 'folder_cascade.list_depth8' + */ +export function jsonParams(token, op) { + return { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + tags: { op }, + }; +} + +/** + * Same as jsonParams but without a body content-type — for GETs and DELETEs. + */ +export function authParams(token, op) { + return { + headers: { Authorization: `Bearer ${token}` }, + tags: { op }, + }; +} + +export const httpClient = http; diff --git a/tests/load/lib/metrics.js b/tests/load/lib/metrics.js new file mode 100644 index 00000000..7f4aa9d3 --- /dev/null +++ b/tests/load/lib/metrics.js @@ -0,0 +1,76 @@ +// Baseline-driven thresholds and shared helpers for k6 scenarios. +// +// Each scenario tags its requests with `op: '.'` (see http.js +// `jsonParams` / `authParams`). The baseline file lists one entry per +// `.` with p50/p95/p99 and a tolerance percentage; we convert +// every relevant entry to a k6 threshold so the run fails directly on +// regression — `compare.mjs` then produces the human-readable diff. + +// Resolve relative to THIS file (lib/metrics.js), not the importer. Future +// k6 versions will align open()'s path-resolution with ES module semantics; +// using import.meta.resolve() future-proofs against the warning logged by +// k6 ≥ 0.50. +const LOAD_BASELINE_PATH = import.meta.resolve('../baseline/load.json'); +const SMOKE_BASELINE_PATH = import.meta.resolve('../baseline/smoke.json'); +const MANIFEST_PATH = import.meta.resolve('../results/seed-manifest.json'); + +// Eagerly load both baseline files at module init (open() is only allowed +// in init context). Keys are disjoint by scenario prefix, so merging the +// two maps is safe; `thresholdsFromBaseline(prefix)` filters from the union. +const BASELINE = { + ...JSON.parse(open(LOAD_BASELINE_PATH)), + ...JSON.parse(open(SMOKE_BASELINE_PATH)), +}; + +/** + * Return the merged baseline (load + smoke) — convenient for tooling that + * wants to inspect everything; scenarios should use `thresholdsFromBaseline`. + */ +export function loadBaseline() { + return BASELINE; +} + +/** + * Build a k6 `thresholds` object from the baseline files, filtered to the + * given scenario prefix (e.g. 'folder_cascade'). + * + * Result shape (k6 expects metric-name → threshold-expression-array): + * { + * 'http_req_duration{op:folder_cascade.list_depth8}': [ + * 'p(95)<49.5', // 45 * (1 + 10/100) + * 'p(99)<88.0', + * ], + * } + * + * @param {string} scenarioPrefix + */ +export function thresholdsFromBaseline(scenarioPrefix) { + const baseline = BASELINE; + const thresholds = {}; + for (const [key, val] of Object.entries(baseline)) { + if (key.startsWith('_')) continue; // skip _comment etc. + if (!key.startsWith(`${scenarioPrefix}.`)) continue; + const tol = (val.tolerance_pct || 10) / 100; + // Mirror compare.mjs: use the larger of the %-based limit and the + // absolute-floor limit (baseline + min_delta_ms). Below sub-millisecond + // scale, the % rule alone fires on noise — the floor stops that. + const minDelta = val.min_delta_ms ?? 0.5; + const p95Limit = Math.max(val.p95 * (1 + tol), val.p95 + minDelta); + const p99Limit = Math.max(val.p99 * (1 + tol), val.p99 + minDelta); + thresholds[`http_req_duration{op:${key}}`] = [ + `p(95)<${p95Limit.toFixed(2)}`, + `p(99)<${p99Limit.toFixed(2)}`, + ]; + } + // `abortOnFail: false` (default) keeps the run going so we collect all + // regressions in one pass; the non-zero exit at the end still fails CI. + return thresholds; +} + +/** + * Read the seed manifest written by `cargo run --bin load-seed`. + */ +export function loadManifest() { + const raw = open(MANIFEST_PATH); + return JSON.parse(raw); +} diff --git a/tests/load/merge-summaries.mjs b/tests/load/merge-summaries.mjs new file mode 100644 index 00000000..9eefcb85 --- /dev/null +++ b/tests/load/merge-summaries.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// merge-summaries.mjs — combine multiple k6 --summary-export outputs into +// one. Used because k6 accepts a single script per invocation, but our +// regression-diff tooling (compare.mjs, bake-baseline.mjs) expects one +// summary per run. +// +// Per-scenario summaries are disjoint in their metric tags +// (folder_cascade.* vs share_cascade_rebac.* vs subject_group_nested.*), +// so merging is just a union of the `metrics` maps. The top-level +// envelope (root_group, options, etc.) is taken from the first input. +// +// Usage: +// node merge-summaries.mjs [in2.json …] + +import { readFileSync, writeFileSync } from 'node:fs'; +import { argv, exit } from 'node:process'; + +if (argv.length < 5) { + console.error('usage: merge-summaries.mjs [in2.json …] '); + exit(2); +} + +const inputs = argv.slice(2, -1); +const output = argv[argv.length - 1]; + +const merged = JSON.parse(readFileSync(inputs[0], 'utf8')); +merged.metrics = { ...merged.metrics }; + +for (const path of inputs.slice(1)) { + const next = JSON.parse(readFileSync(path, 'utf8')); + for (const [k, v] of Object.entries(next.metrics ?? {})) { + // If two scenarios both report a global metric (e.g. http_req_duration + // with no op tag), the second wins — these aren't gated by baseline.json, + // so it's only the per-`op` metrics that need to be preserved precisely. + merged.metrics[k] = v; + } +} + +writeFileSync(output, `${JSON.stringify(merged, null, 2)}\n`); +console.log(`merged ${inputs.length} summaries → ${output}`); diff --git a/tests/load/results/.gitkeep b/tests/load/results/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/load/run.sh b/tests/load/run.sh new file mode 100755 index 00000000..3e95f26e --- /dev/null +++ b/tests/load/run.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# Full k6 load-test runner. +# Starts postgres + OxiCloud server, seeds fixtures, runs k6 scenarios, +# compares results against baseline/load.json, tears everything down. +# +# Usage (from repo root): +# bash tests/load/run.sh +# +# Env overrides: +# BUILD_TARGET=release # prefer release build for accurate timings +# LOAD_DEPTH=8 # override seeder shape (otherwise read from test.env) +# LOAD_FANOUT=3 +# LOAD_FILES_PER_LEAF=3 +# LOAD_EXTRA_USERS=20 +# LOAD_GROUP_DEPTH=3 +# LOAD_GROUP_FANOUT=5 +# K6_SUMMARY_OUT=path # explicit output path (default: tests/load/results/.json) +# +# Prerequisites: docker, cargo, k6 >= 0.46, node >= 18 + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +LOAD_DIR="$REPO_ROOT/tests/load" + +# shellcheck source=test.env +source "$LOAD_DIR/test.env" + +SERVER_PORT="${base_url##*:}" + +log() { echo "[load] $*"; } +die() { echo "[load] ERROR: $*" >&2; exit 1; } + +wait_for_http() { + local url="$1" timeout="${2:-120}" + local deadline=$(( $(date +%s) + timeout )) + until curl -sf "$url" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url" + sleep 1 + done +} + +command -v k6 >/dev/null 2>&1 || die "k6 is required (https://k6.io/docs/get-started/installation/)" +command -v node >/dev/null 2>&1 || die "node >= 18 is required for compare.mjs" + +SERVER_PID="" +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud server (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + bash "$COMMON/stop-db.sh" || true +} +trap cleanup EXIT + +bash "$COMMON/spawn-db.sh" + +# NOTE: do NOT run init-test-schema.sh here. The OxiCloud server applies +# sqlx migrations on startup; applying them via raw psql first leaves the +# server's _sqlx_migrations tracking table empty, which makes the second +# pass try to re-ALTER tables that already have the column (e.g. migration +# 20260507000000_session_family.sql), and the server panics on boot. + +set -a +# shellcheck source=../common/server.env +source "$COMMON/server.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$LOAD_DIR/storage" +set +a + +rm -rf "$OXICLOUD_STORAGE_PATH" +mkdir -p "$OXICLOUD_STORAGE_PATH" + +BUILD_TARGET="${BUILD_TARGET:-release}" +# Respect CARGO_TARGET_DIR for self-hosted runners that bind-mount target/ +# outside the workspace (avoids actions/checkout EBUSY on the mount point). +TARGET_DIR="${CARGO_TARGET_DIR:-$REPO_ROOT/target}" +OXICLOUD_BIN="$TARGET_DIR/$BUILD_TARGET/oxicloud" +SEED_BIN="$TARGET_DIR/$BUILD_TARGET/load-seed" + +# Build both bins in one invocation. `load_seed_bin` is an empty marker +# feature that gates the load-seed bin without affecting oxicloud's dep +# graph, so cargo plans a single workspace build and oxicloud compiles +# exactly once. (See Cargo.toml comments on `load_seed_bin`.) +if [[ ! -x "$OXICLOUD_BIN" || ! -x "$SEED_BIN" ]]; then + log "Building OxiCloud + load-seed ($BUILD_TARGET)..." + if [[ "$BUILD_TARGET" == "release" ]]; then + cargo build --release --features load_seed_bin --bin oxicloud --bin load-seed + else + cargo build --features load_seed_bin --bin oxicloud --bin load-seed + fi +fi + +# Start the server FIRST so its sqlx::migrate! populates _sqlx_migrations +# against the clean DB. The seeder then runs against the migrated schema +# while the server is still alive (it issues plain INSERTs, no DDL). +log "Starting OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..." +"$OXICLOUD_BIN" & +SERVER_PID=$! +wait_for_http "$base_url/ready" 120 +log "Server ready." + +log "Seeding fixtures..." +DEPTH="${LOAD_DEPTH:-${load_depth:-5}}" +FANOUT="${LOAD_FANOUT:-${load_fanout:-4}}" +FILES_PER_LEAF="${LOAD_FILES_PER_LEAF:-${load_files_per_leaf:-3}}" +EXTRA_USERS="${LOAD_EXTRA_USERS:-${load_extra_users:-20}}" +GROUP_DEPTH="${LOAD_GROUP_DEPTH:-${load_group_depth:-3}}" +GROUP_FANOUT="${LOAD_GROUP_FANOUT:-${load_group_fanout:-5}}" + +mkdir -p "$LOAD_DIR/results" +MANIFEST_PATH="$LOAD_DIR/results/seed-manifest.json" + +"$SEED_BIN" \ + --depth "$DEPTH" \ + --fanout "$FANOUT" \ + --files-per-leaf "$FILES_PER_LEAF" \ + --extra-users "$EXTRA_USERS" \ + --group-depth "$GROUP_DEPTH" \ + --group-fanout "$GROUP_FANOUT" \ + --password "${password:-TestPassword1!}" \ + --manifest "$MANIFEST_PATH" + +TS="$(date +%s)" +SUMMARY_OUT="${K6_SUMMARY_OUT:-$LOAD_DIR/results/run-$TS.json}" + +export K6_BASE_URL="$base_url" +export K6_USERNAME="${username:-admin}" +export K6_PASSWORD="${password:-TestPassword1!}" + +# k6 only accepts one script per invocation, so each scenario runs separately +# and we merge the summaries afterwards. Per-scenario summaries also make it +# easier to attribute regressions when looking at raw artifacts in CI. +SCENARIOS=(folder_cascade share_cascade_rebac subject_group_nested) +PARTIAL_SUMMARIES=() +K6_FAILED=0 + +for name in "${SCENARIOS[@]}"; do + partial="$LOAD_DIR/results/run-$TS-$name.json" + PARTIAL_SUMMARIES+=("$partial") + log "Running k6 scenario: $name" + # --summary-trend-stats forces p(99) into the summary export; k6's default + # only includes avg/min/med/max/p(90)/p(95), so without it baseline.p99 can + # never be baked or diffed. + k6 run \ + --summary-export="$partial" \ + --summary-trend-stats="avg,min,med,max,p(90),p(95),p(99)" \ + --quiet \ + "$LOAD_DIR/scenarios/$name.js" \ + || K6_FAILED=$? +done + +log "Merging summaries -> $SUMMARY_OUT" +node "$LOAD_DIR/merge-summaries.mjs" "${PARTIAL_SUMMARIES[@]}" "$SUMMARY_OUT" + +log "Comparing against baseline..." +COMPARE_RC=0 +node "$LOAD_DIR/compare.mjs" "$SUMMARY_OUT" "$LOAD_DIR/baseline/load.json" || COMPARE_RC=$? + +if [[ "$K6_FAILED" -ne 0 ]]; then + log "k6 reported threshold failures (exit $K6_FAILED)." + exit "$K6_FAILED" +fi +exit "$COMPARE_RC" diff --git a/tests/load/scenarios/folder_cascade.js b/tests/load/scenarios/folder_cascade.js new file mode 100644 index 00000000..d2302418 --- /dev/null +++ b/tests/load/scenarios/folder_cascade.js @@ -0,0 +1,60 @@ +// folder_cascade.js — measures the read-path of `GET /api/folders/{id}/resources?resource_types=folder` +// at four depths against a pre-seeded tree. Captures how listing cost scales +// with ltree depth (cf. `idx_folders_lpath` GiST index). Mid-depth samples +// (depth4, depth8) fall back to `deepest` when the seeded tree is shallower +// than that depth — see load-seed.rs::build_subtree. + +import { check } from 'k6'; +import http from 'k6/http'; +import { BASE, authParams } from '../lib/http.js'; +import { login } from '../lib/auth.js'; +import { thresholdsFromBaseline, loadManifest } from '../lib/metrics.js'; + +const manifest = loadManifest(); + +export const options = { + vus: 1, + // 100 iterations gives p99 statistical meaning: at N=100, p99 = position 99, + // representing one bad sample out of a hundred — a real percentile rather + // than the worst-of-the-batch. At N=25 (the typical k6 example default), + // p99 ≈ max, dominated by single-sample kernel/scheduler noise. + iterations: 100, + thresholds: thresholdsFromBaseline('folder_cascade'), +}; + +// One per-VU login. K6 calls setup() once across the whole test, default() +// `iterations` times per VU. Logging in inside default() would dominate the +// per-iter cost; we hand the token down through the `data` arg. +export function setup() { + const token = login(manifest.admin.username, manifest.admin.password); + return { token }; +} + +export default function (data) { + const { token } = data; + const t = manifest.shared_subtree; + + const r1 = http.get( + `${BASE}/api/folders/${t.root}/resources?resource_types=folder`, + authParams(token, 'folder_cascade.list_depth1'), + ); + check(r1, { 'list depth1 200': (r) => r.status === 200 }); + + const r4 = http.get( + `${BASE}/api/folders/${t.depth4}/resources?resource_types=folder`, + authParams(token, 'folder_cascade.list_depth4'), + ); + check(r4, { 'list depth4 200': (r) => r.status === 200 }); + + const r8 = http.get( + `${BASE}/api/folders/${t.depth8}/resources?resource_types=folder`, + authParams(token, 'folder_cascade.list_depth8'), + ); + check(r8, { 'list depth8 200': (r) => r.status === 200 }); + + const rD = http.get( + `${BASE}/api/folders/${t.deepest}/resources?resource_types=folder`, + authParams(token, 'folder_cascade.list_depth_deep'), + ); + check(rD, { 'list deepest 200': (r) => r.status === 200 }); +} diff --git a/tests/load/scenarios/share_cascade_rebac.js b/tests/load/scenarios/share_cascade_rebac.js new file mode 100644 index 00000000..a63a3dc7 --- /dev/null +++ b/tests/load/scenarios/share_cascade_rebac.js @@ -0,0 +1,84 @@ +// share_cascade_rebac.js — measures ReBAC AuthZ cascade via a direct user +// grant. The seeder grants `read` on shared_subtree.root (depth 0) to the +// grantee user; this scenario times how long the grantee takes to fetch +// folders at varying depths inside that subtree. +// +// The AuthZ recursive-CTE in `pg_acl_engine` walks up the ancestor chain +// from the requested folder until it finds a matching grant (or runs out +// of ancestors). The deeper the requested folder, the more ancestors the +// CTE has to traverse before hitting the grant on the root: +// +// fetch_at_grant_root → 0 ancestors walked (grant is right here) +// fetch_as_grantee_depth4 → 4 ancestors walked +// fetch_as_grantee_depth8 → 8 ancestors walked +// fetch_as_grantee_depth_deep → `load_depth` ancestors walked +// +// A regression in AuthZ cost should show up as the cascade-depth curve +// flattening or steepening — that's the value of intermediate samples. + +import { check } from 'k6'; +import http from 'k6/http'; +import { BASE, authParams } from '../lib/http.js'; +import { login } from '../lib/auth.js'; +import { thresholdsFromBaseline, loadManifest } from '../lib/metrics.js'; + +const manifest = loadManifest(); + +export const options = { + vus: 1, + // See folder_cascade.js for the iteration-count rationale: N=100 makes + // p99 a real percentile instead of the worst-of-the-batch. + iterations: 100, + thresholds: thresholdsFromBaseline('share_cascade_rebac'), +}; + +export function setup() { + const adminToken = login(manifest.admin.username, manifest.admin.password); + const granteeToken = login(manifest.grantee.username, manifest.grantee.password); + return { adminToken, granteeToken }; +} + +export default function (data) { + const { adminToken, granteeToken } = data; + const t = manifest.shared_subtree; + + // List grants on the granted folder (admin only). + const grantsRes = http.get( + `${BASE}/api/grants?resource_type=folder&resource_id=${t.root}`, + authParams(adminToken, 'share_cascade_rebac.list_grants'), + ); + check(grantsRes, { 'list grants 200': (r) => r.status === 200 }); + + // Baseline: grantee fetches the folder the grant is directly on. AuthZ + // finds the matching grant on the first row of the CTE; zero ancestors + // walked. This metric measures the constant overhead of an authorized + // request — moves to the right of this metric is "cascade cost." + const d1 = http.get( + `${BASE}/api/folders/${t.root}/resources?resource_types=folder`, + authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth1'), + ); + check(d1, { 'fetch root 200': (r) => r.status === 200 }); + + // Grantee fetches a mid-tree folder. AuthZ walks 4 ancestors before + // hitting the grant. + const d4 = http.get( + `${BASE}/api/folders/${t.depth4}/resources?resource_types=folder`, + authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth4'), + ); + check(d4, { 'fetch depth4 200': (r) => r.status === 200 }); + + // Grantee fetches a folder 8 levels under the granted root. AuthZ + // walks 8 ancestors. + const d8 = http.get( + `${BASE}/api/folders/${t.depth8}/resources?resource_types=folder`, + authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth8'), + ); + check(d8, { 'fetch depth8 200': (r) => r.status === 200 }); + + // Grantee fetches the deepest descendant — full-length cascade. + const dD = http.get( + `${BASE}/api/folders/${t.deepest}/resources?resource_types=folder`, + authParams(granteeToken, 'share_cascade_rebac.fetch_as_grantee_depth_deep'), + ); + check(dD, { 'fetch deepest 200': (r) => r.status === 200 }); +} diff --git a/tests/load/scenarios/smoke.js b/tests/load/scenarios/smoke.js new file mode 100644 index 00000000..a5578cc9 --- /dev/null +++ b/tests/load/scenarios/smoke.js @@ -0,0 +1,64 @@ +// smoke.js — fast PR-tier check. Verifies the load harness still builds and +// the server boots, exercising one happy-path of every HTTP verb the long +// scenarios use. No regression gate; run.sh's `smoke.sh` skips compare.mjs. + +import { check, sleep } from 'k6'; +import http from 'k6/http'; +import { BASE, jsonParams, authParams } from '../lib/http.js'; +import { login } from '../lib/auth.js'; +import { thresholdsFromBaseline } from '../lib/metrics.js'; + +export const options = { + vus: 1, + iterations: 1, + thresholds: thresholdsFromBaseline('smoke'), +}; + +// Admin creds match tests/load/test.env defaults — overridable via env. +const USERNAME = __ENV.K6_USERNAME || 'admin'; +const PASSWORD = __ENV.K6_PASSWORD || 'TestPassword1!'; + +export default function () { + // 1. login + const token = login(USERNAME, PASSWORD, 'smoke.login'); + + // 2. create a scratch folder at root + const folderName = `smoke_${Date.now()}`; + const createRes = http.post( + `${BASE}/api/folders`, + JSON.stringify({ name: folderName }), + jsonParams(token, 'smoke.create_folder'), + ); + check(createRes, { 'create folder 200/201': (r) => r.status === 200 || r.status === 201 }); + const folderId = createRes.json('id'); + + // 3. upload a tiny file via multipart + const fileData = http.file('hello\n', 'smoke.txt', 'text/plain'); + const uploadRes = http.post( + `${BASE}/api/files/upload`, + { folder_id: folderId, file: fileData }, + { headers: { Authorization: `Bearer ${token}` }, tags: { op: 'smoke.upload_tiny' } }, + ); + const uploadOk = check(uploadRes, { + 'upload 200/201': (r) => r.status === 200 || r.status === 201, + }); + if (!uploadOk) { + console.error( + `upload failed: status=${uploadRes.status}, body=${uploadRes.body}, headers=${JSON.stringify(uploadRes.headers)}`, + ); + } + + // 4. list root folders for this user + const listRes = http.get(`${BASE}/api/folders`, authParams(token, 'smoke.list_root')); + check(listRes, { 'list root 200': (r) => r.status === 200 }); + + // 5. delete the scratch folder + const delRes = http.del( + `${BASE}/api/folders/${folderId}`, + null, + authParams(token, 'smoke.delete_folder'), + ); + check(delRes, { 'delete 200/204': (r) => r.status === 200 || r.status === 204 }); + + sleep(0.1); +} diff --git a/tests/load/scenarios/subject_group_nested.js b/tests/load/scenarios/subject_group_nested.js new file mode 100644 index 00000000..b3d572d5 --- /dev/null +++ b/tests/load/scenarios/subject_group_nested.js @@ -0,0 +1,73 @@ +// subject_group_nested.js — measures the worst-case AuthZ path: a user who +// is a direct member of the *innermost* group of a depth-N nested chain, and +// the grant is on the *outermost* group. Every AuthZ check has to expand the +// chain transitively. Pairs with share_cascade_rebac.js to attribute regressions +// to either the resource side (folder cascade) or the subject side (group +// expansion). +// +// Same depth-gradient idea as share_cascade_rebac: measure AuthZ cost at +// depth 1 / 4 / 8 / deep so we can see WHERE in the chain a regression +// lands. The subject-side expansion (group_member → leaf → mid → root group +// → grant) runs once per request regardless of folder depth, but the +// resource-side cascade (folder ancestors walked to find the grant) grows +// linearly with depth — so depth4/depth8/deep moving together while depth1 +// stays flat would point at the resource side; all four moving together +// would point at the group-expansion path. + +import { check } from 'k6'; +import http from 'k6/http'; +import { BASE, authParams } from '../lib/http.js'; +import { login } from '../lib/auth.js'; +import { thresholdsFromBaseline, loadManifest } from '../lib/metrics.js'; + +const manifest = loadManifest(); + +export const options = { + vus: 1, + // See folder_cascade.js for the iteration-count rationale: N=100 makes + // p99 a real percentile instead of the worst-of-the-batch. + iterations: 100, + thresholds: thresholdsFromBaseline('subject_group_nested'), +}; + +export function setup() { + const memberToken = login(manifest.group_member.username, manifest.group_member.password); + return { memberToken }; +} + +export default function (data) { + const { memberToken } = data; + const t = manifest.group_subtree; + + // Baseline: group member fetches the folder the grant is directly on. + // Subject-side expansion runs (user → leaf → mid → root group → grant); + // resource-side walk is zero ancestors. Captures the constant subject- + // expansion overhead. + const d1 = http.get( + `${BASE}/api/folders/${t.root}/resources?resource_types=folder`, + authParams(memberToken, 'subject_group_nested.fetch_as_member_depth1'), + ); + check(d1, { 'fetch root 200': (r) => r.status === 200 }); + + // Mid-tree: 4 folder ancestors walked + subject expansion. + const d4 = http.get( + `${BASE}/api/folders/${t.depth4}/resources?resource_types=folder`, + authParams(memberToken, 'subject_group_nested.fetch_as_member_depth4'), + ); + check(d4, { 'fetch depth4 200': (r) => r.status === 200 }); + + // 8 folder ancestors walked + subject expansion. + const d8 = http.get( + `${BASE}/api/folders/${t.depth8}/resources?resource_types=folder`, + authParams(memberToken, 'subject_group_nested.fetch_as_member_depth8'), + ); + check(d8, { 'fetch depth8 200': (r) => r.status === 200 }); + + // Worst case: full-depth folder cascade × full-chain subject expansion. + // group_member → leaf group → mid group → root group → grant → folder root → deepest. + const dD = http.get( + `${BASE}/api/folders/${t.deepest}/resources?resource_types=folder`, + authParams(memberToken, 'subject_group_nested.fetch_as_member_depth_deep'), + ); + check(dD, { 'fetch deepest 200': (r) => r.status === 200 }); +} diff --git a/tests/load/smoke.sh b/tests/load/smoke.sh new file mode 100755 index 00000000..0f7580c2 --- /dev/null +++ b/tests/load/smoke.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Smoke (PR-tier) k6 load runner. +# Same shape as run.sh but: no seeder, only the smoke scenario, no regression +# diff. Goal is harness liveness - does the server still boot and does k6 still +# wire up - not perf gating. +# +# Usage (from repo root): +# bash tests/load/smoke.sh +# +# Env overrides: +# BUILD_TARGET=debug # debug is fine; smoke doesn't measure timings +# +# Prerequisites: docker, cargo, k6 >= 0.46 + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +LOAD_DIR="$REPO_ROOT/tests/load" + +# shellcheck source=test.env +source "$LOAD_DIR/test.env" + +SERVER_PORT="${base_url##*:}" + +log() { echo "[load-smoke] $*"; } +die() { echo "[load-smoke] ERROR: $*" >&2; exit 1; } + +wait_for_http() { + local url="$1" timeout="${2:-60}" + local deadline=$(( $(date +%s) + timeout )) + until curl -sf "$url" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url" + sleep 1 + done +} + +command -v k6 >/dev/null 2>&1 || die "k6 is required (https://k6.io/docs/get-started/installation/)" + +SERVER_PID="" +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud server (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + bash "$COMMON/stop-db.sh" || true +} +trap cleanup EXIT + +bash "$COMMON/spawn-db.sh" +# Server applies sqlx migrations on startup; don't double-apply via psql here. +# See run.sh for the gory details. + +set -a +# shellcheck source=../common/server.env +source "$COMMON/server.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$LOAD_DIR/storage" +set +a + +rm -rf "$OXICLOUD_STORAGE_PATH" +mkdir -p "$OXICLOUD_STORAGE_PATH" + +BUILD_TARGET="${BUILD_TARGET:-debug}" +OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" + +if [[ -x "$OXICLOUD_BIN" ]]; then + log "Starting pre-built OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..." + "$OXICLOUD_BIN" & +else + log "Building and starting OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..." + cd "$REPO_ROOT" + if [[ "$BUILD_TARGET" == "release" ]]; then + cargo run --release & + else + cargo run & + fi +fi +SERVER_PID=$! +wait_for_http "$base_url/ready" 120 +log "Server ready." + +# Bootstrap the admin account via /api/setup (one-shot — disabled once an +# admin exists, mirrors tests/api/setup.hurl). Without this the smoke scenario +# would have no one to log in as. +log "Creating admin via /api/setup..." +SETUP_BODY=$(printf '{"username":"%s","email":"%s","password":"%s"}' \ + "${username:-admin}" "${email:-admin@example.com}" "${password:-TestPassword1!}") +SETUP_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "$base_url/api/setup" \ + -H 'Content-Type: application/json' \ + -d "$SETUP_BODY") +if [[ "$SETUP_STATUS" != "201" ]]; then + die "/api/setup returned $SETUP_STATUS (expected 201)" +fi + +export K6_BASE_URL="$base_url" +export K6_USERNAME="${username:-admin}" +export K6_PASSWORD="${password:-TestPassword1!}" + +log "Running smoke scenario..." +k6 run \ + --summary-trend-stats="avg,min,med,max,p(90),p(95),p(99)" \ + --quiet \ + "$LOAD_DIR/scenarios/smoke.js" + +log "Smoke OK." diff --git a/tests/load/test.env b/tests/load/test.env new file mode 100644 index 00000000..56a1b34c --- /dev/null +++ b/tests/load/test.env @@ -0,0 +1,31 @@ +# Test credentials and base URL for tests/load/ — NOT real secrets. +# Mirrors tests/api/test.env shape; uses a different server port (8088) +# so it does not collide with api-test (8087). +base_url=http://localhost:8088 +username=admin +email=admin@example.com +# gitguardian:ignore +password=TestPassword1! + +# ── Seed sizing ───────────────────────────────────────────────────────────── +# Folder count is exponential: total folders per subtree ≈ fanout^depth. +# The seeder builds TWO subtrees (one for the user-grant scenario, one for the +# group-grant scenario), so multiply by 2 for the grand total. Each leaf folder +# then gets `files_per_leaf` rows. +# +# Examples (folders per subtree): +# depth=4 fanout=3 → 121 (seed ≈ 1 s) +# depth=5 fanout=4 → 1 365 (seed ≈ 10 s) ← default +# depth=6 fanout=3 → 1 093 (seed ≈ 10 s) +# depth=8 fanout=3 → 9 841 (seed ≈ 30 s) +# depth=8 fanout=5 → 488 281 (seed ≈ several minutes — exponential blow-up) +# +# Override per run with LOAD_DEPTH / LOAD_FANOUT / LOAD_FILES_PER_LEAF. + +load_depth=10 +load_fanout=3 + +load_files_per_leaf=3 +load_extra_users=20 +load_group_depth=3 +load_group_fanout=5 From fd467b2d3b82f8c24da3a60cb5a1733e5e59c686 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 18 Jun 2026 09:30:01 +0200 Subject: [PATCH 11/39] ci(load): add ref input to dispatch nightly against any branch Adds a `ref` input to workflow_dispatch so the nightly can be triggered from main and run the scenarios against a feature branch that does not yet carry the workflow file (e.g. feat/drive). Empty input falls back to `github.ref`, so cron and bare dispatch are unchanged. The regression-issue title and body now show the tested ref instead of `github.sha`, which always resolves to the workflow's ref (main) under workflow_dispatch and would otherwise mislead. --- .github/workflows/load-nightly.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/load-nightly.yml b/.github/workflows/load-nightly.yml index a799dffb..31e16c73 100644 --- a/.github/workflows/load-nightly.yml +++ b/.github/workflows/load-nightly.yml @@ -14,6 +14,11 @@ on: # 03:00 UTC daily — outside US/EU working hours, low contention. - cron: '0 3 * * *' workflow_dispatch: + inputs: + ref: + description: 'Branch / tag / SHA to load-test (leave blank to use the workflow ref). Lets you dispatch from main and run the scenarios against a feature branch — useful when the target branch does not have the workflow file yet.' + required: false + default: '' env: CARGO_TERM_COLOR: always @@ -35,6 +40,12 @@ jobs: steps: - uses: actions/checkout@v4 with: + # `inputs.ref` is set only for workflow_dispatch with a non-empty + # value; for cron and bare dispatch it's empty, in which case the + # action falls back to the workflow's own ref (`github.ref`). The + # `||` short-circuits on empty strings, so the cron path keeps the + # exact behaviour it had before. + ref: ${{ inputs.ref || github.ref }} # The self-hosted runner bind-mounts target/ as a persistent volume # for fast incremental rebuilds. actions/checkout's default cleanup # tries to rmdir it and hits EBUSY on the mount point. Git still @@ -87,6 +98,7 @@ jobs: echo "## Load nightly regression" echo "" echo "Run: \`${{ github.run_id }}\` · Commit: \`${{ github.sha }}\`" + echo "Ref tested: \`${{ inputs.ref || github.ref }}\`" echo "Runner: \`${{ runner.name }}\` (\`${{ runner.os }}/${{ runner.arch }}\`)" echo "" echo "Compare exited non-zero — see uploaded artifact \`load-results-${{ github.run_id }}\` for raw summaries." @@ -104,7 +116,10 @@ jobs: if: steps.load.outcome == 'failure' && github.repository == 'AtalayaLabs/OxiCloud' uses: peter-evans/create-issue-from-file@v5 with: - title: "Load nightly: regression on ${{ github.sha }}" + # `github.sha` resolves to the workflow's ref, not the tested ref, + # so when dispatched against a non-default branch we surface the + # input explicitly — otherwise the title misleads with main's SHA. + title: "Load nightly: regression on ${{ inputs.ref || github.sha }}" content-filepath: regression-issue.md labels: | load-test From abc75962e0ccbec2df5c9ca934694f19406b73bc Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 18 Jun 2026 09:50:21 +0200 Subject: [PATCH 12/39] fix(load-seed): write storage.role_grants instead of access_grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-Prep (migration 20260801000002_drop_access_grants) drops storage.access_grants and replaces it with storage.role_grants — one row per role assignment instead of N rows per permission bundle. The load seeder still spoke the old per-permission shape and failed every nightly with `relation "storage.access_grants" does not exist`. Both seeder call sites already grant the read bundle (single permission), which maps cleanly to the `viewer` role; switching them to the new schema is a one-row INSERT with the role name. The conflict key drops `permission` since uniqueness is now per (subject, resource). --- src/bin/load-seed.rs | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/bin/load-seed.rs b/src/bin/load-seed.rs index 34208b99..eca0c6e7 100644 --- a/src/bin/load-seed.rs +++ b/src/bin/load-seed.rs @@ -255,25 +255,26 @@ async fn main() -> Result<(), Box> { .await?; println!("[load-seed] inserting grants…"); - // Grant the grantee read on shared_subtree.root. + // Grant the grantee the viewer role on shared_subtree.root — the read + // bundle the share_cascade_rebac scenario exercises. insert_grant( &pool, "user", grantee.id, "folder", shared_subtree.root, - "read", + "viewer", admin.id, ) .await?; - // Grant the outermost group read on group_subtree.root. + // Grant the outermost group the viewer role on group_subtree.root. insert_grant( &pool, "group", nested_groups.root, "folder", group_subtree.root, - "read", + "viewer", admin.id, ) .await?; @@ -711,20 +712,29 @@ async fn insert_grant( subject_id: Uuid, resource_type: &str, resource_id: Uuid, - permission: &str, + role: &str, granted_by: Uuid, ) -> Result<(), sqlx::Error> { + // D-Prep replaced `storage.access_grants` (one row per Permission) with + // `storage.role_grants` (one row per role assignment; the role expands to + // a permission bundle in-code at engine read time). The seeder now writes + // role names ('viewer'/'editor'/etc.) instead of individual permissions. + // + // The `role` column was promoted from TEXT to the `storage.grant_role` + // enum by migration 20260801000000_role_grants_enum, so the cast on $5 + // is required — sqlx binds Rust &str as TEXT, which postgres won't + // implicitly coerce into the enum. sqlx::query( - "INSERT INTO storage.access_grants - (subject_type, subject_id, resource_type, resource_id, permission, granted_by) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (subject_type, subject_id, resource_type, resource_id, permission) DO NOTHING", + "INSERT INTO storage.role_grants + (subject_type, subject_id, resource_type, resource_id, role, granted_by) + VALUES ($1, $2, $3, $4, $5::storage.grant_role, $6) + ON CONFLICT (subject_type, subject_id, resource_type, resource_id) DO NOTHING", ) .bind(subject_type) .bind(subject_id) .bind(resource_type) .bind(resource_id) - .bind(permission) + .bind(role) .bind(granted_by) .execute(pool) .await?; From fe852d3b798d65e7d03b4ebcdb81aee1c4ca3567 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 08:38:27 +0000 Subject: [PATCH 13/39] fix(dav): repair CalDAV/CardDAV client connectivity (#480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standard CalDAV/CardDAV clients (Thunderbird, DAVx5, Apple Calendar/Contacts) failed to connect, mounted collections read-only, or could not discover address books, even though curl worked. Three protocol-compliance gaps caused this: 1. Missing Basic-auth challenge on /caldav and /carddav. The 401 returned for these surfaces carried no `WWW-Authenticate` header (only /webdav did). Spec-compliant clients never send credentials preemptively the way `curl -u` does — they wait for the challenge — so Thunderbird never authenticated and failed with "discovery failed" / 401. Extend the challenge to all DAV surfaces via shared `is_dav_path` / `dav_basic_auth_challenge` helpers. 2. Calendars always advertised read-only. The `current-user-privilege-set` write gate compared `owner_id` against the literal string "current_user_id", which never matched a real UUID, so `` was never emitted and clients mounted every calendar read-only. Thread the caller's id through the CalDAV adapter and grant write when the caller owns the calendar. 3. CardDAV discovery was incomplete. There was no `/.well-known/carddav` route and the root PROPFIND exposed neither `current-user-principal` nor `addressbook-home-set`, so clients could not locate address books. Add the well-known redirect and root/principal discovery responses mirroring the CalDAV adapter. Adds unit tests for the auth challenge predicate, the calendar owner/non-owner privilege split, and the CardDAV root/principal discovery responses. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016cVV9nRQjP6G6a8zbNUWMw --- src/application/adapters/caldav_adapter.rs | 39 ++- .../adapters/caldav_adapter_test.rs | 54 ++++ src/application/adapters/carddav_adapter.rs | 265 +++++++++++++++++- .../adapters/carddav_adapter_test.rs | 91 ++++++ src/interfaces/api/handlers/caldav_handler.rs | 7 + .../api/handlers/carddav_handler.rs | 84 +++++- src/interfaces/middleware/auth.rs | 109 +++++-- src/main.rs | 4 +- 8 files changed, 605 insertions(+), 48 deletions(-) diff --git a/src/application/adapters/caldav_adapter.rs b/src/application/adapters/caldav_adapter.rs index fd6cfdb2..e7e67cff 100644 --- a/src/application/adapters/caldav_adapter.rs +++ b/src/application/adapters/caldav_adapter.rs @@ -17,6 +17,19 @@ use crate::application::adapters::webdav_adapter::{ }; use crate::application::dtos::calendar_dto::{CalendarDto, CalendarEventDto}; +/// Returns whether `caller_id` owns `calendar`. +/// +/// CalDAV clients (DAVx5, Apple Calendar, Thunderbird) only mount a collection +/// read-write when its `current-user-privilege-set` advertises ``, so +/// this gate decides read-only vs read-write for the caller. `caller_id` and +/// [`CalendarDto::owner_id`] are both the user's UUID rendered via +/// `Uuid::to_string()`, so a direct comparison is exact. Calendars merely shared +/// with the caller (non-owner access) stay read-only for now — this never +/// over-grants write. +fn caller_owns_calendar(calendar: &CalendarDto, caller_id: &str) -> bool { + !caller_id.is_empty() && calendar.owner_id == caller_id +} + /// CalDAV report type #[derive(Debug, PartialEq)] pub enum CalDavReportType { @@ -205,6 +218,7 @@ impl CalDavAdapter { request: &PropFindRequest, base_href: &str, username: &str, + caller_id: &str, ) -> Result<()> { let mut xml_writer = Writer::new(writer); @@ -227,6 +241,7 @@ impl CalDavAdapter { calendar, request, &format!("{}{}/", base_href, calendar.id), + caller_id, )?; } @@ -242,6 +257,7 @@ impl CalDavAdapter { calendars: &[CalendarDto], request: &PropFindRequest, base_href: &str, + caller_id: &str, ) -> Result<()> { let mut xml_writer = Writer::new(writer); @@ -261,6 +277,7 @@ impl CalDavAdapter { calendar, request, &format!("{}{}/", base_href, calendar.id), + caller_id, )?; } @@ -557,6 +574,7 @@ impl CalDavAdapter { calendar: &CalendarDto, request: &PropFindRequest, href: &str, + caller_id: &str, ) -> Result<()> { // Start response element xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; @@ -576,7 +594,7 @@ impl CalDavAdapter { match &request.prop_find_type { PropFindType::AllProp => { // Write all standard properties for a calendar - Self::write_calendar_standard_props(xml_writer, calendar)?; + Self::write_calendar_standard_props(xml_writer, calendar, caller_id)?; } PropFindType::PropName => { // Write only property names (empty elements) @@ -584,7 +602,7 @@ impl CalDavAdapter { } PropFindType::Prop(props) => { // Write requested properties - Self::write_calendar_requested_props(xml_writer, calendar, props)?; + Self::write_calendar_requested_props(xml_writer, calendar, props, caller_id)?; } } @@ -609,6 +627,7 @@ impl CalDavAdapter { fn write_calendar_standard_props( xml_writer: &mut Writer, calendar: &CalendarDto, + caller_id: &str, ) -> Result<()> { // Common WebDAV properties @@ -676,9 +695,10 @@ impl CalDavAdapter { xml_writer.write_event(Event::Empty(BytesStart::new("D:read")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?; - // Only add write privilege if user owns the calendar or has write access - if calendar.owner_id == "current_user_id" { - // This should be replaced with actual user check + // Advertise write only when the caller owns the calendar. Clients + // (DAVx5, Apple Calendar, Thunderbird) mount the collection read-only + // unless this privilege is present. + if caller_owns_calendar(calendar, caller_id) { xml_writer.write_event(Event::Start(BytesStart::new("D:privilege")))?; xml_writer.write_event(Event::Empty(BytesStart::new("D:write")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?; @@ -735,6 +755,7 @@ impl CalDavAdapter { xml_writer: &mut Writer, calendar: &CalendarDto, props: &[QualifiedName], + caller_id: &str, ) -> Result<()> { for prop in props { match (prop.namespace.as_str(), prop.name.as_str()) { @@ -780,9 +801,8 @@ impl CalDavAdapter { xml_writer.write_event(Event::Empty(BytesStart::new("D:read")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?; - // Only add write privilege if user owns the calendar or has write access - if calendar.owner_id == "current_user_id" { - // This should be replaced with actual user check + // Advertise write only when the caller owns the calendar. + if caller_owns_calendar(calendar, caller_id) { xml_writer.write_event(Event::Start(BytesStart::new("D:privilege")))?; xml_writer.write_event(Event::Empty(BytesStart::new("D:write")))?; xml_writer.write_event(Event::End(BytesEnd::new("D:privilege")))?; @@ -882,6 +902,7 @@ impl CalDavAdapter { request: &PropFindRequest, base_href: &str, depth: &str, + caller_id: &str, ) -> Result<()> { let mut xml_writer = Writer::new(writer); @@ -894,7 +915,7 @@ impl CalDavAdapter { ))?; // Write the calendar collection itself - Self::write_calendar_response(&mut xml_writer, calendar, request, base_href)?; + Self::write_calendar_response(&mut xml_writer, calendar, request, base_href, caller_id)?; // If depth > 0, include event resources if depth != "0" { diff --git a/src/application/adapters/caldav_adapter_test.rs b/src/application/adapters/caldav_adapter_test.rs index 75e21e3f..5f7847c6 100644 --- a/src/application/adapters/caldav_adapter_test.rs +++ b/src/application/adapters/caldav_adapter_test.rs @@ -171,6 +171,7 @@ mod tests { &calendars, &request, "/caldav/", + "user-001", ); assert!( @@ -210,6 +211,7 @@ mod tests { &request, "/caldav/cal-001", "0", + "user-001", ); assert!( @@ -240,6 +242,7 @@ mod tests { &request, "/caldav/cal-001", "1", + "user-001", ); assert!( @@ -258,6 +261,54 @@ mod tests { ); } + #[test] + fn test_owner_gets_write_privilege_but_non_owner_is_read_only() { + // Regression for #480: the privilege gate previously compared owner_id + // against the literal "current_user_id", so was never emitted + // and every CalDAV client mounted calendars read-only. + let calendar = sample_calendar(); // owner_id = "user-001" + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + // Owner → read + write. + let mut owner_out = Vec::new(); + CalDavAdapter::generate_calendar_collection_propfind( + &mut owner_out, + &calendar, + &[], + &request, + "/caldav/cal-001/", + "0", + "user-001", + ) + .expect("owner propfind"); + let owner_xml = String::from_utf8(owner_out).expect("utf8"); + assert!( + owner_xml.contains("D:write"), + "Owner must be granted , got: {owner_xml}" + ); + + // A different caller (e.g. a read-only share) → read only, never write. + let mut other_out = Vec::new(); + CalDavAdapter::generate_calendar_collection_propfind( + &mut other_out, + &calendar, + &[], + &request, + "/caldav/cal-001/", + "0", + "a-different-user", + ) + .expect("non-owner propfind"); + let other_xml = String::from_utf8(other_out).expect("utf8"); + assert!(other_xml.contains("D:read"), "Non-owner keeps "); + assert!( + !other_xml.contains("D:write"), + "Non-owner must NOT get , got: {other_xml}" + ); + } + // ======================== // Calendar events response tests // ======================== @@ -434,6 +485,7 @@ mod tests { &request, "/caldav/", "testuser", + "user-001", ); assert!( result.is_ok(), @@ -483,6 +535,7 @@ mod tests { &request, "/caldav/", "testuser", + "user-001", ); assert!(result.is_ok()); @@ -565,6 +618,7 @@ mod tests { &request, "/caldav/cal-001/", "0", + "user-001", ); assert!(result.is_ok(), "Failed: {:?}", result.err()); diff --git a/src/application/adapters/carddav_adapter.rs b/src/application/adapters/carddav_adapter.rs index 2d0e16f2..2f6b66e4 100644 --- a/src/application/adapters/carddav_adapter.rs +++ b/src/application/adapters/carddav_adapter.rs @@ -17,6 +17,20 @@ use crate::application::adapters::webdav_adapter::{ use crate::application::dtos::address_book_dto::AddressBookDto; use crate::application::dtos::contact_dto::ContactDto; +/// Render a requested property as a namespaced response element name, mapping +/// the known namespaces to their response prefixes (`D:` for DAV, `CR:` for +/// CardDAV). Used for the catch-all arms of the requested-property writers so +/// the prefix mapping lives in exactly one place. +fn carddav_prop_name(prop: &QualifiedName) -> String { + if prop.namespace == "urn:ietf:params:xml:ns:carddav" { + format!("CR:{}", prop.name) + } else if prop.namespace == "DAV:" { + format!("D:{}", prop.name) + } else { + prop.name.clone() + } +} + /// CardDAV report type #[derive(Debug, PartialEq)] pub enum CardDavReportType { @@ -163,6 +177,96 @@ impl CardDavAdapter { Ok(()) } + /// Generate a PROPFIND response for the CardDAV root `/carddav/`. + /// + /// Mirrors the CalDAV root: emits a discovery entry for `/carddav/` itself + /// advertising `current-user-principal` and `addressbook-home-set` (the + /// properties DAVx5 / Apple Contacts read to locate address books), then — + /// at Depth > 0 — one entry per address book. Without these discovery + /// properties clients never find the address books at all. + pub fn generate_root_propfind_response( + writer: W, + address_books: &[AddressBookDto], + request: &PropFindRequest, + base_href: &str, + username: &str, + ) -> Result<()> { + let mut xml_writer = Writer::new(writer); + + xml_writer.write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([ + ("xmlns:D", "DAV:"), + ("xmlns:CR", "urn:ietf:params:xml:ns:carddav"), + ("xmlns:CS", "http://calendarserver.org/ns/"), + ]), + ))?; + + Self::write_carddav_root_response(&mut xml_writer, request, base_href, username)?; + + for book in address_books { + Self::write_addressbook_response( + &mut xml_writer, + book, + request, + &format!("{}{}/", base_href, book.id), + )?; + } + + xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + Ok(()) + } + + /// Generate a PROPFIND response for a CardDAV user principal resource at + /// `/carddav/principals/{username}/`. + /// + /// Returns `addressbook-home-set` so clients can resolve the collection + /// holding the user's address books, plus a self-referential + /// `current-user-principal`. + pub fn generate_principal_propfind_response( + writer: W, + request: &PropFindRequest, + username: &str, + ) -> Result<()> { + let mut xml_writer = Writer::new(writer); + + xml_writer.write_event(Event::Start( + BytesStart::new("D:multistatus").with_attributes([ + ("xmlns:D", "DAV:"), + ("xmlns:CR", "urn:ietf:params:xml:ns:carddav"), + ("xmlns:CS", "http://calendarserver.org/ns/"), + ]), + ))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; + + let href = format!("/carddav/principals/{}/", username); + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&href)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + + match &request.prop_find_type { + PropFindType::AllProp | PropFindType::PropName => { + Self::write_carddav_principal_props(&mut xml_writer, username)?; + } + PropFindType::Prop(props) => { + Self::write_carddav_principal_requested_props(&mut xml_writer, username, props)?; + } + } + + xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; + xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + + Ok(()) + } + /// Generate PROPFIND for a single address book collection + contacts pub fn generate_addressbook_collection_propfind( writer: W, @@ -384,14 +488,159 @@ impl CardDavAdapter { .write_event(Event::End(BytesEnd::new("D:current-user-privilege-set")))?; } _ => { - let prop_name = if prop.namespace == "urn:ietf:params:xml:ns:carddav" { - format!("CR:{}", prop.name) - } else if prop.namespace == "DAV:" { - format!("D:{}", prop.name) - } else { - prop.name.clone() - }; - xml_writer.write_event(Event::Empty(BytesStart::new(&prop_name)))?; + xml_writer + .write_event(Event::Empty(BytesStart::new(carddav_prop_name(prop))))?; + } + } + } + Ok(()) + } + + /// Write a populated `current-user-principal` element pointing at the user's + /// CardDAV principal. Shared by the root and principal discovery responses. + fn write_current_user_principal( + xml_writer: &mut Writer, + username: &str, + ) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:current-user-principal")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/carddav/principals/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?; + Ok(()) + } + + /// Write a populated `addressbook-home-set` element pointing at the user's + /// address-book home collection. Shared by the root and principal responses. + fn write_addressbook_home_set( + xml_writer: &mut Writer, + username: &str, + ) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("CR:addressbook-home-set")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(&format!( + "/carddav/{}/", + username + ))))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + xml_writer.write_event(Event::End(BytesEnd::new("CR:addressbook-home-set")))?; + Ok(()) + } + + /// Write the root `/carddav/` discovery entry. + fn write_carddav_root_response( + xml_writer: &mut Writer, + request: &PropFindRequest, + href: &str, + username: &str, + ) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:response")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?; + xml_writer.write_event(Event::Text(BytesText::new(href)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + + match &request.prop_find_type { + PropFindType::AllProp => { + xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + Self::write_current_user_principal(xml_writer, username)?; + Self::write_addressbook_home_set(xml_writer, username)?; + } + PropFindType::PropName => { + xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?; + xml_writer + .write_event(Event::Empty(BytesStart::new("D:current-user-principal")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("CR:addressbook-home-set")))?; + } + PropFindType::Prop(props) => { + for prop in props { + match (prop.namespace.as_str(), prop.name.as_str()) { + ("DAV:", "resourcetype") => { + xml_writer + .write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + xml_writer + .write_event(Event::Empty(BytesStart::new("D:collection")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + } + ("DAV:", "current-user-principal") => { + Self::write_current_user_principal(xml_writer, username)?; + } + ("urn:ietf:params:xml:ns:carddav", "addressbook-home-set") => { + Self::write_addressbook_home_set(xml_writer, username)?; + } + _ => { + xml_writer.write_event(Event::Empty(BytesStart::new( + carddav_prop_name(prop), + )))?; + } + } + } + } + } + + xml_writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + xml_writer.write_event(Event::Start(BytesStart::new("D:status")))?; + xml_writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:status")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:response")))?; + Ok(()) + } + + /// Write the standard properties for a CardDAV principal resource. + fn write_carddav_principal_props( + xml_writer: &mut Writer, + username: &str, + ) -> Result<()> { + xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:principal")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + + xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + xml_writer.write_event(Event::Text(BytesText::new(username)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + + Self::write_addressbook_home_set(xml_writer, username)?; + Self::write_current_user_principal(xml_writer, username)?; + Ok(()) + } + + /// Write the requested properties for a CardDAV principal resource. + fn write_carddav_principal_requested_props( + xml_writer: &mut Writer, + username: &str, + props: &[QualifiedName], + ) -> Result<()> { + for prop in props { + match (prop.namespace.as_str(), prop.name.as_str()) { + ("DAV:", "resourcetype") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + xml_writer.write_event(Event::Empty(BytesStart::new("D:principal")))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + } + ("DAV:", "displayname") => { + xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + xml_writer.write_event(Event::Text(BytesText::new(username)))?; + xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + } + ("DAV:", "current-user-principal") => { + Self::write_current_user_principal(xml_writer, username)?; + } + ("urn:ietf:params:xml:ns:carddav", "addressbook-home-set") => { + Self::write_addressbook_home_set(xml_writer, username)?; + } + _ => { + xml_writer + .write_event(Event::Empty(BytesStart::new(carddav_prop_name(prop))))?; } } } diff --git a/src/application/adapters/carddav_adapter_test.rs b/src/application/adapters/carddav_adapter_test.rs index bc51e080..ac50a647 100644 --- a/src/application/adapters/carddav_adapter_test.rs +++ b/src/application/adapters/carddav_adapter_test.rs @@ -307,6 +307,97 @@ mod tests { ); } + #[test] + fn test_root_propfind_advertises_principal_and_home_set() { + // Regression for #480: without these discovery properties DAVx5 / Apple + // Contacts never locate the user's address books. + let books = vec![sample_address_book()]; + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + let mut output = Vec::new(); + CardDavAdapter::generate_root_propfind_response( + &mut output, + &books, + &request, + "/carddav/", + "testuser", + ) + .expect("root propfind"); + + let xml = String::from_utf8(output).expect("utf8"); + assert!( + xml.contains("/carddav/principals/testuser/"), + "Root must expose current-user-principal href, got: {xml}" + ); + assert!( + xml.contains("/carddav/testuser/"), + "Root must expose addressbook-home-set href, got: {xml}" + ); + // Depth 1 also enumerates the books. + assert!(xml.contains("ab-001"), "Should list address book"); + } + + #[test] + fn test_root_propfind_prop_request_returns_populated_discovery() { + // A DAVx5-style targeted request for the two discovery properties. + let request = PropFindRequest { + prop_find_type: PropFindType::Prop(vec![ + QualifiedName { + namespace: "DAV:".to_string(), + name: "current-user-principal".to_string(), + }, + QualifiedName { + namespace: "urn:ietf:params:xml:ns:carddav".to_string(), + name: "addressbook-home-set".to_string(), + }, + ]), + }; + + let mut output = Vec::new(); + CardDavAdapter::generate_root_propfind_response( + &mut output, + &[], + &request, + "/carddav/", + "testuser", + ) + .expect("root propfind"); + + let xml = String::from_utf8(output).expect("utf8"); + assert!(xml.contains("/carddav/principals/testuser/")); + assert!(xml.contains("/carddav/testuser/")); + // Properties must be populated, not empty self-closing placeholders. + assert!(!xml.contains("")); + assert!(!xml.contains("")); + } + + #[test] + fn test_principal_propfind_returns_home_set() { + let request = PropFindRequest { + prop_find_type: PropFindType::AllProp, + }; + + let mut output = Vec::new(); + CardDavAdapter::generate_principal_propfind_response(&mut output, &request, "testuser") + .expect("principal propfind"); + + let xml = String::from_utf8(output).expect("utf8"); + assert!( + xml.contains("/carddav/principals/testuser/"), + "Principal href should be present" + ); + assert!( + xml.contains("/carddav/testuser/"), + "addressbook-home-set should be present" + ); + assert!( + xml.contains("D:principal"), + "resourcetype should include principal" + ); + } + #[test] fn test_generate_addressbook_collection_propfind_depth_0() { let addressbook = sample_address_book(); diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index b6d6eb88..a4f6e22b 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -218,6 +218,9 @@ async fn handle_propfind( .to_string(); let user = extract_user(&req)?; + // Caller UUID (string form) — gates the `` privilege on calendars + // the caller owns, so clients mount their own calendars read-write. + let caller_id = user.id.to_string(); let calendar_service = get_calendar_service(&state)?; let body_bytes = body::to_bytes(req.into_body(), MAX_CALDAV_BODY) @@ -256,6 +259,7 @@ async fn handle_propfind( &propfind_request, base_href, &user.username, + &caller_id, ) .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; @@ -333,6 +337,7 @@ async fn handle_propfind( &propfind_request, base_href, &depth, + &caller_id, ) .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; @@ -359,6 +364,7 @@ async fn handle_propfind( &calendars, &propfind_request, base_href, + &caller_id, ) .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; @@ -407,6 +413,7 @@ async fn handle_propfind( &propfind_request, base_href, &depth, + &caller_id, ) .map_err(|e| { AppError::internal_error(format!("Failed to generate XML: {}", e)) diff --git a/src/interfaces/api/handlers/carddav_handler.rs b/src/interfaces/api/handlers/carddav_handler.rs index 1f45b017..29a8260a 100644 --- a/src/interfaces/api/handlers/carddav_handler.rs +++ b/src/interfaces/api/handlers/carddav_handler.rs @@ -58,6 +58,24 @@ pub fn carddav_routes() -> Router> { .route("/carddav", axum::routing::any(handle_carddav_methods_root)) } +/// Creates the RFC 6764 well-known discovery route for CardDAV. +/// Public (no auth) — simply redirects to the CardDAV root so clients that +/// bootstrap from `/.well-known/carddav` can locate the service. +pub fn well_known_routes() -> Router> { + Router::new().route( + "/.well-known/carddav", + axum::routing::any(handle_well_known_carddav), + ) +} + +async fn handle_well_known_carddav() -> Response { + Response::builder() + .status(StatusCode::MOVED_PERMANENTLY) + .header(header::LOCATION, "/carddav/") + .body(Body::empty()) + .unwrap() +} + async fn handle_carddav_methods_root( axum::extract::State(state): axum::extract::State>, req: Request, @@ -226,10 +244,66 @@ async fn handle_propfind( .map_err(|e| AppError::bad_request(format!("Failed to parse PROPFIND: {}", e)))? }; + // Discovery: the true root `/carddav/` advertises current-user-principal and + // addressbook-home-set so clients (DAVx5, Apple Contacts) can locate the + // address books. Depth 0 → only the root entry; Depth 1+ → also the books. + if path.is_empty() { + let address_books = if depth == "0" { + vec![] + } else { + addressbook_service + .list_user_address_books(user.id) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to list address books: {}", e)) + })? + }; + + let mut response_body = Vec::new(); + CardDavAdapter::generate_root_propfind_response( + &mut response_body, + &address_books, + &propfind_request, + "/carddav/", + &user.username, + ) + .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; + + return Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(response_body)) + .unwrap()); + } + + // Discovery: principal resource `/carddav/principals/{username}/` returns the + // addressbook-home-set the client should enumerate next. + if path == "principals" || path.starts_with("principals/") { + let username = path + .strip_prefix("principals/") + .map(|s| s.trim_end_matches('/')) + .filter(|s| !s.is_empty()) + .unwrap_or(&user.username); + + let mut response_body = Vec::new(); + CardDavAdapter::generate_principal_propfind_response( + &mut response_body, + &propfind_request, + username, + ) + .map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?; + + return Ok(Response::builder() + .status(StatusCode::MULTI_STATUS) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(response_body)) + .unwrap()); + } + let effective_path = strip_username_prefix(path); if effective_path.is_empty() { - // Root CardDAV path or user home — list user's address books + // User address-book home `/carddav/{username}/` — list the user's books. let address_books = addressbook_service .list_user_address_books(user.id) .await @@ -237,12 +311,8 @@ async fn handle_propfind( AppError::internal_error(format!("Failed to list address books: {}", e)) })?; - let base_href = if path.is_empty() { - "/carddav/".to_string() - } else { - let user_part = path.split('/').next().unwrap_or(path); - format!("/carddav/{}/", user_part) - }; + let user_part = path.split('/').next().unwrap_or(path); + let base_href = format!("/carddav/{}/", user_part); let mut response_body = Vec::new(); CardDavAdapter::generate_addressbooks_propfind_response( &mut response_body, diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 34ca7c1a..547523c7 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -240,17 +240,12 @@ pub async fn auth_middleware( } Err(e) => { tracing::warn!("App password verification failed: {}", e); - // For WebDAV: include WWW-Authenticate so the client - // knows to re-prompt rather than silently failing. - if request.uri().path().starts_with("/webdav") { - return Ok(Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#) - .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") - .body(axum::body::Body::from( - "Invalid username or app password", - )) - .unwrap()); + // For DAV clients: include WWW-Authenticate so the client + // re-prompts for credentials rather than failing silently. + if is_dav_path(request.uri().path()) { + return Ok(dav_basic_auth_challenge( + "Invalid username or app password", + )); } return Err(AuthError::InvalidToken( "Invalid username or app password".to_string(), @@ -312,23 +307,41 @@ pub async fn auth_middleware( return Err(AuthError::AuthServiceUnavailable); } - // For WebDAV requests with no credentials at all: return 401 with - // WWW-Authenticate so that spec-compliant clients (Nautilus, Cyberduck, - // Windows Explorer, macOS Finder) know to prompt for a username/password. - // Non-WebDAV routes return the standard AuthError which renders without - // this header — keeping browser sessions redirecting to /login as before. - if request.uri().path().starts_with("/webdav") { - return Ok(Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#) - .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") - .body(axum::body::Body::from("Authentication required")) - .unwrap()); + // For DAV requests with no credentials at all: return 401 with + // WWW-Authenticate so that spec-compliant clients (Thunderbird, DAVx5, + // Apple Calendar/Contacts, Nautilus, Cyberduck, Windows Explorer, macOS + // Finder) know to prompt for credentials and retry. Unlike `curl -u`, these + // clients do NOT send Basic credentials preemptively — without the + // challenge they never authenticate and fail with "discovery failed" / 401. + // Non-DAV routes return the standard AuthError which renders without this + // header — keeping browser sessions redirecting to /login as before. + if is_dav_path(request.uri().path()) { + return Ok(dav_basic_auth_challenge("Authentication required")); } Err(AuthError::TokenNotProvided) } +/// DAV protocol surfaces (WebDAV, CalDAV, CardDAV) authenticate over HTTP Basic. +/// Spec-compliant clients (Thunderbird, DAVx5, Apple Calendar/Contacts, file +/// managers) only send credentials after receiving a `401` carrying a +/// `WWW-Authenticate: Basic` challenge, so these paths must emit it. Browser and +/// JSON-API routes deliberately do not, so they keep redirecting to `/login`. +fn is_dav_path(path: &str) -> bool { + path.starts_with("/webdav") || path.starts_with("/caldav") || path.starts_with("/carddav") +} + +/// Build the `401 Unauthorized` Basic-auth challenge shared by every DAV +/// surface, so clients re-prompt for credentials instead of failing silently. +fn dav_basic_auth_challenge(message: &'static str) -> Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(header::WWW_AUTHENTICATE, r#"Basic realm="OxiCloud""#) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(axum::body::Body::from(message)) + .unwrap() +} + /// Middleware to verify that the authenticated user has an admin role. /// /// Must be applied AFTER auth_middleware, as it depends on @@ -353,3 +366,53 @@ pub async fn require_admin(request: Request, next: Next) -> Response { let error = AuthError::AccessDenied("Admin role required".to_string()); error.into_response() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dav_paths_receive_basic_auth_challenge() { + // Regression for #480: CalDAV/CardDAV clients (Thunderbird, DAVx5) only + // send credentials after a 401 carrying WWW-Authenticate. All three DAV + // surfaces must qualify so the challenge is emitted. + for path in [ + "/webdav/", + "/webdav/admin/file.txt", + "/caldav/", + "/caldav/admin/cal/", + "/carddav/", + "/carddav/principals/admin/", + ] { + assert!(is_dav_path(path), "{path} should be treated as a DAV path"); + } + } + + #[test] + fn non_dav_paths_do_not_receive_basic_auth_challenge() { + for path in [ + "/", + "/api/files", + "/login", + "/index.html", + "/.well-known/caldav", + ] { + assert!( + !is_dav_path(path), + "{path} must not get a Basic-auth challenge (browser/API surface)" + ); + } + } + + #[test] + fn challenge_sets_www_authenticate_header() { + let resp = dav_basic_auth_challenge("Authentication required"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + resp.headers() + .get(header::WWW_AUTHENTICATE) + .and_then(|v| v.to_str().ok()), + Some(r#"Basic realm="OxiCloud""#), + ); + } +} diff --git a/src/main.rs b/src/main.rs index 3aa56196..6f462fbb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -311,7 +311,9 @@ async fn main() -> Result<(), Box> { use oxicloud::interfaces::api::handlers::carddav_handler; use oxicloud::interfaces::api::handlers::webdav_handler; let caldav_router = caldav_handler::caldav_routes(); - let well_known_router = caldav_handler::well_known_routes(); + // RFC 6764 discovery for both CalDAV and CardDAV (public redirects). + let well_known_router = + caldav_handler::well_known_routes().merge(carddav_handler::well_known_routes()); let carddav_router = carddav_handler::carddav_routes(); let webdav_router = webdav_handler::webdav_routes(); From 081b2b68d8a4327d3f5b28360674a6a4e9f0b2d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 09:30:41 +0000 Subject: [PATCH 14/39] perf(photos): virtualize the timeline to bound DOM node count The photos timeline rendered every tile into the DOM and grew it unbounded on infinite scroll, degrading on large libraries. Each date-group is now a
whose grid is materialized (tiles inserted) only while near the viewport and dematerialized (emptied, height frozen as a spacer) once it scrolls away, driven by an IntersectionObserver rooted on the scroll container. DOM nodes stay bounded by a few screens regardless of library size. - Grouping (day/month/year), infinite scroll, multi-select, video thumbnails and fade-in are all preserved. - Selection state and the video-thumbnail cache survive the materialize/dematerialize cycle. - Falls back to full rendering when IntersectionObserver is unavailable. - Spacer heights are estimated from grid geometry and re-estimated on resize. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- static/css/views/photos.css | 6 + static/js/features/library/photos.js | 359 ++++++++++++++++++++------- 2 files changed, 273 insertions(+), 92 deletions(-) diff --git a/static/css/views/photos.css b/static/css/views/photos.css index 6bd29cd6..33221c9a 100644 --- a/static/css/views/photos.css +++ b/static/css/views/photos.css @@ -8,6 +8,12 @@ display: block; } +/* Virtualized timeline: each date-group is a
; its grid is + materialized (tiles inserted) only while near the viewport — see photos.js. */ +.photos-group { + display: block; +} + /* Toolbar with group mode toggle */ .photos-toolbar { display: flex; diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index f60aae31..9c7b2c7f 100644 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -14,6 +14,14 @@ import { photosLightbox } from './photosLightbox.js'; * @typedef {'daily'|'monthly'|'yearly'} PhotoModeEnum */ +/** + * @typedef {Object} PhotoGroup + * @property {string} label + * @property {FileItem[]} files + * @property {HTMLElement} section + * @property {boolean} materialized + */ + const photosView = { /** @type {Array} All loaded photo items */ items: [], @@ -25,18 +33,28 @@ const photosView = { exhausted: false, /** @type {Set} Selected item IDs */ selected: new Set(), - /** @type {IntersectionObserver|null} */ - _observer: null, + /** @type {IntersectionObserver|null} Materializes/dematerializes group tiles by viewport proximity */ + _materializeObserver: null, + /** @type {IntersectionObserver|null} Infinite-scroll trigger on the sentinel */ + _sentinelObserver: null, /** @type {HTMLElement|null} */ _container: null, + /** @type {HTMLElement|null} The infinite-scroll sentinel element */ + _sentinelEl: null, /** @type {boolean} */ _initialized: false, /** @type {PhotoModeEnum} */ groupMode: 'monthly', /** @type {Map} fileId → thumbnail URL (persists across re-renders) */ _videoThumbCache: new Map(), - /** @type {number} Items already rendered in the DOM */ - _renderedCount: 0, + /** @type {Map} group label → group record (DOM + data) */ + _groupData: new Map(), + /** @type {string[]} Ordered group labels (timeline order) */ + _groupOrder: [], + /** @type {(() => void)|null} Debounced window resize handler */ + _resizeHandler: null, + /** @type {number} */ + _resizeTimer: 0, PAGE_SIZE: 200, @@ -73,7 +91,9 @@ const photosView = { this.nextCursor = null; this.exhausted = false; this.selected.clear(); - this._renderedCount = 0; + this._groupData = new Map(); + this._groupOrder = []; + this._destroyObserver(); this._container.innerHTML = ''; this._loadPage(); }, @@ -84,6 +104,7 @@ const photosView = { this._container.classList.remove('active'); } this._destroyObserver(); + this._unbindResize(); this._hideSelectionBar(); }, @@ -97,7 +118,6 @@ const photosView = { if (this.groupMode === mode) return; this.groupMode = mode; localStorage.setItem('oxicloud-photos-group', mode); - this._renderedCount = 0; this._renderFull(); }, @@ -149,15 +169,24 @@ const photosView = { } }, - // ── Rendering ─────────────────────────────────────────────────── - // Two render paths: - // _renderFull() — full DOM rebuild (first load, group-mode change, delete) - // _appendBatch(n) — append-only for infinite-scroll pages (O(batch)) + // ── Virtualized rendering ─────────────────────────────────────── + // The timeline can hold tens of thousands of items, so we never keep + // every tile in the DOM. Each date-group is a
with a header + // (always present, cheap) and a grid that is *materialized* (tiles in + // the DOM) only while near the viewport, and *dematerialized* (emptied, + // its height frozen as a spacer) once it scrolls far away. An + // IntersectionObserver rooted on the scroll container drives the swap, + // so the DOM node count stays bounded by a few screens regardless of + // library size. + // _renderFull() — rebuild the group skeleton (first load, mode switch, delete) + // _appendBatch(n) — append new groups for infinite-scroll pages - /** Full DOM rebuild — first load, group-mode switch, or after deletions. */ + /** Rebuild the group skeleton — first load, group-mode switch, or deletions. */ _renderFull() { if (!this._container) return; this._destroyObserver(); + this._groupData = new Map(); + this._groupOrder = []; this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly'); this._container.classList.add(`photos-group-${this.groupMode}`); @@ -168,77 +197,233 @@ const photosView = { } if (this.items.length === 0) return; - const groups = this._groupItems(this.items); - let html = this._renderToolbar(); + // Toolbar via innerHTML, then append group
s + sentinel as + // real elements so we keep references for the observer. + this._container.innerHTML = this._renderToolbar(); + this._container.onclick = (e) => this._handleClick(e); + const groups = this._groupItems(this.items); for (const [label, files] of groups) { - html += `
${this._escHtml(label)}${files.length}
`; - html += '
'; - for (const file of files) html += this._renderTile(file); - html += '
'; + /** @type {PhotoGroup} */ + const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false }; + this._groupData.set(label, rec); + this._groupOrder.push(label); + this._container.appendChild(rec.section); } - html += '
'; - this._container.innerHTML = html; - this._container.onclick = (e) => this._handleClick(e); - this._fadeInTiles(); - this._renderedCount = this.items.length; - this._observeSentinel(); - this._setupVideoThumbnails(); + const sentinel = document.createElement('div'); + sentinel.className = 'photos-sentinel'; + this._container.appendChild(sentinel); + this._sentinelEl = sentinel; + + this._setupObservers(); + this._eagerMaterialize(); + this._bindResize(); }, - /** Append-only render for infinite scroll — inserts only the items - * from this.items[startIndex..] without destroying existing DOM. - * Complexity: O(batch) instead of O(total_items). + /** Append new groups for an infinite-scroll page without rebuilding the + * existing skeleton. The first new group may continue the previous tail + * label, in which case we merge into it. Complexity: O(new groups). * @param {number} startIndex */ _appendBatch(startIndex) { - if (!this._container) return; - this._destroyObserver(); - - const newItems = this.items.slice(startIndex); - if (newItems.length === 0) { - this._observeSentinel(); - return; - } - - const newGroups = this._groupItems(newItems); - const sentinel = this._container.querySelector('.photos-sentinel'); - if (!sentinel) { - // Fallback: sentinel missing — full rebuild - this._renderedCount = 0; + if (!this._container || !this._sentinelEl) { this._renderFull(); return; } + const newItems = this.items.slice(startIndex); + if (newItems.length === 0) return; + const newGroups = this._groupItems(newItems); for (const [label, files] of newGroups) { - let tilesHtml = ''; - for (const file of files) tilesHtml += this._renderTile(file); - - // Does this date-group already exist in the DOM? - const existingHeader = this._container.querySelector(`.photos-day-header[data-group="${CSS.escape(label)}"]`); - - if (existingHeader) { - // Append tiles to existing grid and update count badge - const grid = existingHeader.nextElementSibling; - if (grid?.classList.contains('photos-grid')) { - grid.insertAdjacentHTML('beforeend', tilesHtml); - const countSpan = existingHeader.querySelector('.photos-day-count'); - if (countSpan) countSpan.textContent = String(grid.children.length); + const existing = this._groupData.get(label); + if (existing) { + // Continuation of a group already in the timeline. + existing.files = existing.files.concat(files); + const countEl = existing.section.querySelector('.photos-day-count'); + if (countEl) countEl.textContent = String(existing.files.length); + const grid = /** @type {HTMLElement|null} */ (existing.section.querySelector('.photos-grid')); + if (grid) { + if (existing.materialized) { + let tilesHtml = ''; + for (const file of files) tilesHtml += this._renderTile(file); + grid.insertAdjacentHTML('beforeend', tilesHtml); + this._setupVideoThumbnails(grid); + this._fadeInTiles(grid); + } else { + grid.style.minHeight = `${this._estimateHeight(existing.files.length)}px`; + } } } else { - // New group — insert header + grid before sentinel - const sectionHtml = - `
${this._escHtml(label)}${files.length}
` + - `
${tilesHtml}
`; - sentinel.insertAdjacentHTML('beforebegin', sectionHtml); + /** @type {PhotoGroup} */ + const rec = { label, files, section: this._buildGroupEl(label, files), materialized: false }; + this._groupData.set(label, rec); + this._groupOrder.push(label); + this._container.insertBefore(rec.section, this._sentinelEl); + this._materializeObserver?.observe(rec.section); } } + }, - this._renderedCount = this.items.length; - this._observeSentinel(); - this._setupVideoThumbnails(startIndex); - this._fadeInTiles(); + /** Build a dematerialized group section (header + empty grid spacer). + * @param {string} label + * @param {FileItem[]} files + * @returns {HTMLElement} + */ + _buildGroupEl(label, files) { + const section = document.createElement('section'); + section.className = 'photos-group'; + section.dataset.group = label; + section.innerHTML = + `
${this._escHtml(label)}${files.length}
` + + `
`; + return section; + }, + + /** Wire the two IntersectionObservers (materialization + infinite scroll). */ + _setupObservers() { + const root = this._container?.parentElement || null; + + if (!('IntersectionObserver' in window)) { + // Degrade gracefully: render every group (legacy behaviour). + for (const label of this._groupOrder) { + const rec = this._groupData.get(label); + if (rec) this._materializeGroup(rec.section); + } + return; + } + + this._materializeObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const section = /** @type {HTMLElement} */ (entry.target); + if (entry.isIntersecting) this._materializeGroup(section); + else this._dematerializeGroup(section); + } + }, + { root, rootMargin: '1200px 0px' } + ); + for (const label of this._groupOrder) { + const rec = this._groupData.get(label); + if (rec) this._materializeObserver.observe(rec.section); + } + + if (this._sentinelEl) { + this._sentinelObserver = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) this._loadPage(); + }, + { root, rootMargin: '600px 0px' } + ); + this._sentinelObserver.observe(this._sentinelEl); + } + }, + + /** Synchronously materialize the first groups within ~1.5 viewports so + * the initial paint has tiles before the observer's first callback. */ + _eagerMaterialize() { + const budget = (this._container?.parentElement?.clientHeight || window.innerHeight) * 1.5; + let acc = 0; + for (const label of this._groupOrder) { + const rec = this._groupData.get(label); + if (!rec) continue; + this._materializeGroup(rec.section); + acc += rec.section.offsetHeight; + if (acc > budget) break; + } + }, + + /** Fill a group's grid with tiles (idempotent). + * @param {HTMLElement} section + */ + _materializeGroup(section) { + const rec = this._groupData.get(section.dataset.group || ''); + if (!rec || rec.materialized) return; + rec.materialized = true; + const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid')); + if (!grid) return; + let html = ''; + for (const file of rec.files) html += this._renderTile(file); + grid.innerHTML = html; + grid.style.minHeight = ''; + this._setupVideoThumbnails(grid); + this._fadeInTiles(grid); + }, + + /** Empty a group's grid, freezing its current height as a spacer. + * @param {HTMLElement} section + */ + _dematerializeGroup(section) { + const rec = this._groupData.get(section.dataset.group || ''); + if (!rec?.materialized) return; + rec.materialized = false; + const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid')); + if (!grid) return; + grid.style.minHeight = `${grid.offsetHeight}px`; + grid.innerHTML = ''; + }, + + /** Current grid geometry (columns / gap / square tile px) for the active + * mode, used to estimate off-screen group heights. + * @returns {{cols: number, gap: number, tile: number}} + */ + _gridMetrics() { + const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid')); + const width = sample?.clientWidth || (this._container?.clientWidth || 1200) - 16; + const mobile = window.matchMedia('(max-width: 768px)').matches; + let min; + let gap; + if (this.groupMode === 'yearly') { + min = mobile ? 80 : 120; + gap = mobile ? 4 : 10; + } else if (this.groupMode === 'monthly') { + min = mobile ? 110 : 180; + gap = mobile ? 2 : 14; + } else { + min = mobile ? 100 : 150; + gap = mobile ? 2 : 12; + } + const cols = Math.max(1, Math.floor((width + gap) / (min + gap))); + const tile = (width - (cols - 1) * gap) / cols; + return { cols, gap, tile }; + }, + + /** Estimated pixel height of a grid holding `count` square tiles. + * @param {number} count + * @returns {number} + */ + _estimateHeight(count) { + const { cols, gap, tile } = this._gridMetrics(); + const rows = Math.max(1, Math.ceil(count / cols)); + return Math.round(rows * tile + (rows - 1) * gap); + }, + + /** Re-estimate spacer heights for dematerialized groups after a resize. */ + _bindResize() { + if (this._resizeHandler) return; + this._resizeHandler = () => { + clearTimeout(this._resizeTimer); + this._resizeTimer = window.setTimeout(() => this._onResize(), 150); + }; + window.addEventListener('resize', this._resizeHandler); + }, + + _onResize() { + if (!this._container?.classList.contains('active')) return; + for (const label of this._groupOrder) { + const rec = this._groupData.get(label); + if (!rec || rec.materialized) continue; + const grid = /** @type {HTMLElement|null} */ (rec.section.querySelector('.photos-grid')); + if (grid) grid.style.minHeight = `${this._estimateHeight(rec.files.length)}px`; + } + }, + + _unbindResize() { + if (this._resizeHandler) { + window.removeEventListener('resize', this._resizeHandler); + this._resizeHandler = null; + } + clearTimeout(this._resizeTimer); }, /** @@ -264,9 +449,11 @@ const photosView = { /** * Fade tiles in as their thumbnails finish loading (kills the pop-in). * Idempotent — only wires images not already marked loaded. + * @param {ParentNode} [scope] Limit to a subtree (a group grid); defaults to the whole container. */ - _fadeInTiles() { - this._container?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => { + _fadeInTiles(scope) { + const root = scope || this._container; + root?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => { const img = /** @type {HTMLImageElement} */ (el); if (img.complete) { img.classList.add('is-loaded'); @@ -278,40 +465,25 @@ const photosView = { }); }, - /** (Re-)observe the sentinel element for infinite scroll */ - _observeSentinel() { - this._destroyObserver(); - const sentinel = this._container?.querySelector('.photos-sentinel'); - if (sentinel && !this.exhausted) { - this._observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) this._loadPage(); - }, - { rootMargin: '400px' } - ); - this._observer.observe(sentinel); - } - }, - // ── Client-side video thumbnail generation ────────────────────── // Uses the browser's native video decoder (
` whose grid is materialized only near the viewport. +**Remaining:** browser smoke-test, then it's closed. + +#### 0.2 Expose image dimensions on the timeline (enables justified layout, kills CLS) +**`src/application/dtos/file_dto.rs`** — add to `FileDto`: +```rust +#[serde(skip_serializing_if = "Option::is_none")] +pub width: Option, +#[serde(skip_serializing_if = "Option::is_none")] +pub height: Option, +``` +**`src/infrastructure/repositories/pg/file_blob_read_repository.rs`** — in the +`list_media_files` query, `LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id` and +select `fm.width, fm.height`; map into the new fields. +**`static/js/core/types.js`** — add `width?`/`height?` to `FileItem` (already on +`FileMetadata`). + +#### 0.3 Justified rows layout (modern, aspect-preserving) +**`static/js/features/library/photos.js`** — add a `layoutMode: 'square' | 'justified'` +toggle in the toolbar. In justified mode, replace the CSS grid with a row-packing pass +(target row height ~180–220px, distribute by aspect ratio = `width/height`, fallback 1:1 +when dimensions are absent). Keep the existing virtualization: row-pack **within each +materialized group**, so it composes with section materialize/dematerialize. +**`static/css/views/photos.css`** — `.photos-grid--justified` (flex rows) variant. + +#### 0.4 Lightbox upgrades +**`static/js/features/library/photosLightbox.js`** +- **Zoom/pan** (wheel + pinch) and **mobile swipe** for prev/next. +- **Info panel** (toggle) showing EXIF from `/api/files/{id}/metadata`. +- **Map pin** (resolves the existing `//TODO: add geoloc pointer` at line ~301): when + `latitude/longitude` present, render a small static MapLibre mini-map / "Show on map" + link that deep-links into the Places view. +- **Favorite initial state** (bug fix): call `favorites.isFavorite(item.id, 'file')` in + `_show()` to set the star correctly (today it always starts empty). + +#### 0.5 UX & a11y +**`static/js/features/library/photos.js`** +- **Shift-click range select** and optional drag-marquee. +- Replace native `confirm()`/`alert()` with the app modal + (`static/js/components/modal.js`; add an async `Modal.confirm()` helper). +- Tiles become focusable/role-correct; arrow-key navigation across the grid. + +#### 0.6 (Optional) HEIC support +`image` crate ships only `jpeg/png/gif/webp` — iPhone HEIC photos currently get **no +server thumbnail**. Either add `libheif-rs` decoding in `thumbnail_service.rs` / +`media_metadata_service.rs`, or transcode HEIC→JPEG on upload. Flagged as its own task +(native dep). + +#### 0.7 Sub-navigation inside Photos +**`static/index.html`** + **`static/js/app/navigation.js`** (`switchToPhotosSection`, +line ~434) + i18n: add a tab strip **Moments · Places · People** within the Photos view. +Places/People tabs are hidden unless their feature flags are on. This is the mount point +for Phases 1 & 2. + +--- + +## Phase 1 — Places (map) + +Data already exists (`storage.file_metadata.latitude/longitude`, `DOUBLE PRECISION`). +No PostGIS. + +### Execution order + +#### 1.1 Migration — index (+ optional geohash) +**New file:** `migrations/_places_geo_index.sql` +```sql +-- Fast bbox scans over geotagged photos +CREATE INDEX IF NOT EXISTS idx_file_metadata_geo + ON storage.file_metadata (latitude, longitude) + WHERE latitude IS NOT NULL AND longitude IS NOT NULL; +-- Optional (scale): a geohash/quadkey integer + btree for prefix grouping by zoom. +-- ALTER TABLE storage.file_metadata ADD COLUMN geohash BIGINT; +``` + +#### 1.2 Application port + PG repository (grid aggregation) +**`src/application/ports/`** — new `GeoPhotoReadPort` (or extend an existing media port): +```rust +pub struct GeoCluster { pub lng: f64, pub lat: f64, pub count: i64, pub sample_file_id: Uuid } +pub struct GeoBounds { pub w: f64, pub s: f64, pub e: f64, pub n: f64 } + +#[async_trait] +pub trait GeoPhotoReadPort: Send + Sync { + async fn clusters_in_bounds(&self, user_id: Uuid, b: GeoBounds, cell: f64) + -> Result, DomainError>; + async fn photos_in_bounds(&self, user_id: Uuid, b: GeoBounds, limit: i64) + -> Result, DomainError>; +} +``` +**`src/infrastructure/repositories/pg/`** — PG impl. Grid aggregation (no PostGIS): +```sql +SELECT round(fm.longitude / $6) * $6 AS gx, + round(fm.latitude / $6) * $6 AS gy, + count(*) AS n, + avg(fm.longitude) AS clng, + avg(fm.latitude) AS clat, + min(fm.file_id) AS sample_id +FROM storage.file_metadata fm +JOIN storage.files fi ON fi.id = fm.file_id +WHERE fi.user_id = $1::uuid AND NOT fi.is_trashed + AND fm.longitude BETWEEN $2 AND $3 -- west .. east + AND fm.latitude BETWEEN $4 AND $5 -- south .. north + AND fm.latitude IS NOT NULL +GROUP BY gx, gy; +``` +`$6` (`cell`) shrinks with zoom. Single indexed scan + hash aggregate; the browser only +receives `{count, center, sample_file_id}` per cell. + +#### 1.3 Application service (AuthZ + audit) +**`src/application/services/places_service.rs`** (new): +```rust +pub async fn list_clusters_with_perms( + &self, caller_id: Uuid, bounds: GeoBounds, zoom: u8, +) -> Result, AppError> { + // Scoped to the caller's own library; no cross-user data. + self.authz.require(caller_id, /* own photos */).await?; // audit on deny inside require() + let cell = cell_for_zoom(zoom); + self.geo.clusters_in_bounds(caller_id, bounds, cell).await +} +``` +Wire it in **`src/common/di.rs`** (`AppServiceFactory` → `AppState`), `Option>` +gated on the feature flag. + +#### 1.4 Config flag +**`src/common/config.rs`** — `FeaturesConfig::enable_places` from `OXICLOUD_ENABLE_PLACES`. + +#### 1.5 Basemap serving (PMTiles via Axum) +- Add the **`pmtiles`** crate (`cargo add pmtiles`). +- Ship a `.pmtiles` basemap (config: path; default global z0–6 ≈ 60 MB) + self-hosted + **glyphs** and **sprites** under `static/` (from `basemaps-assets`). +- **`src/interfaces/api/handlers/basemap_handler.rs`** (new): open the reader once + (`AsyncPmTilesReader::new_with_path`, `Arc` into `AppState`), serve + `GET /api/basemap/{z}/{x}/{y}.mvt` (`reader.get_tile(...)`). *Alt:* serve the raw + `.pmtiles` over Range and let `pmtiles.js` do directory math (no tile handler). + +#### 1.6 HTTP endpoints + routes +**`src/interfaces/api/handlers/places_handler.rs`** (new): +- `GET /api/photos/geo?bbox=w,s,e,n&zoom=Z` → `Vec` (auth middleware injects + `caller_id`; handler does **no** AuthZ — service does). +- `GET /api/photos/geo/cell?bbox=…` → photos in a cell (opens lightbox). +Register in **`src/interfaces/api/routes.rs`** (protected routes) + the basemap route +(public/cached). + +#### 1.7 Frontend — vendored map + Places module +- **Vendor** (needs sign-off): `maplibre-gl` (UMD + CSS), `pmtiles.js`, + `@protomaps/basemaps` style JSON → `static/js/vendors/` + `static/css/`. +- **`static/js/features/library/places.js`** (+ `static/css/views/places.css`): init + MapLibre with the self-hosted style (light/dark flavor by theme), register the + `pmtiles://` protocol, add a clustered GeoJSON source fed from `/api/photos/geo` + (`cluster: true`) — or, above ~100k, the server-aggregated endpoint. Click cluster → + zoom; click point → open lightbox filtered to that cell. Mandatory ODbL attribution + control. +- **`static/js/core/types.js`** — `GeoCluster` typedef. +- Mount under the **Places** sub-nav tab (§0.7). + +#### 1.8 (Optional) thumbnail markers +deck.gl `IconLayer` (MIT, no React) atlas for **visible cluster representatives only** — +never atlas all points. Start without it (count bubbles), add later. + +--- + +## Phase 2 — People (faces) + +Feature-flagged, opt-in, OFF by default. Biometric data → privacy-first. + +### Execution order + +#### 2.1 Config flag + privacy switch +**`src/common/config.rs`** — `OXICLOUD_ENABLE_FACES`. Plus a **per-user opt-in** setting +(stored in `auth.users` or a user-settings table) — clustering only runs for users who +opted in. + +#### 2.2 Migration — pgvector + schema +**New file:** `migrations/_faces.sql` +```sql +CREATE EXTENSION IF NOT EXISTS vector; -- pgvector (or vectorchord) +CREATE SCHEMA IF NOT EXISTS faces; + +CREATE TABLE faces.persons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + display_name TEXT, -- null = unnamed + cover_face_id UUID, + is_hidden BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE faces.faces ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + bbox REAL[4] NOT NULL, -- x,y,w,h (normalized) + det_score REAL NOT NULL, + quality REAL, -- blur/size gate result + embedding vector(512) NOT NULL, + person_id UUID REFERENCES faces.persons(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_faces_embedding ON faces.faces + USING hnsw (embedding vector_cosine_ops); +CREATE INDEX idx_faces_person ON faces.faces (person_id); +CREATE INDEX idx_faces_user ON faces.faces (user_id); +``` +Cascade-delete guarantees the **right to erasure**: deleting a file/user removes its +faces; deleting a Person unlinks its faces. + +#### 2.3 Domain + ports +**`src/domain/entities/`** — `Face`, `Person`. +**`src/application/ports/face_ports.rs`** (new): +```rust +pub struct DetectedFace { pub bbox: [f32;4], pub landmarks: [[f32;2];5], pub score: f32 } + +#[async_trait] pub trait FaceDetectorPort: Send + Sync { + async fn detect(&self, image: &DynamicImage) -> Result, DomainError>; +} +#[async_trait] pub trait FaceEmbedderPort: Send + Sync { + async fn embed(&self, aligned_112: &DynamicImage) -> Result<[f32;512], DomainError>; +} +``` +**`src/application/ports/`** — `FaceRepository` (CRUD + ANN search via pgvector `<=>`). + +#### 2.4 Infrastructure — ONNX runtime adapter +- `cargo add ort ndarray`. +- **`src/infrastructure/services/onnx_face_service.rs`** (new): loads detector + embedder + ONNX models, runs on a **dedicated thread pool** (mirror `thumbnail_service.rs` / + `image_transcode_service.rs` to avoid starving Tokio). Pipeline: detect → 5-point + similarity align to 112×112 → embed → L2-normalize. Implements `FaceDetectorPort` + + `FaceEmbedderPort`. +- **Models** are **downloaded at runtime** to a models dir (NOT committed). Default: + SCRFD-2.5G + `buffalo_l/w600k_r50` (immich pattern). Config switch to the + permissive fallback (RetinaFace-MobileNet0.25 MIT + bundled-by-you embedder). +- GPU optional via `ort` execution providers (`ORT_DYLIB_PATH` / EP Cargo features); same + code path falls back to CPU. + +#### 2.5 Indexing pipeline (lifecycle hook + backfill) +- **`src/infrastructure/services/face_indexing_service.rs`** (new) implements + `FileLifecycleHook` (same pattern as `media_metadata_service.rs`): on image create → + decode (reuse decode path) → detect → **quality-gate** (score ≥0.7, face ≥50–80px, + Laplacian blur) → embed → store. **Dedup by `blob_hash`**: identical photos reuse faces. +- **Backfill**: a throttled background job over the existing library on the **maintenance + pool**. + +#### 2.6 Clustering (incremental + periodic) — application service +**`src/application/services/people_service.rs`** (new). All methods +`*_with_perms(caller_id)` → `authz.require(...)` → audit on deny. +- **Online (per import):** ANN candidate via pgvector `<=>` + **exact cosine re-rank**; + assign to existing Person if within the *match* threshold (tighter), else leave + unassigned. Thresholds: form ≈ cosine-sim 0.75–0.80 (Euclid ≈ 0.5); match tighter (≈0.4 + Euclid) for precision. +- **Periodic full re-cluster:** threshold connected-components over the user's faces; + `minFaces ≥3` to promote a cluster to a Person; singletons → "Unknown". + +#### 2.7 HTTP endpoints + routes (AuthZ in service) +**`src/interfaces/api/handlers/people_handler.rs`** (new): +- `GET /api/people` — persons (cover + count). +- `GET /api/people/{id}/photos`. +- `PATCH /api/people/{id}` — rename. +- `POST /api/people/merge` · `/split` · `POST /api/people/{id}/hide`. +- `GET /api/files/{id}/faces` — face boxes for lightbox tagging. +- Settings: enable/disable, re-index, **delete all my face data**. +Register in `routes.rs`. Wire service in `di.rs` (`Option>`). + +#### 2.8 Frontend — People module +- **`static/js/features/library/people.js`** (+ `people.css`): grid of person tiles + (circular cover face + name), click → that person's photos; rename/merge/hide UI; + lightbox face boxes + "tag person". +- **`static/js/core/types.js`** — `Person`, `Face` typedefs. +- Mount under the **People** sub-nav tab (§0.7); show an explicit **opt-in consent** gate + before first indexing. + +#### 2.9 (Optional, later) Semantic search +CLIP/SigLIP via the same `ort` stack → natural-language photo search ("beach", "cake"). +Reuses the embedding-in-Postgres + ANN infrastructure. + +--- + +## Vendoring & dependencies (need sign-off) + +| Kind | Item | License | Notes | +|------|------|---------|-------| +| JS (vendor) | `maplibre-gl` (UMD+CSS) | BSD-3 | Map engine; no framework | +| JS (vendor) | `pmtiles.js` | BSD-3 | Range-reads `.pmtiles` in browser | +| JS (vendor) | `@protomaps/basemaps` style + assets | code BSD-3 / design CC0 | self-host glyphs+sprites | +| JS (vendor, opt) | `deck.gl` core+layers | MIT | thumbnail `IconLayer` only | +| Rust crate | `pmtiles` | MIT/Apache-2.0 | serve basemap from Axum (`cargo add`) | +| Rust crate | `ort` (+`ndarray`) | MIT/Apache-2.0 | ONNX runtime; ships `libonnxruntime.so` | +| Rust crate (opt) | `libheif-rs` | LGPL | HEIC decode (native dep) | +| PG extension | `pgvector` (→ `vectorchord`) | PostgreSQL / Apache-2.0 | 512-d embeddings + HNSW | +| Asset (basemap) | Protomaps `.pmtiles` | data ODbL | self-hosted; attribution required | +| ML models (runtime DL, NOT committed) | SCRFD + `buffalo_l` | **non-commercial** | personal self-host OK; commercial = license InsightFace | + +Repo rules respected: no hand-editing `Cargo.lock`; no JS framework; design tokens only +for CSS; `target:"audit"` denial logs; AuthZ exclusively in services. + +--- + +## Open decisions (need your call) + +1. **Faces embedder strategy:** (a) **immich pattern** — runtime-download `buffalo_l` + (~97 IJB-C, non-commercial, recommended default) · (b) fully-permissive bundle — + retrain EdgeFace/GhostFaceNet (~94, real ML project) · (c) defer People. +2. **Basemap extent / hosting:** global z0–6 (~60 MB, simplest) vs regional extract vs + full planet (~120 GB) — and store path / how shipped. +3. **Vector store start:** `pgvector` now (simplest) vs `VectorChord` from day one + (immich's scaled choice). +4. **Map thumbnails:** start with count bubbles (MapLibre only) vs deck.gl `IconLayer` + from the start. +5. **HEIC:** in scope for Phase 0, or deferred (native `libheif` dep)? +6. **MapLibre vendoring approval** (new JS library — per repo rules, needs explicit OK). + +--- + +## Suggested sequencing + +| Phase | Risk | Notes | +|-------|------|-------| +| 0.1 virtualization | done | smoke-test pending | +| 0.2–0.5 polish | low | self-contained, verifiable | +| 1 Places | low–med | data ready; new vendored map + basemap serving | +| 2 People | high | new ML stack, pgvector, privacy, licensing decision | +| 0.6 HEIC / 2.9 search | opt | independent, schedule freely | + +Recommended order: finish **Phase 0**, ship **Places**, then tackle **People** once the +embedder-licensing decision (#1) is made. From e8520e485f776e08565c208f7de55fb89a09fbae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 09:58:01 +0000 Subject: [PATCH 17/39] feat(photos): modern confirm dialog + correct lightbox favorite state - Add Modal.confirmDialog() (Promise, built on openPanel so it inherits the overlay, animation, focus-trap and Escape handling) and use it to replace native confirm() in the photos batch-delete and lightbox single-delete flows. - The lightbox now reflects the real favorite state when an item opens (previously the star always started empty) and toggles favorites through the favorites module so its cache stays in sync. - Add photos.delete_* i18n keys (English; other locales fall back to en). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- static/js/components/modal.js | 41 ++++++++++++++++++ static/js/features/library/photos.js | 9 +++- static/js/features/library/photosLightbox.js | 45 ++++++++++++++------ static/locales/en.json | 5 ++- 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/static/js/components/modal.js b/static/js/components/modal.js index 6293d47a..63e0e487 100644 --- a/static/js/components/modal.js +++ b/static/js/components/modal.js @@ -436,6 +436,47 @@ const Modal = { requestAnimationFrame(() => { this.overlay.classList.add('active'); }); + }, + + /** + * Confirmation dialog (replacement for window.confirm()). + * Built on openPanel, so it inherits the overlay, animation, focus-trap, + * Escape and click-outside handling. + * @param {Object} options + * @param {string} options.title + * @param {string} options.message + * @param {string} [options.confirmText] + * @param {string} [options.cancelText] + * @param {string} [options.icon] - Font Awesome class, default 'fa-circle-question' + * @returns {Promise} true if confirmed, false otherwise + */ + confirmDialog({ title, message, confirmText = null, cancelText = null, icon = 'fa-circle-question' }) { + return new Promise((resolve) => { + if (!this.overlay) { + resolve(false); + return; + } + const content = document.createElement('p'); + content.className = 'modal-confirm-message'; + content.textContent = message; + + let settled = false; + const done = (/** @type {boolean} */ value) => { + if (settled) return; + settled = true; + resolve(value); + }; + + this.openPanel({ + title, + icon, + content, + confirmText: confirmText ?? i18n.t('actions.confirm'), + cancelText: cancelText ?? i18n.t('actions.cancel'), + onConfirm: () => done(true), + onCancel: () => done(false) + }); + }); } }; diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index 9c7b2c7f..437de6ff 100644 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -3,6 +3,7 @@ * Photo grid grouped by day/month/year, with infinite scroll and multi-select. */ +import { Modal } from '../../components/modal.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; import { thumbnail } from '../thumbnail.js'; @@ -665,7 +666,13 @@ const photosView = { const bar_delete = /** @type {HTMLButtonElement} */ (bar.querySelector('#photos-sel-delete')); if (bar_delete) { bar_delete.onclick = async () => { - if (!confirm('Delete selected items?')) return; + const ok = await Modal.confirmDialog({ + title: i18n.t('photos.delete_title'), + message: i18n.t('photos.delete_selected_confirm'), + confirmText: i18n.t('actions.delete'), + icon: 'fa-trash' + }); + if (!ok) return; // One batch request per chunk instead of one DELETE per photo. // The photos view is files-only, so every id is a file id. diff --git a/static/js/features/library/photosLightbox.js b/static/js/features/library/photosLightbox.js index 705bace5..8875f6f7 100644 --- a/static/js/features/library/photosLightbox.js +++ b/static/js/features/library/photosLightbox.js @@ -10,7 +10,9 @@ * original streams in only on demand via the toolbar expand button. */ +import { Modal } from '../../components/modal.js'; import { getCsrfHeaders } from '../../core/csrf.js'; +import { i18n } from '../../core/i18n.js'; import { favorites } from '../library/favorites.js'; /** @import {FileItem, FileMetadata} from '../../core/types.js' */ @@ -182,6 +184,15 @@ export const photosLightbox = { filename.textContent = item.name; counter.textContent = `${this.index + 1} / ${this.items.length}`; + // Reflect the current favorite state on the toolbar star. + const favBtn = this._overlay.querySelector('.lb-favorite'); + if (favBtn) { + const isFav = favorites.isFavorite(item.id, 'file'); + favBtn.classList.toggle('active', isFav); + const favIcon = favBtn.querySelector('i'); + if (favIcon) favIcon.className = isFav ? 'fas fa-star' : 'far fa-star'; + } + // Format date const ts = (item.sort_date || item.created_at) * 1000; const dateStr = new Date(ts).toLocaleDateString(undefined, { @@ -317,23 +328,25 @@ export const photosLightbox = { a.remove(); }, - /** Toggle favorite on current item */ + /** Toggle favorite on current item (via the favorites module so its + * cache stays in sync — the lightbox can then show the right initial + * star next time the item is opened). */ async _toggleFavorite() { const item = this.items[this.index]; - if (!item || !favorites) return; + if (!item) return; + const isFav = favorites.isFavorite(item.id, 'file'); try { - await fetch(`/api/favorites/file/${item.id}`, { - method: 'POST', - credentials: 'include', - headers: this._headers() - }); - const btn = this._overlay.querySelector('.lb-favorite'); + if (isFav) { + await favorites.removeFromFavorites(item.id, 'file', item.name); + } else { + await favorites.addToFavorites(item.id, item.name, 'file', null); + } + const btn = this._overlay?.querySelector('.lb-favorite'); if (btn) { - btn.classList.toggle('active'); + const nowFav = !isFav; + btn.classList.toggle('active', nowFav); const icon = btn.querySelector('i'); - if (icon) { - icon.className = btn.classList.contains('active') ? 'fas fa-star' : 'far fa-star'; - } + if (icon) icon.className = nowFav ? 'fas fa-star' : 'far fa-star'; } } catch (err) { console.error('Favorite toggle failed:', err); @@ -344,7 +357,13 @@ export const photosLightbox = { async _delete() { const item = this.items[this.index]; if (!item) return; - if (!confirm(`Delete ${item.name}?`)) return; + const ok = await Modal.confirmDialog({ + title: i18n.t('photos.delete_title'), + message: i18n.t('photos.delete_one_confirm', { name: item.name }), + confirmText: i18n.t('actions.delete'), + icon: 'fa-trash' + }); + if (!ok) return; try { await fetch(`/api/files/${item.id}`, { diff --git a/static/locales/en.json b/static/locales/en.json index c829bf57..13b40de5 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -68,7 +68,10 @@ "items_selected": "selected", "view_daily": "Day", "view_monthly": "Month", - "view_yearly": "Year" + "view_yearly": "Year", + "delete_title": "Move to Trash", + "delete_selected_confirm": "Move the selected items to Trash?", + "delete_one_confirm": "Move \"{{name}}\" to Trash?" }, "music": { "create_playlist": "Create Playlist", From 9a6b174a30ec710224797fe8a68a08534d6aa7fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 09:58:51 +0000 Subject: [PATCH 18/39] Defer CDC chunk reclamation in manifest dereference to GC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove_manifest_reference unlinked a chunk's backing file right after the row-delete committed — the same TOCTOU the GC grace window was added to close: a concurrent upload of identical content can re-reference (pin) the chunk in the gap between commit and unlink, after which the deferred unlink strands a referenced chunk with no bytes. Route physical chunk reclamation through the single grace-protected path: on last reference, delete the manifest and decrement its chunks (stamping orphaned_at on the ones that reach 0), but leave the chunk rows and files for garbage_collect() to reclaim once orphaned past the grace window. The manifest deletion and its blob-keyed thumbnail hook stay eager. remove_legacy_reference and cleanup_if_orphaned's legacy path are left as eager deletes on purpose: a legacy whole-file hash can never be re-created by an ingest (uploads are always CDC now), so there is no writer to race — the existing "row gone ⇒ no resurrection" reasoning holds for them. Adds an integration test asserting a CDC manifest dereference leaves chunks orphaned-but-present, then reclaimed by a post-grace GC. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172rsVwzTwD216R9HXT2aU4 --- src/application/ports/dedup_ports.rs | 5 +- src/infrastructure/services/dedup_service.rs | 132 ++++++++++++++----- 2 files changed, 105 insertions(+), 32 deletions(-) diff --git a/src/application/ports/dedup_ports.rs b/src/application/ports/dedup_ports.rs index 23ddc962..36deed77 100644 --- a/src/application/ports/dedup_ports.rs +++ b/src/application/ports/dedup_ports.rs @@ -112,7 +112,10 @@ pub trait DedupPort: Send + Sync + 'static { /// Remove a reference from a blob. /// - /// Returns `true` if the blob was deleted (ref_count reached 0). + /// Returns `true` if the last reference was removed (the content is now + /// unreferenced). For CDC content the now-orphaned chunks are reclaimed + /// later by garbage collection rather than unlinked inline; legacy + /// whole-file blobs are still freed eagerly. async fn remove_reference(&self, hash: &str) -> Result; /// Calculate BLAKE3 hash of a file (streaming). diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index 1219d093..9e2c93f6 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -1164,9 +1164,13 @@ impl DedupService { /// /// For CDC manifests: decrements manifest ref_count. When it reaches 0 /// the manifest is deleted and all chunk ref_counts are decremented; - /// chunks that reach 0 are deleted from both PG and the blob backend. + /// chunks that reach 0 are left for [`garbage_collect`](Self::garbage_collect) + /// to reclaim once they have been orphaned past the grace window — unlinking + /// them here would race a concurrent upload re-referencing the same chunk. /// - /// For legacy blobs: uses a single TX with `SELECT … FOR UPDATE`. + /// For legacy blobs: uses a single TX with `SELECT … FOR UPDATE`. A legacy + /// whole-file hash can never be re-created by an ingest (uploads are always + /// CDC now), so its file is unlinked eagerly — there is no writer to race. pub async fn remove_reference(&self, hash: &str) -> Result { // ── CDC manifest path ──────────────────────────────────── let manifest = sqlx::query_as::<_, (i32, Vec)>( @@ -1187,7 +1191,12 @@ impl DedupService { self.remove_legacy_reference(hash).await } - /// Remove a manifest reference. Handles chunk cleanup when last ref is removed. + /// Remove a manifest reference. When the last reference is removed the + /// manifest is deleted and its chunks are dereferenced, but the chunk files + /// are NOT unlinked here: a chunk hash can be re-uploaded concurrently, so + /// unlinking right after the commit would race that re-reference (the same + /// TOCTOU the GC grace window guards). Newly-orphaned chunks are stamped and + /// reclaimed by [`garbage_collect`](Self::garbage_collect). async fn remove_manifest_reference( &self, file_hash: &str, @@ -1213,7 +1222,7 @@ impl DedupService { }; if current_rc <= 1 { - // Last reference — delete manifest and decrement chunks + // Last reference — delete the manifest and dereference its chunks. sqlx::query("DELETE FROM storage.chunk_manifests WHERE file_hash = $1") .bind(file_hash) .execute(&mut *tx) @@ -1222,45 +1231,36 @@ impl DedupService { DomainError::internal_error("Dedup", format!("Delete manifest: {}", e)) })?; - // Batch decrement chunk ref_counts - sqlx::query("UPDATE storage.blobs SET ref_count = ref_count - 1 WHERE hash = ANY($1)") - .bind(chunk_hashes) - .execute(&mut *tx) - .await - .map_err(|e| { - DomainError::internal_error("Dedup", format!("Decrement chunks: {}", e)) - })?; - - // Find chunks that reached 0 - let zero_chunks: Vec = sqlx::query_scalar( - "DELETE FROM storage.blobs WHERE hash = ANY($1) AND ref_count <= 0 RETURNING hash", + // Decrement chunk ref_counts and stamp orphaned_at on the ones that + // reach 0. We deliberately do NOT delete the chunk rows or unlink + // their files here: a chunk hash can be re-uploaded concurrently, so + // unlinking right after this commit would race that re-reference + // (the TOCTOU the grace window guards). garbage_collect() reclaims + // them safely once orphaned past the grace window. GREATEST clamps + // the single-chunk case where the PG file-delete trigger already + // decremented the row (file_hash == chunk_hash). + sqlx::query( + "UPDATE storage.blobs + SET ref_count = GREATEST(ref_count - 1, 0), + orphaned_at = CASE WHEN GREATEST(ref_count - 1, 0) = 0 THEN now() ELSE orphaned_at END + WHERE hash = ANY($1)", ) .bind(chunk_hashes) - .fetch_all(&mut *tx) + .execute(&mut *tx) .await - .map_err(|e| { - DomainError::internal_error("Dedup", format!("Delete zero chunks: {}", e)) - })?; + .map_err(|e| DomainError::internal_error("Dedup", format!("Decrement chunks: {}", e)))?; tx.commit() .await .map_err(|e| DomainError::internal_error("Dedup", format!("Commit: {}", e)))?; - // Delete blob files AFTER commit - for chunk_hash in &zero_chunks { - if let Err(e) = self.backend.delete_blob(chunk_hash).await { - tracing::warn!("Failed to delete chunk blob {}: {}", chunk_hash, e); - } - } - - // Bug 4 fix: notify hooks — e.g. thumbnail cleanup keyed by file_hash + // File content is gone — drop its blob-keyed thumbnails now. self.fire_blob_hooks(file_hash); tracing::info!( - "MANIFEST DELETED: {} ({} chunks, {} orphan chunks removed)", + "MANIFEST DELETED: {} ({} chunks dereferenced; orphans reclaimed by GC)", &file_hash[..12], - chunk_hashes.len(), - zero_chunks.len() + chunk_hashes.len() ); Ok(true) } else { @@ -3464,6 +3464,76 @@ mod delta_upload_integration_tests { cleanup(&pool, &file_hash, file_id, &[]).await; } + // ── Manifest dereference defers chunk reclamation to GC ────── + #[tokio::test] + async fn manifest_dereference_defers_chunk_reclamation_to_gc() { + let pool = test_pool().await; + let dir = TempDir::new().unwrap(); + let svc = local_svc(&pool, &dir).await; + let user = seed_user(&pool).await; + + // Single-owner multi-chunk CDC file → its chunks are uniquely owned. + let data = content(3 * 1024 * 1024, 91); + let (file_hash, chunks, file_id) = + seed_owned_content(&svc, &pool, user, &data, "deref").await; + assert!(chunks.len() >= 3, "3 MiB must split into ≥3 chunks"); + + // The delete_file_permanently sequence: drop the file row (PG trigger) + // then dereference the manifest. + sqlx::query("DELETE FROM storage.files WHERE id = $1") + .bind(file_id) + .execute(pool.as_ref()) + .await + .expect("delete file row"); + assert!( + svc.remove_reference(&file_hash).await.expect("deref"), + "last reference removed" + ); + + // Manifest is gone immediately… + let manifest_rc: Option = sqlx::query_scalar( + "SELECT ref_count FROM storage.chunk_manifests WHERE file_hash = $1", + ) + .bind(&file_hash) + .fetch_optional(pool.as_ref()) + .await + .expect("manifest query"); + assert!(manifest_rc.is_none(), "manifest deleted"); + + // …but the chunk rows + bytes survive at ref_count 0: no inline unlink + // that could race a concurrent re-upload of the same chunk. + for c in &chunks { + assert_eq!( + blob_ref(&pool, c).await, + Some(0), + "chunk dereferenced, not yet deleted" + ); + assert!( + svc.backend().blob_exists(c).await.unwrap(), + "chunk bytes kept until GC reclaims them" + ); + } + + // Age the orphans past the grace window; GC then reclaims rows + files. + sqlx::query( + "UPDATE storage.blobs SET orphaned_at = now() - interval '2 hours' WHERE hash = ANY($1)", + ) + .bind(&chunks) + .execute(pool.as_ref()) + .await + .expect("age orphans"); + svc.garbage_collect().await.expect("gc"); + for c in &chunks { + assert!(blob_ref(&pool, c).await.is_none(), "chunk row reclaimed"); + assert!( + !svc.backend().blob_exists(c).await.unwrap(), + "chunk file reclaimed" + ); + } + + cleanup(&pool, &file_hash, file_id, &[]).await; + } + // ── Verification read ──────────────────────────────────────── #[tokio::test] async fn hash_chunk_sequence_recomputes_and_validates_sizes() { From 824df4d42173296ee0e39c3a1723e6d3ffa05de3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:01:19 +0000 Subject: [PATCH 19/39] feat(photos): keyboard a11y + shift-click range selection - Tiles are focusable (tabindex / role=button / aria-label) with a :focus-visible ring; Enter opens the lightbox (or toggles in selection mode), Space toggles selection. - Shift-click extends the selection from the last anchor across the timeline; the range is tracked in the selection Set so it spans dematerialized (off-screen) groups, with visible tiles updated at once. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- static/css/views/photos.css | 5 +++ static/js/features/library/photos.js | 55 +++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/static/css/views/photos.css b/static/css/views/photos.css index 33221c9a..43ab6418 100644 --- a/static/css/views/photos.css +++ b/static/css/views/photos.css @@ -113,6 +113,11 @@ border-color: var(--color-border-medium); } +.photo-tile:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + .photo-tile:hover img { transform: scale(1.03); } diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index 437de6ff..8a116a6d 100644 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -56,6 +56,8 @@ const photosView = { _resizeHandler: null, /** @type {number} */ _resizeTimer: 0, + /** @type {string|null} Anchor id for shift-range selection */ + _selectAnchorId: null, PAGE_SIZE: 200, @@ -202,6 +204,7 @@ const photosView = { // real elements so we keep references for the observer. this._container.innerHTML = this._renderToolbar(); this._container.onclick = (e) => this._handleClick(e); + this._container.onkeydown = (e) => this._handleKeydown(e); const groups = this._groupItems(this.items); for (const [label, files] of groups) { @@ -436,7 +439,7 @@ const photosView = { const selected = this.selected.has(file.id) ? ' selected' : ''; const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null; const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`; - let h = `
`; + let h = `
`; h += `
`; const srcset = cachedThumb ? '' @@ -599,9 +602,16 @@ const photosView = { const id = tile.dataset.id; const check = target.closest('.photo-check'); + // Shift-click extends the selection from the last anchor. + if (id && e.shiftKey && this._selectAnchorId) { + this._selectRange(this._selectAnchorId, id); + return; + } + // If clicking checkbox or in selection mode, toggle select if (check || this.selected.size > 0) { this._toggleSelect(id, tile); + this._selectAnchorId = id || null; return; } @@ -612,6 +622,49 @@ const photosView = { } }, + /** + * Select every item between the anchor and the target (inclusive), in + * timeline order. Tracked in the Set so it survives dematerialized + * groups; currently-visible tiles get the class applied immediately. + * @param {string} anchorId + * @param {string} toId + */ + _selectRange(anchorId, toId) { + const a = this.items.findIndex((f) => f.id === anchorId); + const b = this.items.findIndex((f) => f.id === toId); + if (a < 0 || b < 0) return; + const lo = Math.min(a, b); + const hi = Math.max(a, b); + for (let i = lo; i <= hi; i++) this.selected.add(this.items[i].id); + this._container?.querySelectorAll('.photo-tile').forEach((el) => { + const t = /** @type {HTMLElement} */ (el); + if (t.dataset.id && this.selected.has(t.dataset.id)) t.classList.add('selected'); + }); + this._selectAnchorId = toId; + this._updateSelectionBar(); + }, + + /** + * Keyboard activation for focused tiles: Enter opens the lightbox (or + * toggles selection when in selection mode); Space toggles selection. + * @param {KeyboardEvent} e + */ + _handleKeydown(e) { + if (e.key !== 'Enter' && e.key !== ' ') return; + const target = /** @type {Element} */ (e.target); + const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile')); + if (!tile) return; + e.preventDefault(); + const id = tile.dataset.id; + if (e.key === ' ' || this.selected.size > 0) { + this._toggleSelect(id, tile); + this._selectAnchorId = id || null; + return; + } + const idx = this.items.findIndex((f) => f.id === id); + if (idx >= 0) photosLightbox.open(this.items, idx); + }, + /** * Toggle selection of an item * @param {string} id From 8d09589588926d97cadee150efe72ab9c2be6714 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:15:20 +0000 Subject: [PATCH 20/39] feat(photos): expose image width/height on the /api/photos timeline list_media_files now LEFT JOINs storage.file_metadata and returns each photo's pixel dimensions next to the sort date. The endpoint wraps FileDto in a flattened PhotoDto carrying width/height, so the gallery can lay tiles out at their true aspect ratio (justified layout) without a second per-file metadata round-trip and without layout shift. FileItem gains optional width/height. No change to FileDto or its other construction sites. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- .../pg/file_blob_read_repository.rs | 14 ++++++--- src/interfaces/api/handlers/photos_handler.rs | 31 +++++++++++++++---- static/js/core/types.js | 2 ++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index f2fefdfe..524f36d8 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -20,6 +20,8 @@ type MediaFileRow = ( String, // blob_hash Option, // user_id i64, // sort_date + Option, // width + Option, // height ); use bytes::Bytes; @@ -370,7 +372,7 @@ impl FileBlobReadRepository { owner_id: Uuid, before: Option, limit: i64, - ) -> Result<(Vec, Vec), DomainError> { + ) -> Result<(Vec, Vec, Vec<(Option, Option)>), DomainError> { let rows: Vec = sqlx::query_as( r#" SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, @@ -379,9 +381,11 @@ impl FileBlobReadRepository { EXTRACT(EPOCH FROM fi.updated_at)::bigint, fi.blob_hash, fi.user_id, - EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date + EXTRACT(EPOCH FROM fi.media_sort_date)::bigint AS sort_date, + fm.width, fm.height FROM storage.files fi LEFT JOIN storage.folders fo ON fo.id = fi.folder_id + LEFT JOIN storage.file_metadata fm ON fm.file_id = fi.id WHERE fi.user_id = $1 AND NOT fi.is_trashed AND (fi.mime_type LIKE 'image/%' OR fi.mime_type LIKE 'video/%') @@ -400,15 +404,17 @@ impl FileBlobReadRepository { let mut files = Vec::with_capacity(rows.len()); let mut sort_dates = Vec::with_capacity(rows.len()); + let mut dims = Vec::with_capacity(rows.len()); - for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd) in rows { + for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, sd, w, h) in rows { files.push(Self::row_to_file( id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid, )?); sort_dates.push(sd); + dims.push((w, h)); } - Ok((files, sort_dates)) + Ok((files, sort_dates, dims)) } } diff --git a/src/interfaces/api/handlers/photos_handler.rs b/src/interfaces/api/handlers/photos_handler.rs index 1eb6c8c3..d1110f43 100644 --- a/src/interfaces/api/handlers/photos_handler.rs +++ b/src/interfaces/api/handlers/photos_handler.rs @@ -4,7 +4,7 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{error, info}; @@ -21,6 +21,20 @@ pub struct PhotosQueryParams { pub limit: Option, } +/// Photos-timeline item: a `FileDto` plus the image's original pixel +/// dimensions (from EXIF/metadata), flattened into the same JSON shape so +/// the gallery can lay tiles out at their true aspect ratio without a +/// second per-file metadata round-trip. +#[derive(Serialize)] +struct PhotoDto { + #[serde(flatten)] + file: FileDto, + #[serde(skip_serializing_if = "Option::is_none")] + width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + height: Option, +} + /// Lists all image/video files for the authenticated user, sorted by /// capture date (EXIF DateTimeOriginal) falling back to upload date. /// @@ -55,17 +69,22 @@ pub async fn list_photos( .list_media_files(user_id, params.before, limit) .await { - Ok((files, sort_dates)) => { + Ok((files, sort_dates, dims)) => { info!("Photos: returned {} media files for user", files.len()); - // Convert to DTOs with sort_date populated - let dtos: Vec = files + // Convert to DTOs with sort_date + pixel dimensions populated. + let dtos: Vec = files .into_iter() .zip(sort_dates.iter()) - .map(|(file, &sd)| { + .zip(dims.iter()) + .map(|((file, &sd), &(w, h))| { let mut dto = FileDto::from(file); dto.sort_date = Some(sd as u64); - dto + PhotoDto { + file: dto, + width: w.map(|v| v.max(0) as u32), + height: h.map(|v| v.max(0) as u32), + } }) .collect(); diff --git a/static/js/core/types.js b/static/js/core/types.js index 0c12efb2..c8a5b2c8 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -45,6 +45,8 @@ * @property {number} size * @property {string} size_formatted * @property {number} sort_date + * @property {number} [width] original pixel width (photos timeline only) + * @property {number} [height] original pixel height (photos timeline only) * @property {string} etag opaque HTTP ETag, for If-Match / If-None-Match * @property {string} content_hash raw BLAKE3 content hash, for dedup checks * @property {string} [snippet] plain-text fragment around a content match (search results only) From 75ee9b7cc4e883d48e3315d012efef6ca507239d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:21:00 +0000 Subject: [PATCH 21/39] feat(photos): justified (aspect-preserving) layout option Adds a Grid/Justified toggle to the photos toolbar. Justified mode packs tiles into Flickr-style rows scaled to the container width using each photo's real aspect ratio (from the new /api/photos width/height, falling back to 1:1 when missing). It composes with the virtualized renderer: per-group materialization and the off-screen spacer height estimates are both layout-aware. The choice persists in localStorage. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- static/css/views/photos.css | 20 ++++ static/js/features/library/photos.js | 135 ++++++++++++++++++++++++--- static/locales/en.json | 4 +- 3 files changed, 147 insertions(+), 12 deletions(-) diff --git a/static/css/views/photos.css b/static/css/views/photos.css index 43ab6418..06ed928e 100644 --- a/static/css/views/photos.css +++ b/static/css/views/photos.css @@ -19,6 +19,7 @@ display: flex; align-items: center; justify-content: flex-end; + gap: var(--space-3); padding: var(--space-2) var(--space-2) var(--space-1); } @@ -54,6 +55,25 @@ margin-bottom: var(--space-4); } +/* Justified (aspect-preserving) layout — the grid becomes a column of rows; + tile sizes are set inline by photos.js (see _justifiedRows). */ +.photos-layout-justified .photos-grid { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.photos-jrow { + display: flex; + flex-direction: row; + gap: var(--space-2); +} + +.photos-layout-justified .photo-tile { + aspect-ratio: auto; + flex: 0 0 auto; +} + /* Monthly mode — larger tiles, more breathing room */ .photos-group-monthly .photos-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index 8a116a6d..83a0f3ef 100644 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -46,6 +46,8 @@ const photosView = { _initialized: false, /** @type {PhotoModeEnum} */ groupMode: 'monthly', + /** @type {'square'|'justified'} */ + layoutMode: 'square', /** @type {Map} fileId → thumbnail URL (persists across re-renders) */ _videoThumbCache: new Map(), /** @type {Map} group label → group record (DOM + data) */ @@ -81,6 +83,7 @@ const photosView = { } if (!this._initialized) { this.groupMode = /** @type {'daily'|'monthly'|'yearly'} */ (localStorage.getItem('oxicloud-photos-group')) || 'monthly'; + this.layoutMode = /** @type {'square'|'justified'} */ (localStorage.getItem('oxicloud-photos-layout')) || 'square'; this._initialized = true; } }, @@ -124,6 +127,17 @@ const photosView = { this._renderFull(); }, + /** + * Switch tile layout (square crop vs justified aspect-preserving rows). + * @param {'square'|'justified'} mode + */ + setLayoutMode(mode) { + if (this.layoutMode === mode) return; + this.layoutMode = mode; + localStorage.setItem('oxicloud-photos-layout', mode); + this._renderFull(); + }, + /** Fetch a page of photos from the API */ async _loadPage() { if (this.loading || this.exhausted) return; @@ -193,6 +207,8 @@ const photosView = { this._container.classList.remove('photos-group-daily', 'photos-group-monthly', 'photos-group-yearly'); this._container.classList.add(`photos-group-${this.groupMode}`); + this._container.classList.remove('photos-layout-square', 'photos-layout-justified'); + this._container.classList.add(`photos-layout-${this.layoutMode}`); if (this.items.length === 0 && this.exhausted) { this._renderEmpty(); @@ -249,9 +265,14 @@ const photosView = { const grid = /** @type {HTMLElement|null} */ (existing.section.querySelector('.photos-grid')); if (grid) { if (existing.materialized) { - let tilesHtml = ''; - for (const file of files) tilesHtml += this._renderTile(file); - grid.insertAdjacentHTML('beforeend', tilesHtml); + if (this.layoutMode === 'justified') { + // Justified rows must repack against the whole group. + grid.innerHTML = this._renderGroupTiles(existing.files); + } else { + let tilesHtml = ''; + for (const file of files) tilesHtml += this._renderTile(file); + grid.insertAdjacentHTML('beforeend', tilesHtml); + } this._setupVideoThumbnails(grid); this._fadeInTiles(grid); } else { @@ -346,9 +367,7 @@ const photosView = { rec.materialized = true; const grid = /** @type {HTMLElement|null} */ (section.querySelector('.photos-grid')); if (!grid) return; - let html = ''; - for (const file of rec.files) html += this._renderTile(file); - grid.innerHTML = html; + grid.innerHTML = this._renderGroupTiles(rec.files); grid.style.minHeight = ''; this._setupVideoThumbnails(grid); this._fadeInTiles(grid); @@ -372,8 +391,7 @@ const photosView = { * @returns {{cols: number, gap: number, tile: number}} */ _gridMetrics() { - const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid')); - const width = sample?.clientWidth || (this._container?.clientWidth || 1200) - 16; + const width = this._gridWidth(); const mobile = window.matchMedia('(max-width: 768px)').matches; let min; let gap; @@ -397,6 +415,13 @@ const photosView = { * @returns {number} */ _estimateHeight(count) { + if (this.layoutMode === 'justified') { + const width = this._gridWidth(); + const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200; + const perRow = Math.max(1, Math.round(width / (target * 1.4))); + const rows = Math.max(1, Math.ceil(count / perRow)); + return Math.round(rows * target + (rows - 1) * 8); + } const { cols, gap, tile } = this._gridMetrics(); const rows = Math.max(1, Math.ceil(count / cols)); return Math.round(rows * tile + (rows - 1) * gap); @@ -433,13 +458,15 @@ const photosView = { /** * Generate HTML for a single photo/video tile * @param {FileItem} file + * @param {string} [sizeStyle] Inline `width:..;height:..` for justified rows. */ - _renderTile(file) { + _renderTile(file, sizeStyle) { const isVideo = file.mime_type?.startsWith('video/'); const selected = this.selected.has(file.id) ? ' selected' : ''; const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null; const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`; - let h = `
`; + const styleAttr = sizeStyle ? ` style="${sizeStyle}"` : ''; + let h = `
`; h += `
`; const srcset = cachedThumb ? '' @@ -450,6 +477,77 @@ const photosView = { return h; }, + /** + * Inner HTML for a group's grid in the current layout mode. + * @param {FileItem[]} files + * @returns {string} + */ + _renderGroupTiles(files) { + if (this.layoutMode !== 'justified') { + let html = ''; + for (const file of files) html += this._renderTile(file); + return html; + } + const rows = this._justifiedRows(files, this._gridWidth()); + let html = ''; + for (const row of rows) { + html += `
`; + for (const t of row.tiles) { + html += this._renderTile(t.file, `width:${t.w}px;height:${t.h}px`); + } + html += '
'; + } + return html; + }, + + /** + * Pack files into justified rows (Flickr-style): each full row is scaled so + * it fills the container width while preserving every tile's aspect ratio. + * Missing dimensions fall back to a 1:1 aspect. + * @param {FileItem[]} files + * @param {number} width Available content width in px. + * @returns {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>} + */ + _justifiedRows(files, width) { + const gap = 8; + const target = window.matchMedia('(max-width: 768px)').matches ? 150 : 200; + /** @type {Array<{height: number, tiles: Array<{file: FileItem, w: number, h: number}>}>} */ + const rows = []; + /** @type {Array<{file: FileItem, aspect: number}>} */ + let cur = []; + let aspectSum = 0; + for (const file of files) { + let aspect = file.width && file.height ? file.width / file.height : 1; + if (!Number.isFinite(aspect) || aspect <= 0) aspect = 1; + aspect = Math.min(Math.max(aspect, 0.4), 3); + cur.push({ file, aspect }); + aspectSum += aspect; + const rowWidth = aspectSum * target + (cur.length - 1) * gap; + if (rowWidth >= width) { + const h = (width - (cur.length - 1) * gap) / aspectSum; + rows.push({ + height: Math.round(h), + tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * h)), h: Math.round(h) })) + }); + cur = []; + aspectSum = 0; + } + } + if (cur.length) { + rows.push({ + height: target, + tiles: cur.map((t) => ({ file: t.file, w: Math.max(1, Math.round(t.aspect * target)), h: target })) + }); + } + return rows; + }, + + /** Current grid content width in px (for layout / height estimates). */ + _gridWidth() { + const sample = /** @type {HTMLElement|null} */ (this._container?.querySelector('.photos-grid')); + return sample?.clientWidth || (this._container?.clientWidth || 1200) - 16; + }, + /** * Fade tiles in as their thumbnails finish loading (kills the pop-in). * Idempotent — only wires images not already marked loaded. @@ -532,7 +630,16 @@ const photosView = { ['monthly', i18n.t('photos.view_monthly')], ['yearly', i18n.t('photos.view_yearly')] ]; - let html = '
'; + let html = '
'; + + // Layout toggle (square crop ↔ justified rows) + html += '
'; + html += ``; + html += ``; + html += '
'; + + // Grouping toggle (day / month / year) + html += '
'; for (const [mode, label] of modes) { const active = this.groupMode === mode ? ' active' : ''; html += ``; @@ -596,6 +703,12 @@ const photosView = { return; } + const layoutBtn = /** @type {HTMLButtonElement} */ (target.closest('[data-layout-mode]')); + if (layoutBtn) { + this.setLayoutMode(/** @type {'square'|'justified'} */ (layoutBtn.dataset.layoutMode)); + return; + } + const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile')); if (!tile) return; diff --git a/static/locales/en.json b/static/locales/en.json index 13b40de5..32a0efca 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -71,7 +71,9 @@ "view_yearly": "Year", "delete_title": "Move to Trash", "delete_selected_confirm": "Move the selected items to Trash?", - "delete_one_confirm": "Move \"{{name}}\" to Trash?" + "delete_one_confirm": "Move \"{{name}}\" to Trash?", + "layout_square": "Grid", + "layout_justified": "Justified" }, "music": { "create_playlist": "Create Playlist", From ca1a8cb8b82a7e205eb6037b662160813d02eabc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:25:33 +0000 Subject: [PATCH 22/39] feat(photos): lightbox zoom/pan, mobile swipe & EXIF info panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Photos zoom via wheel, double-click and two-finger pinch (1–5x) with drag-to-pan; a touch swipe navigates prev/next when not zoomed. - A new info button toggles a panel showing date, size, dimensions, camera and GPS coordinates (resolving the old geoloc TODO), pulled from /api/files/{id}/metadata. - Zoom/pan state resets on every item change and on close. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- static/css/views/photosLightbox.css | 39 ++++ static/js/features/library/photosLightbox.js | 176 ++++++++++++++++++- 2 files changed, 213 insertions(+), 2 deletions(-) diff --git a/static/css/views/photosLightbox.css b/static/css/views/photosLightbox.css index a5d3accc..3b158f2d 100644 --- a/static/css/views/photosLightbox.css +++ b/static/css/views/photosLightbox.css @@ -170,6 +170,45 @@ z-index: 10001; } +/* EXIF info panel */ +.lightbox-infopanel { + position: absolute; + top: 64px; + right: var(--space-4); + max-width: 320px; + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: var(--color-lightbox-btn-bg); + color: var(--color-lightbox-btn-text); + border-radius: var(--radius-lg); + font-size: var(--text-sm); + z-index: 10001; +} + +.lightbox-infopanel.hidden { + display: none; +} + +.lb-info-row { + display: flex; + align-items: center; + gap: var(--space-2); + word-break: break-word; +} + +.lb-info-row i { + width: 18px; + text-align: center; + opacity: 0.8; +} + +/* Zoomed photo shows a grab cursor for panning */ +.lightbox-content img.is-zoomed { + cursor: grab; +} + /* Responsive */ @media (max-width: 768px) { .lightbox-nav { diff --git a/static/js/features/library/photosLightbox.js b/static/js/features/library/photosLightbox.js index 8875f6f7..0293f998 100644 --- a/static/js/features/library/photosLightbox.js +++ b/static/js/features/library/photosLightbox.js @@ -37,6 +37,23 @@ export const photosLightbox = { */ _showGeneration: 0, + /** @type {number} Current zoom factor (1 = fit) */ + _zoom: 1, + /** @type {number} */ + _panX: 0, + /** @type {number} */ + _panY: 0, + /** @type {Map} Active pointers (for pinch) */ + _pointers: new Map(), + /** @type {number} */ + _pinchStartDist: 0, + /** @type {number} */ + _pinchStartZoom: 1, + /** @type {{x: number, y: number, panX: number, panY: number}|null} */ + _dragStart: null, + /** @type {{x: number, y: number, t: number}|null} */ + _swipeStart: null, + /** * Register the photosView reference (called from photos.js to avoid circular imports). * @param {any} pv @@ -93,6 +110,7 @@ export const photosLightbox = { } }, 200); } + this._resetZoom(); this._unbindKeys(); }, @@ -129,11 +147,13 @@ export const photosLightbox = { + `; document.body.appendChild(el); this._overlay = el; @@ -151,6 +171,7 @@ export const photosLightbox = { }); // Toolbar actions (`.lb-fullres` is wired per-item in `_show`) + /** @type {HTMLButtonElement} */ (el.querySelector('.lb-info')).onclick = () => this._toggleInfoPanel(); /** @type {HTMLButtonElement} */ (el.querySelector('.lb-download')).onclick = () => this._download(); /** @type {HTMLButtonElement} */ (el.querySelector('.lb-favorite')).onclick = () => this._toggleFavorite(); /** @type {HTMLButtonElement} */ (el.querySelector('.lb-delete')).onclick = () => this._delete(); @@ -174,6 +195,7 @@ export const photosLightbox = { _show() { if (!this._overlay || this.index < 0) return; const generation = ++this._showGeneration; + this._resetZoom(); const item = this.items[this.index]; const content = this._overlay.querySelector('.lightbox-content'); @@ -246,6 +268,7 @@ export const photosLightbox = { let showingOriginal = isGif; const img = document.createElement('img'); img.alt = item.name; + this._wireZoomPan(img); img.addEventListener('load', () => { if (generation !== this._showGeneration) return; @@ -308,8 +331,7 @@ export const photosLightbox = { parts.push(`${metadata.width}×${metadata.height}`); } metaEl.textContent = parts.join(' · '); - - //TODO: add geoloc pointer to openstreetmap ? + this._fillInfoPanel(metadata, dateStr, sizeStr); } } catch (_err) { // Non-critical, keep existing meta @@ -389,6 +411,156 @@ export const photosLightbox = { } }, + // ── Zoom / pan / swipe ────────────────────────────────────────── + + /** @returns {HTMLImageElement|null} The image element currently shown. */ + _currentImg() { + return /** @type {HTMLImageElement|null} */ (this._overlay?.querySelector('.lightbox-content img') || null); + }, + + /** Reset zoom/pan state (per item and on close). */ + _resetZoom() { + this._zoom = 1; + this._panX = 0; + this._panY = 0; + this._pointers.clear(); + this._pinchStartDist = 0; + this._dragStart = null; + this._swipeStart = null; + }, + + _applyTransform() { + const img = this._currentImg(); + if (img) img.style.transform = `translate(${this._panX}px, ${this._panY}px) scale(${this._zoom})`; + }, + + /** + * Set the zoom factor (clamped 1–5), centered. Resets pan at 1. + * @param {number} z + */ + _setZoom(z) { + z = Math.min(Math.max(z, 1), 5); + if (z === 1) { + this._panX = 0; + this._panY = 0; + } + this._zoom = z; + this._applyTransform(); + const img = this._currentImg(); + if (img) img.classList.toggle('is-zoomed', z > 1); + }, + + /** + * Wire wheel-zoom, double-click zoom, drag-pan, pinch-zoom and (when not + * zoomed) touch swipe-to-navigate onto a photo element. + * @param {HTMLImageElement} img + */ + _wireZoomPan(img) { + img.style.transformOrigin = 'center center'; + img.style.touchAction = 'none'; + + img.addEventListener( + 'wheel', + (e) => { + e.preventDefault(); + this._setZoom(this._zoom * (e.deltaY < 0 ? 1.2 : 1 / 1.2)); + }, + { passive: false } + ); + + img.addEventListener('dblclick', (e) => { + e.preventDefault(); + this._setZoom(this._zoom > 1 ? 1 : 2.5); + }); + + img.addEventListener('pointerdown', (e) => { + img.setPointerCapture?.(e.pointerId); + this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (this._pointers.size === 2) { + const pts = [...this._pointers.values()]; + this._pinchStartDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); + this._pinchStartZoom = this._zoom; + } else { + this._dragStart = { x: e.clientX, y: e.clientY, panX: this._panX, panY: this._panY }; + this._swipeStart = { x: e.clientX, y: e.clientY, t: Date.now() }; + } + }); + + img.addEventListener('pointermove', (e) => { + if (!this._pointers.has(e.pointerId)) return; + this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (this._pointers.size === 2 && this._pinchStartDist > 0) { + const pts = [...this._pointers.values()]; + const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y); + this._setZoom(this._pinchStartZoom * (dist / this._pinchStartDist)); + } else if (this._zoom > 1 && this._dragStart) { + this._panX = this._dragStart.panX + (e.clientX - this._dragStart.x); + this._panY = this._dragStart.panY + (e.clientY - this._dragStart.y); + this._applyTransform(); + } + }); + + const endPointer = (/** @type {PointerEvent} */ e) => { + const wasPinch = this._pointers.size === 2; + this._pointers.delete(e.pointerId); + if (!wasPinch && this._zoom === 1 && this._swipeStart && e.pointerType === 'touch') { + const dx = e.clientX - this._swipeStart.x; + const dy = e.clientY - this._swipeStart.y; + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) { + if (dx > 0) this.prev(); + else this.next(); + } + } + if (wasPinch) this._pinchStartDist = 0; + this._dragStart = null; + this._swipeStart = null; + }; + img.addEventListener('pointerup', endPointer); + img.addEventListener('pointercancel', endPointer); + }, + + // ── Info panel ────────────────────────────────────────────────── + + /** Toggle the EXIF info panel. */ + _toggleInfoPanel() { + this._overlay?.querySelector('.lightbox-infopanel')?.classList.toggle('hidden'); + }, + + /** + * Populate the info panel from fetched EXIF metadata. + * @param {FileMetadata} metadata + * @param {string} dateStr + * @param {string} sizeStr + */ + _fillInfoPanel(metadata, dateStr, sizeStr) { + const panel = this._overlay?.querySelector('.lightbox-infopanel'); + if (!panel) return; + const item = this.items[this.index]; + const rows = [this._infoRow('fa-image', item?.name || ''), this._infoRow('fa-calendar', dateStr)]; + if (sizeStr) rows.push(this._infoRow('fa-hard-drive', sizeStr)); + if (metadata.width && metadata.height) { + rows.push(this._infoRow('fa-ruler-combined', `${metadata.width} × ${metadata.height}`)); + } + if (metadata.camera_make || metadata.camera_model) { + rows.push(this._infoRow('fa-camera', [metadata.camera_make, metadata.camera_model].filter(Boolean).join(' '))); + } + if (metadata.latitude != null && metadata.longitude != null) { + rows.push(this._infoRow('fa-location-dot', `${metadata.latitude.toFixed(5)}, ${metadata.longitude.toFixed(5)}`)); + } + panel.innerHTML = rows.join(''); + }, + + /** + * @param {string} icon FontAwesome class + * @param {string} text + * @returns {string} + */ + _infoRow(icon, text) { + const d = document.createElement('div'); + d.textContent = text; + return `
${d.innerHTML}
`; + }, + /** Keyboard navigation */ _bindKeys() { this._keyHandler = (e) => { From 3b30a111617287efb6b82d9e73f4d0d6eeb1825d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:36:17 +0000 Subject: [PATCH 23/39] docs(plan): mark Phase 0 complete and record the PhotoDto deviation Adds an implementation-status section: Phase 0 done (with commit refs), 0.2 implemented via a flattened PhotoDto rather than widening FileDto, the two Phase 0 loose ends (embedded map pin, drag-marquee, HEIC, sub-nav), and Phase 1 (Places) marked backend-in-progress. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- docs/plan/Photos-People-Places.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/plan/Photos-People-Places.md b/docs/plan/Photos-People-Places.md index 95644012..70e0f38e 100644 --- a/docs/plan/Photos-People-Places.md +++ b/docs/plan/Photos-People-Places.md @@ -30,6 +30,24 @@ migrations, and a **vanilla-JS / vanilla-CSS** frontend (design tokens from --- +## Implementation status (updated) + +**Phase 0 — Gallery polish: essentially complete** (branch `claude/zealous-faraday-58s1at`). + +| Item | Status | Commit / note | +|------|--------|---------------| +| 0.1 Virtualization | ✅ done | `081b2b6` | +| 0.2 width/height on `/api/photos` | ✅ done | `8d09589` — implemented via a flattened `PhotoDto` (`#[serde(flatten)]`) instead of widening `FileDto` + its 6 construction sites; `list_media_files` LEFT JOINs `storage.file_metadata`. `FileItem` gained optional `width`/`height`. | +| 0.3 Justified layout | ✅ done | `75ee9b7` | +| 0.4 Lightbox (zoom/pan, swipe, info panel, favorite fix) | 🟡 mostly | `ca1a8cb`, `e8520e4` — the map pin shows **coordinates as text**; the embedded mini-map / deep-link into Places is deferred until Places exists. | +| 0.5 Shift-select, confirm→Modal, keyboard a11y | 🟡 mostly | `824df4d`, `e8520e4` — optional drag-marquee not done. | +| 0.6 HEIC | ⬜ pending | open decision (native `libheif` dep). | +| 0.7 Sub-nav tabs | ⬜ pending | deferred to Phase 1 (tabs need the Places/People views). | + +**Phase 1 — Places: backend in progress** (data API: index, repo aggregation, service, flag, endpoints). The map frontend (1.5 PMTiles serving, 1.7 MapLibre module, 1.8 thumbnails) is gated on the MapLibre-vendoring decision + a basemap asset. **Phase 2 — People: not started.** No new vendored JS/assets added yet. + +--- + ## Research summary (the decisions these phases encode) **Map (no third-party APIs, self-host, extreme perf):** From f4b431bb03b5e06cda89a674df2ee846914c51b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:57:01 +0000 Subject: [PATCH 24/39] feat(places): backend for the photo map (geo clusters API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 server side, gated on OXICLOUD_ENABLE_PLACES (off by default): - migration: partial index on storage.file_metadata(longitude, latitude). - FileBlobReadRepository::list_geo_clusters — plain-SQL grid aggregation (no PostGIS) scoped to the caller's own non-trashed photos, returning a centroid, count and a representative file id per non-empty cell. - PlacesService (caller_id-scoped; user-scoped data needs no authz check, mirroring RecentService) with a zoom→cell-size mapping. - GET /api/photos/geo?bbox=w,s,e,n&zoom=N returning GeoCluster[]. The route is mounted only when the Places service is present, and is registered in the OpenAPI path list. The map frontend (PMTiles serving + MapLibre module) is deferred pending the basemap-sourcing and vendoring decision. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- .../20260801000003_places_geo_index.sql | 10 +++ src/application/dtos/geo_dto.rs | 26 +++++++ src/application/dtos/mod.rs | 1 + src/application/services/mod.rs | 1 + src/application/services/places_service.rs | 45 +++++++++++ src/common/config.rs | 9 +++ src/common/di.rs | 21 ++++++ .../pg/file_blob_read_repository.rs | 51 +++++++++++++ src/interfaces/api/handlers/photos_handler.rs | 74 +++++++++++++++++++ src/interfaces/api/mod.rs | 1 + src/interfaces/api/routes.rs | 8 +- 11 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 migrations/20260801000003_places_geo_index.sql create mode 100644 src/application/dtos/geo_dto.rs create mode 100644 src/application/services/places_service.rs diff --git a/migrations/20260801000003_places_geo_index.sql b/migrations/20260801000003_places_geo_index.sql new file mode 100644 index 00000000..34ea70fb --- /dev/null +++ b/migrations/20260801000003_places_geo_index.sql @@ -0,0 +1,10 @@ +-- ════════════════════════════════════════════════════════════════════════ +-- Places (photo map): partial index for fast bounding-box scans over the +-- caller's geotagged photos. Plain B-tree on (longitude, latitude); no +-- PostGIS required. The partial predicate keeps the index small — only rows +-- that actually carry GPS coordinates are indexed. +-- ════════════════════════════════════════════════════════════════════════ + +CREATE INDEX IF NOT EXISTS idx_file_metadata_geo + ON storage.file_metadata (longitude, latitude) + WHERE latitude IS NOT NULL AND longitude IS NOT NULL; diff --git a/src/application/dtos/geo_dto.rs b/src/application/dtos/geo_dto.rs new file mode 100644 index 00000000..de3edd14 --- /dev/null +++ b/src/application/dtos/geo_dto.rs @@ -0,0 +1,26 @@ +//! DTOs for the "Places" (photo map) feature. + +use serde::Serialize; +use utoipa::ToSchema; + +/// A geographic bounding box in decimal degrees. +#[derive(Debug, Clone, Copy)] +pub struct GeoBounds { + pub west: f64, + pub south: f64, + pub east: f64, + pub north: f64, +} + +/// A clustered group of geotagged photos within one aggregation cell. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct GeoCluster { + /// Cluster centroid longitude. + pub lng: f64, + /// Cluster centroid latitude. + pub lat: f64, + /// Number of photos in the cluster. + pub count: i64, + /// A representative photo id, for the cluster thumbnail. + pub sample_file_id: String, +} diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index b19c940d..d86edb67 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -10,6 +10,7 @@ pub mod favorites_dto; pub mod file_dto; pub mod folder_dto; pub mod folder_listing_dto; +pub mod geo_dto; pub mod grant_dto; pub mod i18n_dto; pub mod pagination; diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 7006ef9f..299b723d 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -20,6 +20,7 @@ pub mod magic_link_invite_service; pub mod music_service; pub mod nextcloud_file_id_service; pub mod nextcloud_login_flow_service; +pub mod places_service; pub mod recent_service; pub mod recipient_notification_service; pub mod search_service; diff --git a/src/application/services/places_service.rs b/src/application/services/places_service.rs new file mode 100644 index 00000000..6f0f42ad --- /dev/null +++ b/src/application/services/places_service.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster}; +use crate::common::errors::DomainError; +use crate::infrastructure::repositories::pg::FileBlobReadRepository; + +/// "Places" use case: the caller's geotagged photos aggregated into map +/// clusters. +/// +/// Strictly user-scoped — the repository filters `WHERE fi.user_id = $1`, so, +/// like [`RecentService`](super::recent_service::RecentService) and the photos +/// timeline, it needs no `AuthorizationEngine` check: the `caller_id` +/// parameter *is* the access scope. +pub struct PlacesService { + file_read: Arc, +} + +impl PlacesService { + pub fn new(file_read: Arc) -> Self { + Self { file_read } + } + + /// Aggregation cell side, in degrees, for a slippy-map zoom level. The + /// world (360°) is split into `2^zoom` tiles; we use ~4 cells per tile so + /// clusters refine as the user zooms in. Clamped to a sane range. + fn cell_for_zoom(zoom: u8) -> f64 { + let z = i32::from(zoom.min(20)); + 360.0 / (2_f64.powi(z) * 4.0) + } + + /// Clustered geotagged photos for `caller_id` within `bounds`. + pub async fn clusters( + &self, + caller_id: Uuid, + bounds: GeoBounds, + zoom: u8, + ) -> Result, DomainError> { + let cell = Self::cell_for_zoom(zoom); + self.file_read + .list_geo_clusters(caller_id, bounds, cell) + .await + } +} diff --git a/src/common/config.rs b/src/common/config.rs index a6f42c28..0a1147c9 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -879,6 +879,8 @@ pub struct FeaturesConfig { pub enable_trash: bool, pub enable_search: bool, pub enable_music: bool, + /// Lists the user's geotagged photos on a map (GET /api/photos/geo). + pub enable_places: bool, /// Expose other OxiCloud users as a read-only "system" address book /// at GET /api/address-books. Set to false to hide the user directory. pub expose_system_users: bool, @@ -893,6 +895,7 @@ impl Default for FeaturesConfig { enable_trash: true, // Enable trash feature enable_search: true, // Enable search feature enable_music: true, // Enable music feature + enable_places: false, // Photo map; off until the map UI ships expose_system_users: true, // Expose OxiCloud users as address book by default } } @@ -1378,6 +1381,12 @@ impl AppConfig { config.features.enable_music = val; } + if let Ok(enable_places) = env::var("OXICLOUD_ENABLE_PLACES").map(|v| v.parse::()) + && let Ok(val) = enable_places + { + config.features.enable_places = val; + } + // Content search (embedded Tantivy index) if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::()) && let Ok(val) = v diff --git a/src/common/di.rs b/src/common/di.rs index 62a3e133..37cf04fe 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -17,6 +17,7 @@ use crate::application::services::folder_service::FolderService; use crate::application::services::i18n_application_service::I18nApplicationService; use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService; use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService; +use crate::application::services::places_service::PlacesService; use crate::application::services::recent_service::RecentService; use crate::application::services::search_service::SearchService; use crate::application::services::share_browse_service::ShareBrowseService; @@ -798,6 +799,17 @@ impl AppServiceFactory { service } + /// Creates the Places (photo map) service. Reuses the existing file-read + /// repository — the data is the caller's own geotagged photos. + pub fn create_places_service( + &self, + file_read: &Arc, + ) -> Arc { + let service = Arc::new(PlacesService::new(file_read.clone())); + tracing::info!("Places service initialized"); + service + } + /// Preloads translations for every locale in the registry. Build /// the registry at startup via `LocaleRegistry::discover` and pass /// the resulting list here. @@ -1005,6 +1017,7 @@ impl AppServiceFactory { // 6. Database-dependent services (PgPool always available in blob model) let favorites_service: Option>; let recent_service: Option>; + let places_service: Option>; let storage_usage_service: Option>; let mut auth_services: Option = None; let mut nextcloud_services: Option = None; @@ -1027,6 +1040,12 @@ impl AppServiceFactory { recent_service = Some(recent.clone()); apps.recent_service = Some(recent); + places_service = if core.config.features.enable_places { + Some(self.create_places_service(&repos.file_read_repository)) + } else { + None + }; + storage_usage_service = Some(storage_usage.clone()); self.start_tree_etag_flush_job(&maintenance_pool); @@ -1253,6 +1272,7 @@ impl AppServiceFactory { share_browse_service, favorites_service, recent_service, + places_service, storage_usage_service, calendar_service: None, contact_service: None, @@ -1699,6 +1719,7 @@ pub struct AppState { pub share_browse_service: Option>, pub favorites_service: Option>, pub recent_service: Option>, + pub places_service: Option>, pub storage_usage_service: Option>, pub calendar_service: Option>, pub contact_service: Option>, diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 524f36d8..cbcdcce2 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -32,6 +32,7 @@ use std::pin::Pin; use std::sync::Arc; use std::time::Duration; +use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster}; use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::application::ports::storage_ports::FileReadPort; use crate::common::errors::DomainError; @@ -416,6 +417,56 @@ impl FileBlobReadRepository { Ok((files, sort_dates, dims)) } + + /// Aggregate the caller's geotagged photos into grid cells of side `cell` + /// (degrees) within `bounds`. Plain SQL (no PostGIS), scoped to `user_id`. + /// Returns one cluster per non-empty cell with its centroid, photo count + /// and a representative photo id (for the cluster thumbnail). + pub async fn list_geo_clusters( + &self, + user_id: Uuid, + bounds: GeoBounds, + cell: f64, + ) -> Result, DomainError> { + let rows: Vec<(i64, f64, f64, String)> = sqlx::query_as( + r#" + SELECT count(*) AS n, + avg(fm.longitude) AS clng, + avg(fm.latitude) AS clat, + min(fm.file_id::text) AS sample_id + FROM storage.file_metadata fm + JOIN storage.files fi ON fi.id = fm.file_id + WHERE fi.user_id = $1 + AND NOT fi.is_trashed + AND fm.latitude IS NOT NULL + AND fm.longitude IS NOT NULL + AND fm.longitude BETWEEN $2 AND $3 + AND fm.latitude BETWEEN $4 AND $5 + GROUP BY round(fm.longitude / $6), round(fm.latitude / $6) + "#, + ) + .bind(user_id) + .bind(bounds.west) + .bind(bounds.east) + .bind(bounds.south) + .bind(bounds.north) + .bind(cell) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("FileBlobRead", format!("list_geo_clusters: {e}")) + })?; + + Ok(rows + .into_iter() + .map(|(n, clng, clat, sample_id)| GeoCluster { + lng: clng, + lat: clat, + count: n, + sample_file_id: sample_id, + }) + .collect()) + } } impl FileReadPort for FileBlobReadRepository { diff --git a/src/interfaces/api/handlers/photos_handler.rs b/src/interfaces/api/handlers/photos_handler.rs index d1110f43..c6243e82 100644 --- a/src/interfaces/api/handlers/photos_handler.rs +++ b/src/interfaces/api/handlers/photos_handler.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use tracing::{error, info}; use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::geo_dto::GeoBounds; use crate::common::di::AppState; use crate::interfaces::middleware::auth::AuthUser; @@ -110,3 +111,76 @@ pub async fn list_photos( } } } + +/// Query parameters for the photos map (clustered) endpoint. +#[derive(Deserialize)] +pub struct GeoQueryParams { + /// Bounding box as `west,south,east,north` (decimal degrees). + pub bbox: String, + /// Slippy-map zoom level (0–20); controls cluster granularity. + pub zoom: Option, +} + +/// Lists the caller's geotagged photos aggregated into map clusters within a +/// bounding box. Gated on `OXICLOUD_ENABLE_PLACES` (the route is only mounted +/// when the Places service is present). +#[utoipa::path( + get, + path = "/api/photos/geo", + params( + ("bbox" = String, Query, description = "Bounding box 'west,south,east,north' (decimal degrees)"), + ("zoom" = Option, Query, description = "Map zoom level (0-20), controls cluster size") + ), + responses( + (status = 200, description = "Geotagged photos aggregated into map clusters"), + (status = 400, description = "Invalid bounding box"), + (status = 401, description = "Unauthorized") + ), + security(("bearerAuth" = [])), + tag = "photos" +)] +pub async fn list_photos_geo( + State(state): State>, + auth_user: AuthUser, + Query(params): Query, +) -> impl IntoResponse { + let Some(places) = state.places_service.as_ref() else { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Places feature is disabled" })), + ) + .into_response(); + }; + + let coords: Vec = params + .bbox + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + if coords.len() != 4 { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "bbox must be 'west,south,east,north'" })), + ) + .into_response(); + } + let bounds = GeoBounds { + west: coords[0], + south: coords[1], + east: coords[2], + north: coords[3], + }; + let zoom = params.zoom.unwrap_or(3); + + match places.clusters(auth_user.id, bounds, zoom).await { + Ok(clusters) => Json(clusters).into_response(), + Err(err) => { + error!("Error listing photo geo clusters: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("{}", err) })), + ) + .into_response() + } + } +} diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 2c07d61f..c906f75f 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -164,6 +164,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::recent_handler::clear_recent_items, // Photos handler (free function) handlers::photos_handler::list_photos, + handlers::photos_handler::list_photos_geo, // Batch handlers (free functions) handlers::batch_handler::move_files_batch, handlers::batch_handler::copy_files_batch, diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 08f08896..af2ff022 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -431,9 +431,11 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { { use crate::interfaces::api::handlers::photos_handler; - let photos_router = Router::new() - .route("/", get(photos_handler::list_photos)) - .with_state(app_state.clone()); + let mut photos_router = Router::new().route("/", get(photos_handler::list_photos)); + if app_state.places_service.is_some() { + photos_router = photos_router.route("/geo", get(photos_handler::list_photos_geo)); + } + let photos_router = photos_router.with_state(app_state.clone()); router = router.nest("/photos", photos_router); } From bb3d73926a5048dbb4bad90f13a35a43e154b43b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:10:58 +0000 Subject: [PATCH 25/39] chore(places): vendor MapLibre GL JS + pmtiles.js for the photo map Vendored for the self-hosted Places map (Approach A: client-side PMTiles decoding, basemap served as a static file via the existing Range-capable ServeDir): - maplibre-gl 5.24.0 (BSD-3) + its CSS - pmtiles 4.4.1 Also adds static/basemaps/ with a .gitignore (*.pmtiles is operator-provided, never committed) and a README explaining how to drop in a basemap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- static/basemaps/.gitignore | 5 +++ static/basemaps/README.md | 33 +++++++++++++++++ static/js/vendors/VERSIONS.txt | 2 ++ static/js/vendors/maplibre-gl.css | 1 + static/js/vendors/maplibre-gl.js | 59 +++++++++++++++++++++++++++++++ static/js/vendors/pmtiles.js | 2 ++ 6 files changed, 102 insertions(+) create mode 100644 static/basemaps/.gitignore create mode 100644 static/basemaps/README.md create mode 100644 static/js/vendors/VERSIONS.txt create mode 100644 static/js/vendors/maplibre-gl.css create mode 100644 static/js/vendors/maplibre-gl.js create mode 100644 static/js/vendors/pmtiles.js diff --git a/static/basemaps/.gitignore b/static/basemaps/.gitignore new file mode 100644 index 00000000..4b9a289c --- /dev/null +++ b/static/basemaps/.gitignore @@ -0,0 +1,5 @@ +# The vector basemap is large (tens of MB) and operator-provided — never +# commit it to the repo. Drop a Protomaps `.pmtiles` here as `basemap.pmtiles` +# and the existing static file server (tower-http ServeDir, Range-capable) +# will serve it to the Places map. See README.md. +*.pmtiles diff --git a/static/basemaps/README.md b/static/basemaps/README.md new file mode 100644 index 00000000..4ad70e71 --- /dev/null +++ b/static/basemaps/README.md @@ -0,0 +1,33 @@ +# Places basemap (optional) + +The **Places** photo map renders your geotagged photos as clusters. It works +out of the box **without** a basemap (clusters on a plain background). To get a +real street/terrain backdrop, drop a self-hosted vector basemap here — no +third-party tile API, fully offline. + +## How it works (Approach "A") + +OxiCloud already serves `static/` through `tower-http`'s `ServeDir`, which +honours **HTTP Range** requests. A [PMTiles](https://docs.protomaps.com/pmtiles/) +basemap is a *single file* read directly by the browser via Range — so the +basemap is just a static file the app already knows how to serve. No extra +backend, no tile server, no API keys. + +## Enabling it + +1. Get a Protomaps `.pmtiles` basemap (vector, ODbL OpenStreetMap data): + - Whole planet z0–15 (~120 GB) or a smaller global `z0-6` (~60 MB), or + - A **regional extract** (recommended — only the area you need, a few MB): + ```sh + # one-time, downloads only your bounding box from the remote planet + pmtiles extract https://build.protomaps.com/.pmtiles basemap.pmtiles \ + --bbox=,,, + ``` + See https://docs.protomaps.com/basemaps/downloads +2. Place it here as **`static/basemaps/basemap.pmtiles`** (this path is + git-ignored on purpose — see `.gitignore`). +3. Reload the Places view. The map will pick it up automatically. + +The bundled style is **label-light** (water / land / roads / buildings, no +text) so it needs no glyph/sprite assets. Attribution “© OpenStreetMap” +(ODbL) is shown automatically when a basemap is present. diff --git a/static/js/vendors/VERSIONS.txt b/static/js/vendors/VERSIONS.txt new file mode 100644 index 00000000..ed0a267c --- /dev/null +++ b/static/js/vendors/VERSIONS.txt @@ -0,0 +1,2 @@ +maplibre-gl 5.24.0 +pmtiles 4.4.1 diff --git a/static/js/vendors/maplibre-gl.css b/static/js/vendors/maplibre-gl.css new file mode 100644 index 00000000..6b4bfda7 --- /dev/null +++ b/static/js/vendors/maplibre-gl.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-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;-moz-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;position:absolute;z-index:2}.maplibregl-ctrl-top-left{left:0;top:0}.maplibregl-ctrl-top-right{right:0;top: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 rgba(0,0,0,.1)}@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-group button+button{border-top:1px solid #ddd}.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::-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:rgba(0,0,0,.05)}}.maplibregl-ctrl button:not(:disabled):active{background-color:rgba(0,0,0,.05)}.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: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")}}@keyframes maplibregl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}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}a.maplibregl-ctrl-logo.maplibregl-compact{width:14px}@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")}}.maplibregl-ctrl.maplibregl-ctrl-attrib{background-color:hsla(0,0%,100%,.5);margin:0;padding:0 5px}@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:hsla(0,0%,100%,.5);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:rgba(0,0,0,.05)}.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-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.maplibregl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.maplibregl-attrib-empty{display:none}.maplibregl-ctrl-scale{background-color:hsla(0,0%,100%,.75);border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px;white-space:nowrap}.maplibregl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.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{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}[dir=rtl] .maplibregl-popup-anchor-left{flex-direction:row-reverse}[dir=rtl] .maplibregl-popup-anchor-right{flex-direction:row}[dir=rtl] .maplibregl-popup-anchor-top-left .maplibregl-popup-tip{align-self:flex-end}[dir=rtl] .maplibregl-popup-anchor-top-right .maplibregl-popup-tip{align-self:flex-start}[dir=rtl] .maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip{align-self:flex-end}[dir=rtl] .maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip{align-self:flex-start}.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-close-button:hover{background-color:rgba(0,0,0,.05)}.maplibregl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:15px 10px;pointer-events:auto;position:relative}.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;-moz-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{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 rgba(0,0,0,.35);box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}@media (prefers-reduced-motion:reduce){.maplibregl-user-location-dot:before{animation:none}}@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%;height:1px;width:1px}.maplibregl-crosshair,.maplibregl-crosshair .maplibregl-interactive,.maplibregl-crosshair .maplibregl-interactive:active{cursor:crosshair}.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:rgba(0,0,0,.4);color:#fff;display:flex;font-size:1.4em;inset: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-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{height:100%!important;left:0!important;position:fixed!important;top:0!important;width:100%!important;z-index:99999} \ No newline at end of file diff --git a/static/js/vendors/maplibre-gl.js b/static/js/vendors/maplibre-gl.js new file mode 100644 index 00000000..c5acb688 --- /dev/null +++ b/static/js/vendors/maplibre-gl.js @@ -0,0 +1,59 @@ +/** + * MapLibre GL JS + * @license 3-Clause BSD. Full text of license: https://github.com/maplibre/maplibre-gl-js/blob/v5.24.0/LICENSE.txt + */ +(function (global, factory) { +typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : +typeof define === 'function' && define.amd ? define(factory) : +(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.maplibregl = factory()); +})(this, (function () { 'use strict'; + +/* eslint-disable */ + +var maplibregl = {}; +var modules = {}; +function define(moduleName, _dependencies, moduleFactory) { + modules[moduleName] = moduleFactory; + + // to get the list of modules see generated dist/maplibre-gl-dev.js file (look for `define(` calls) + if (moduleName !== 'index') { + return; + } + + // we assume that when an index module is initializing then other modules are loaded already + var workerBundleString = 'var sharedModule = {}; (' + modules.shared + ')(sharedModule); (' + modules.worker + ')(sharedModule);' + + var sharedModule = {}; + // the order of arguments of a module factory depends on rollup (it decides who is whose dependency) + // to check the correct order, see dist/maplibre-gl-dev.js file (look for `define(` calls) + // we assume that for our 3 chunks it will generate 3 modules and their order is predefined like the following + modules.shared(sharedModule); + modules.index(maplibregl, sharedModule); + + if (typeof window !== 'undefined') { + maplibregl.setWorkerUrl(window.URL.createObjectURL(new Blob([workerBundleString], { type: 'text/javascript' }))); + } + + return maplibregl; +}; + + + +define("shared",["exports"],(function(t){"use strict";function e(t,e,n,r){return new(n||(n=Promise))((function(i,s){function o(t){try{l(r.next(t));}catch(t){s(t);}}function a(t){try{l(r.throw(t));}catch(t){s(t);}}function l(t){var e;t.done?i(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e);}))).then(o,a);}l((r=r.apply(t,e||[])).next());}))}function n(t,e){this.x=t,this.y=e;}function r(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var i,s;"function"==typeof SuppressedError&&SuppressedError,n.prototype={clone(){return new n(this.x,this.y)},add(t){return this.clone()._add(t)},sub(t){return this.clone()._sub(t)},multByPoint(t){return this.clone()._multByPoint(t)},divByPoint(t){return this.clone()._divByPoint(t)},mult(t){return this.clone()._mult(t)},div(t){return this.clone()._div(t)},rotate(t){return this.clone()._rotate(t)},rotateAround(t,e){return this.clone()._rotateAround(t,e)},matMult(t){return this.clone()._matMult(t)},unit(){return this.clone()._unit()},perp(){return this.clone()._perp()},round(){return this.clone()._round()},mag(){return Math.sqrt(this.x*this.x+this.y*this.y)},equals(t){return this.x===t.x&&this.y===t.y},dist(t){return Math.sqrt(this.distSqr(t))},distSqr(t){const e=t.x-this.x,n=t.y-this.y;return e*e+n*n},angle(){return Math.atan2(this.y,this.x)},angleTo(t){return Math.atan2(this.y-t.y,this.x-t.x)},angleWith(t){return this.angleWithSep(t.x,t.y)},angleWithSep(t,e){return Math.atan2(this.x*e-this.y*t,this.x*t+this.y*e)},_matMult(t){const e=t[2]*this.x+t[3]*this.y;return this.x=t[0]*this.x+t[1]*this.y,this.y=e,this},_add(t){return this.x+=t.x,this.y+=t.y,this},_sub(t){return this.x-=t.x,this.y-=t.y,this},_mult(t){return this.x*=t,this.y*=t,this},_div(t){return this.x/=t,this.y/=t,this},_multByPoint(t){return this.x*=t.x,this.y*=t.y,this},_divByPoint(t){return this.x/=t.x,this.y/=t.y,this},_unit(){return this._div(this.mag()),this},_perp(){const t=this.y;return this.y=this.x,this.x=-t,this},_rotate(t){const e=Math.cos(t),n=Math.sin(t),r=n*this.x+e*this.y;return this.x=e*this.x-n*this.y,this.y=r,this},_rotateAround(t,e){const n=Math.cos(t),r=Math.sin(t),i=e.y+r*(this.x-e.x)+n*(this.y-e.y);return this.x=e.x+n*(this.x-e.x)-r*(this.y-e.y),this.y=i,this},_round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this},constructor:n},n.convert=function(t){if(t instanceof n)return t;if(Array.isArray(t))return new n(+t[0],+t[1]);if(void 0!==t.x&&void 0!==t.y)return new n(+t.x,+t.y);throw new Error("Expected [x, y] or {x, y} point format")};var o=function(){if(s)return i;function t(t,e,n,r){this.cx=3*t,this.bx=3*(n-t)-this.cx,this.ax=1-this.cx-this.bx,this.cy=3*e,this.by=3*(r-e)-this.cy,this.ay=1-this.cy-this.by,this.p1x=t,this.p1y=e,this.p2x=n,this.p2y=r;}return s=1,i=t,t.prototype={sampleCurveX:function(t){return ((this.ax*t+this.bx)*t+this.cx)*t},sampleCurveY:function(t){return ((this.ay*t+this.by)*t+this.cy)*t},sampleCurveDerivativeX:function(t){return (3*this.ax*t+2*this.bx)*t+this.cx},solveCurveX:function(t,e){if(void 0===e&&(e=1e-6),t<0)return 0;if(t>1)return 1;for(var n=t,r=0;r<8;r++){var i=this.sampleCurveX(n)-t;if(Math.abs(i)i?o=n:a=n,n=.5*(a-o)+o;return n},solve:function(t,e){return this.sampleCurveY(this.solveCurveX(t,e))}},i}(),a=r(o);let l,u;function c(){return null!=l||(l="undefined"!=typeof OffscreenCanvas&&new OffscreenCanvas(1,1).getContext("2d")&&"function"==typeof createImageBitmap),l}function h(){if(null==u&&(u=!1,c())){const t=5,e=new OffscreenCanvas(t,t).getContext("2d",{willReadFrequently:!0});if(e){for(let n=0;n4&&void 0!==arguments[4]?arguments[4]:"zyx",s=Math.PI/360;e*=s,r*=s,n*=s;var o=Math.sin(e),a=Math.cos(e),l=Math.sin(n),u=Math.cos(n),c=Math.sin(r),h=Math.cos(r);switch(i){case "xyz":t[0]=o*u*h+a*l*c,t[1]=a*l*h-o*u*c,t[2]=a*u*c+o*l*h,t[3]=a*u*h-o*l*c;break;case "xzy":t[0]=o*u*h-a*l*c,t[1]=a*l*h-o*u*c,t[2]=a*u*c+o*l*h,t[3]=a*u*h+o*l*c;break;case "yxz":t[0]=o*u*h+a*l*c,t[1]=a*l*h-o*u*c,t[2]=a*u*c-o*l*h,t[3]=a*u*h+o*l*c;break;case "yzx":t[0]=o*u*h+a*l*c,t[1]=a*l*h+o*u*c,t[2]=a*u*c-o*l*h,t[3]=a*u*h-o*l*c;break;case "zxy":t[0]=o*u*h-a*l*c,t[1]=a*l*h+o*u*c,t[2]=a*u*c+o*l*h,t[3]=a*u*h-o*l*c;break;case "zyx":t[0]=o*u*h-a*l*c,t[1]=a*l*h+o*u*c,t[2]=a*u*c-o*l*h,t[3]=a*u*h+o*l*c;break;default:throw new Error("Unknown angle order "+i)}return t}function I(){var t=new f(2);return f!=Float32Array&&(t[0]=0,t[1]=0),t}function E(t,e){var n=new f(2);return n[0]=t,n[1]=e,n}m(),_=new f(4),f!=Float32Array&&(_[0]=0,_[1]=0,_[2]=0,_[3]=0),m(),x(1,0,0),x(0,1,0),M(),M(),d(),I();const T=8192;function F(t,e,n){return e*(T/(t.tileSize*Math.pow(2,n-t.tileID.overscaledZ)))}function P(t){return t instanceof Error?t:new Error("string"==typeof t?t:String(t))}function D(t,e){return (t%e+e)%e}function z(t,e,n){return t*(1-n)+e*n}function B(t){if(t<=0)return 0;if(t>=1)return 1;const e=t*t,n=e*t;return 4*(t<.5?n:3*(t-e)+n-.75)}function C(t,e,n,r){const i=new a(t,e,n,r);return t=>i.solve(t)}const V=C(.25,.1,.25,1);function L(t,e,n){return Math.min(n,Math.max(e,t))}function O(t,e,n){const r=n-e,i=((t-e)%r+r)%r+e;return i===e?n:i}function $(t,...e){for(const n of e)for(const e in n)t[e]=n[e];return t}let R=1;function N(t,e,n){const r={};for(const n in t)r[n]=e.call(this,t[n],n,t);return r}function U(t,e,n){const r={};for(const n in t)e.call(this,t[n],n,t)&&(r[n]=t[n]);return r}function j(t){return Array.isArray(t)?t.map(j):"object"==typeof t&&t?N(t,j):t}const q={};function G(t){q[t]||("undefined"!=typeof console&&console.warn(t),q[t]=!0);}function X(t,e,n){return (n.y-t.y)*(e.x-t.x)>(e.y-t.y)*(n.x-t.x)}function Y(t){return "undefined"!=typeof WorkerGlobalScope&&void 0!==t&&t instanceof WorkerGlobalScope}let Z=null;function H(t){return "undefined"!=typeof ImageBitmap&&t instanceof ImageBitmap}const W="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQYV2NgAAIAAAUAAarVyFEAAAAASUVORK5CYII=";function K(t,n,r,i,s){return e(this,void 0,void 0,(function*(){if("undefined"==typeof VideoFrame)throw new Error("VideoFrame not supported");const e=new VideoFrame(t,{timestamp:0});try{const o=null==e?void 0:e.format;if(!o||!o.startsWith("BGR")&&!o.startsWith("RGB"))throw new Error(`Unrecognized format ${o}`);const a=o.startsWith("BGR"),l=new Uint8ClampedArray(i*s*4);if(yield e.copyTo(l,function(t,e,n,r,i){const s=4*Math.max(-e,0),o=(Math.max(0,n)-n)*r*4+s,a=4*r,l=Math.max(0,e),u=Math.max(0,n);return {rect:{x:l,y:u,width:Math.min(t.width,e+r)-l,height:Math.min(t.height,n+i)-u},layout:[{offset:o,stride:a}]}}(t,n,r,i,s)),a)for(let t=0;t{t.removeEventListener(e,n,r);}}}function et(t){return t*Math.PI/180}function nt(t){return t/Math.PI*180}const rt={touchstart:!0,touchmove:!0,touchmoveWindow:!0,touchend:!0,touchcancel:!0},it={dblclick:!0,click:!0,mouseover:!0,mouseout:!0,mousedown:!0,mousemove:!0,mousemoveWindow:!0,mouseup:!0,mouseupWindow:!0,contextmenu:!0,wheel:!0},st="AbortError";class ot extends Error{constructor(t=st){super(t instanceof Error?t.message:t),this.name=st,t instanceof Error&&t.stack&&(this.stack=t.stack);}}function at(t){return t instanceof Error&&t.name===st}function lt(t){if(t.aborted)throw new ot(t.reason)}const ut={MAX_PARALLEL_IMAGE_REQUESTS:16,MAX_PARALLEL_IMAGE_REQUESTS_PER_FRAME:8,MAX_TILE_CACHE_ZOOM_LEVELS:5,REGISTERED_PROTOCOLS:{},WORKER_URL:""};function ct(t){return ut.REGISTERED_PROTOCOLS[t.substring(0,t.indexOf("://"))]}const ht="global-dispatcher";class pt extends Error{constructor(t,e,n,r){super(`AJAXError: ${e} (${t}): ${n}`),this.status=t,this.statusText=e,this.url=n,this.body=r;}}const ft=()=>{var t;return Y(self)?null===(t=self.worker)||void 0===t?void 0:t.referrer:("blob:"===window.location.protocol?window.parent:window).location.href},dt=function(t,n){return e(this,void 0,void 0,(function*(){var r,i;if(t.url.includes("://")&&!/^https?:|^file:/.test(t.url)){const e=ct(t.url);if(e){const r=yield e(t,n);return r.data||"arrayBuffer"!==t.type?r:$(r,{data:new ArrayBuffer(0)})}if(Y(self)&&(null===(r=self.worker)||void 0===r?void 0:r.actor))return self.worker.actor.sendAsync({type:"GR",data:t,targetMapId:ht},n)}if(!(t=>{var e;return t.startsWith("file:")||(null===(e=ft())||void 0===e?void 0:e.startsWith("file:"))&&!/^\w+:/.test(t)})(t.url)){if(fetch&&Request&&AbortController&&Object.hasOwn(Request.prototype,"signal"))return function(t,n){return e(this,void 0,void 0,(function*(){const e=new Request(t.url,{method:t.method||"GET",body:t.body,credentials:t.credentials,headers:t.headers,cache:t.cache,referrer:ft(),referrerPolicy:t.referrerPolicy,signal:n.signal});let r,i;"json"!==t.type||e.headers.has("Accept")||e.headers.set("Accept","application/json");try{r=yield fetch(e);}catch(e){if(at(e))throw e;throw new pt(0,P(e).message,t.url,new Blob)}if(!r.ok){const e=yield r.blob();throw new pt(r.status,r.statusText,t.url,e)}i="arrayBuffer"===t.type||"image"===t.type?r.arrayBuffer():"json"===t.type?r.json():r.text();const s=yield i;return lt(n.signal),{data:s,cacheControl:r.headers.get("Cache-Control"),expires:r.headers.get("Expires"),etag:r.headers.get("ETag")}}))}(t,n);if(Y(self)&&(null===(i=self.worker)||void 0===i?void 0:i.actor))return self.worker.actor.sendAsync({type:"GR",data:t,mustQueue:!0,targetMapId:ht},n)}return function(t,e){return new Promise(((n,r)=>{var i;const s=new XMLHttpRequest;s.open(t.method||"GET",t.url,!0),"arrayBuffer"!==t.type&&"image"!==t.type||(s.responseType="arraybuffer");for(const e in t.headers)s.setRequestHeader(e,t.headers[e]);"json"===t.type&&(s.responseType="text",(null===(i=t.headers)||void 0===i?void 0:i.Accept)||s.setRequestHeader("Accept","application/json")),s.withCredentials="include"===t.credentials,s.onerror=()=>{r(new Error(s.statusText));},s.onload=()=>{if(!e.signal.aborted)if((s.status>=200&&s.status<300||0===s.status)&&null!==s.response){let e=s.response;if("json"===t.type)try{e=JSON.parse(s.response);}catch(t){return void r(t)}n({data:e,cacheControl:s.getResponseHeader("Cache-Control"),expires:s.getResponseHeader("Expires"),etag:s.getResponseHeader("ETag")});}else {const e=new Blob([s.response],{type:s.getResponseHeader("Content-Type")});r(new pt(s.status,s.statusText,t.url,e));}},e.signal.addEventListener("abort",(()=>{s.abort(),r(new ot(e.signal.reason));})),s.send(t.body);}))}(t,n)}))};function yt(t){if(!t||t.indexOf("://")<=0||t.startsWith("data:image/")||t.startsWith("blob:"))return !0;const e=new URL(t),n=window.location;return e.protocol===n.protocol&&e.host===n.host}function mt(t,e,n){var r;(null===(r=n[t])||void 0===r?void 0:r.includes(e))||(n[t]||(n[t]=[]),n[t].push(e));}function gt(t,e,n){if(null==n?void 0:n[t]){const r=n[t].indexOf(e);-1!==r&&n[t].splice(r,1);}}class xt{constructor(t,e={}){$(this,e),this.type=t;}}class vt extends xt{constructor(t,e={}){super("error",$({error:t},e));}}class bt{on(t,e){return this._listeners||(this._listeners={}),mt(t,e,this._listeners),{unsubscribe:()=>{this.off(t,e);}}}off(t,e){return gt(t,e,this._listeners),gt(t,e,this._oneTimeListeners),this}once(t,e){return e?(this._oneTimeListeners||(this._oneTimeListeners={}),mt(t,e,this._oneTimeListeners),this):new Promise((e=>this.once(t,e)))}fire(t,e){var n,r;"string"==typeof t&&(t=new xt(t,e||{}));const i=t.type;if(this.listens(i)){t.target=this;const e=(null===(n=this._listeners)||void 0===n?void 0:n[i])?this._listeners[i].slice():[];for(const n of e)n.call(this,t);const s=(null===(r=this._oneTimeListeners)||void 0===r?void 0:r[i])?this._oneTimeListeners[i].slice():[];for(const e of s)gt(i,e,this._oneTimeListeners),e.call(this,t);const o=this._eventedParent;o&&($(t,"function"==typeof this._eventedParentData?this._eventedParentData():this._eventedParentData),o.fire(t));}else t instanceof vt&&console.error(t.error);return this}listens(t){var e,n,r,i,s;return (null===(n=null===(e=this._listeners)||void 0===e?void 0:e[t])||void 0===n?void 0:n.length)>0||(null===(i=null===(r=this._oneTimeListeners)||void 0===r?void 0:r[t])||void 0===i?void 0:i.length)>0||(null===(s=this._eventedParent)||void 0===s?void 0:s.listens(t))}setEventedParent(t,e){return this._eventedParent=t,this._eventedParentData=e,this}}var wt={$version:8,$root:{version:{required:!0,type:"enum",values:[8]},name:{type:"string"},metadata:{type:"*"},center:{type:"array",value:"number",length:2},centerAltitude:{type:"number"},zoom:{type:"number"},bearing:{type:"number",default:0,period:360,units:"degrees"},pitch:{type:"number",default:0,units:"degrees"},roll:{type:"number",default:0,units:"degrees"},state:{type:"state",default:{}},light:{type:"light"},sky:{type:"sky"},projection:{type:"projection"},terrain:{type:"terrain"},sources:{required:!0,type:"sources"},sprite:{type:"sprite"},glyphs:{type:"string"},"font-faces":{type:"fontFaces"},transition:{type:"transition"},layers:{required:!0,type:"array",value:"layer"}},sources:{"*":{type:"source"}},source:["source_vector","source_raster","source_raster_dem","source_geojson","source_video","source_image"],source_vector:{type:{required:!0,type:"enum",values:{vector:{}}},url:{type:"string"},tiles:{type:"array",value:"string"},bounds:{type:"array",value:"number",length:4,default:[-180,-85.051129,180,85.051129]},scheme:{type:"enum",values:{xyz:{},tms:{}},default:"xyz"},minzoom:{type:"number",default:0},maxzoom:{type:"number",default:22},attribution:{type:"string"},promoteId:{type:"promoteId"},volatile:{type:"boolean",default:!1},encoding:{type:"enum",values:{mvt:{},mlt:{}},default:"mvt"},"*":{type:"*"}},source_raster:{type:{required:!0,type:"enum",values:{raster:{}}},url:{type:"string"},tiles:{type:"array",value:"string"},bounds:{type:"array",value:"number",length:4,default:[-180,-85.051129,180,85.051129]},minzoom:{type:"number",default:0},maxzoom:{type:"number",default:22},tileSize:{type:"number",default:512,units:"pixels"},scheme:{type:"enum",values:{xyz:{},tms:{}},default:"xyz"},attribution:{type:"string"},volatile:{type:"boolean",default:!1},"*":{type:"*"}},source_raster_dem:{type:{required:!0,type:"enum",values:{"raster-dem":{}}},url:{type:"string"},tiles:{type:"array",value:"string"},bounds:{type:"array",value:"number",length:4,default:[-180,-85.051129,180,85.051129]},minzoom:{type:"number",default:0},maxzoom:{type:"number",default:22},tileSize:{type:"number",default:512,units:"pixels"},attribution:{type:"string"},encoding:{type:"enum",values:{terrarium:{},mapbox:{},custom:{}},default:"mapbox"},redFactor:{type:"number",default:1},blueFactor:{type:"number",default:1},greenFactor:{type:"number",default:1},baseShift:{type:"number",default:0},volatile:{type:"boolean",default:!1},"*":{type:"*"}},source_geojson:{type:{required:!0,type:"enum",values:{geojson:{}}},data:{required:!0,type:"*"},maxzoom:{type:"number",default:18},attribution:{type:"string"},buffer:{type:"number",default:128,maximum:512,minimum:0},filter:{type:"filter"},tolerance:{type:"number",default:.375},cluster:{type:"boolean",default:!1},clusterRadius:{type:"number",default:50,minimum:0},clusterMaxZoom:{type:"number"},clusterMinPoints:{type:"number"},clusterProperties:{type:"*"},lineMetrics:{type:"boolean",default:!1},generateId:{type:"boolean",default:!1},promoteId:{type:"promoteId"}},source_video:{type:{required:!0,type:"enum",values:{video:{}}},urls:{required:!0,type:"array",value:"string"},coordinates:{required:!0,type:"array",length:4,value:{type:"array",length:2,value:"number"}}},source_image:{type:{required:!0,type:"enum",values:{image:{}}},url:{required:!0,type:"string"},coordinates:{required:!0,type:"array",length:4,value:{type:"array",length:2,value:"number"}}},layer:{id:{type:"string",required:!0},type:{type:"enum",values:{fill:{},line:{},symbol:{},circle:{},heatmap:{},"fill-extrusion":{},raster:{},hillshade:{},"color-relief":{},background:{}},required:!0},metadata:{type:"*"},source:{type:"string"},"source-layer":{type:"string"},minzoom:{type:"number",minimum:0,maximum:24},maxzoom:{type:"number",minimum:0,maximum:24},filter:{type:"filter"},layout:{type:"layout"},paint:{type:"paint"}},layout:["layout_fill","layout_line","layout_circle","layout_heatmap","layout_fill-extrusion","layout_symbol","layout_raster","layout_hillshade","layout_color-relief","layout_background"],layout_background:{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible",expression:{interpolated:!1,parameters:["global-state"]},"property-type":"data-constant"}},layout_fill:{"fill-sort-key":{type:"number",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},visibility:{type:"enum",values:{visible:{},none:{}},default:"visible",expression:{interpolated:!1,parameters:["global-state"]},"property-type":"data-constant"}},layout_circle:{"circle-sort-key":{type:"number",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},visibility:{type:"enum",values:{visible:{},none:{}},default:"visible",expression:{interpolated:!1,parameters:["global-state"]},"property-type":"data-constant"}},layout_heatmap:{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible",expression:{interpolated:!1,parameters:["global-state"]},"property-type":"data-constant"}},"layout_fill-extrusion":{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible",expression:{interpolated:!1,parameters:["global-state"]},"property-type":"data-constant"}},layout_line:{"line-cap":{type:"enum",values:{butt:{},round:{},square:{}},default:"butt",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"line-join":{type:"enum",values:{bevel:{},round:{},miter:{}},default:"miter",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"line-miter-limit":{type:"number",default:2,requires:[{"line-join":"miter"}],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"line-round-limit":{type:"number",default:1.05,requires:[{"line-join":"round"}],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"line-sort-key":{type:"number",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},visibility:{type:"enum",values:{visible:{},none:{}},default:"visible",expression:{interpolated:!1,parameters:["global-state"]},"property-type":"data-constant"}},layout_symbol:{"symbol-placement":{type:"enum",values:{point:{},line:{},"line-center":{}},default:"point",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"symbol-spacing":{type:"number",default:250,minimum:1,units:"pixels",requires:[{"symbol-placement":"line"}],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"symbol-avoid-edges":{type:"boolean",default:!1,expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"symbol-sort-key":{type:"number",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"symbol-z-order":{type:"enum",values:{auto:{},"viewport-y":{},source:{}},default:"auto",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-allow-overlap":{type:"boolean",default:!1,requires:["icon-image",{"!":"icon-overlap"}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-overlap":{type:"enum",values:{never:{},always:{},cooperative:{}},requires:["icon-image"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-ignore-placement":{type:"boolean",default:!1,requires:["icon-image"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-optional":{type:"boolean",default:!1,requires:["icon-image","text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-rotation-alignment":{type:"enum",values:{map:{},viewport:{},auto:{}},default:"auto",requires:["icon-image"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-size":{type:"number",default:1,minimum:0,units:"factor of the original icon size",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-text-fit":{type:"enum",values:{none:{},width:{},height:{},both:{}},default:"none",requires:["icon-image","text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-text-fit-padding":{type:"array",value:"number",length:4,default:[0,0,0,0],units:"pixels",requires:["icon-image","text-field",{"icon-text-fit":["both","width","height"]}],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"icon-image":{type:"resolvedImage",tokens:!0,expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-rotate":{type:"number",default:0,period:360,units:"degrees",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-padding":{type:"padding",default:[2],units:"pixels",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-keep-upright":{type:"boolean",default:!1,requires:["icon-image",{"icon-rotation-alignment":"map"},{"symbol-placement":["line","line-center"]}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"icon-offset":{type:"array",value:"number",length:2,default:[0,0],requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-anchor":{type:"enum",values:{center:{},left:{},right:{},top:{},bottom:{},"top-left":{},"top-right":{},"bottom-left":{},"bottom-right":{}},default:"center",requires:["icon-image"],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"icon-pitch-alignment":{type:"enum",values:{map:{},viewport:{},auto:{}},default:"auto",requires:["icon-image"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-pitch-alignment":{type:"enum",values:{map:{},viewport:{},auto:{}},default:"auto",requires:["text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-rotation-alignment":{type:"enum",values:{map:{},viewport:{},"viewport-glyph":{},auto:{}},default:"auto",requires:["text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-field":{type:"formatted",default:"",tokens:!0,expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-font":{type:"array",value:"string",default:["Open Sans Regular","Arial Unicode MS Regular"],requires:["text-field"],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-size":{type:"number",default:16,minimum:0,units:"pixels",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-max-width":{type:"number",default:10,minimum:0,units:"ems",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-line-height":{type:"number",default:1.2,units:"ems",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"text-letter-spacing":{type:"number",default:0,units:"ems",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-justify":{type:"enum",values:{auto:{},left:{},center:{},right:{}},default:"center",requires:["text-field"],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-radial-offset":{type:"number",units:"ems",default:0,requires:["text-field"],"property-type":"data-driven",expression:{interpolated:!0,parameters:["zoom","feature"]}},"text-variable-anchor":{type:"array",value:"enum",values:{center:{},left:{},right:{},top:{},bottom:{},"top-left":{},"top-right":{},"bottom-left":{},"bottom-right":{}},requires:["text-field",{"symbol-placement":["point"]}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-variable-anchor-offset":{type:"variableAnchorOffsetCollection",requires:["text-field",{"symbol-placement":["point"]}],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-anchor":{type:"enum",values:{center:{},left:{},right:{},top:{},bottom:{},"top-left":{},"top-right":{},"bottom-left":{},"bottom-right":{}},default:"center",requires:["text-field",{"!":"text-variable-anchor"}],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-max-angle":{type:"number",default:45,units:"degrees",requires:["text-field",{"symbol-placement":["line","line-center"]}],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"text-writing-mode":{type:"array",value:"enum",values:{horizontal:{},vertical:{}},requires:["text-field",{"symbol-placement":["point"]}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-rotate":{type:"number",default:0,period:360,units:"degrees",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-padding":{type:"number",default:2,minimum:0,units:"pixels",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"text-keep-upright":{type:"boolean",default:!0,requires:["text-field",{"text-rotation-alignment":"map"},{"symbol-placement":["line","line-center"]}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-transform":{type:"enum",values:{none:{},uppercase:{},lowercase:{}},default:"none",requires:["text-field"],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-offset":{type:"array",value:"number",units:"ems",length:2,default:[0,0],requires:["text-field",{"!":"text-radial-offset"}],expression:{interpolated:!0,parameters:["zoom","feature"]},"property-type":"data-driven"},"text-allow-overlap":{type:"boolean",default:!1,requires:["text-field",{"!":"text-overlap"}],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-overlap":{type:"enum",values:{never:{},always:{},cooperative:{}},requires:["text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-ignore-placement":{type:"boolean",default:!1,requires:["text-field"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-optional":{type:"boolean",default:!1,requires:["text-field","icon-image"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},visibility:{type:"enum",values:{visible:{},none:{}},default:"visible",expression:{interpolated:!1,parameters:["global-state"]},"property-type":"data-constant"}},layout_raster:{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible",expression:{interpolated:!1,parameters:["global-state"]},"property-type":"data-constant"}},layout_hillshade:{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible",expression:{interpolated:!1,parameters:["global-state"]},"property-type":"data-constant"}},"layout_color-relief":{visibility:{type:"enum",values:{visible:{},none:{}},default:"visible",expression:{interpolated:!1,parameters:["global-state"]},"property-type":"data-constant"}},filter:{type:"boolean",expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"data-driven"},filter_operator:{type:"enum",values:{"==":{},"!=":{},">":{},">=":{},"<":{},"<=":{},in:{},"!in":{},all:{},any:{},none:{},has:{},"!has":{}}},geometry_type:{type:"enum",values:{Point:{},LineString:{},Polygon:{}}},function:{expression:{type:"expression"},stops:{type:"array",value:"function_stop"},base:{type:"number",default:1,minimum:0},property:{type:"string",default:"$zoom"},type:{type:"enum",values:{identity:{},exponential:{},interval:{},categorical:{}},default:"exponential"},colorSpace:{type:"enum",values:{rgb:{},lab:{},hcl:{}},default:"rgb"},default:{type:"*",required:!1}},function_stop:{type:"array",minimum:0,maximum:24,value:["number","color"],length:2},expression:{type:"array",value:"expression_name",minimum:1},light:{anchor:{type:"enum",default:"viewport",values:{map:{},viewport:{}},"property-type":"data-constant",transition:!1,expression:{interpolated:!1,parameters:["zoom"]}},position:{type:"array",default:[1.15,210,30],length:3,value:"number","property-type":"data-constant",transition:!0,expression:{interpolated:!0,parameters:["zoom"]}},color:{type:"color","property-type":"data-constant",default:"#ffffff",expression:{interpolated:!0,parameters:["zoom"]},transition:!0},intensity:{type:"number","property-type":"data-constant",default:.5,minimum:0,maximum:1,expression:{interpolated:!0,parameters:["zoom"]},transition:!0}},sky:{"sky-color":{type:"color","property-type":"data-constant",default:"#88C6FC",expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"horizon-color":{type:"color","property-type":"data-constant",default:"#ffffff",expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"fog-color":{type:"color","property-type":"data-constant",default:"#ffffff",expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"fog-ground-blend":{type:"number","property-type":"data-constant",default:.5,minimum:0,maximum:1,expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"horizon-fog-blend":{type:"number","property-type":"data-constant",default:.8,minimum:0,maximum:1,expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"sky-horizon-blend":{type:"number","property-type":"data-constant",default:.8,minimum:0,maximum:1,expression:{interpolated:!0,parameters:["zoom"]},transition:!0},"atmosphere-blend":{type:"number","property-type":"data-constant",default:.8,minimum:0,maximum:1,expression:{interpolated:!0,parameters:["zoom"]},transition:!0}},terrain:{source:{type:"string",required:!0},exaggeration:{type:"number",minimum:0,default:1}},projection:{type:{type:"projectionDefinition",default:"mercator","property-type":"data-constant",transition:!1,expression:{interpolated:!0,parameters:["zoom"]}}},paint:["paint_fill","paint_line","paint_circle","paint_heatmap","paint_fill-extrusion","paint_symbol","paint_raster","paint_hillshade","paint_color-relief","paint_background"],paint_fill:{"fill-antialias":{type:"boolean",default:!0,expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"fill-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-color":{type:"color",default:"#000000",transition:!0,requires:[{"!":"fill-pattern"}],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-outline-color":{type:"color",transition:!0,requires:[{"!":"fill-pattern"},{"fill-antialias":!0}],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"fill-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["fill-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"fill-pattern":{type:"resolvedImage",transition:!0,expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"cross-faded-data-driven"}},"paint_fill-extrusion":{"fill-extrusion-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"fill-extrusion-color":{type:"color",default:"#000000",transition:!0,requires:[{"!":"fill-extrusion-pattern"}],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-extrusion-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"fill-extrusion-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["fill-extrusion-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"fill-extrusion-pattern":{type:"resolvedImage",transition:!0,expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"cross-faded-data-driven"},"fill-extrusion-height":{type:"number",default:0,minimum:0,units:"meters",transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-extrusion-base":{type:"number",default:0,minimum:0,units:"meters",transition:!0,requires:["fill-extrusion-height"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"fill-extrusion-vertical-gradient":{type:"boolean",default:!0,transition:!1,expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"}},paint_line:{"line-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-color":{type:"color",default:"#000000",transition:!0,requires:[{"!":"line-pattern"}],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"line-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["line-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"line-width":{type:"number",default:1,minimum:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-gap-width":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-offset":{type:"number",default:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-blur":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"line-dasharray":{type:"array",value:"number",minimum:0,transition:!0,units:"line widths",requires:[{"!":"line-pattern"}],expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"cross-faded-data-driven"},"line-pattern":{type:"resolvedImage",transition:!0,expression:{interpolated:!1,parameters:["zoom","feature"]},"property-type":"cross-faded-data-driven"},"line-gradient":{type:"color",transition:!1,requires:[{"!":"line-dasharray"},{"!":"line-pattern"},{source:"geojson",has:{lineMetrics:!0}}],expression:{interpolated:!0,parameters:["line-progress"]},"property-type":"color-ramp"}},paint_circle:{"circle-radius":{type:"number",default:5,minimum:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-color":{type:"color",default:"#000000",transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-blur":{type:"number",default:0,transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"circle-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["circle-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"circle-pitch-scale":{type:"enum",values:{map:{},viewport:{}},default:"map",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"circle-pitch-alignment":{type:"enum",values:{map:{},viewport:{}},default:"viewport",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"circle-stroke-width":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-stroke-color":{type:"color",default:"#000000",transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"circle-stroke-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"}},paint_heatmap:{"heatmap-radius":{type:"number",default:30,minimum:1,transition:!0,units:"pixels",expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"heatmap-weight":{type:"number",default:1,minimum:0,transition:!1,expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"heatmap-intensity":{type:"number",default:1,minimum:0,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"heatmap-color":{type:"color",default:["interpolate",["linear"],["heatmap-density"],0,"rgba(0, 0, 255, 0)",.1,"royalblue",.3,"cyan",.5,"lime",.7,"yellow",1,"red"],transition:!1,expression:{interpolated:!0,parameters:["heatmap-density"]},"property-type":"color-ramp"},"heatmap-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"}},paint_symbol:{"icon-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"icon-color":{type:"color",default:"#000000",transition:!0,requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"icon-halo-color":{type:"color",default:"rgba(0, 0, 0, 0)",transition:!0,requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"icon-halo-width":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"icon-halo-blur":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"icon-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",requires:["icon-image"],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"icon-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["icon-image","icon-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"text-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"text-color":{type:"color",default:"#000000",transition:!0,overridable:!0,requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"text-halo-color":{type:"color",default:"rgba(0, 0, 0, 0)",transition:!0,requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"text-halo-width":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"text-halo-blur":{type:"number",default:0,minimum:0,transition:!0,units:"pixels",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom","feature","feature-state"]},"property-type":"data-driven"},"text-translate":{type:"array",value:"number",length:2,default:[0,0],transition:!0,units:"pixels",requires:["text-field"],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"text-translate-anchor":{type:"enum",values:{map:{},viewport:{}},default:"map",requires:["text-field","text-translate"],expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"}},paint_raster:{"raster-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-hue-rotate":{type:"number",default:0,period:360,transition:!0,units:"degrees",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-brightness-min":{type:"number",default:0,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-brightness-max":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-saturation":{type:"number",default:0,minimum:-1,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"raster-contrast":{type:"number",default:0,minimum:-1,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},resampling:{type:"enum",values:{linear:{},nearest:{}},default:"linear",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"raster-resampling":{type:"enum",values:{linear:{},nearest:{}},default:"linear",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"raster-fade-duration":{type:"number",default:300,minimum:0,transition:!1,units:"milliseconds",expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"}},paint_hillshade:{"hillshade-illumination-direction":{type:"numberArray",default:335,minimum:0,maximum:359,transition:!1,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-illumination-altitude":{type:"numberArray",default:45,minimum:0,maximum:90,transition:!1,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-illumination-anchor":{type:"enum",values:{map:{},viewport:{}},default:"viewport",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-exaggeration":{type:"number",default:.5,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-shadow-color":{type:"colorArray",default:"#000000",transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-highlight-color":{type:"colorArray",default:"#FFFFFF",transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-accent-color":{type:"color",default:"#000000",transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"hillshade-method":{type:"enum",values:{standard:{},basic:{},combined:{},igor:{},multidirectional:{}},default:"standard",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"},resampling:{type:"enum",values:{linear:{},nearest:{}},default:"linear",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"}},"paint_color-relief":{"color-relief-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"color-relief-color":{type:"color",transition:!1,expression:{interpolated:!0,parameters:["elevation"]},"property-type":"color-ramp"},resampling:{type:"enum",values:{linear:{},nearest:{}},default:"linear",expression:{interpolated:!1,parameters:["zoom"]},"property-type":"data-constant"}},paint_background:{"background-color":{type:"color",default:"#000000",transition:!0,requires:[{"!":"background-pattern"}],expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"},"background-pattern":{type:"resolvedImage",transition:!0,expression:{interpolated:!1,parameters:["zoom"]},"property-type":"cross-faded"},"background-opacity":{type:"number",default:1,minimum:0,maximum:1,transition:!0,expression:{interpolated:!0,parameters:["zoom"]},"property-type":"data-constant"}},transition:{duration:{type:"number",default:300,minimum:0,units:"milliseconds"},delay:{type:"number",default:0,minimum:0,units:"milliseconds"}},"property-type":{"data-driven":{type:"property-type"},"cross-faded":{type:"property-type"},"cross-faded-data-driven":{type:"property-type"},"color-ramp":{type:"property-type"},"data-constant":{type:"property-type"},constant:{type:"property-type"}},promoteId:{"*":{type:"string"}},interpolation:{type:"array",value:"interpolation_name",minimum:1},interpolation_name:{type:"enum",values:{linear:{syntax:{overloads:[{parameters:[],"output-type":"interpolation"}],parameters:[]}},exponential:{syntax:{overloads:[{parameters:["base"],"output-type":"interpolation"}],parameters:[{name:"base",type:"number literal"}]}},"cubic-bezier":{syntax:{overloads:[{parameters:["x1","y1","x2","y2"],"output-type":"interpolation"}],parameters:[{name:"x1",type:"number literal"},{name:"y1",type:"number literal"},{name:"x2",type:"number literal"},{name:"y2",type:"number literal"}]}}}}};const _t=["type","source","source-layer","minzoom","maxzoom","filter","layout"];function St(t,e){const n={};for(const e in t)"ref"!==e&&(n[e]=t[e]);return _t.forEach((t=>{t in e&&(n[t]=e[t]);})),n}function At(t,e){if(Array.isArray(t)){if(!Array.isArray(e)||t.length!==e.length)return !1;for(let n=0;n`:"value"===t.itemType.kind?"array":`array<${e}>`}return t.kind}const te=[Lt,Ot,$t,Rt,Nt,Ut,Xt,jt,Jt(qt),Yt,Ht,Zt,Wt,Kt];function ee(t,e){if("error"===e.kind)return null;if("array"===t.kind){if("array"===e.kind&&(0===e.N&&"value"===e.itemType.kind||!ee(t.itemType,e.itemType))&&("number"!=typeof t.N||t.N===e.N))return null}else {if(t.kind===e.kind)return null;if("value"===t.kind)for(const t of te)if(!ee(t,e))return null}return `Expected ${Qt(t)} but found ${Qt(e)} instead.`}function ne(t,e){return e.some((e=>e.kind===t.kind))}function re(t,e){return e.some((e=>"null"===e?null===t:"array"===e?Array.isArray(t):"object"===e?t&&!Array.isArray(t)&&"object"==typeof t:e===typeof t))}function ie(t,e){return "array"===t.kind&&"array"===e.kind?t.itemType.kind===e.itemType.kind&&"number"==typeof t.N:t.kind===e.kind}const se=.96422,oe=.82521,ae=4/29,le=6/29,ue=3*le*le,ce=le*le*le,he=Math.PI/180,pe=180/Math.PI;function fe(t){return (t%=360)<0&&(t+=360),t}function de([t,e,n,r]){let i,s;const o=me((.2225045*(t=ye(t))+.7168786*(e=ye(e))+.0606169*(n=ye(n)))/1);t===e&&e===n?i=s=o:(i=me((.4360747*t+.3850649*e+.1430804*n)/se),s=me((.0139322*t+.0971045*e+.7141733*n)/oe));const a=116*o-16;return [a<0?0:a,500*(i-o),200*(o-s),r]}function ye(t){return t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function me(t){return t>ce?Math.pow(t,1/3):t/ue+ae}function ge([t,e,n,r]){let i=(t+16)/116,s=isNaN(e)?i:i+e/500,o=isNaN(n)?i:i-n/200;return i=1*ve(i),s=se*ve(s),o=oe*ve(o),[xe(3.1338561*s-1.6168667*i-.4906146*o),xe(-.9787684*s+1.9161415*i+.033454*o),xe(.0719453*s-.2289914*i+1.4052427*o),r]}function xe(t){return (t=t<=.00304?12.92*t:1.055*Math.pow(t,1/2.4)-.055)<0?0:t>1?1:t}function ve(t){return t>le?t*t*t:ue*(t-ae)}const be=Object.hasOwn||function(t,e){return Object.prototype.hasOwnProperty.call(t,e)};function we(t,e){return be(t,e)?t[e]:void 0}function _e(t){return parseInt(t.padEnd(2,t),16)/255}function Se(t,e){return Ae(e?t/100:t,0,1)}function Ae(t,e,n){return Math.min(Math.max(e,t),n)}function Me(t){return !t.some(Number.isNaN)}const ke={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]};function Ie(t,e,n){return t+n*(e-t)}function Ee(t,e,n){return t.map(((t,r)=>Ie(t,e[r],n)))}class Te{constructor(t,e,n,r=1,i=!0){this.r=t,this.g=e,this.b=n,this.a=r,i||(this.r*=r,this.g*=r,this.b*=r,r||this.overwriteGetter("rgb",[t,e,n,r]));}static parse(t){if(t instanceof Te)return t;if("string"!=typeof t)return;const e=function(t){if("transparent"===(t=t.toLowerCase().trim()))return [0,0,0,0];const e=we(ke,t);if(e){const[t,n,r]=e;return [t/255,n/255,r/255,1]}if(t.startsWith("#")&&/^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(t)){const e=t.length<6?1:2;let n=1;return [_e(t.slice(n,n+=e)),_e(t.slice(n,n+=e)),_e(t.slice(n,n+=e)),_e(t.slice(n,n+e)||"ff")]}if(t.startsWith("rgb")){const e=t.match(/^rgba?\(\s*([\de.+-]+)(%)?(?:\s+|\s*(,)\s*)([\de.+-]+)(%)?(?:\s+|\s*(,)\s*)([\de.+-]+)(%)?(?:\s*([,\/])\s*([\de.+-]+)(%)?)?\s*\)$/);if(e){const[t,n,r,i,s,o,a,l,u,c,h,p]=e,f=[i||" ",a||" ",c].join("");if(" "===f||" /"===f||",,"===f||",,,"===f){const t=[r,o,u].join(""),e="%%%"===t?100:""===t?255:0;if(e){const t=[Ae(+n/e,0,1),Ae(+s/e,0,1),Ae(+l/e,0,1),h?Se(+h,p):1];if(Me(t))return t}}return}}const n=t.match(/^hsla?\(\s*([\de.+-]+)(?:deg)?(?:\s+|\s*(,)\s*)([\de.+-]+)%(?:\s+|\s*(,)\s*)([\de.+-]+)%(?:\s*([,\/])\s*([\de.+-]+)(%)?)?\s*\)$/);if(n){const[t,e,r,i,s,o,a,l,u]=n,c=[r||" ",s||" ",a].join("");if(" "===c||" /"===c||",,"===c||",,,"===c){const t=[+e,Ae(+i,0,100),Ae(+o,0,100),l?Se(+l,u):1];if(Me(t))return function([t,e,n,r]){function i(r){const i=(r+t/30)%12,s=e*Math.min(n,1-n);return n-s*Math.max(-1,Math.min(i-3,9-i,1))}return t=fe(t),e/=100,n/=100,[i(0),i(8),i(4),r]}(t)}}}(t);return e?new Te(...e,!1):void 0}get rgb(){const{r:t,g:e,b:n,a:r}=this,i=r||1/0;return this.overwriteGetter("rgb",[t/i,e/i,n/i,r])}get hcl(){return this.overwriteGetter("hcl",function(t){const[e,n,r,i]=de(t),s=Math.sqrt(n*n+r*r);return [Math.round(1e4*s)?fe(Math.atan2(r,n)*pe):NaN,s,e,i]}(this.rgb))}get lab(){return this.overwriteGetter("lab",de(this.rgb))}overwriteGetter(t,e){return Object.defineProperty(this,t,{value:e}),e}toString(){const[t,e,n,r]=this.rgb;return `rgba(${[t,e,n].map((t=>Math.round(255*t))).join(",")},${r})`}static interpolate(t,e,n,r="rgb"){switch(r){case "rgb":{const[r,i,s,o]=Ee(t.rgb,e.rgb,n);return new Te(r,i,s,o,!1)}case "hcl":{const[r,i,s,o]=t.hcl,[a,l,u,c]=e.hcl;let h,p;if(isNaN(r)||isNaN(a))isNaN(r)?isNaN(a)?h=NaN:(h=a,1!==s&&0!==s||(p=l)):(h=r,1!==u&&0!==u||(p=i));else {let t=a-r;a>r&&t>180?t-=360:a180&&(t+=360),h=r+n*t;}const[f,d,y,m]=function([t,e,n,r]){return t=isNaN(t)?0:t*he,ge([n,Math.cos(t)*e,Math.sin(t)*e,r])}([h,null!=p?p:Ie(i,l,n),Ie(s,u,n),Ie(o,c,n)]);return new Te(f,d,y,m,!1)}case "lab":{const[r,i,s,o]=ge(Ee(t.lab,e.lab,n));return new Te(r,i,s,o,!1)}}}}Te.black=new Te(0,0,0,1),Te.white=new Te(1,1,1,1),Te.transparent=new Te(0,0,0,0),Te.red=new Te(1,0,0,1);class Fe{constructor(t,e,n){this.sensitivity=t?e?"variant":"case":e?"accent":"base",this.locale=n,this.collator=new Intl.Collator(this.locale?this.locale:[],{sensitivity:this.sensitivity,usage:"search"});}compare(t,e){return this.collator.compare(t,e)}resolvedLocale(){return new Intl.Collator(this.locale?this.locale:[]).resolvedOptions().locale}}const Pe=["bottom","center","top"];class De{constructor(t,e,n,r,i,s){this.text=t,this.image=e,this.scale=n,this.fontStack=r,this.textColor=i,this.verticalAlign=s;}}class ze{constructor(t){this.sections=t;}static fromString(t){return new ze([new De(t,null,null,null,null,null)])}isEmpty(){return 0===this.sections.length||!this.sections.some((t=>0!==t.text.length||t.image&&0!==t.image.name.length))}static factory(t){return t instanceof ze?t:ze.fromString(t)}toString(){return 0===this.sections.length?"":this.sections.map((t=>t.text)).join("")}}class Be{constructor(t){this.values=t.slice();}static parse(t){if(t instanceof Be)return t;if("number"==typeof t)return new Be([t,t,t,t]);if(Array.isArray(t)&&!(t.length<1||t.length>4)){for(const e of t)if("number"!=typeof e)return;switch(t.length){case 1:t=[t[0],t[0],t[0],t[0]];break;case 2:t=[t[0],t[1],t[0],t[1]];break;case 3:t=[t[0],t[1],t[2],t[1]];}return new Be(t)}}toString(){return JSON.stringify(this.values)}static interpolate(t,e,n){return new Be(Ee(t.values,e.values,n))}}class Ce{constructor(t){this.values=t.slice();}static parse(t){if(t instanceof Ce)return t;if("number"==typeof t)return new Ce([t]);if(Array.isArray(t)){for(const e of t)if("number"!=typeof e)return;return new Ce(t)}}toString(){return JSON.stringify(this.values)}static interpolate(t,e,n){return new Ce(Ee(t.values,e.values,n))}}class Ve{constructor(t){this.values=t.slice();}static parse(t){if(t instanceof Ve)return t;if("string"==typeof t){const e=Te.parse(t);if(!e)return;return new Ve([e])}if(!Array.isArray(t))return;const e=[];for(const n of t){if("string"!=typeof n)return;const t=Te.parse(n);if(!t)return;e.push(t);}return new Ve(e)}toString(){return JSON.stringify(this.values)}static interpolate(t,e,n,r="rgb"){const i=[];if(t.values.length!=e.values.length)throw new Error(`colorArray: Arrays have mismatched length (${t.values.length} vs. ${e.values.length}), cannot interpolate.`);for(let s=0;s=0&&t<=255&&"number"==typeof e&&e>=0&&e<=255&&"number"==typeof n&&n>=0&&n<=255?void 0===r||"number"==typeof r&&r>=0&&r<=1?null:`Invalid rgba value [${[t,e,n,r].join(", ")}]: 'a' must be between 0 and 1.`:`Invalid rgba value [${("number"==typeof r?[t,e,n,r]:[t,e,n]).join(", ")}]: 'r', 'g', and 'b' must be between 0 and 255.`}function je(t){if(null===t||"string"==typeof t||"boolean"==typeof t||"number"==typeof t||t instanceof Ne||t instanceof Te||t instanceof Fe||t instanceof ze||t instanceof Be||t instanceof Ce||t instanceof Ve||t instanceof $e||t instanceof Re)return !0;if(Array.isArray(t)){for(const e of t)if(!je(e))return !1;return !0}if("object"==typeof t){for(const e in t)if(!je(t[e]))return !1;return !0}return !1}function qe(t){if(null===t)return Lt;if("string"==typeof t)return $t;if("boolean"==typeof t)return Rt;if("number"==typeof t)return Ot;if(t instanceof Te)return Nt;if(t instanceof Ne)return Ut;if(t instanceof Fe)return Gt;if(t instanceof ze)return Xt;if(t instanceof Be)return Yt;if(t instanceof Ce)return Ht;if(t instanceof Ve)return Zt;if(t instanceof $e)return Kt;if(t instanceof Re)return Wt;if(Array.isArray(t)){const e=t.length;let n;for(const e of t){const t=qe(e);if(n){if(n===t)continue;n=qt;break}n=t;}return Jt(n||qt,e)}return jt}function Ge(t){const e=typeof t;return null===t?"":"string"===e||"number"===e||"boolean"===e?String(t):t instanceof Te||t instanceof Ne||t instanceof ze||t instanceof Be||t instanceof Ce||t instanceof Ve||t instanceof $e||t instanceof Re?t.toString():JSON.stringify(t)}class Xe{constructor(t,e){this.type=t,this.value=e;}static parse(t,e){if(2!==t.length)return e.error(`'literal' expression requires exactly one argument, but found ${t.length-1} instead.`);if(!je(t[1]))return e.error("invalid value");const n=t[1];let r=qe(n);const i=e.expectedType;return "array"!==r.kind||0!==r.N||!i||"array"!==i.kind||"number"==typeof i.N&&0!==i.N||(r=i),new Xe(r,n)}evaluate(){return this.value}eachChild(){}outputDefined(){return !0}}const Ye={string:$t,number:Ot,boolean:Rt,object:jt};class Ze{constructor(t,e){this.type=t,this.args=e;}static parse(t,e){if(t.length<2)return e.error("Expected at least one argument.");let n,r=1;const i=t[0];if("array"===i){let i,s;if(t.length>2){const n=t[1];if("string"!=typeof n||!(n in Ye)||"object"===n)return e.error('The item type argument of "array" must be one of string, number, boolean',1);i=Ye[n],r++;}else i=qt;if(t.length>3){if(null!==t[2]&&("number"!=typeof t[2]||t[2]<0||t[2]!==Math.floor(t[2])))return e.error('The length argument to "array" must be a positive integer literal',2);s=t[2],r++;}n=Jt(i,s);}else {if(!Ye[i])throw new Error(`Types doesn't contain name = ${i}`);n=Ye[i];}const s=[];for(;rt.outputDefined()))}}const He={"to-boolean":Rt,"to-color":Nt,"to-number":Ot,"to-string":$t};class We{constructor(t,e){this.type=t,this.args=e;}static parse(t,e){if(t.length<2)return e.error("Expected at least one argument.");const n=t[0];if(!He[n])throw new Error(`Can't parse ${n} as it is not part of the known types`);if(("to-boolean"===n||"to-string"===n)&&2!==t.length)return e.error("Expected one argument.");const r=He[n],i=[];for(let n=1;n4?`Invalid rgba value ${JSON.stringify(e)}: expected an array containing either three or four numeric values.`:Ue(e[0],e[1],e[2],e[3]),!n))return new Te(e[0]/255,e[1]/255,e[2]/255,e[3])}throw new Le(n||`Could not parse color from value '${"string"==typeof e?e:JSON.stringify(e)}'`)}case "padding":{let e;for(const n of this.args){e=n.evaluate(t);const r=Be.parse(e);if(r)return r}throw new Le(`Could not parse padding from value '${"string"==typeof e?e:JSON.stringify(e)}'`)}case "numberArray":{let e;for(const n of this.args){e=n.evaluate(t);const r=Ce.parse(e);if(r)return r}throw new Le(`Could not parse numberArray from value '${"string"==typeof e?e:JSON.stringify(e)}'`)}case "colorArray":{let e;for(const n of this.args){e=n.evaluate(t);const r=Ve.parse(e);if(r)return r}throw new Le(`Could not parse colorArray from value '${"string"==typeof e?e:JSON.stringify(e)}'`)}case "variableAnchorOffsetCollection":{let e;for(const n of this.args){e=n.evaluate(t);const r=$e.parse(e);if(r)return r}throw new Le(`Could not parse variableAnchorOffsetCollection from value '${"string"==typeof e?e:JSON.stringify(e)}'`)}case "number":{let e=null;for(const n of this.args){if(e=n.evaluate(t),null===e)return 0;const r=Number(e);if(!isNaN(r))return r}throw new Le(`Could not convert ${JSON.stringify(e)} to number.`)}case "formatted":return ze.fromString(Ge(this.args[0].evaluate(t)));case "resolvedImage":return Re.fromString(Ge(this.args[0].evaluate(t)));case "projectionDefinition":return this.args[0].evaluate(t);default:return Ge(this.args[0].evaluate(t))}}eachChild(t){this.args.forEach(t);}outputDefined(){return this.args.every((t=>t.outputDefined()))}}const Ke=["Unknown","Point","LineString","Polygon"];class Je{constructor(){this.globals=null,this.feature=null,this.featureState=null,this.formattedSection=null,this._parseColorCache=new Map,this.availableImages=null,this.canonical=null;}id(){return this.feature&&"id"in this.feature?this.feature.id:null}geometryType(){return this.feature?"number"==typeof this.feature.type?Ke[this.feature.type]:this.feature.type:null}geometry(){return this.feature&&"geometry"in this.feature?this.feature.geometry:null}canonicalID(){return this.canonical}properties(){return this.feature&&this.feature.properties||{}}parseColor(t){let e=this._parseColorCache.get(t);return e||(e=Te.parse(t),this._parseColorCache.set(t,e)),e}}class Qe{constructor(t,e,n=[],r,i=new Vt,s=[]){this.registry=t,this.path=n,this.key=n.map((t=>`[${t}]`)).join(""),this.scope=i,this.errors=s,this.expectedType=r,this._isConstant=e;}parse(t,e,n,r,i={}){return e?this.concat(e,n,r)._parse(t,i):this._parse(t,i)}_parse(t,e){function n(t,e,n){return "assert"===n?new Ze(e,[t]):"coerce"===n?new We(e,[t]):t}if(null!==t&&"string"!=typeof t&&"boolean"!=typeof t&&"number"!=typeof t||(t=["literal",t]),Array.isArray(t)){if(0===t.length)return this.error('Expected an array with at least one element. If you wanted a literal array, use ["literal", []].');const r=t[0];if("string"!=typeof r)return this.error(`Expression name must be a string, but found ${typeof r} instead. If you wanted a literal array, use ["literal", [...]].`,0),null;const i=this.registry[r];if(i){let r=i.parse(t,this);if(!r)return null;if(this.expectedType){const t=this.expectedType,i=r.type;if("string"!==t.kind&&"number"!==t.kind&&"boolean"!==t.kind&&"object"!==t.kind&&"array"!==t.kind||"value"!==i.kind){if("projectionDefinition"===t.kind&&["string","array"].includes(i.kind)||["color","formatted","resolvedImage"].includes(t.kind)&&["value","string"].includes(i.kind)||["padding","numberArray"].includes(t.kind)&&["value","number","array"].includes(i.kind)||"colorArray"===t.kind&&["value","string","array"].includes(i.kind)||"variableAnchorOffsetCollection"===t.kind&&["value","array"].includes(i.kind))r=n(r,t,e.typeAnnotation||"coerce");else if(this.checkSubtype(t,i))return null}else r=n(r,t,e.typeAnnotation||"assert");}if(!(r instanceof Xe)&&"resolvedImage"!==r.type.kind&&this._isConstant(r)){const t=new Je;try{r=new Xe(r.type,r.evaluate(t));}catch(t){return this.error(t.message),null}}return r}return this.error(`Unknown expression "${r}". If you wanted a literal array, use ["literal", [...]].`,0)}return this.error(void 0===t?"'undefined' value invalid. Use null instead.":"object"==typeof t?'Bare objects invalid. Use ["literal", {...}] instead.':`Expected an array, but found ${typeof t} instead.`)}concat(t,e,n){const r="number"==typeof t?this.path.concat(t):this.path,i=n?this.scope.concat(n):this.scope;return new Qe(this.registry,this._isConstant,r,e||null,i,this.errors)}error(t,...e){const n=`${this.key}${e.map((t=>`[${t}]`)).join("")}`;this.errors.push(new Ct(n,t));}checkSubtype(t,e){const n=ee(t,e);return n&&this.error(n),n}}class tn{constructor(t,e){this.type=e.type,this.bindings=[].concat(t),this.result=e;}evaluate(t){return this.result.evaluate(t)}eachChild(t){for(const e of this.bindings)t(e[1]);t(this.result);}static parse(t,e){if(t.length<4)return e.error(`Expected at least 3 arguments, but found ${t.length-1} instead.`);const n=[];for(let r=1;r=n.length)throw new Le(`Array index out of bounds: ${e} > ${n.length-1}.`);if(e!==Math.floor(e))throw new Le(`Array index must be an integer, but found ${e} instead.`);return n[e]}eachChild(t){t(this.index),t(this.input);}outputDefined(){return !1}}class rn{constructor(t,e){this.type=Rt,this.needle=t,this.haystack=e;}static parse(t,e){if(3!==t.length)return e.error(`Expected 2 arguments, but found ${t.length-1} instead.`);const n=e.parse(t[1],1,qt),r=e.parse(t[2],2,qt);return n&&r?ne(n.type,[Rt,$t,Ot,Lt,qt])?new rn(n,r):e.error(`Expected first argument to be of type boolean, string, number or null, but found ${Qt(n.type)} instead`):null}evaluate(t){const e=this.needle.evaluate(t),n=this.haystack.evaluate(t);if(!n)return !1;if(!re(e,["boolean","string","number","null"]))throw new Le(`Expected first argument to be of type boolean, string, number or null, but found ${Qt(qe(e))} instead.`);if(!re(n,["string","array"]))throw new Le(`Expected second argument to be of type array or string, but found ${Qt(qe(n))} instead.`);return n.indexOf(e)>=0}eachChild(t){t(this.needle),t(this.haystack);}outputDefined(){return !0}}class sn{constructor(t,e,n){this.type=Ot,this.needle=t,this.haystack=e,this.fromIndex=n;}static parse(t,e){if(t.length<=2||t.length>=5)return e.error(`Expected 2 or 3 arguments, but found ${t.length-1} instead.`);const n=e.parse(t[1],1,qt),r=e.parse(t[2],2,qt);if(!n||!r)return null;if(!ne(n.type,[Rt,$t,Ot,Lt,qt]))return e.error(`Expected first argument to be of type boolean, string, number or null, but found ${Qt(n.type)} instead`);if(4===t.length){const i=e.parse(t[3],3,Ot);return i?new sn(n,r,i):null}return new sn(n,r)}evaluate(t){const e=this.needle.evaluate(t),n=this.haystack.evaluate(t);if(!re(e,["boolean","string","number","null"]))throw new Le(`Expected first argument to be of type boolean, string, number or null, but found ${Qt(qe(e))} instead.`);let r;if(this.fromIndex&&(r=this.fromIndex.evaluate(t)),re(n,["string"])){const t=n.indexOf(e,r);return -1===t?-1:[...n.slice(0,t)].length}if(re(n,["array"]))return n.indexOf(e,r);throw new Le(`Expected second argument to be of type array or string, but found ${Qt(qe(n))} instead.`)}eachChild(t){t(this.needle),t(this.haystack),this.fromIndex&&t(this.fromIndex);}outputDefined(){return !1}}class on{constructor(t,e,n,r,i,s){this.inputType=t,this.type=e,this.input=n,this.cases=r,this.outputs=i,this.otherwise=s;}static parse(t,e){if(t.length<5)return e.error(`Expected at least 4 arguments, but found only ${t.length-1}.`);if(t.length%2!=1)return e.error("Expected an even number of arguments.");let n,r;e.expectedType&&"value"!==e.expectedType.kind&&(r=e.expectedType);const i={},s=[];for(let o=2;oNumber.MAX_SAFE_INTEGER)return u.error(`Branch labels must be integers no larger than ${Number.MAX_SAFE_INTEGER}.`);if("number"==typeof t&&Math.floor(t)!==t)return u.error("Numeric branch labels must be integer values.");if(n){if(u.checkSubtype(n,qe(t)))return null}else n=qe(t);if(void 0!==i[String(t)])return u.error("Branch labels must be unique.");i[String(t)]=s.length;}const c=e.parse(l,o,r);if(!c)return null;r=r||c.type,s.push(c);}const o=e.parse(t[1],1,qt);if(!o)return null;const a=e.parse(t[t.length-1],t.length-1,r);return a?"value"!==o.type.kind&&e.concat(1).checkSubtype(n,o.type)?null:new on(n,r,o,i,s,a):null}evaluate(t){const e=this.input.evaluate(t);return (qe(e)===this.inputType&&this.outputs[this.cases[e]]||this.otherwise).evaluate(t)}eachChild(t){t(this.input),this.outputs.forEach(t),t(this.otherwise);}outputDefined(){return this.outputs.every((t=>t.outputDefined()))&&this.otherwise.outputDefined()}}class an{constructor(t,e,n){this.type=t,this.branches=e,this.otherwise=n;}static parse(t,e){if(t.length<4)return e.error(`Expected at least 3 arguments, but found only ${t.length-1}.`);if(t.length%2!=0)return e.error("Expected an odd number of arguments.");let n;e.expectedType&&"value"!==e.expectedType.kind&&(n=e.expectedType);const r=[];for(let i=1;ie.outputDefined()))&&this.otherwise.outputDefined()}}class ln{constructor(t,e,n,r){this.type=t,this.input=e,this.beginIndex=n,this.endIndex=r;}static parse(t,e){if(t.length<=2||t.length>=5)return e.error(`Expected 2 or 3 arguments, but found ${t.length-1} instead.`);const n=e.parse(t[1],1,qt),r=e.parse(t[2],2,Ot);if(!n||!r)return null;if(!ne(n.type,[Jt(qt),$t,qt]))return e.error(`Expected first argument to be of type array or string, but found ${Qt(n.type)} instead`);if(4===t.length){const i=e.parse(t[3],3,Ot);return i?new ln(n.type,n,r,i):null}return new ln(n.type,n,r)}evaluate(t){const e=this.input.evaluate(t),n=this.beginIndex.evaluate(t);let r;if(this.endIndex&&(r=this.endIndex.evaluate(t)),re(e,["string"]))return [...e].slice(n,r).join("");if(re(e,["array"]))return e.slice(n,r);throw new Le(`Expected first argument to be of type array or string, but found ${Qt(qe(e))} instead.`)}eachChild(t){t(this.input),t(this.beginIndex),this.endIndex&&t(this.endIndex);}outputDefined(){return !1}}function un(t,e){const n=t.length-1;let r,i,s=0,o=n,a=0;for(;s<=o;)if(a=Math.floor((s+o)/2),r=t[a],i=t[a+1],r<=e){if(a===n||ee))throw new Le("Input is not a number.");o=a-1;}return 0}class cn{constructor(t,e,n){this.type=t,this.input=e,this.labels=[],this.outputs=[];for(const[t,e]of n)this.labels.push(t),this.outputs.push(e);}static parse(t,e){if(t.length-1<4)return e.error(`Expected at least 4 arguments, but found only ${t.length-1}.`);if((t.length-1)%2!=0)return e.error("Expected an even number of arguments.");const n=e.parse(t[1],1,Ot);if(!n)return null;const r=[];let i=null;e.expectedType&&"value"!==e.expectedType.kind&&(i=e.expectedType);for(let n=1;n=s)return e.error('Input/output pairs for "step" expressions must be arranged with input values in strictly ascending order.',a);const u=e.parse(o,l,i);if(!u)return null;i=i||u.type,r.push([s,u]);}return new cn(i,n,r)}evaluate(t){const e=this.labels,n=this.outputs;if(1===e.length)return n[0].evaluate(t);const r=this.input.evaluate(t);if(r<=e[0])return n[0].evaluate(t);const i=e.length;return r>=e[i-1]?n[i-1].evaluate(t):n[un(e,r)].evaluate(t)}eachChild(t){t(this.input);for(const e of this.outputs)t(e);}outputDefined(){return this.outputs.every((t=>t.outputDefined()))}}function hn(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var pn,fn,dn=function(){if(fn)return pn;function t(t,e,n,r){this.cx=3*t,this.bx=3*(n-t)-this.cx,this.ax=1-this.cx-this.bx,this.cy=3*e,this.by=3*(r-e)-this.cy,this.ay=1-this.cy-this.by,this.p1x=t,this.p1y=e,this.p2x=n,this.p2y=r;}return fn=1,pn=t,t.prototype={sampleCurveX:function(t){return ((this.ax*t+this.bx)*t+this.cx)*t},sampleCurveY:function(t){return ((this.ay*t+this.by)*t+this.cy)*t},sampleCurveDerivativeX:function(t){return (3*this.ax*t+2*this.bx)*t+this.cx},solveCurveX:function(t,e){if(void 0===e&&(e=1e-6),t<0)return 0;if(t>1)return 1;for(var n=t,r=0;r<8;r++){var i=this.sampleCurveX(n)-t;if(Math.abs(i)i?o=n:a=n,n=.5*(a-o)+o;return n},solve:function(t,e){return this.sampleCurveY(this.solveCurveX(t,e))}},pn}(),yn=hn(dn);class mn{constructor(t,e,n,r,i){this.type=t,this.operator=e,this.interpolation=n,this.input=r,this.labels=[],this.outputs=[];for(const[t,e]of i)this.labels.push(t),this.outputs.push(e);}static interpolationFactor(t,e,n,r){let i=0;if("exponential"===t.name)i=gn(e,t.base,n,r);else if("linear"===t.name)i=gn(e,1,n,r);else if("cubic-bezier"===t.name){const s=t.controlPoints;i=new yn(s[0],s[1],s[2],s[3]).solve(gn(e,1,n,r));}return i}static parse(t,e){let[n,r,i,...s]=t;if(!Array.isArray(r)||0===r.length)return e.error("Expected an interpolation type expression.",1);if("linear"===r[0])r={name:"linear"};else if("exponential"===r[0]){const t=r[1];if("number"!=typeof t)return e.error("Exponential interpolation requires a numeric base.",1,1);r={name:"exponential",base:t};}else {if("cubic-bezier"!==r[0])return e.error(`Unknown interpolation type ${String(r[0])}`,1,0);{const t=r.slice(1);if(4!==t.length||t.some((t=>"number"!=typeof t||t<0||t>1)))return e.error("Cubic bezier interpolation requires four numeric arguments with values between 0 and 1.",1);r={name:"cubic-bezier",controlPoints:t};}}if(t.length-1<4)return e.error(`Expected at least 4 arguments, but found only ${t.length-1}.`);if((t.length-1)%2!=0)return e.error("Expected an even number of arguments.");if(i=e.parse(i,2,Ot),!i)return null;const o=[];let a=null;"interpolate-hcl"!==n&&"interpolate-lab"!==n||e.expectedType==Zt?e.expectedType&&"value"!==e.expectedType.kind&&(a=e.expectedType):a=Nt;for(let t=0;t=n)return e.error('Input/output pairs for "interpolate" expressions must be arranged with input values in strictly ascending order.',i);const u=e.parse(r,l,a);if(!u)return null;a=a||u.type,o.push([n,u]);}return ie(a,Ot)||ie(a,Ut)||ie(a,Nt)||ie(a,Yt)||ie(a,Ht)||ie(a,Zt)||ie(a,Kt)||ie(a,Jt(Ot))?new mn(a,n,r,i,o):e.error(`Type ${Qt(a)} is not interpolatable.`)}evaluate(t){const e=this.labels,n=this.outputs;if(1===e.length)return n[0].evaluate(t);const r=this.input.evaluate(t);if(r<=e[0])return n[0].evaluate(t);const i=e.length;if(r>=e[i-1])return n[i-1].evaluate(t);const s=un(e,r),o=mn.interpolationFactor(this.interpolation,r,e[s],e[s+1]),a=n[s].evaluate(t),l=n[s+1].evaluate(t);switch(this.operator){case "interpolate":switch(this.type.kind){case "number":return Ie(a,l,o);case "color":return Te.interpolate(a,l,o);case "padding":return Be.interpolate(a,l,o);case "colorArray":return Ve.interpolate(a,l,o);case "numberArray":return Ce.interpolate(a,l,o);case "variableAnchorOffsetCollection":return $e.interpolate(a,l,o);case "array":return Ee(a,l,o);case "projectionDefinition":return Ne.interpolate(a,l,o)}case "interpolate-hcl":switch(this.type.kind){case "color":return Te.interpolate(a,l,o,"hcl");case "colorArray":return Ve.interpolate(a,l,o,"hcl")}case "interpolate-lab":switch(this.type.kind){case "color":return Te.interpolate(a,l,o,"lab");case "colorArray":return Ve.interpolate(a,l,o,"lab")}}}eachChild(t){t(this.input);for(const e of this.outputs)t(e);}outputDefined(){return this.outputs.every((t=>t.outputDefined()))}}function gn(t,e,n,r){const i=r-n,s=t-n;return 0===i?0:1===e?s/i:(Math.pow(e,s)-1)/(Math.pow(e,i)-1)}const xn={color:Te.interpolate,number:Ie,padding:Be.interpolate,numberArray:Ce.interpolate,colorArray:Ve.interpolate,variableAnchorOffsetCollection:$e.interpolate,array:Ee};class vn{constructor(t,e){this.type=t,this.args=e;}static parse(t,e){if(t.length<2)return e.error("Expected at least one argument.");let n=null;const r=e.expectedType;r&&"value"!==r.kind&&(n=r);const i=[];for(const r of t.slice(1)){const t=e.parse(r,1+i.length,n,void 0,{typeAnnotation:"omit"});if(!t)return null;n=n||t.type,i.push(t);}if(!n)throw new Error("No output type");const s=r&&i.some((t=>ee(r,t.type)));return new vn(s?qt:n,i)}evaluate(t){let e,n=null,r=0;for(const i of this.args)if(r++,n=i.evaluate(t),n&&n instanceof Re&&!n.available&&(e||(e=n.name),n=null,r===this.args.length&&(n=e)),null!==n)break;return n}eachChild(t){this.args.forEach(t);}outputDefined(){return this.args.every((t=>t.outputDefined()))}}function bn(t,e){return "=="===t||"!="===t?"boolean"===e.kind||"string"===e.kind||"number"===e.kind||"null"===e.kind||"value"===e.kind:"string"===e.kind||"number"===e.kind||"value"===e.kind}function wn(t,e,n,r){return 0===r.compare(e,n)}function _n(t,e,n){const r="=="!==t&&"!="!==t;return class i{constructor(t,e,n){this.type=Rt,this.lhs=t,this.rhs=e,this.collator=n,this.hasUntypedArgument="value"===t.type.kind||"value"===e.type.kind;}static parse(t,e){if(3!==t.length&&4!==t.length)return e.error("Expected two or three arguments.");const n=t[0];let s=e.parse(t[1],1,qt);if(!s)return null;if(!bn(n,s.type))return e.concat(1).error(`"${n}" comparisons are not supported for type '${Qt(s.type)}'.`);let o=e.parse(t[2],2,qt);if(!o)return null;if(!bn(n,o.type))return e.concat(2).error(`"${n}" comparisons are not supported for type '${Qt(o.type)}'.`);if(s.type.kind!==o.type.kind&&"value"!==s.type.kind&&"value"!==o.type.kind)return e.error(`Cannot compare types '${Qt(s.type)}' and '${Qt(o.type)}'.`);r&&("value"===s.type.kind&&"value"!==o.type.kind?s=new Ze(o.type,[s]):"value"!==s.type.kind&&"value"===o.type.kind&&(o=new Ze(s.type,[o])));let a=null;if(4===t.length){if("string"!==s.type.kind&&"string"!==o.type.kind&&"value"!==s.type.kind&&"value"!==o.type.kind)return e.error("Cannot use collator to compare non-string types.");if(a=e.parse(t[3],3,Gt),!a)return null}return new i(s,o,a)}evaluate(i){const s=this.lhs.evaluate(i),o=this.rhs.evaluate(i);if(r&&this.hasUntypedArgument){const e=qe(s),n=qe(o);if(e.kind!==n.kind||"string"!==e.kind&&"number"!==e.kind)throw new Le(`Expected arguments for "${t}" to be (string, string) or (number, number), but found (${e.kind}, ${n.kind}) instead.`)}if(this.collator&&!r&&this.hasUntypedArgument){const t=qe(s),n=qe(o);if("string"!==t.kind||"string"!==n.kind)return e(i,s,o)}return this.collator?n(i,s,o,this.collator.evaluate(i)):e(i,s,o)}eachChild(t){t(this.lhs),t(this.rhs),this.collator&&t(this.collator);}outputDefined(){return !0}}}const Sn=_n("==",(function(t,e,n){return e===n}),wn),An=_n("!=",(function(t,e,n){return e!==n}),(function(t,e,n,r){return !wn(0,e,n,r)})),Mn=_n("<",(function(t,e,n){return e",(function(t,e,n){return e>n}),(function(t,e,n,r){return r.compare(e,n)>0})),In=_n("<=",(function(t,e,n){return e<=n}),(function(t,e,n,r){return r.compare(e,n)<=0})),En=_n(">=",(function(t,e,n){return e>=n}),(function(t,e,n,r){return r.compare(e,n)>=0}));class Tn{constructor(t,e,n){this.type=Gt,this.locale=n,this.caseSensitive=t,this.diacriticSensitive=e;}static parse(t,e){if(2!==t.length)return e.error("Expected one argument.");const n=t[1];if("object"!=typeof n||Array.isArray(n))return e.error("Collator options argument must be an object.");const r=e.parse(void 0!==n["case-sensitive"]&&n["case-sensitive"],1,Rt);if(!r)return null;const i=e.parse(void 0!==n["diacritic-sensitive"]&&n["diacritic-sensitive"],1,Rt);if(!i)return null;let s=null;return n.locale&&(s=e.parse(n.locale,1,$t),!s)?null:new Tn(r,i,s)}evaluate(t){return new Fe(this.caseSensitive.evaluate(t),this.diacriticSensitive.evaluate(t),this.locale?this.locale.evaluate(t):null)}eachChild(t){t(this.caseSensitive),t(this.diacriticSensitive),this.locale&&t(this.locale);}outputDefined(){return !1}}class Fn{constructor(t,e,n,r,i,s){this.type=$t,this.number=t,this.locale=e,this.currency=n,this.unit=r,this.minFractionDigits=i,this.maxFractionDigits=s;}static parse(t,e){if(3!==t.length)return e.error("Expected two arguments.");const n=e.parse(t[1],1,Ot);if(!n)return null;const r=t[2];if("object"!=typeof r||Array.isArray(r))return e.error("NumberFormat options argument must be an object.");let i=null;if(r.locale&&(i=e.parse(r.locale,1,$t),!i))return null;let s=null;if(r.currency&&(s=e.parse(r.currency,1,$t),!s))return null;let o=null;if(r.unit&&(o=e.parse(r.unit,1,$t),!o))return null;if(s&&o)return e.error("NumberFormat options `currency` and `unit` are mutually exclusive");let a=null;if(r["min-fraction-digits"]&&(a=e.parse(r["min-fraction-digits"],1,Ot),!a))return null;let l=null;return r["max-fraction-digits"]&&(l=e.parse(r["max-fraction-digits"],1,Ot),!l)?null:new Fn(n,i,s,o,a,l)}evaluate(t){return new Intl.NumberFormat(this.locale?this.locale.evaluate(t):[],{style:this.currency?"currency":this.unit?"unit":"decimal",currency:this.currency?this.currency.evaluate(t):void 0,unit:this.unit?this.unit.evaluate(t):void 0,minimumFractionDigits:this.minFractionDigits?this.minFractionDigits.evaluate(t):void 0,maximumFractionDigits:this.maxFractionDigits?this.maxFractionDigits.evaluate(t):void 0}).format(this.number.evaluate(t))}eachChild(t){t(this.number),this.locale&&t(this.locale),this.currency&&t(this.currency),this.unit&&t(this.unit),this.minFractionDigits&&t(this.minFractionDigits),this.maxFractionDigits&&t(this.maxFractionDigits);}outputDefined(){return !1}}class Pn{constructor(t){this.type=Xt,this.sections=t;}static parse(t,e){if(t.length<2)return e.error("Expected at least one argument.");const n=t[1];if(!Array.isArray(n)&&"object"==typeof n)return e.error("First argument must be an image or text section.");const r=[];let i=!1;for(let n=1;n<=t.length-1;++n){const s=t[n];if(i&&"object"==typeof s&&!Array.isArray(s)){i=!1;let t=null;if(s["font-scale"]&&(t=e.parse(s["font-scale"],1,Ot),!t))return null;let n=null;if(s["text-font"]&&(n=e.parse(s["text-font"],1,Jt($t)),!n))return null;let o=null;if(s["text-color"]&&(o=e.parse(s["text-color"],1,Nt),!o))return null;let a=null;if(s["vertical-align"]){if("string"==typeof s["vertical-align"]&&!Pe.includes(s["vertical-align"]))return e.error(`'vertical-align' must be one of: 'bottom', 'center', 'top' but found '${s["vertical-align"]}' instead.`);if(a=e.parse(s["vertical-align"],1,$t),!a)return null}const l=r[r.length-1];l.scale=t,l.font=n,l.textColor=o,l.verticalAlign=a;}else {const s=e.parse(t[n],1,qt);if(!s)return null;const o=s.type.kind;if("string"!==o&&"value"!==o&&"null"!==o&&"resolvedImage"!==o)return e.error("Formatted text type must be 'string', 'value', 'image' or 'null'.");i=!0,r.push({content:s,scale:null,font:null,textColor:null,verticalAlign:null});}}return new Pn(r)}evaluate(t){return new ze(this.sections.map((e=>{const n=e.content.evaluate(t);return qe(n)===Wt?new De("",n,null,null,null,e.verticalAlign?e.verticalAlign.evaluate(t):null):new De(Ge(n),null,e.scale?e.scale.evaluate(t):null,e.font?e.font.evaluate(t).join(","):null,e.textColor?e.textColor.evaluate(t):null,e.verticalAlign?e.verticalAlign.evaluate(t):null)})))}eachChild(t){for(const e of this.sections)t(e.content),e.scale&&t(e.scale),e.font&&t(e.font),e.textColor&&t(e.textColor),e.verticalAlign&&t(e.verticalAlign);}outputDefined(){return !1}}class Dn{constructor(t){this.type=Wt,this.input=t;}static parse(t,e){if(2!==t.length)return e.error("Expected two arguments.");const n=e.parse(t[1],1,$t);return n?new Dn(n):e.error("No image name provided.")}evaluate(t){const e=this.input.evaluate(t),n=Re.fromString(e);return n&&t.availableImages&&(n.available=t.availableImages.indexOf(e)>-1),n}eachChild(t){t(this.input);}outputDefined(){return !1}}class zn{constructor(t){this.type=Ot,this.input=t;}static parse(t,e){if(2!==t.length)return e.error(`Expected 1 argument, but found ${t.length-1} instead.`);const n=e.parse(t[1],1);return n?"array"!==n.type.kind&&"string"!==n.type.kind&&"value"!==n.type.kind?e.error(`Expected argument of type string or array, but found ${Qt(n.type)} instead.`):new zn(n):null}evaluate(t){const e=this.input.evaluate(t);if("string"==typeof e)return [...e].length;if(Array.isArray(e))return e.length;throw new Le(`Expected value to be of type string or array, but found ${Qt(qe(e))} instead.`)}eachChild(t){t(this.input);}outputDefined(){return !1}}const Bn=8192;function Cn(t,e){const n=(180+t[0])/360,r=(180-180/Math.PI*Math.log(Math.tan(Math.PI/4+t[1]*Math.PI/360)))/360,i=Math.pow(2,e.z);return [Math.round(n*i*Bn),Math.round(r*i*Bn)]}function Vn(t,e){const n=Math.pow(2,e.z);return [(i=(t[0]/Bn+e.x)/n,360*i-180),(r=(t[1]/Bn+e.y)/n,360/Math.PI*Math.atan(Math.exp((180-360*r)*Math.PI/180))-90)];var r,i;}function Ln(t,e){t[0]=Math.min(t[0],e[0]),t[1]=Math.min(t[1],e[1]),t[2]=Math.max(t[2],e[0]),t[3]=Math.max(t[3],e[1]);}function On(t,e){return !(t[0]<=e[0]||t[2]>=e[2]||t[1]<=e[1]||t[3]>=e[3])}function $n(t,e,n){const r=t[0]-e[0],i=t[1]-e[1],s=t[0]-n[0],o=t[1]-n[1];return r*o-s*i==0&&r*s<=0&&i*o<=0}function Rn(t,e,n,r){return 0!=(i=[r[0]-n[0],r[1]-n[1]])[0]*(s=[e[0]-t[0],e[1]-t[1]])[1]-i[1]*s[0]&&!(!Xn(t,e,n,r)||!Xn(n,r,t,e));var i,s;}function Nn(t,e,n){for(const r of n)for(let n=0;n(i=t)[1]!=(o=a[e+1])[1]>i[1]&&i[0]<(o[0]-s[0])*(i[1]-s[1])/(o[1]-s[1])+s[0]&&(r=!r);}var i,s,o;return r}function jn(t,e){for(const n of e)if(Un(t,n))return !0;return !1}function qn(t,e){for(const n of t)if(!Un(n,e))return !1;for(let n=0;n0&&a<0||o<0&&a>0}function Yn(t,e,n){const r=[];for(let i=0;in[2]){const e=.5*r;let i=t[0]-n[0]>e?-r:n[0]-t[0]>e?r:0;0===i&&(i=t[0]-n[2]>e?-r:n[2]-t[0]>e?r:0),t[0]+=i;}Ln(e,t);}function Wn(t,e,n,r){const i=Math.pow(2,r.z)*Bn,s=[r.x*Bn,r.y*Bn],o=[];for(const r of t)for(const t of r){const r=[t.x+s[0],t.y+s[1]];Hn(r,e,n,i),o.push(r);}return o}function Kn(t,e,n,r){const i=Math.pow(2,r.z)*Bn,s=[r.x*Bn,r.y*Bn],o=[];for(const n of t){const t=[];for(const r of n){const n=[r.x+s[0],r.y+s[1]];Ln(e,n),t.push(n);}o.push(t);}if(e[2]-e[0]<=i/2){(a=e)[0]=a[1]=1/0,a[2]=a[3]=-1/0;for(const t of o)for(const r of t)Hn(r,e,n,i);}var a;return o}class Jn{constructor(t,e){this.type=Rt,this.geojson=t,this.geometries=e;}static parse(t,e){if(2!==t.length)return e.error(`'within' expression requires exactly one argument, but found ${t.length-1} instead.`);if(je(t[1])){const e=t[1];if("FeatureCollection"===e.type){const t=[];for(const n of e.features){const{type:e,coordinates:r}=n.geometry;"Polygon"===e&&t.push(r),"MultiPolygon"===e&&t.push(...r);}if(t.length)return new Jn(e,{type:"MultiPolygon",coordinates:t})}else if("Feature"===e.type){const t=e.geometry.type;if("Polygon"===t||"MultiPolygon"===t)return new Jn(e,e.geometry)}else if("Polygon"===e.type||"MultiPolygon"===e.type)return new Jn(e,e)}return e.error("'within' expression requires valid geojson object that contains polygon geometry type.")}evaluate(t){if(null!=t.geometry()&&null!=t.canonicalID()){if("Point"===t.geometryType())return function(t,e){const n=[1/0,1/0,-1/0,-1/0],r=[1/0,1/0,-1/0,-1/0],i=t.canonicalID();if("Polygon"===e.type){const s=Yn(e.coordinates,r,i),o=Wn(t.geometry(),n,r,i);if(!On(n,r))return !1;for(const t of o)if(!Un(t,s))return !1}if("MultiPolygon"===e.type){const s=Zn(e.coordinates,r,i),o=Wn(t.geometry(),n,r,i);if(!On(n,r))return !1;for(const t of o)if(!jn(t,s))return !1}return !0}(t,this.geometries);if("LineString"===t.geometryType())return function(t,e){const n=[1/0,1/0,-1/0,-1/0],r=[1/0,1/0,-1/0,-1/0],i=t.canonicalID();if("Polygon"===e.type){const s=Yn(e.coordinates,r,i),o=Kn(t.geometry(),n,r,i);if(!On(n,r))return !1;for(const t of o)if(!qn(t,s))return !1}if("MultiPolygon"===e.type){const s=Zn(e.coordinates,r,i),o=Kn(t.geometry(),n,r,i);if(!On(n,r))return !1;for(const t of o)if(!Gn(t,s))return !1}return !0}(t,this.geometries)}return !1}eachChild(){}outputDefined(){return !0}}let Qn=class{constructor(t=[],e=(t,e)=>te?1:0){if(this.data=t,this.length=this.data.length,this.compare=e,this.length>0)for(let t=(this.length>>1)-1;t>=0;t--)this._down(t);}push(t){this.data.push(t),this._up(this.length++);}pop(){if(0===this.length)return;const t=this.data[0],e=this.data.pop();return --this.length>0&&(this.data[0]=e,this._down(0)),t}peek(){return this.data[0]}_up(t){const{data:e,compare:n}=this,r=e[t];for(;t>0;){const i=t-1>>1,s=e[i];if(n(r,s)>=0)break;e[t]=s,t=i;}e[t]=r;}_down(t){const{data:e,compare:n}=this,r=this.length>>1,i=e[t];for(;t=0)break;e[t]=e[r],t=r;}e[t]=i;}};function tr(t,e,n=0,r=t.length-1,i=nr){for(;r>n;){if(r-n>600){const s=r-n+1,o=e-n+1,a=Math.log(s),l=.5*Math.exp(2*a/3),u=.5*Math.sqrt(a*l*(s-l)/s)*(o-s/2<0?-1:1);tr(t,e,Math.max(n,Math.floor(e-o*l/s+u)),Math.min(r,Math.floor(e+(s-o)*l/s+u)),i);}const s=t[e];let o=n,a=r;for(er(t,n,e),i(t[r],s)>0&&er(t,n,r);o0;)a--;}0===i(t[n],s)?er(t,n,a):(a++,er(t,a,r)),a<=e&&(n=a+1),e<=a&&(r=a-1);}}function er(t,e,n){const r=t[e];t[e]=t[n],t[n]=r;}function nr(t,e){return te?1:0}function rr(t,e){if(t.length<=1)return [t];const n=[];let r,i;for(const e of t){const t=sr(e);0!==t&&(e.area=Math.abs(t),void 0===i&&(i=t<0),i===t<0?(r&&n.push(r),r=[e]):r.push(e));}if(r&&n.push(r),e>1)for(let t=0;t1?(l=t[a+1][0],u=t[a+1][1]):p>0&&(l+=c/this.kx*p,u+=h/this.ky*p)),c=this.wrap(e[0]-l)*this.kx,h=(e[1]-u)*this.ky;const f=c*c+h*h;f180;)t-=360;return t}}function cr(t,e){return e[0]-t[0]}function hr(t){return t[1]-t[0]+1}function pr(t,e){return t[1]>=t[0]&&t[1]t[1])return [null,null];const n=hr(t);if(e){if(2===n)return [t,null];const e=Math.floor(n/2);return [[t[0],t[0]+e],[t[0]+e,t[1]]]}if(1===n)return [t,null];const r=Math.floor(n/2)-1;return [[t[0],t[0]+r],[t[0]+r+1,t[1]]]}function dr(t,e){if(!pr(e,t.length))return [1/0,1/0,-1/0,-1/0];const n=[1/0,1/0,-1/0,-1/0];for(let r=e[0];r<=e[1];++r)Ln(n,t[r]);return n}function yr(t){const e=[1/0,1/0,-1/0,-1/0];for(const n of t)for(const t of n)Ln(e,t);return e}function mr(t){return t[0]!==-1/0&&t[1]!==-1/0&&t[2]!==1/0&&t[3]!==1/0}function gr(t,e,n){if(!mr(t)||!mr(e))return NaN;let r=0,i=0;return t[2]e[2]&&(r=t[0]-e[2]),t[1]>e[3]&&(i=t[1]-e[3]),t[3]=r)return r;if(On(i,s)){if(Ar(t,e))return 0}else if(Ar(e,t))return 0;let o=1/0;for(const r of t)for(let t=0,i=r.length,s=i-1;t0;){const i=o.pop();if(i[0]>=s)continue;const l=i[1],u=e?50:100;if(hr(l)<=u){if(!pr(l,t.length))return NaN;if(e){const e=Sr(t,l,n,r);if(isNaN(e)||0===e)return e;s=Math.min(s,e);}else for(let e=l[0];e<=l[1];++e){const i=_r(t[e],n,r);if(s=Math.min(s,i),0===s)return 0}}else {const n=fr(l,e);kr(o,s,r,t,a,n[0]),kr(o,s,r,t,a,n[1]);}}return s}function Tr(t,e,n,r,i,s=1/0){let o=Math.min(s,i.distance(t[0],n[0]));if(0===o)return o;const a=new Qn([[0,[0,t.length-1],[0,n.length-1]]],cr);for(;a.length>0;){const s=a.pop();if(s[0]>=o)continue;const l=s[1],u=s[2],c=e?50:100,h=r?50:100;if(hr(l)<=c&&hr(u)<=h){if(!pr(l,t.length)&&pr(u,n.length))return NaN;let s;if(e&&r)s=br(t,l,n,u,i),o=Math.min(o,s);else if(e&&!r){const e=t.slice(l[0],l[1]+1);for(let t=u[0];t<=u[1];++t)if(s=xr(n[t],e,i),o=Math.min(o,s),0===o)return o}else if(!e&&r){const e=n.slice(u[0],u[1]+1);for(let n=l[0];n<=l[1];++n)if(s=xr(t[n],e,i),o=Math.min(o,s),0===o)return o}else s=wr(t,l,n,u,i),o=Math.min(o,s);}else {const s=fr(l,e),c=fr(u,r);Ir(a,o,i,t,n,s[0],c[0]),Ir(a,o,i,t,n,s[0],c[1]),Ir(a,o,i,t,n,s[1],c[0]),Ir(a,o,i,t,n,s[1],c[1]);}}return o}function Fr(t){return "MultiPolygon"===t.type?t.coordinates.map((t=>({type:"Polygon",coordinates:t}))):"MultiLineString"===t.type?t.coordinates.map((t=>({type:"LineString",coordinates:t}))):"MultiPoint"===t.type?t.coordinates.map((t=>({type:"Point",coordinates:t}))):[t]}class Pr{constructor(t,e){this.type=Ot,this.geojson=t,this.geometries=e;}static parse(t,e){if(2!==t.length)return e.error(`'distance' expression requires exactly one argument, but found ${t.length-1} instead.`);if(je(t[1])){const e=t[1];if("FeatureCollection"===e.type)return new Pr(e,e.features.map((t=>Fr(t.geometry))).flat());if("Feature"===e.type)return new Pr(e,Fr(e.geometry));if("type"in e&&"coordinates"in e)return new Pr(e,Fr(e))}return e.error("'distance' expression requires valid geojson object that contains polygon geometry type.")}evaluate(t){if(null!=t.geometry()&&null!=t.canonicalID()){if("Point"===t.geometryType())return function(t,e){const n=t.geometry(),r=n.flat().map((e=>Vn([e.x,e.y],t.canonical)));if(0===n.length)return NaN;const i=new ur(r[0][1]);let s=1/0;for(const t of e){switch(t.type){case "Point":s=Math.min(s,Tr(r,!1,[t.coordinates],!1,i,s));break;case "LineString":s=Math.min(s,Tr(r,!1,t.coordinates,!0,i,s));break;case "Polygon":s=Math.min(s,Er(r,!1,t.coordinates,i,s));}if(0===s)return s}return s}(t,this.geometries);if("LineString"===t.geometryType())return function(t,e){const n=t.geometry(),r=n.flat().map((e=>Vn([e.x,e.y],t.canonical)));if(0===n.length)return NaN;const i=new ur(r[0][1]);let s=1/0;for(const t of e){switch(t.type){case "Point":s=Math.min(s,Tr(r,!0,[t.coordinates],!1,i,s));break;case "LineString":s=Math.min(s,Tr(r,!0,t.coordinates,!0,i,s));break;case "Polygon":s=Math.min(s,Er(r,!0,t.coordinates,i,s));}if(0===s)return s}return s}(t,this.geometries);if("Polygon"===t.geometryType())return function(t,e){const n=t.geometry();if(0===n.length||0===n[0].length)return NaN;const r=rr(n,0).map((e=>e.map((e=>e.map((e=>Vn([e.x,e.y],t.canonical))))))),i=new ur(r[0][0][0][1]);let s=1/0;for(const t of e)for(const e of r){switch(t.type){case "Point":s=Math.min(s,Er([t.coordinates],!1,e,i,s));break;case "LineString":s=Math.min(s,Er(t.coordinates,!0,e,i,s));break;case "Polygon":s=Math.min(s,Mr(e,t.coordinates,i,s));}if(0===s)return s}return s}(t,this.geometries)}return NaN}eachChild(){}outputDefined(){return !0}}class Dr{constructor(t){this.type=qt,this.key=t;}static parse(t,e){if(2!==t.length)return e.error(`Expected 1 argument, but found ${t.length-1} instead.`);const n=t[1];return null==n?e.error("Global state property must be defined."):"string"!=typeof n?e.error(`Global state property must be string, but found ${typeof t[1]} instead.`):new Dr(n)}evaluate(t){var e;const n=null===(e=t.globals)||void 0===e?void 0:e.globalState;return n&&0!==Object.keys(n).length?we(n,this.key):null}eachChild(){}outputDefined(){return !1}}const zr={"==":Sn,"!=":An,">":kn,"<":Mn,">=":En,"<=":In,array:Ze,at:nn,boolean:Ze,case:an,coalesce:vn,collator:Tn,format:Pn,image:Dn,in:rn,"index-of":sn,interpolate:mn,"interpolate-hcl":mn,"interpolate-lab":mn,length:zn,let:tn,literal:Xe,match:on,number:Ze,"number-format":Fn,object:Ze,slice:ln,step:cn,string:Ze,"to-boolean":We,"to-color":We,"to-number":We,"to-string":We,var:en,within:Jn,distance:Pr,"global-state":Dr};class Br{constructor(t,e,n,r){this.name=t,this.type=e,this._evaluate=n,this.args=r;}evaluate(t){return this._evaluate(t,this.args)}eachChild(t){this.args.forEach(t);}outputDefined(){return !1}static parse(t,e){const n=t[0],r=Br.definitions[n];if(!r)return e.error(`Unknown expression "${n}". If you wanted a literal array, use ["literal", [...]].`,0);const i=Array.isArray(r)?r[0]:r.type,s=Array.isArray(r)?[[r[1],r[2]]]:r.overloads,o=s.filter((([e])=>!Array.isArray(e)||e.length===t.length-1));let a=null;for(const[r,s]of o){a=new Qe(e.registry,$r,e.path,null,e.scope);const o=[];let l=!1;for(let e=1;e{return e=t,Array.isArray(e)?`(${e.map(Qt).join(", ")})`:`(${Qt(e.type)}...)`;var e;})).join(" | "),r=[];for(let n=1;n{n=e?n&&$r(t):n&&t instanceof Xe;})),!!n&&Rr(t)&&Ur(t,["zoom","heatmap-density","elevation","line-progress","accumulated","is-supported-script"])}function Rr(t){if(t instanceof Br){if("get"===t.name&&1===t.args.length)return !1;if("feature-state"===t.name)return !1;if("has"===t.name&&1===t.args.length)return !1;if("properties"===t.name||"geometry-type"===t.name||"id"===t.name)return !1;if(/^filter-/.test(t.name))return !1}if(t instanceof Jn)return !1;if(t instanceof Pr)return !1;let e=!0;return t.eachChild((t=>{e&&!Rr(t)&&(e=!1);})),e}function Nr(t){if(t instanceof Br&&"feature-state"===t.name)return !1;let e=!0;return t.eachChild((t=>{e&&!Nr(t)&&(e=!1);})),e}function Ur(t,e){if(t instanceof Br&&e.indexOf(t.name)>=0)return !1;let n=!0;return t.eachChild((t=>{n&&!Ur(t,e)&&(n=!1);})),n}function jr(t){return {result:"success",value:t}}function qr(t){return {result:"error",value:t}}function Gr(t){return "data-driven"===t["property-type"]||"cross-faded-data-driven"===t["property-type"]}function Xr(t){return !!t.expression&&t.expression.parameters.indexOf("zoom")>-1}function Yr(t){return !!t.expression&&t.expression.interpolated}function Zr(t){return t instanceof Number?"number":t instanceof String?"string":t instanceof Boolean?"boolean":Array.isArray(t)?"array":null===t?"null":typeof t}function Hr(t){return "object"==typeof t&&null!==t&&!Array.isArray(t)&&qe(t)===jt}function Wr(t){return t}function Kr(t,e){const n=t.stops&&"object"==typeof t.stops[0][0],r=n||!(n||void 0!==t.property),i=t.type||(Yr(e)?"exponential":"interval"),s=function(t){switch(t.type){case "color":return Te.parse;case "padding":return Be.parse;case "numberArray":return Ce.parse;case "colorArray":return Ve.parse;default:return null}}(e);if(s&&((t=Bt({},t)).stops&&(t.stops=t.stops.map((t=>[t[0],s(t[1])]))),t.default=s(t.default?t.default:e.default)),t.colorSpace&&"rgb"!==(o=t.colorSpace)&&"hcl"!==o&&"lab"!==o)throw new Error(`Unknown color space: "${t.colorSpace}"`);var o;const a=function(t){switch(t){case "exponential":return ei;case "interval":return ti;case "categorical":return Qr;case "identity":return ni;default:throw new Error(`Unknown function type "${t}"`)}}(i);let l,u;if("categorical"===i){l=Object.create(null);for(const e of t.stops)l[e[0]]=e[1];u=typeof t.stops[0][0];}if(n){const n={},r=[];for(let e=0;et[0])),evaluate:({zoom:n},r)=>ei({stops:i,base:t.base},e,n).evaluate(n,r)}}if(r){const n="exponential"===i?{name:"exponential",base:void 0!==t.base?t.base:1}:null;return {kind:"camera",interpolationType:n,interpolationFactor:mn.interpolationFactor.bind(void 0,n),zoomStops:t.stops.map((t=>t[0])),evaluate:({zoom:n})=>a(t,e,n,l,u)}}return {kind:"source",evaluate(n,r){const i=r&&r.properties?r.properties[t.property]:void 0;return void 0===i?Jr(t.default,e.default):a(t,e,i,l,u)}}}function Jr(t,e,n){return void 0!==t?t:void 0!==e?e:void 0!==n?n:void 0}function Qr(t,e,n,r,i){return Jr(typeof n===i?r[n]:void 0,t.default,e.default)}function ti(t,e,n){if("number"!==Zr(n))return Jr(t.default,e.default);const r=t.stops.length;if(1===r)return t.stops[0][1];if(n<=t.stops[0][0])return t.stops[0][1];if(n>=t.stops[r-1][0])return t.stops[r-1][1];const i=un(t.stops.map((t=>t[0])),n);return t.stops[i][1]}function ei(t,e,n){const r=void 0!==t.base?t.base:1;if("number"!==Zr(n))return Jr(t.default,e.default);const i=t.stops.length;if(1===i)return t.stops[0][1];if(n<=t.stops[0][0])return t.stops[0][1];if(n>=t.stops[i-1][0])return t.stops[i-1][1];const s=un(t.stops.map((t=>t[0])),n),o=function(t,e,n,r){const i=r-n,s=t-n;return 0===i?0:1===e?s/i:(Math.pow(e,s)-1)/(Math.pow(e,i)-1)}(n,r,t.stops[s][0],t.stops[s+1][0]),a=t.stops[s][1],l=t.stops[s+1][1],u=xn[e.type]||Wr;return "function"==typeof a.evaluate?{evaluate(...e){const n=a.evaluate.apply(void 0,e),r=l.evaluate.apply(void 0,e);if(void 0!==n&&void 0!==r)return u(n,r,o,t.colorSpace)}}:u(a,l,o,t.colorSpace)}function ni(t,e,n){switch(e.type){case "color":n=Te.parse(n);break;case "formatted":n=ze.fromString(n.toString());break;case "resolvedImage":n=Re.fromString(n.toString());break;case "padding":n=Be.parse(n);break;case "colorArray":n=Ve.parse(n);break;case "numberArray":n=Ce.parse(n);break;default:Zr(n)===e.type||"enum"===e.type&&e.values[n]||(n=void 0);}return Jr(n,t.default,e.default)}Br.register(zr,{error:[{kind:"error"},[$t],(t,[e])=>{throw new Le(e.evaluate(t))}],typeof:[$t,[qt],(t,[e])=>Qt(qe(e.evaluate(t)))],"to-rgba":[Jt(Ot,4),[Nt],(t,[e])=>{const[n,r,i,s]=e.evaluate(t).rgb;return [255*n,255*r,255*i,s]}],rgb:[Nt,[Ot,Ot,Ot],Cr],rgba:[Nt,[Ot,Ot,Ot,Ot],Cr],has:{type:Rt,overloads:[[[$t],(t,[e])=>Vr(e.evaluate(t),t.properties())],[[$t,jt],(t,[e,n])=>Vr(e.evaluate(t),n.evaluate(t))]]},get:{type:qt,overloads:[[[$t],(t,[e])=>Lr(e.evaluate(t),t.properties())],[[$t,jt],(t,[e,n])=>Lr(e.evaluate(t),n.evaluate(t))]]},"feature-state":[qt,[$t],(t,[e])=>Lr(e.evaluate(t),t.featureState||{})],properties:[jt,[],t=>t.properties()],"geometry-type":[$t,[],t=>t.geometryType()],id:[qt,[],t=>t.id()],zoom:[Ot,[],t=>t.globals.zoom],"heatmap-density":[Ot,[],t=>t.globals.heatmapDensity||0],elevation:[Ot,[],t=>t.globals.elevation||0],"line-progress":[Ot,[],t=>t.globals.lineProgress||0],accumulated:[qt,[],t=>void 0===t.globals.accumulated?null:t.globals.accumulated],"+":[Ot,Or(Ot),(t,e)=>{let n=0;for(const r of e)n+=r.evaluate(t);return n}],"*":[Ot,Or(Ot),(t,e)=>{let n=1;for(const r of e)n*=r.evaluate(t);return n}],"-":{type:Ot,overloads:[[[Ot,Ot],(t,[e,n])=>e.evaluate(t)-n.evaluate(t)],[[Ot],(t,[e])=>-e.evaluate(t)]]},"/":[Ot,[Ot,Ot],(t,[e,n])=>e.evaluate(t)/n.evaluate(t)],"%":[Ot,[Ot,Ot],(t,[e,n])=>e.evaluate(t)%n.evaluate(t)],ln2:[Ot,[],()=>Math.LN2],pi:[Ot,[],()=>Math.PI],e:[Ot,[],()=>Math.E],"^":[Ot,[Ot,Ot],(t,[e,n])=>Math.pow(e.evaluate(t),n.evaluate(t))],sqrt:[Ot,[Ot],(t,[e])=>Math.sqrt(e.evaluate(t))],log10:[Ot,[Ot],(t,[e])=>Math.log(e.evaluate(t))/Math.LN10],ln:[Ot,[Ot],(t,[e])=>Math.log(e.evaluate(t))],log2:[Ot,[Ot],(t,[e])=>Math.log(e.evaluate(t))/Math.LN2],sin:[Ot,[Ot],(t,[e])=>Math.sin(e.evaluate(t))],cos:[Ot,[Ot],(t,[e])=>Math.cos(e.evaluate(t))],tan:[Ot,[Ot],(t,[e])=>Math.tan(e.evaluate(t))],asin:[Ot,[Ot],(t,[e])=>Math.asin(e.evaluate(t))],acos:[Ot,[Ot],(t,[e])=>Math.acos(e.evaluate(t))],atan:[Ot,[Ot],(t,[e])=>Math.atan(e.evaluate(t))],min:[Ot,Or(Ot),(t,e)=>Math.min(...e.map((e=>e.evaluate(t))))],max:[Ot,Or(Ot),(t,e)=>Math.max(...e.map((e=>e.evaluate(t))))],abs:[Ot,[Ot],(t,[e])=>Math.abs(e.evaluate(t))],round:[Ot,[Ot],(t,[e])=>{const n=e.evaluate(t);return n<0?-Math.round(-n):Math.round(n)}],floor:[Ot,[Ot],(t,[e])=>Math.floor(e.evaluate(t))],ceil:[Ot,[Ot],(t,[e])=>Math.ceil(e.evaluate(t))],"filter-==":[Rt,[$t,qt],(t,[e,n])=>t.properties()[e.value]===n.value],"filter-id-==":[Rt,[qt],(t,[e])=>t.id()===e.value],"filter-type-==":[Rt,[$t],(t,[e])=>t.geometryType()===e.value],"filter-<":[Rt,[$t,qt],(t,[e,n])=>{const r=t.properties()[e.value],i=n.value;return typeof r==typeof i&&r{const n=t.id(),r=e.value;return typeof n==typeof r&&n":[Rt,[$t,qt],(t,[e,n])=>{const r=t.properties()[e.value],i=n.value;return typeof r==typeof i&&r>i}],"filter-id->":[Rt,[qt],(t,[e])=>{const n=t.id(),r=e.value;return typeof n==typeof r&&n>r}],"filter-<=":[Rt,[$t,qt],(t,[e,n])=>{const r=t.properties()[e.value],i=n.value;return typeof r==typeof i&&r<=i}],"filter-id-<=":[Rt,[qt],(t,[e])=>{const n=t.id(),r=e.value;return typeof n==typeof r&&n<=r}],"filter->=":[Rt,[$t,qt],(t,[e,n])=>{const r=t.properties()[e.value],i=n.value;return typeof r==typeof i&&r>=i}],"filter-id->=":[Rt,[qt],(t,[e])=>{const n=t.id(),r=e.value;return typeof n==typeof r&&n>=r}],"filter-has":[Rt,[qt],(t,[e])=>e.value in t.properties()],"filter-has-id":[Rt,[],t=>null!==t.id()&&void 0!==t.id()],"filter-type-in":[Rt,[Jt($t)],(t,[e])=>e.value.indexOf(t.geometryType())>=0],"filter-id-in":[Rt,[Jt(qt)],(t,[e])=>e.value.indexOf(t.id())>=0],"filter-in-small":[Rt,[$t,Jt(qt)],(t,[e,n])=>n.value.indexOf(t.properties()[e.value])>=0],"filter-in-large":[Rt,[$t,Jt(qt)],(t,[e,n])=>function(t,e,n,r){for(;n<=r;){const i=n+r>>1;if(e[i]===t)return !0;e[i]>t?r=i-1:n=i+1;}return !1}(t.properties()[e.value],n.value,0,n.value.length-1)],all:{type:Rt,overloads:[[[Rt,Rt],(t,[e,n])=>e.evaluate(t)&&n.evaluate(t)],[Or(Rt),(t,e)=>{for(const n of e)if(!n.evaluate(t))return !1;return !0}]]},any:{type:Rt,overloads:[[[Rt,Rt],(t,[e,n])=>e.evaluate(t)||n.evaluate(t)],[Or(Rt),(t,e)=>{for(const n of e)if(n.evaluate(t))return !0;return !1}]]},"!":[Rt,[Rt],(t,[e])=>!e.evaluate(t)],"is-supported-script":[Rt,[$t],(t,[e])=>{const n=t.globals&&t.globals.isSupportedScript;return !n||n(e.evaluate(t))}],upcase:[$t,[$t],(t,[e])=>e.evaluate(t).toUpperCase()],downcase:[$t,[$t],(t,[e])=>e.evaluate(t).toLowerCase()],concat:[$t,Or(qt),(t,e)=>e.map((e=>Ge(e.evaluate(t)))).join("")],split:[Jt($t),[$t,$t],(t,[e,n])=>e.evaluate(t).split(n.evaluate(t))],join:[$t,[Jt($t),$t],(t,[e,n])=>e.evaluate(t).join(n.evaluate(t))],"resolved-locale":[$t,[Gt],(t,[e])=>e.evaluate(t).resolvedLocale()]});class ri{constructor(t,e,n){this.expression=t,this._warningHistory={},this._evaluator=new Je,this._defaultValue=e?function(t){if("color"===t.type&&Hr(t.default))return new Te(0,0,0,0);switch(t.type){case "color":return Te.parse(t.default)||null;case "padding":return Be.parse(t.default)||null;case "numberArray":return Ce.parse(t.default)||null;case "colorArray":return Ve.parse(t.default)||null;case "variableAnchorOffsetCollection":return $e.parse(t.default)||null;case "projectionDefinition":return Ne.parse(t.default)||null;default:return void 0===t.default?null:t.default}}(e):null,this._enumValues=e&&"enum"===e.type?e.values:null,this._globalState=n;}evaluateWithoutErrorHandling(t,e,n,r,i,s){return this._globalState&&(t=pi(t,this._globalState)),this._evaluator.globals=t,this._evaluator.feature=e,this._evaluator.featureState=n,this._evaluator.canonical=r,this._evaluator.availableImages=i||null,this._evaluator.formattedSection=s,this.expression.evaluate(this._evaluator)}evaluate(t,e,n,r,i,s){this._globalState&&(t=pi(t,this._globalState)),this._evaluator.globals=t,this._evaluator.feature=e||null,this._evaluator.featureState=n||null,this._evaluator.canonical=r,this._evaluator.availableImages=i||null,this._evaluator.formattedSection=s||null;try{const t=this.expression.evaluate(this._evaluator);if(null==t||"number"==typeof t&&t!=t)return this._defaultValue;if(this._enumValues&&!(t in this._enumValues))throw new Le(`Expected value to be one of ${Object.keys(this._enumValues).map((t=>JSON.stringify(t))).join(", ")}, but found ${JSON.stringify(t)} instead.`);return t}catch(t){return this._warningHistory[t.message]||(this._warningHistory[t.message]=!0,"undefined"!=typeof console&&console.warn(t.message)),this._defaultValue}}}function ii(t){return Array.isArray(t)&&t.length>0&&"string"==typeof t[0]&&t[0]in zr}function si(t,e,n){const r=new Qe(zr,$r,[],e?function(t){const e={color:Nt,string:$t,number:Ot,enum:$t,boolean:Rt,formatted:Xt,padding:Yt,numberArray:Ht,colorArray:Zt,projectionDefinition:Ut,resolvedImage:Wt,variableAnchorOffsetCollection:Kt};return "array"===t.type?Jt(e[t.value]||qt,t.length):e[t.type]}(e):void 0),i=r.parse(t,void 0,void 0,void 0,e&&"string"===e.type?{typeAnnotation:"coerce"}:void 0);return i?jr(new ri(i,e,n)):qr(r.errors)}class oi{constructor(t,e,n){this.kind=t,this._styleExpression=e,this.isStateDependent="constant"!==t&&!Nr(e.expression),this.globalStateRefs=hi(e.expression),this._globalState=n;}evaluateWithoutErrorHandling(t,e,n,r,i,s){return this._globalState&&(t=pi(t,this._globalState)),this._styleExpression.evaluateWithoutErrorHandling(t,e,n,r,i,s)}evaluate(t,e,n,r,i,s){return this._globalState&&(t=pi(t,this._globalState)),this._styleExpression.evaluate(t,e,n,r,i,s)}}class ai{constructor(t,e,n,r,i){this.kind=t,this.zoomStops=n,this._styleExpression=e,this.isStateDependent="camera"!==t&&!Nr(e.expression),this.globalStateRefs=hi(e.expression),this.interpolationType=r,this._globalState=i;}evaluateWithoutErrorHandling(t,e,n,r,i,s){return this._globalState&&(t=pi(t,this._globalState)),this._styleExpression.evaluateWithoutErrorHandling(t,e,n,r,i,s)}evaluate(t,e,n,r,i,s){return this._globalState&&(t=pi(t,this._globalState)),this._styleExpression.evaluate(t,e,n,r,i,s)}interpolationFactor(t,e,n){return this.interpolationType?mn.interpolationFactor(this.interpolationType,t,e,n):0}}function li(t,e,n){const r=si(t,e,n);if("error"===r.result)return r;const i=r.value.expression,s=Rr(i);if(!s&&!Gr(e))return qr([new Ct("","data expressions not supported")]);const o=Ur(i,["zoom"]);if(!o&&!Xr(e))return qr([new Ct("","zoom expressions not supported")]);const a=ci(i);return a||o?a instanceof Ct?qr([a]):a instanceof mn&&!Yr(e)?qr([new Ct("",'"interpolate" expressions cannot be used with this property')]):jr(a?new ai(s?"camera":"composite",r.value,a.labels,a instanceof mn?a.interpolation:void 0,n):new oi(s?"constant":"source",r.value,n)):qr([new Ct("",'"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.')])}class ui{constructor(t,e){this._parameters=t,this._specification=e,Bt(this,Kr(this._parameters,this._specification));}static deserialize(t){return new ui(t._parameters,t._specification)}static serialize(t){return {_parameters:t._parameters,_specification:t._specification}}}function ci(t){let e=null;if(t instanceof tn)e=ci(t.result);else if(t instanceof vn){for(const n of t.args)if(e=ci(n),e)break}else (t instanceof cn||t instanceof mn)&&t.input instanceof Br&&"zoom"===t.input.name&&(e=t);return e instanceof Ct||t.eachChild((t=>{const n=ci(t);n instanceof Ct?e=n:!e&&n?e=new Ct("",'"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.'):e&&n&&e!==n&&(e=new Ct("",'Only one zoom-based "step" or "interpolate" subexpression may be used in an expression.'));})),e}function hi(t,e=new Set){return t instanceof Dr&&e.add(t.key),t.eachChild((t=>{hi(t,e);})),e}function pi(t,e){const{zoom:n,heatmapDensity:r,elevation:i,lineProgress:s,isSupportedScript:o,accumulated:a}=null!=t?t:{};return {zoom:n,heatmapDensity:r,elevation:i,lineProgress:s,isSupportedScript:o,accumulated:a,globalState:e}}function fi(t){if(!0===t||!1===t)return !0;if(!Array.isArray(t)||0===t.length)return !1;switch(t[0]){case "has":return t.length>=2&&"$id"!==t[1]&&"$type"!==t[1];case "in":return t.length>=3&&("string"!=typeof t[1]||Array.isArray(t[2]));case "!in":case "!has":case "none":return !1;case "==":case "!=":case ">":case ">=":case "<":case "<=":return 3!==t.length||Array.isArray(t[1])||Array.isArray(t[2]);case "any":case "all":for(const e of t.slice(1))if(!fi(e)&&"boolean"!=typeof e)return !1;return !0;default:return !0}}const di={type:"boolean",default:!1,transition:!1,"property-type":"data-driven",expression:{interpolated:!1,parameters:["zoom","feature"]}};function yi(t,e){if(null==t)return {filter:()=>!0,needGeometry:!1,getGlobalStateRefs:()=>new Set};fi(t)||(t=xi(t));const n=si(t,di,e);if("error"===n.result)throw new Error(n.value.map((t=>`${t.key}: ${t.message}`)).join(", "));return {filter:(t,e,r)=>n.value.evaluate(t,e,{},r),needGeometry:gi(t),getGlobalStateRefs:()=>hi(n.value.expression)}}function mi(t,e){return te?1:0}function gi(t){if(!Array.isArray(t))return !1;if("within"===t[0]||"distance"===t[0])return !0;for(let e=1;e"===e||"<="===e||">="===e?vi(t[1],t[2],e):"any"===e?(n=t.slice(1),["any"].concat(n.map(xi))):"all"===e?["all"].concat(t.slice(1).map(xi)):"none"===e?["all"].concat(t.slice(1).map(xi).map(_i)):"in"===e?bi(t[1],t.slice(2)):"!in"===e?_i(bi(t[1],t.slice(2))):"has"===e?wi(t[1]):"!has"!==e||_i(wi(t[1]));var n;}function vi(t,e,n){switch(t){case "$type":return [`filter-type-${n}`,e];case "$id":return [`filter-id-${n}`,e];default:return [`filter-${n}`,t,e]}}function bi(t,e){if(0===e.length)return !1;switch(t){case "$type":return ["filter-type-in",["literal",e]];case "$id":return ["filter-id-in",["literal",e]];default:return e.length>200&&!e.some((t=>typeof t!=typeof e[0]))?["filter-in-large",t,["literal",e.sort(mi)]]:["filter-in-small",t,["literal",e]]}}function wi(t){switch(t){case "$type":return !0;case "$id":return ["filter-has-id"];default:return ["filter-has",t]}}function _i(t){return ["!",t]}function Si(t){const e=typeof t;if("number"===e||"boolean"===e||"string"===e||null==t)return JSON.stringify(t);if(Array.isArray(t)){let e="[";for(const n of t)e+=`${Si(n)},`;return `${e}]`}const n=Object.keys(t).sort();let r="{";for(let e=0;er.maximum?[new zt(e,n,`${n} is greater than the maximum value ${r.maximum}`)]:[]}function Pi(t){const e=t.valueSpec,n=ki(t.value.type);let r,i,s,o={};const a="categorical"!==n&&void 0===t.value.property,l=!a,u="array"===Zr(t.value.stops)&&"array"===Zr(t.value.stops[0])&&"object"===Zr(t.value.stops[0][0]),c=Ei({key:t.key,value:t.value,valueSpec:t.styleSpec.function,validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec,objectElementValidators:{stops:function(t){if("identity"===n)return [new zt(t.key,t.value,'identity function may not have a "stops" property')];let e=[];const r=t.value;return e=e.concat(Ti({key:t.key,value:r,valueSpec:t.valueSpec,validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec,arrayElementValidator:h})),"array"===Zr(r)&&0===r.length&&e.push(new zt(t.key,r,"array must have at least one stop")),e},default:function(t){return t.validateSpec({key:t.key,value:t.value,valueSpec:e,validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec})}}});return "identity"===n&&a&&c.push(new zt(t.key,t.value,'missing required property "property"')),"identity"===n||t.value.stops||c.push(new zt(t.key,t.value,'missing required property "stops"')),"exponential"===n&&t.valueSpec.expression&&!Yr(t.valueSpec)&&c.push(new zt(t.key,t.value,"exponential functions not supported")),t.styleSpec.$version>=8&&(l&&!Gr(t.valueSpec)?c.push(new zt(t.key,t.value,"property functions not supported")):a&&!Xr(t.valueSpec)&&c.push(new zt(t.key,t.value,"zoom functions not supported"))),"categorical"!==n&&!u||void 0!==t.value.property||c.push(new zt(t.key,t.value,'"property" property is required')),c;function h(t){let n=[];const r=t.value,a=t.key;if("array"!==Zr(r))return [new zt(a,r,`array expected, ${Zr(r)} found`)];if(2!==r.length)return [new zt(a,r,`array length 2 expected, length ${r.length} found`)];if(u){if("object"!==Zr(r[0]))return [new zt(a,r,`object expected, ${Zr(r[0])} found`)];if(void 0===r[0].zoom)return [new zt(a,r,"object stop key must have zoom")];if(void 0===r[0].value)return [new zt(a,r,"object stop key must have value")];if(s&&s>ki(r[0].zoom))return [new zt(a,r[0].zoom,"stop zoom values must appear in ascending order")];ki(r[0].zoom)!==s&&(s=ki(r[0].zoom),i=void 0,o={}),n=n.concat(Ei({key:`${a}[0]`,value:r[0],valueSpec:{zoom:{}},validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec,objectElementValidators:{zoom:Fi,value:p}}));}else n=n.concat(p({key:`${a}[0]`,value:r[0],validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec},r));return ii(Ii(r[1]))?n.concat([new zt(`${a}[1]`,r[1],"expressions are not allowed in function stops.")]):n.concat(t.validateSpec({key:`${a}[1]`,value:r[1],valueSpec:e,validateSpec:t.validateSpec,style:t.style,styleSpec:t.styleSpec}))}function p(t,s){const a=Zr(t.value),l=ki(t.value),u=null!==t.value?t.value:s;if(r){if(a!==r)return [new zt(t.key,u,`${a} stop domain type must match previous stop domain type ${r}`)]}else r=a;if("number"!==a&&"string"!==a&&"boolean"!==a)return [new zt(t.key,u,"stop domain value must be a number, string, or boolean")];if("number"!==a&&"categorical"!==n){let r=`number expected, ${a} found`;return Gr(e)&&void 0===n&&(r+='\nIf you intended to use a categorical function, specify `"type": "categorical"`.'),[new zt(t.key,u,r)]}return "categorical"!==n||"number"!==a||isFinite(l)&&Math.floor(l)===l?"categorical"!==n&&"number"===a&&void 0!==i&&lnew zt(`${t.key}${e.key}`,t.value,e.message)));const n=e.value.expression||e.value._styleExpression.expression;if("property"===t.expressionContext&&"text-font"===t.propertyKey&&!n.outputDefined())return [new zt(t.key,t.value,`Invalid data expression for "${t.propertyKey}". Output values must be contained as literals within the expression.`)];if("property"===t.expressionContext&&"layout"===t.propertyType&&!Nr(n))return [new zt(t.key,t.value,'"feature-state" data expressions are not supported with layout properties.')];if("filter"===t.expressionContext&&!Nr(n))return [new zt(t.key,t.value,'"feature-state" data expressions are not supported with filters.')];if(t.expressionContext&&0===t.expressionContext.indexOf("cluster")){if(!Ur(n,["zoom","feature-state"]))return [new zt(t.key,t.value,'"zoom" and "feature-state" expressions are not supported with cluster properties.')];if("cluster-initial"===t.expressionContext&&!Rr(n))return [new zt(t.key,t.value,"Feature data expressions are not supported with initial expression part of cluster properties.")]}return []}function zi(t){const e=t.key,n=t.value,r=Zr(n);return "string"!==r?[new zt(e,n,`color expected, ${r} found`)]:Te.parse(String(n))?[]:[new zt(e,n,`color expected, "${n}" found`)]}function Bi(t){const e=t.key,n=t.value,r=t.valueSpec,i=[];return Array.isArray(r.values)?-1===r.values.indexOf(ki(n))&&i.push(new zt(e,n,`expected one of [${r.values.join(", ")}], ${JSON.stringify(n)} found`)):-1===Object.keys(r.values).indexOf(ki(n))&&i.push(new zt(e,n,`expected one of [${Object.keys(r.values).join(", ")}], ${JSON.stringify(n)} found`)),i}function Ci(t){return fi(Ii(t.value))?Di(Bt({},t,{expressionContext:"filter",valueSpec:{value:"boolean"}})):Vi(t)}function Vi(t){const e=t.value,n=t.key;if("array"!==Zr(e))return [new zt(n,e,`array expected, ${Zr(e)} found`)];const r=t.styleSpec;let i,s=[];if(e.length<1)return [new zt(n,e,"filter array must have at least 1 element")];switch(s=s.concat(Bi({key:`${n}[0]`,value:e[0],valueSpec:r.filter_operator,style:t.style,styleSpec:t.styleSpec})),ki(e[0])){case "<":case "<=":case ">":case ">=":e.length>=2&&"$type"===ki(e[1])&&s.push(new zt(n,e,`"$type" cannot be use with operator "${e[0]}"`));case "==":case "!=":3!==e.length&&s.push(new zt(n,e,`filter array for operator "${e[0]}" must have 3 elements`));case "in":case "!in":e.length>=2&&(i=Zr(e[1]),"string"!==i&&s.push(new zt(`${n}[1]`,e[1],`string expected, ${i} found`)));for(let o=2;o{t in i&&r.push(new zt(s,i[t],`"${t}" is prohibited for ref layers`));})),o.layers.forEach((e=>{ki(e.id)===u&&(t=e);})),t?t.ref?r.push(new zt(s,i.ref,"ref cannot reference another ref layer")):l=ki(t.type):r.push(new zt(s,i.ref,`ref layer "${u}" not found`));}else if("background"!==l)if(i.source){const t=o.sources&&o.sources[i.source],e=t&&ki(t.type);t?"vector"===e&&"raster"===l?r.push(new zt(s,i.source,`layer "${i.id}" requires a raster source`)):"raster-dem"!==e&&"hillshade"===l||"raster-dem"!==e&&"color-relief"===l?r.push(new zt(s,i.source,`layer "${i.id}" requires a raster-dem source`)):"raster"===e&&"raster"!==l?r.push(new zt(s,i.source,`layer "${i.id}" requires a vector source`)):"vector"!==e||i["source-layer"]?"raster-dem"===e&&"hillshade"!==l&&"color-relief"!==l?r.push(new zt(s,i.source,"raster-dem source can only be used with layer type 'hillshade' or 'color-relief'.")):"line"!==l||!i.paint||!i.paint["line-gradient"]||"geojson"===e&&t.lineMetrics||r.push(new zt(s,i,`layer "${i.id}" specifies a line-gradient, which requires a GeoJSON source with \`lineMetrics\` enabled.`)):r.push(new zt(s,i,`layer "${i.id}" must specify a "source-layer"`)):r.push(new zt(s,i.source,`source "${i.source}" not found`));}else r.push(new zt(s,i,'missing required property "source"'));return "raster"===l&&(null===(e=i.paint)||void 0===e?void 0:e.resampling)&&(null===(n=i.paint)||void 0===n?void 0:n["raster-resampling"])&&r.push(new zt(s,i.paint,`layer "${i.id}" redundantly specifies "resampling" and "raster-resampling" paint properties, but only one is allowed. It is advised to use "resampling".`)),r=r.concat(Ei({key:s,value:i,valueSpec:a.layer,style:t.style,styleSpec:t.styleSpec,validateSpec:t.validateSpec,objectElementValidators:{"*":()=>[],type:()=>t.validateSpec({key:`${s}.type`,value:i.type,valueSpec:a.layer.type,style:t.style,styleSpec:t.styleSpec,validateSpec:t.validateSpec,object:i,objectKey:"type"}),filter:Ci,layout:t=>Ei({layer:i,key:t.key,value:t.value,style:t.style,styleSpec:t.styleSpec,validateSpec:t.validateSpec,objectElementValidators:{"*":t=>$i(Bt({layerType:l},t))}}),paint:t=>Ei({layer:i,key:t.key,value:t.value,style:t.style,styleSpec:t.styleSpec,validateSpec:t.validateSpec,objectElementValidators:{"*":t=>Oi(Bt({layerType:l},t))}})}})),r}function Ni(t){const e=t.value,n=t.key,r=Zr(e);return "string"!==r?[new zt(n,e,`string expected, ${r} found`)]:[]}const Ui={promoteId:function({key:t,value:e}){if("string"===Zr(e))return Ni({key:t,value:e});{const n=[];for(const r in e)n.push(...Ni({key:`${t}.${r}`,value:e[r]}));return n}}};function ji(t){const e=t.value,n=t.key,r=t.styleSpec,i=t.style,s=t.validateSpec;if(!e.type)return [new zt(n,e,'"type" is required')];const o=ki(e.type);let a;switch(o){case "vector":case "raster":return a=Ei({key:n,value:e,valueSpec:r[`source_${o.replace("-","_")}`],style:t.style,styleSpec:r,objectElementValidators:Ui,validateSpec:s}),a;case "raster-dem":return a=function(t){var e;const n=null!==(e=t.sourceName)&&void 0!==e?e:"",r=t.value,i=t.styleSpec,s=i.source_raster_dem,o=t.style;let a=[];const l=Zr(r);if(void 0===r)return a;if("object"!==l)return a.push(new zt("source_raster_dem",r,`object expected, ${l} found`)),a;const u="custom"===ki(r.encoding),c=["redFactor","greenFactor","blueFactor","baseShift"],h=t.value.encoding?`"${t.value.encoding}"`:"Default";for(const e in r)!u&&c.includes(e)?a.push(new zt(e,r[e],`In "${n}": "${e}" is only valid when "encoding" is set to "custom". ${h} encoding found`)):s[e]?a=a.concat(t.validateSpec({key:e,value:r[e],valueSpec:s[e],validateSpec:t.validateSpec,style:o,styleSpec:i})):a.push(new zt(e,r[e],`unknown property "${e}"`));return a}({sourceName:n,value:e,style:t.style,styleSpec:r,validateSpec:s}),a;case "geojson":if(a=Ei({key:n,value:e,valueSpec:r.source_geojson,style:i,styleSpec:r,validateSpec:s,objectElementValidators:Ui}),e.cluster)for(const t in e.clusterProperties){const[r,i]=e.clusterProperties[t],s="string"==typeof r?[r,["accumulated"],["get",t]]:r;a.push(...Di({key:`${n}.${t}.map`,value:i,expressionContext:"cluster-map"})),a.push(...Di({key:`${n}.${t}.reduce`,value:s,expressionContext:"cluster-reduce"}));}return a;case "video":return Ei({key:n,value:e,valueSpec:r.source_video,style:i,validateSpec:s,styleSpec:r});case "image":return Ei({key:n,value:e,valueSpec:r.source_image,style:i,validateSpec:s,styleSpec:r});case "canvas":return [new zt(n,null,"Please use runtime APIs to add canvas sources, rather than including them in stylesheets.","source.canvas")];default:return Bi({key:`${n}.type`,value:e.type,valueSpec:{values:["vector","raster","raster-dem","geojson","video","image"]}})}}function qi(t){const e=t.value,n=t.styleSpec,r=n.light,i=t.style;let s=[];const o=Zr(e);if(void 0===e)return s;if("object"!==o)return s=s.concat([new zt("light",e,`object expected, ${o} found`)]),s;for(const o in e){const a=o.match(/^(.*)-transition$/);s=s.concat(a&&r[a[1]]&&r[a[1]].transition?t.validateSpec({key:o,value:e[o],valueSpec:n.transition,validateSpec:t.validateSpec,style:i,styleSpec:n}):r[o]?t.validateSpec({key:o,value:e[o],valueSpec:r[o],validateSpec:t.validateSpec,style:i,styleSpec:n}):[new zt(o,e[o],`unknown property "${o}"`)]);}return s}function Gi(t){const e=t.value,n=t.styleSpec,r=n.sky,i=t.style,s=Zr(e);if(void 0===e)return [];if("object"!==s)return [new zt("sky",e,`object expected, ${s} found`)];let o=[];for(const s in e)o=o.concat(r[s]?t.validateSpec({key:s,value:e[s],valueSpec:r[s],style:i,styleSpec:n}):[new zt(s,e[s],`unknown property "${s}"`)]);return o}function Xi(t){const e=t.value,n=t.styleSpec,r=n.terrain,i=t.style;let s=[];const o=Zr(e);if(void 0===e)return s;if("object"!==o)return s=s.concat([new zt("terrain",e,`object expected, ${o} found`)]),s;for(const o in e)s=s.concat(r[o]?t.validateSpec({key:o,value:e[o],valueSpec:r[o],validateSpec:t.validateSpec,style:i,styleSpec:n}):[new zt(o,e[o],`unknown property "${o}"`)]);return s}function Yi(t){let e=[];const n=t.value,r=t.key;if(Array.isArray(n)){const i=[],s=[];for(const o in n)n[o].id&&i.includes(n[o].id)&&e.push(new zt(r,n,`all the sprites' ids must be unique, but ${n[o].id} is duplicated`)),i.push(n[o].id),n[o].url&&s.includes(n[o].url)&&e.push(new zt(r,n,`all the sprites' URLs must be unique, but ${n[o].url} is duplicated`)),s.push(n[o].url),e=e.concat(Ei({key:`${r}[${o}]`,value:n[o],valueSpec:{id:{type:"string",required:!0},url:{type:"string",required:!0}},validateSpec:t.validateSpec}));return e}return Ni({key:r,value:n})}function Zi(t){return Boolean(t)&&t.constructor===Object}function Hi(t){return Zi(t.value)?[]:[new zt(t.key,t.value,`object expected, ${Zr(t.value)} found`)]}const Wi={"*":()=>[],array:Ti,boolean:function(t){const e=t.value,n=t.key,r=Zr(e);return "boolean"!==r?[new zt(n,e,`boolean expected, ${r} found`)]:[]},number:Fi,color:zi,constants:Mi,enum:Bi,filter:Ci,function:Pi,layer:Ri,object:Ei,source:ji,light:qi,sky:Gi,terrain:Xi,projection:function(t){const e=t.value,n=t.styleSpec,r=n.projection,i=t.style,s=Zr(e);if(void 0===e)return [];if("object"!==s)return [new zt("projection",e,`object expected, ${s} found`)];let o=[];for(const s in e)o=o.concat(r[s]?t.validateSpec({key:s,value:e[s],valueSpec:r[s],style:i,styleSpec:n}):[new zt(s,e[s],`unknown property "${s}"`)]);return o},projectionDefinition:function(t){const e=t.key;let n=t.value;n=n instanceof String?n.valueOf():n;const r=Zr(n);return "array"!==r||function(t){return Array.isArray(t)&&3===t.length&&"string"==typeof t[0]&&"string"==typeof t[1]&&"number"==typeof t[2]}(n)||function(t){return !!["interpolate","step","literal"].includes(t[0])}(n)?["array","string"].includes(r)?[]:[new zt(e,n,`projection expected, invalid type "${r}" found`)]:[new zt(e,n,`projection expected, invalid array ${JSON.stringify(n)} found`)]},string:Ni,formatted:function(t){return 0===Ni(t).length?[]:Di(t)},resolvedImage:function(t){return 0===Ni(t).length?[]:Di(t)},padding:function(t){const e=t.key,n=t.value;if("array"===Zr(n)){if(n.length<1||n.length>4)return [new zt(e,n,`padding requires 1 to 4 values; ${n.length} values found`)];const r={type:"number"};let i=[];for(let s=0;s[]}})),t.constants&&(n=n.concat(Mi({key:"constants",value:t.constants}))),es(n)}function ts(t){return function(e){return t(Object.assign({},e,{validateSpec:Ki}))}}function es(t){return [].concat(t).sort(((t,e)=>t.line-e.line))}function ns(t){return function(...e){return es(t.apply(this,e))}}Qi.source=ns(ts(ji)),Qi.sprite=ns(ts(Yi)),Qi.glyphs=ns(ts(Ji)),Qi.light=ns(ts(qi)),Qi.sky=ns(ts(Gi)),Qi.terrain=ns(ts(Xi)),Qi.state=ns(ts(Hi)),Qi.layer=ns(ts(Ri)),Qi.filter=ns(ts(Ci)),Qi.paintProperty=ns(ts(Oi)),Qi.layoutProperty=ns(ts($i));const rs={type:"enum","property-type":"data-constant",expression:{interpolated:!1,parameters:["global-state"]},values:{visible:{},none:{}},transition:!1,default:"visible"};class is{constructor(t,e){this._globalState=e,this.setValue(t);}evaluate(){var t;return null!==(t=this._literalValue)&&void 0!==t?t:this._compiledValue.evaluate({})}setValue(t){if(null==t||"visible"===t||"none"===t)return this._literalValue="none"===t?"none":"visible",this._compiledValue=void 0,void(this._globalStateRefs=new Set);const e=si(t,rs,this._globalState);if("error"===e.result)throw this._literalValue="visible",this._compiledValue=void 0,new Error(e.value.map((t=>`${t.key}: ${t.message}`)).join(", "));this._literalValue=void 0,this._compiledValue=e.value,this._globalStateRefs=hi(e.value.expression);}getGlobalStateRefs(){return this._globalStateRefs}}const ss=wt,os=Qi,as=os.light,ls=os.sky,us=os.paintProperty,cs=os.layoutProperty;function hs(t,e){let n=!1;if(null==e?void 0:e.length)for(const r of e)t.fire(new vt(new Error(r.message))),n=!0;return n}class ps{constructor(t,e,n){const r=this.cells=[];if(t instanceof ArrayBuffer){this.arrayBuffer=t;const i=new Int32Array(this.arrayBuffer);t=i[0],this.d=(e=i[1])+2*(n=i[2]);for(let t=0;t=u[l+0]&&r>=u[l+1])?(o[c]=!0,s.push(i[c])):o[c]=!1;}}}_forEachCell(t,e,n,r,i,s,o,a){const l=this._convertToCellCoord(t),u=this._convertToCellCoord(e),c=this._convertToCellCoord(n),h=this._convertToCellCoord(r);for(let p=l;p<=c;p++)for(let l=u;l<=h;l++){const u=this.d*l+p;if((!a||a(this._convertFromCellCoord(p),this._convertFromCellCoord(l),this._convertFromCellCoord(p+1),this._convertFromCellCoord(l+1)))&&i.call(this,t,e,n,r,u,s,o,a))return}}_convertFromCellCoord(t){return (t-this.padding)/this.scale}_convertToCellCoord(t){return Math.max(0,Math.min(this.d-1,Math.floor(t*this.scale)+this.padding))}toArrayBuffer(){if(this.arrayBuffer)return this.arrayBuffer;const t=this.cells,e=3+this.cells.length+1+1;let n=0;for(const t of this.cells)n+=t.length;const r=new Int32Array(e+n+this.keys.length+this.bboxes.length);r[0]=this.extent,r[1]=this.n,r[2]=this.padding;let i=e;for(let e=0;en?(this.lastIntegerZoom=n+1,this.lastIntegerZoomTime=e):this.lastFloorZoom{try{return new RegExp(`\\p{sc=${t}}`,"u").source}catch(t){return null}})).filter((t=>t));return new RegExp(e.join("|"),"u")}const Is=ks(["Arab","Dupl","Mong","Ougr","Syrc"]);function Es(t){return !Is.test(String.fromCodePoint(t))}function Ts(t){return !(_s(t)||(e=t,/[\xA7\xA9\xAE\xB1\xBC-\xBE\xD7\xF7\u2016\u2020\u2021\u2030\u2031\u203B\u203C\u2042\u2047-\u2049\u2051\u2100-\u218F\u221E\u2234\u2235\u2300-\u2307\u230C-\u231F\u2324-\u2328\u232B\u237D-\u239A\u23BE-\u23CD\u23CF\u23D1-\u23DB\u23E2-\u2422\u2424-\u24FF\u25A0-\u2619\u2620-\u2767\u2776-\u2793\u2B12-\u2B2F\u2B50-\u2B59\u2BB8-\u2BEB\u3000-\u303F\u30A0-\u30FF\uE000-\uF8FF\uFE30-\uFE6F\uFF00-\uFFEF\uFFFC\uFFFD]|[\uDB80-\uDBFF][\uDC00-\uDFFF]/gim.test(String.fromCodePoint(e))));var e;}const Fs=ks(["Adlm","Arab","Armi","Avst","Chrs","Cprt","Egyp","Elym","Gara","Hatr","Hebr","Hung","Khar","Lydi","Mand","Mani","Mend","Merc","Mero","Narb","Nbat","Nkoo","Orkh","Palm","Phli","Phlp","Phnx","Prti","Rohg","Samr","Sarb","Sogo","Syrc","Thaa","Todr","Yezi"]);function Ps(t){return Fs.test(String.fromCodePoint(t))}function Ds(t,e){return !(!e&&Ps(t)||/[\u0900-\u0DFF\u0F00-\u109F\u1780-\u17FF]/gim.test(String.fromCodePoint(t)))}function zs(t){for(const e of t)if(Ps(e.codePointAt(0)))return !0;return !1}const Bs=new class{constructor(){this.TIMEOUT=5e3,this.applyArabicShaping=null,this.processBidirectionalText=null,this.processStyledBidirectionalText=null,this.pluginStatus="unavailable",this.pluginURL=null,this.loadScriptResolve=()=>{};}setState(t){this.pluginStatus=t.pluginStatus,this.pluginURL=t.pluginURL;}getState(){return {pluginStatus:this.pluginStatus,pluginURL:this.pluginURL}}setMethods(t){if(Bs.isParsed())throw new Error("RTL text plugin already registered.");this.applyArabicShaping=t.applyArabicShaping,this.processBidirectionalText=t.processBidirectionalText,this.processStyledBidirectionalText=t.processStyledBidirectionalText,this.loadScriptResolve();}isParsed(){return null!=this.applyArabicShaping&&null!=this.processBidirectionalText&&null!=this.processStyledBidirectionalText}getRTLTextPluginStatus(){return this.pluginStatus}syncState(t,n){return e(this,void 0,void 0,(function*(){if(this.isParsed())return this.getState();if("loading"!==t.pluginStatus)return this.setState(t),t;const e=t.pluginURL,r=new Promise((t=>{this.loadScriptResolve=t;}));n(e);const i=new Promise((t=>setTimeout((()=>t()),this.TIMEOUT)));if(yield Promise.race([r,i]),this.isParsed()){const t={pluginStatus:"loaded",pluginURL:e};return this.setState(t),t}throw this.setState({pluginStatus:"error",pluginURL:""}),new Error(`RTL Text Plugin failed to import scripts from ${e}`)}))}};class Cs{constructor(t,e){this.isSupportedScript=Vs,this.zoom=t,e?(this.now=e.now||0,this.fadeDuration=e.fadeDuration||0,this.zoomHistory=e.zoomHistory||new bs,this.transition=e.transition||{}):(this.now=0,this.fadeDuration=0,this.zoomHistory=new bs,this.transition={});}crossFadingFactor(){return 0===this.fadeDuration?1:Math.min((this.now-this.zoomHistory.lastIntegerZoomTime)/this.fadeDuration,1)}getCrossfadeParameters(){const t=this.zoom,e=t-Math.floor(t),n=this.crossFadingFactor();return t>this.zoomHistory.lastIntegerZoom?{fromScale:2,toScale:1,t:e+(1-e)*n}:{fromScale:.5,toScale:1,t:1-(1-n)*e}}}function Vs(t){return function(t,e){for(const n of t)if(!Ds(n.codePointAt(0),e))return !1;return !0}(t,"loaded"===Bs.getRTLTextPluginStatus())}const Ls="-transition";class Os{constructor(t,e,n){this.property=t,this.value=e,this.expression=function(t,e,n){if(Hr(t))return new ui(t,e);if(ii(t)){const r=li(t,e,n);if("error"===r.result)throw new Error(r.value.map((t=>`${t.key}: ${t.message}`)).join(", "));return r.value}{let n=t;return "color"===e.type&&"string"==typeof t?n=Te.parse(t):"padding"!==e.type||"number"!=typeof t&&!Array.isArray(t)?"numberArray"!==e.type||"number"!=typeof t&&!Array.isArray(t)?"colorArray"!==e.type||"string"!=typeof t&&!Array.isArray(t)?"variableAnchorOffsetCollection"===e.type&&Array.isArray(t)?n=$e.parse(t):"projectionDefinition"===e.type&&"string"==typeof t&&(n=Ne.parse(t)):n=Ve.parse(t):n=Ce.parse(t):n=Be.parse(t),{globalStateRefs:new Set,_globalState:null,kind:"constant",evaluate:()=>n}}}(void 0===e?t.specification.default:e,t.specification,n);}isDataDriven(){return "source"===this.expression.kind||"composite"===this.expression.kind}getGlobalStateRefs(){return this.expression.globalStateRefs||new Set}possiblyEvaluate(t,e,n){return this.property.possiblyEvaluate(this,t,e,n)}}class $s{constructor(t,e){this.property=t,this.value=new Os(t,void 0,e);}transitioned(t,e){return new Ns(this.property,this.value,e,$({},t.transition,this.transition),t.now)}untransitioned(){return new Ns(this.property,this.value,null,{},0)}}class Rs{constructor(t,e){this._properties=t,this._values=Object.create(t.defaultTransitionablePropertyValues),this._globalState=e;}hasProperty(t){return t in this._properties.defaultTransitionablePropertyValues}getValue(t){return j(this._values[t].value.value)}setValue(t,e){Object.hasOwn(this._values,t)||(this._values[t]=new $s(this._values[t].property,this._globalState)),this._values[t].value=new Os(this._values[t].property,null===e?void 0:j(e),this._globalState);}getTransition(t){return j(this._values[t].transition)}setTransition(t,e){Object.hasOwn(this._values,t)||(this._values[t]=new $s(this._values[t].property,this._globalState)),this._values[t].transition=j(e)||void 0;}serialize(){const t={};for(const e of Object.keys(this._values)){const n=this.getValue(e);void 0!==n&&(t[e]=n);const r=this.getTransition(e);void 0!==r&&(t[`${e}${Ls}`]=r);}return t}transitioned(t,e){const n=new Us(this._properties);for(const r of Object.keys(this._values))n._values[r]=this._values[r].transitioned(t,e._values[r]);return n}untransitioned(){const t=new Us(this._properties);for(const e of Object.keys(this._values))t._values[e]=this._values[e].untransitioned();return t}}class Ns{constructor(t,e,n,r,i){this.property=t,this.value=e,this.begin=i+r.delay||0,this.end=this.begin+r.duration||0,t.specification.transition&&(r.delay||r.duration)&&(this.prior=n);}possiblyEvaluate(t,e,n){const r=t.now||0,i=this.value.possiblyEvaluate(t,e,n),s=this.prior;if(s){if(r>this.end)return this.prior=null,i;if(this.value.isDataDriven())return this.prior=null,i;if(rr.zoomHistory.lastIntegerZoom?{from:t,to:e}:{from:n,to:e}}interpolate(t){return t}}class Hs{constructor(t){this.specification=t;}possiblyEvaluate(t,e,n,r){if(void 0!==t.value){if("constant"===t.expression.kind){const i=t.expression.evaluate(e,null,{},n,r);return this._calculate(i,i,i,e)}return this._calculate(t.expression.evaluate(new Cs(Math.floor(e.zoom-1),e)),t.expression.evaluate(new Cs(Math.floor(e.zoom),e)),t.expression.evaluate(new Cs(Math.floor(e.zoom+1),e)),e)}}_calculate(t,e,n,r){return r.zoom>r.zoomHistory.lastIntegerZoom?{from:t,to:e}:{from:n,to:e}}interpolate(t){return t}}class Ws{constructor(t){this.specification=t;}possiblyEvaluate(t,e,n,r){return !!t.expression.evaluate(e,null,{},n,r)}interpolate(){return !1}}class Ks{constructor(t){this.properties=t,this.defaultPropertyValues={},this.defaultTransitionablePropertyValues={},this.defaultTransitioningPropertyValues={},this.defaultPossiblyEvaluatedValues={},this.overridableProperties=[];for(const e in t){const n=t[e];n.specification.overridable&&this.overridableProperties.push(e);const r=this.defaultPropertyValues[e]=new Os(n,void 0,void 0),i=this.defaultTransitionablePropertyValues[e]=new $s(n,void 0);this.defaultTransitioningPropertyValues[e]=i.untransitioned(),this.defaultPossiblyEvaluatedValues[e]=r.possiblyEvaluate({});}}}ds("DataDrivenProperty",Ys),ds("DataConstantProperty",Xs),ds("CrossFadedDataDrivenProperty",Zs),ds("CrossFadedProperty",Hs),ds("ColorRampProperty",Ws);const Js=" is a PAINT property not a LAYOUT property. Use get/setPaintProperty instead?",Qs=" is a LAYOUT property not a PAINT property. Use get/setLayoutProperty instead?";class to extends bt{constructor(t,e,n){if(super(),this.id=t.id,this.type=t.type,this._globalState=n,this._featureFilter={filter:()=>!0,needGeometry:!1,getGlobalStateRefs:()=>new Set},this._visibilityExpression=function(t,e){return new is(t,e)}(this.visibility,n),"custom"!==t.type&&(this.metadata=t.metadata,this.minzoom=t.minzoom,this.maxzoom=t.maxzoom,"background"!==t.type&&(this.source=t.source,this.sourceLayer=t["source-layer"],this.filter=t.filter,this._featureFilter=yi(t.filter,n)),e.layout&&(this._unevaluatedLayout=new js(e.layout,n)),e.paint)){this._transitionablePaint=new Rs(e.paint,n);for(const e in t.paint)this.setPaintProperty(e,t.paint[e],{validate:!1});for(const e in t.layout)this.setLayoutProperty(e,t.layout[e],{validate:!1});this._transitioningPaint=this._transitionablePaint.untransitioned(),this.paint=new Gs(e.paint);}}setFilter(t){this.filter=t,this._featureFilter=yi(t,this._globalState);}getCrossfadeParameters(){return this._crossfadeParameters}getLayoutProperty(t){var e;if("visibility"===t)return this.visibility;if(null===(e=this._transitionablePaint)||void 0===e?void 0:e.hasProperty(t))throw new Error(t+Js);if(!this._unevaluatedLayout)throw new Error(`Cannot get layout property "${t}" on layer type "${this.type}" which has no layout properties.`);return this._unevaluatedLayout.getValue(t)}getLayoutAffectingGlobalStateRefs(){const t=new Set;for(const e of this._visibilityExpression.getGlobalStateRefs())t.add(e);if(this._unevaluatedLayout)for(const e in this._unevaluatedLayout._values){const n=this._unevaluatedLayout._values[e];for(const e of n.getGlobalStateRefs())t.add(e);}for(const e of this._featureFilter.getGlobalStateRefs())t.add(e);return t}getPaintAffectingGlobalStateRefs(){var t;const e=new globalThis.Map;if(this._transitionablePaint)for(const n in this._transitionablePaint._values){const r=this._transitionablePaint._values[n].value;for(const i of r.getGlobalStateRefs()){const s=null!==(t=e.get(i))&&void 0!==t?t:[];s.push({name:n,value:r.value}),e.set(i,s);}}return e}getVisibilityAffectingGlobalStateRefs(){return this._visibilityExpression.getGlobalStateRefs()}setLayoutProperty(t,e,n={}){var r;if("visibility"===t)return this.visibility=e,this._visibilityExpression.setValue(e),void this.recalculateVisibility();(null===(r=this._transitionablePaint)||void 0===r?void 0:r.hasProperty(t))?this.fire(new vt(new Error(t+Js))):null!=e&&this._validate(cs,`layers.${this.id}.layout.${t}`,t,e,n)||this._unevaluatedLayout.setValue(t,e);}getPaintProperty(t){var e,n;if(t.endsWith(Ls)){const n=t.slice(0,-11);if("visibility"===n||(null===(e=this._unevaluatedLayout)||void 0===e?void 0:e.hasProperty(n)))throw new Error(t+Qs);return this._transitionablePaint.getTransition(n)}if("visibility"===t||(null===(n=this._unevaluatedLayout)||void 0===n?void 0:n.hasProperty(t)))throw new Error(t+Qs);return this._transitionablePaint.getValue(t)}setPaintProperty(t,e,n={}){var r;if("visibility"===t||(null===(r=this._unevaluatedLayout)||void 0===r?void 0:r.hasProperty(t)))return this.fire(new vt(new Error(t+Qs))),!1;if(null!=e&&this._validate(us,`layers.${this.id}.paint.${t}`,t,e,n))return !1;if(t.endsWith(Ls))return this._transitionablePaint.setTransition(t.slice(0,-11),e||void 0),!1;{const n=this._transitionablePaint._values[t],r="cross-faded-data-driven"===n.property.specification["property-type"],i=n.value.isDataDriven(),s=n.value;this._transitionablePaint.setValue(t,e),this._handleSpecialPaintPropertyUpdate(t);const o=this._transitionablePaint._values[t].value;return o.isDataDriven()||i||r||this._handleOverridablePaintPropertyUpdate(t,s,o)}}_handleSpecialPaintPropertyUpdate(t){}_handleOverridablePaintPropertyUpdate(t,e,n){return !1}isHidden(t=this.minzoom,e=!1){return !!(this.minzoom&&t<(e?Math.floor(this.minzoom):this.minzoom))||!!(this.maxzoom&&t>=this.maxzoom)||"none"===this._evaluatedVisibility}updateTransitions(t){this._transitioningPaint=this._transitionablePaint.transitioned(t,this._transitioningPaint);}hasTransition(){return this._transitioningPaint.hasTransition()}recalculateVisibility(){this._evaluatedVisibility=this._visibilityExpression.evaluate();}recalculate(t,e){t.getCrossfadeParameters&&(this._crossfadeParameters=t.getCrossfadeParameters()),this._unevaluatedLayout&&(this.layout=this._unevaluatedLayout.possiblyEvaluate(t,void 0,e)),this.paint=this._transitioningPaint.possiblyEvaluate(t,void 0,e);}serialize(){var t,e;const n={id:this.id,type:this.type,source:this.source,"source-layer":this.sourceLayer,metadata:this.metadata,minzoom:this.minzoom,maxzoom:this.maxzoom,filter:this.filter,layout:null===(t=this._unevaluatedLayout)||void 0===t?void 0:t.serialize(),paint:null===(e=this._transitionablePaint)||void 0===e?void 0:e.serialize()};return this.visibility&&(n.layout||(n.layout={}),n.layout.visibility=this.visibility),U(n,((t,e)=>!(void 0===t||"layout"===e&&!Object.keys(t).length||"paint"===e&&!Object.keys(t).length)))}_validate(t,e,n,r,i={}){return !1!==(null==i?void 0:i.validate)&&hs(this,t.call(os,{key:e,layerType:this.type,objectKey:n,value:r,styleSpec:wt,style:{glyphs:!0,sprite:!0}}))}is3D(){return !1}isTileClipped(){return !1}hasOffscreenPass(){return !1}resize(){}isStateDependent(){for(const t in this.paint._values){const e=this.paint.get(t);if(e instanceof qs&&Gr(e.property.specification)&&("source"===e.value.kind||"composite"===e.value.kind)&&e.value.isStateDependent)return !0}return !1}}let eo;var no={get paint(){return eo=eo||new Ks({"raster-opacity":new Xs(wt.paint_raster["raster-opacity"]),"raster-hue-rotate":new Xs(wt.paint_raster["raster-hue-rotate"]),"raster-brightness-min":new Xs(wt.paint_raster["raster-brightness-min"]),"raster-brightness-max":new Xs(wt.paint_raster["raster-brightness-max"]),"raster-saturation":new Xs(wt.paint_raster["raster-saturation"]),"raster-contrast":new Xs(wt.paint_raster["raster-contrast"]),resampling:new Xs(wt.paint_raster.resampling),"raster-resampling":new Xs(wt.paint_raster["raster-resampling"]),"raster-fade-duration":new Xs(wt.paint_raster["raster-fade-duration"])})}};class ro extends to{constructor(t,e){super(t,no,e);}}const io={Int8:Int8Array,Uint8:Uint8Array,Int16:Int16Array,Uint16:Uint16Array,Int32:Int32Array,Uint32:Uint32Array,Float32:Float32Array};class so{constructor(t,e){this._structArray=t,this._pos1=e*this.size,this._pos2=this._pos1/2,this._pos4=this._pos1/4,this._pos8=this._pos1/8;}}class oo{constructor(){this.isTransferred=!1,this.capacity=-1,this.resize(0);}static serialize(t,e){return t._trim(),e&&(t.isTransferred=!0,e.push(t.arrayBuffer)),{length:t.length,arrayBuffer:t.arrayBuffer}}static deserialize(t){const e=Object.create(this.prototype);return e.arrayBuffer=t.arrayBuffer,e.length=t.length,e.capacity=t.arrayBuffer.byteLength/e.bytesPerElement,e._refreshViews(),e}_trim(){this.length!==this.capacity&&(this.capacity=this.length,this.arrayBuffer=this.arrayBuffer.slice(0,this.length*this.bytesPerElement),this._refreshViews());}clear(){this.length=0;}resize(t){this.reserve(t),this.length=t;}reserve(t){if(t>this.capacity){this.capacity=Math.max(t,Math.floor(5*this.capacity),128),this.arrayBuffer=new ArrayBuffer(this.capacity*this.bytesPerElement);const e=this.uint8;this._refreshViews(),e&&this.uint8.set(e);}}_refreshViews(){throw new Error("_refreshViews() must be implemented by each concrete StructArray layout")}freeBufferAfterUpload(){this.arrayBuffer=new ArrayBuffer(0),this._refreshViews();}}function ao(t,e=1){let n=0,r=0;return {members:t.map((t=>{const i=io[t.type].BYTES_PER_ELEMENT,s=n=lo(n,Math.max(e,i)),o=t.components||1;return r=Math.max(r,i),n+=i*o,{name:t.name,type:t.type,components:o,offset:s}})),size:lo(n,Math.max(r,e)),alignment:e}}function lo(t,e){return Math.ceil(t/e)*e}class uo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e){const n=this.length;return this.resize(n+1),this.emplace(n,t,e)}emplace(t,e,n){const r=2*t;return this.int16[r+0]=e,this.int16[r+1]=n,t}}uo.prototype.bytesPerElement=4,ds("StructArrayLayout2i4",uo);class co extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,n){const r=this.length;return this.resize(r+1),this.emplace(r,t,e,n)}emplace(t,e,n,r){const i=3*t;return this.int16[i+0]=e,this.int16[i+1]=n,this.int16[i+2]=r,t}}co.prototype.bytesPerElement=6,ds("StructArrayLayout3i6",co);class ho extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,n,r){const i=this.length;return this.resize(i+1),this.emplace(i,t,e,n,r)}emplace(t,e,n,r,i){const s=4*t;return this.int16[s+0]=e,this.int16[s+1]=n,this.int16[s+2]=r,this.int16[s+3]=i,t}}ho.prototype.bytesPerElement=8,ds("StructArrayLayout4i8",ho);class po extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i,s){const o=this.length;return this.resize(o+1),this.emplace(o,t,e,n,r,i,s)}emplace(t,e,n,r,i,s,o){const a=6*t;return this.int16[a+0]=e,this.int16[a+1]=n,this.int16[a+2]=r,this.int16[a+3]=i,this.int16[a+4]=s,this.int16[a+5]=o,t}}po.prototype.bytesPerElement=12,ds("StructArrayLayout2i4i12",po);class fo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i,s){const o=this.length;return this.resize(o+1),this.emplace(o,t,e,n,r,i,s)}emplace(t,e,n,r,i,s,o){const a=4*t,l=8*t;return this.int16[a+0]=e,this.int16[a+1]=n,this.uint8[l+4]=r,this.uint8[l+5]=i,this.uint8[l+6]=s,this.uint8[l+7]=o,t}}fo.prototype.bytesPerElement=8,ds("StructArrayLayout2i4ub8",fo);class yo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e){const n=this.length;return this.resize(n+1),this.emplace(n,t,e)}emplace(t,e,n){const r=2*t;return this.float32[r+0]=e,this.float32[r+1]=n,t}}yo.prototype.bytesPerElement=8,ds("StructArrayLayout2f8",yo);class mo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i,s,o,a,l,u){const c=this.length;return this.resize(c+1),this.emplace(c,t,e,n,r,i,s,o,a,l,u)}emplace(t,e,n,r,i,s,o,a,l,u,c){const h=10*t;return this.uint16[h+0]=e,this.uint16[h+1]=n,this.uint16[h+2]=r,this.uint16[h+3]=i,this.uint16[h+4]=s,this.uint16[h+5]=o,this.uint16[h+6]=a,this.uint16[h+7]=l,this.uint16[h+8]=u,this.uint16[h+9]=c,t}}mo.prototype.bytesPerElement=20,ds("StructArrayLayout10ui20",mo);class go extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i,s,o,a){const l=this.length;return this.resize(l+1),this.emplace(l,t,e,n,r,i,s,o,a)}emplace(t,e,n,r,i,s,o,a,l){const u=8*t;return this.uint16[u+0]=e,this.uint16[u+1]=n,this.uint16[u+2]=r,this.uint16[u+3]=i,this.uint16[u+4]=s,this.uint16[u+5]=o,this.uint16[u+6]=a,this.uint16[u+7]=l,t}}go.prototype.bytesPerElement=16,ds("StructArrayLayout8ui16",go);class xo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i,s,o,a,l,u,c,h){const p=this.length;return this.resize(p+1),this.emplace(p,t,e,n,r,i,s,o,a,l,u,c,h)}emplace(t,e,n,r,i,s,o,a,l,u,c,h,p){const f=12*t;return this.int16[f+0]=e,this.int16[f+1]=n,this.int16[f+2]=r,this.int16[f+3]=i,this.uint16[f+4]=s,this.uint16[f+5]=o,this.uint16[f+6]=a,this.uint16[f+7]=l,this.int16[f+8]=u,this.int16[f+9]=c,this.int16[f+10]=h,this.int16[f+11]=p,t}}xo.prototype.bytesPerElement=24,ds("StructArrayLayout4i4ui4i24",xo);class vo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e,n){const r=this.length;return this.resize(r+1),this.emplace(r,t,e,n)}emplace(t,e,n,r){const i=3*t;return this.float32[i+0]=e,this.float32[i+1]=n,this.float32[i+2]=r,t}}vo.prototype.bytesPerElement=12,ds("StructArrayLayout3f12",vo);class bo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint32=new Uint32Array(this.arrayBuffer);}emplaceBack(t){const e=this.length;return this.resize(e+1),this.emplace(e,t)}emplace(t,e){return this.uint32[1*t+0]=e,t}}bo.prototype.bytesPerElement=4,ds("StructArrayLayout1ul4",bo);class wo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer),this.uint32=new Uint32Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i,s,o,a,l){const u=this.length;return this.resize(u+1),this.emplace(u,t,e,n,r,i,s,o,a,l)}emplace(t,e,n,r,i,s,o,a,l,u){const c=10*t,h=5*t;return this.int16[c+0]=e,this.int16[c+1]=n,this.int16[c+2]=r,this.int16[c+3]=i,this.int16[c+4]=s,this.int16[c+5]=o,this.uint32[h+3]=a,this.uint16[c+8]=l,this.uint16[c+9]=u,t}}wo.prototype.bytesPerElement=20,ds("StructArrayLayout6i1ul2ui20",wo);class _o extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i,s){const o=this.length;return this.resize(o+1),this.emplace(o,t,e,n,r,i,s)}emplace(t,e,n,r,i,s,o){const a=6*t;return this.int16[a+0]=e,this.int16[a+1]=n,this.int16[a+2]=r,this.int16[a+3]=i,this.int16[a+4]=s,this.int16[a+5]=o,t}}_o.prototype.bytesPerElement=12,ds("StructArrayLayout2i2i2i12",_o);class So extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i){const s=this.length;return this.resize(s+1),this.emplace(s,t,e,n,r,i)}emplace(t,e,n,r,i,s){const o=4*t,a=8*t;return this.float32[o+0]=e,this.float32[o+1]=n,this.float32[o+2]=r,this.int16[a+6]=i,this.int16[a+7]=s,t}}So.prototype.bytesPerElement=16,ds("StructArrayLayout2f1f2i16",So);class Ao extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i,s){const o=this.length;return this.resize(o+1),this.emplace(o,t,e,n,r,i,s)}emplace(t,e,n,r,i,s,o){const a=16*t,l=4*t,u=8*t;return this.uint8[a+0]=e,this.uint8[a+1]=n,this.float32[l+1]=r,this.float32[l+2]=i,this.int16[u+6]=s,this.int16[u+7]=o,t}}Ao.prototype.bytesPerElement=16,ds("StructArrayLayout2ub2f2i16",Ao);class Mo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,n){const r=this.length;return this.resize(r+1),this.emplace(r,t,e,n)}emplace(t,e,n,r){const i=3*t;return this.uint16[i+0]=e,this.uint16[i+1]=n,this.uint16[i+2]=r,t}}Mo.prototype.bytesPerElement=6,ds("StructArrayLayout3ui6",Mo);class ko extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer),this.uint32=new Uint32Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i,s,o,a,l,u,c,h,p,f,d,y,m){const g=this.length;return this.resize(g+1),this.emplace(g,t,e,n,r,i,s,o,a,l,u,c,h,p,f,d,y,m)}emplace(t,e,n,r,i,s,o,a,l,u,c,h,p,f,d,y,m,g){const x=24*t,v=12*t,b=48*t;return this.int16[x+0]=e,this.int16[x+1]=n,this.uint16[x+2]=r,this.uint16[x+3]=i,this.uint32[v+2]=s,this.uint32[v+3]=o,this.uint32[v+4]=a,this.uint16[x+10]=l,this.uint16[x+11]=u,this.uint16[x+12]=c,this.float32[v+7]=h,this.float32[v+8]=p,this.uint8[b+36]=f,this.uint8[b+37]=d,this.uint8[b+38]=y,this.uint32[v+10]=m,this.int16[x+22]=g,t}}ko.prototype.bytesPerElement=48,ds("StructArrayLayout2i2ui3ul3ui2f3ub1ul1i48",ko);class Io extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.int16=new Int16Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer),this.uint32=new Uint32Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e,n,r,i,s,o,a,l,u,c,h,p,f,d,y,m,g,x,v,b,w,_,S,A,M,k,I){const E=this.length;return this.resize(E+1),this.emplace(E,t,e,n,r,i,s,o,a,l,u,c,h,p,f,d,y,m,g,x,v,b,w,_,S,A,M,k,I)}emplace(t,e,n,r,i,s,o,a,l,u,c,h,p,f,d,y,m,g,x,v,b,w,_,S,A,M,k,I,E){const T=32*t,F=16*t;return this.int16[T+0]=e,this.int16[T+1]=n,this.int16[T+2]=r,this.int16[T+3]=i,this.int16[T+4]=s,this.int16[T+5]=o,this.int16[T+6]=a,this.int16[T+7]=l,this.uint16[T+8]=u,this.uint16[T+9]=c,this.uint16[T+10]=h,this.uint16[T+11]=p,this.uint16[T+12]=f,this.uint16[T+13]=d,this.uint16[T+14]=y,this.uint16[T+15]=m,this.uint16[T+16]=g,this.uint16[T+17]=x,this.uint16[T+18]=v,this.uint16[T+19]=b,this.uint16[T+20]=w,this.uint16[T+21]=_,this.uint16[T+22]=S,this.uint32[F+12]=A,this.float32[F+13]=M,this.float32[F+14]=k,this.uint16[T+30]=I,this.uint16[T+31]=E,t}}Io.prototype.bytesPerElement=64,ds("StructArrayLayout8i15ui1ul2f2ui64",Io);class Eo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t){const e=this.length;return this.resize(e+1),this.emplace(e,t)}emplace(t,e){return this.float32[1*t+0]=e,t}}Eo.prototype.bytesPerElement=4,ds("StructArrayLayout1f4",Eo);class To extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e,n){const r=this.length;return this.resize(r+1),this.emplace(r,t,e,n)}emplace(t,e,n,r){const i=3*t;return this.uint16[6*t+0]=e,this.float32[i+1]=n,this.float32[i+2]=r,t}}To.prototype.bytesPerElement=12,ds("StructArrayLayout1ui2f12",To);class Fo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint32=new Uint32Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e,n){const r=this.length;return this.resize(r+1),this.emplace(r,t,e,n)}emplace(t,e,n,r){const i=4*t;return this.uint32[2*t+0]=e,this.uint16[i+2]=n,this.uint16[i+3]=r,t}}Fo.prototype.bytesPerElement=8,ds("StructArrayLayout1ul2ui8",Fo);class Po extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t,e){const n=this.length;return this.resize(n+1),this.emplace(n,t,e)}emplace(t,e,n){const r=2*t;return this.uint16[r+0]=e,this.uint16[r+1]=n,t}}Po.prototype.bytesPerElement=4,ds("StructArrayLayout2ui4",Po);class Do extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.uint16=new Uint16Array(this.arrayBuffer);}emplaceBack(t){const e=this.length;return this.resize(e+1),this.emplace(e,t)}emplace(t,e){return this.uint16[1*t+0]=e,t}}Do.prototype.bytesPerElement=2,ds("StructArrayLayout1ui2",Do);class zo extends oo{_refreshViews(){this.uint8=new Uint8Array(this.arrayBuffer),this.float32=new Float32Array(this.arrayBuffer);}emplaceBack(t,e,n,r){const i=this.length;return this.resize(i+1),this.emplace(i,t,e,n,r)}emplace(t,e,n,r,i){const s=4*t;return this.float32[s+0]=e,this.float32[s+1]=n,this.float32[s+2]=r,this.float32[s+3]=i,t}}zo.prototype.bytesPerElement=16,ds("StructArrayLayout4f16",zo);class Bo extends so{get anchorPointX(){return this._structArray.int16[this._pos2+0]}get anchorPointY(){return this._structArray.int16[this._pos2+1]}get x1(){return this._structArray.int16[this._pos2+2]}get y1(){return this._structArray.int16[this._pos2+3]}get x2(){return this._structArray.int16[this._pos2+4]}get y2(){return this._structArray.int16[this._pos2+5]}get featureIndex(){return this._structArray.uint32[this._pos4+3]}get sourceLayerIndex(){return this._structArray.uint16[this._pos2+8]}get bucketIndex(){return this._structArray.uint16[this._pos2+9]}get anchorPoint(){return new n(this.anchorPointX,this.anchorPointY)}}Bo.prototype.size=20;class Co extends wo{get(t){return new Bo(this,t)}}ds("CollisionBoxArray",Co);class Vo extends so{get anchorX(){return this._structArray.int16[this._pos2+0]}get anchorY(){return this._structArray.int16[this._pos2+1]}get glyphStartIndex(){return this._structArray.uint16[this._pos2+2]}get numGlyphs(){return this._structArray.uint16[this._pos2+3]}get vertexStartIndex(){return this._structArray.uint32[this._pos4+2]}get lineStartIndex(){return this._structArray.uint32[this._pos4+3]}get lineLength(){return this._structArray.uint32[this._pos4+4]}get segment(){return this._structArray.uint16[this._pos2+10]}get lowerSize(){return this._structArray.uint16[this._pos2+11]}get upperSize(){return this._structArray.uint16[this._pos2+12]}get lineOffsetX(){return this._structArray.float32[this._pos4+7]}get lineOffsetY(){return this._structArray.float32[this._pos4+8]}get writingMode(){return this._structArray.uint8[this._pos1+36]}get placedOrientation(){return this._structArray.uint8[this._pos1+37]}set placedOrientation(t){this._structArray.uint8[this._pos1+37]=t;}get hidden(){return this._structArray.uint8[this._pos1+38]}set hidden(t){this._structArray.uint8[this._pos1+38]=t;}get crossTileID(){return this._structArray.uint32[this._pos4+10]}set crossTileID(t){this._structArray.uint32[this._pos4+10]=t;}get associatedIconIndex(){return this._structArray.int16[this._pos2+22]}}Vo.prototype.size=48;class Lo extends ko{get(t){return new Vo(this,t)}}ds("PlacedSymbolArray",Lo);class Oo extends so{get anchorX(){return this._structArray.int16[this._pos2+0]}get anchorY(){return this._structArray.int16[this._pos2+1]}get rightJustifiedTextSymbolIndex(){return this._structArray.int16[this._pos2+2]}get centerJustifiedTextSymbolIndex(){return this._structArray.int16[this._pos2+3]}get leftJustifiedTextSymbolIndex(){return this._structArray.int16[this._pos2+4]}get verticalPlacedTextSymbolIndex(){return this._structArray.int16[this._pos2+5]}get placedIconSymbolIndex(){return this._structArray.int16[this._pos2+6]}get verticalPlacedIconSymbolIndex(){return this._structArray.int16[this._pos2+7]}get key(){return this._structArray.uint16[this._pos2+8]}get textBoxStartIndex(){return this._structArray.uint16[this._pos2+9]}get textBoxEndIndex(){return this._structArray.uint16[this._pos2+10]}get verticalTextBoxStartIndex(){return this._structArray.uint16[this._pos2+11]}get verticalTextBoxEndIndex(){return this._structArray.uint16[this._pos2+12]}get iconBoxStartIndex(){return this._structArray.uint16[this._pos2+13]}get iconBoxEndIndex(){return this._structArray.uint16[this._pos2+14]}get verticalIconBoxStartIndex(){return this._structArray.uint16[this._pos2+15]}get verticalIconBoxEndIndex(){return this._structArray.uint16[this._pos2+16]}get featureIndex(){return this._structArray.uint16[this._pos2+17]}get numHorizontalGlyphVertices(){return this._structArray.uint16[this._pos2+18]}get numVerticalGlyphVertices(){return this._structArray.uint16[this._pos2+19]}get numIconVertices(){return this._structArray.uint16[this._pos2+20]}get numVerticalIconVertices(){return this._structArray.uint16[this._pos2+21]}get useRuntimeCollisionCircles(){return this._structArray.uint16[this._pos2+22]}get crossTileID(){return this._structArray.uint32[this._pos4+12]}set crossTileID(t){this._structArray.uint32[this._pos4+12]=t;}get textBoxScale(){return this._structArray.float32[this._pos4+13]}get collisionCircleDiameter(){return this._structArray.float32[this._pos4+14]}get textAnchorOffsetStartIndex(){return this._structArray.uint16[this._pos2+30]}get textAnchorOffsetEndIndex(){return this._structArray.uint16[this._pos2+31]}}Oo.prototype.size=64;class $o extends Io{get(t){return new Oo(this,t)}}ds("SymbolInstanceArray",$o);class Ro extends Eo{getoffsetX(t){return this.float32[1*t+0]}}ds("GlyphOffsetArray",Ro);class No extends co{getx(t){return this.int16[3*t+0]}gety(t){return this.int16[3*t+1]}gettileUnitDistanceFromAnchor(t){return this.int16[3*t+2]}}ds("SymbolLineVertexArray",No);class Uo extends so{get textAnchor(){return this._structArray.uint16[this._pos2+0]}get textOffset0(){return this._structArray.float32[this._pos4+1]}get textOffset1(){return this._structArray.float32[this._pos4+2]}}Uo.prototype.size=12;class jo extends To{get(t){return new Uo(this,t)}}ds("TextAnchorOffsetArray",jo);class qo extends so{get featureIndex(){return this._structArray.uint32[this._pos4+0]}get sourceLayerIndex(){return this._structArray.uint16[this._pos2+2]}get bucketIndex(){return this._structArray.uint16[this._pos2+3]}}qo.prototype.size=8;class Go extends Fo{get(t){return new qo(this,t)}}ds("FeatureIndexArray",Go);class Xo extends uo{}class Yo extends uo{}class Zo extends uo{}class Ho extends po{}class Wo extends fo{}class Ko extends yo{}class Jo extends mo{}class Qo extends go{}class ta extends xo{}class ea extends vo{}class na extends bo{}class ra extends _o{}class ia extends Ao{}class sa extends Mo{}class oa extends Po{}const aa=ao([{name:"a_pos",components:2,type:"Int16"}],4),{members:la}=aa;class ua{constructor(t=[]){this._forceNewSegmentOnNextPrepare=!1,this.segments=t;}prepareSegment(t,e,n,r){const i=this.segments[this.segments.length-1];return t>ua.MAX_VERTEX_ARRAY_LENGTH&&G(`Max vertices per segment is ${ua.MAX_VERTEX_ARRAY_LENGTH}: bucket requested ${t}. Consider using the \`fillLargeMeshArrays\` function if you require meshes with more than ${ua.MAX_VERTEX_ARRAY_LENGTH} vertices.`),this._forceNewSegmentOnNextPrepare||!i||i.vertexLength+t>ua.MAX_VERTEX_ARRAY_LENGTH||i.sortKey!==r?this.createNewSegment(e,n,r):i}createNewSegment(t,e,n){const r={vertexOffset:t.length,primitiveOffset:e.length,vertexLength:0,primitiveLength:0,vaos:{}};return void 0!==n&&(r.sortKey=n),this._forceNewSegmentOnNextPrepare=!1,this.segments.push(r),r}getOrCreateLatestSegment(t,e,n){return this.prepareSegment(0,t,e,n)}forceNewSegmentOnNextPrepare(){this._forceNewSegmentOnNextPrepare=!0;}get(){return this.segments}destroy(){for(const t of this.segments)for(const e in t.vaos)t.vaos[e].destroy();}static simpleSegment(t,e,n,r){return new ua([{vertexOffset:t,primitiveOffset:e,vertexLength:n,primitiveLength:r,vaos:{},sortKey:0}])}}function ca(t,e){return 256*(t=L(Math.floor(t),0,255))+L(Math.floor(e),0,255)}ua.MAX_VERTEX_ARRAY_LENGTH=Math.pow(2,16)-1,ds("SegmentVector",ua);const ha=ao([{name:"a_pattern_from",components:4,type:"Uint16"},{name:"a_pattern_to",components:4,type:"Uint16"},{name:"a_pixel_ratio_from",components:1,type:"Uint16"},{name:"a_pixel_ratio_to",components:1,type:"Uint16"}]),pa=ao([{name:"a_dasharray_from",components:4,type:"Uint16"},{name:"a_dasharray_to",components:4,type:"Uint16"}]);var fa,da,ya,ma={exports:{}},ga={exports:{}},xa={exports:{}},va=function(){if(ya)return ma.exports;ya=1;var t=(fa||(fa=1,ga.exports=function(t,e){var n,r,i,s,o,a,l,u;for(r=t.length-(n=3&t.length),i=e,o=3432918353,a=461845907,u=0;u>>16)*o&65535)<<16)&4294967295)<<15|l>>>17))*a+(((l>>>16)*a&65535)<<16)&4294967295)<<13|i>>>19))+((5*(i>>>16)&65535)<<16)&4294967295))+((58964+(s>>>16)&65535)<<16);switch(l=0,n){case 3:l^=(255&t.charCodeAt(u+2))<<16;case 2:l^=(255&t.charCodeAt(u+1))<<8;case 1:i^=l=(65535&(l=(l=(65535&(l^=255&t.charCodeAt(u)))*o+(((l>>>16)*o&65535)<<16)&4294967295)<<15|l>>>17))*a+(((l>>>16)*a&65535)<<16)&4294967295;}return i^=t.length,i=2246822507*(65535&(i^=i>>>16))+((2246822507*(i>>>16)&65535)<<16)&4294967295,i=3266489909*(65535&(i^=i>>>13))+((3266489909*(i>>>16)&65535)<<16)&4294967295,(i^=i>>>16)>>>0}),ga.exports),e=(da||(da=1,xa.exports=function(t,e){for(var n,r=t.length,i=e^r,s=0;r>=4;)n=1540483477*(65535&(n=255&t.charCodeAt(s)|(255&t.charCodeAt(++s))<<8|(255&t.charCodeAt(++s))<<16|(255&t.charCodeAt(++s))<<24))+((1540483477*(n>>>16)&65535)<<16),i=1540483477*(65535&i)+((1540483477*(i>>>16)&65535)<<16)^(n=1540483477*(65535&(n^=n>>>24))+((1540483477*(n>>>16)&65535)<<16)),r-=4,++s;switch(r){case 3:i^=(255&t.charCodeAt(s+2))<<16;case 2:i^=(255&t.charCodeAt(s+1))<<8;case 1:i=1540483477*(65535&(i^=255&t.charCodeAt(s)))+((1540483477*(i>>>16)&65535)<<16);}return i=1540483477*(65535&(i^=i>>>13))+((1540483477*(i>>>16)&65535)<<16),(i^=i>>>15)>>>0}),xa.exports);return ma.exports=t,ma.exports.murmur3=t,ma.exports.murmur2=e,ma.exports}(),ba=r(va);class wa{constructor(){this.ids=[],this.positions=[],this.indexed=!1;}add(t,e,n,r){this.ids.push(_a(t)),this.positions.push(e,n,r);}getPositions(t){if(!this.indexed)throw new Error("Trying to get index, but feature positions are not indexed");const e=_a(t);let n=0,r=this.ids.length-1;for(;n>1;this.ids[t]>=e?r=t:n=t+1;}const i=[];for(;this.ids[n]===e;)i.push({index:this.positions[3*n],start:this.positions[3*n+1],end:this.positions[3*n+2]}),n++;return i}static serialize(t,e){const n=new Float64Array(t.ids),r=new Uint32Array(t.positions);return Sa(n,r,0,n.length-1),e&&e.push(n.buffer,r.buffer),{ids:n,positions:r}}static deserialize(t){const e=new wa;return e.ids=t.ids,e.positions=t.positions,e.indexed=!0,e}}function _a(t){const e=+t;return !isNaN(e)&&e<=Number.MAX_SAFE_INTEGER?e:ba(String(t))}function Sa(t,e,n,r){for(;n>1];let s=n-1,o=r+1;for(;;){do{s++;}while(t[s]i);if(s>=o)break;Aa(t,s,o),Aa(e,3*s,3*o),Aa(e,3*s+1,3*o+1),Aa(e,3*s+2,3*o+2);}o-n`u_${t}`)),this.type=n;}setUniform(t,e,n){t.set(n.constantOr(this.value));}getBinding(t,e,n){return "color"===this.type?new Ea(t,e):new ka(t,e)}}class Da{constructor(t,e){this.uniformNames=e.map((t=>`u_${t}`)),this.patternFrom=null,this.patternTo=null,this.pixelRatioFrom=1,this.pixelRatioTo=1;}setConstantPatternPositions(t,e){this.pixelRatioFrom=e.pixelRatio,this.pixelRatioTo=t.pixelRatio,this.patternFrom=e.tlbr,this.patternTo=t.tlbr;}setConstantDashPositions(t,e){this.dashTo=[0,t.y,t.height,t.width],this.dashFrom=[0,e.y,e.height,e.width];}setUniform(t,e,n,r){let i=null;"u_pattern_to"===r?i=this.patternTo:"u_pattern_from"===r?i=this.patternFrom:"u_dasharray_to"===r?i=this.dashTo:"u_dasharray_from"===r?i=this.dashFrom:"u_pixel_ratio_to"===r?i=this.pixelRatioTo:"u_pixel_ratio_from"===r&&(i=this.pixelRatioFrom),null!==i&&t.set(i);}getBinding(t,e,n){return n.startsWith("u_pattern")||n.startsWith("u_dasharray_")?new Ia(t,e):new ka(t,e)}}class za{constructor(t,e,n,r){this.expression=t,this.type=n,this.maxValue=0,this.paintVertexAttributes=e.map((t=>({name:`a_${t}`,type:"Float32",components:"color"===n?2:1,offset:0}))),this.paintVertexArray=new r;}populatePaintArray(t,e,n){const r=this.paintVertexArray.length,i=this.expression.evaluate(new Cs(0,n),e,{},n.canonical,[],n.formattedSection);this.paintVertexArray.resize(t),this._setPaintValue(r,t,i);}updatePaintArray(t,e,n,r,i){const s=this.expression.evaluate(new Cs(0,i),n,r);this._setPaintValue(t,e,s);}_setPaintValue(t,e,n){if("color"===this.type){const r=Fa(n);for(let n=t;n`u_${t}_t`)),this.type=n,this.useIntegerZoom=r,this.zoom=i,this.maxValue=0,this.paintVertexAttributes=e.map((t=>({name:`a_${t}`,type:"Float32",components:"color"===n?4:2,offset:0}))),this.paintVertexArray=new s;}populatePaintArray(t,e,n){const r=this.expression.evaluate(new Cs(this.zoom,n),e,{},n.canonical,[],n.formattedSection),i=this.expression.evaluate(new Cs(this.zoom+1,n),e,{},n.canonical,[],n.formattedSection),s=this.paintVertexArray.length;this.paintVertexArray.resize(t),this._setPaintValue(s,t,r,i);}updatePaintArray(t,e,n,r,i){const s=this.expression.evaluate(new Cs(this.zoom,i),n,r),o=this.expression.evaluate(new Cs(this.zoom+1,i),n,r);this._setPaintValue(t,e,s,o);}_setPaintValue(t,e,n,r){if("color"===this.type){const i=Fa(n),s=Fa(r);for(let n=t;n`#define HAS_UNIFORM_${t}`)));}return t}getBinderAttributes(){const t=[];for(const e in this.binders){const n=this.binders[e];if(n instanceof za||n instanceof Ba)for(const e of n.paintVertexAttributes)t.push(e.name);else if(n instanceof Ca){const e=n.getVertexAttributes();for(const n of e)t.push(n.name);}}return t}getBinderUniforms(){const t=[];for(const e in this.binders){const n=this.binders[e];if(n instanceof Pa||n instanceof Da||n instanceof Ba)for(const e of n.uniformNames)t.push(e);}return t}getPaintVertexBuffers(){return this._buffers}getUniforms(t,e){const n=[];for(const r in this.binders){const i=this.binders[r];if(i instanceof Pa||i instanceof Da||i instanceof Ba)for(const s of i.uniformNames)if(e[s]){const o=i.getBinding(t,e[s],s);n.push({name:s,property:r,binding:o});}}return n}setUniforms(t,e,n,r){for(const{name:t,property:i,binding:s}of e)this.binders[i].setUniform(s,r,n.get(i),t);}updatePaintBuffers(t){this._buffers=[];for(const e in this.binders){const n=this.binders[e];if(t&&n instanceof Ca){const e=2===t.fromScale?n.zoomInPaintVertexBuffer:n.zoomOutPaintVertexBuffer;e&&this._buffers.push(e);}else (n instanceof za||n instanceof Ba)&&n.paintVertexBuffer&&this._buffers.push(n.paintVertexBuffer);}}upload(t){for(const e in this.binders){const n=this.binders[e];(n instanceof za||n instanceof Ba||n instanceof Ca)&&n.upload(t);}this.updatePaintBuffers();}destroy(){for(const t in this.binders){const e=this.binders[t];(e instanceof za||e instanceof Ba||e instanceof Ca)&&e.destroy();}}}class $a{constructor(t,e,n=()=>!0){this.programConfigurations={};for(const r of t)this.programConfigurations[r.id]=new Oa(r,e,n);this.needsUpload=!1,this._featureMap=new wa,this._bufferOffset=0;}populatePaintArrays(t,e,n,r){for(const n in this.programConfigurations)this.programConfigurations[n].populatePaintArrays(t,e,r);void 0!==e.id&&this._featureMap.add(e.id,n,this._bufferOffset,t),this._bufferOffset=t,this.needsUpload=!0;}updatePaintArrays(t,e,n,r){for(const i of n)this.needsUpload=this.programConfigurations[i.id].updatePaintArrays(t,this._featureMap,e,i,r)||this.needsUpload;}get(t){return this.programConfigurations[t]}upload(t){if(this.needsUpload){for(const e in this.programConfigurations)this.programConfigurations[e].upload(t);this.needsUpload=!1;}}destroy(){for(const t in this.programConfigurations)this.programConfigurations[t].destroy();}}function Ra(t,e){return {"text-opacity":["opacity"],"icon-opacity":["opacity"],"text-color":["fill_color"],"icon-color":["fill_color"],"text-halo-color":["halo_color"],"icon-halo-color":["halo_color"],"text-halo-blur":["halo_blur"],"icon-halo-blur":["halo_blur"],"text-halo-width":["halo_width"],"icon-halo-width":["halo_width"],"line-gap-width":["gapwidth"],"line-dasharray":["dasharray_to","dasharray_from"],"line-pattern":["pattern_to","pattern_from","pixel_ratio_to","pixel_ratio_from"],"fill-pattern":["pattern_to","pattern_from","pixel_ratio_to","pixel_ratio_from"],"fill-extrusion-pattern":["pattern_to","pattern_from","pixel_ratio_to","pixel_ratio_from"]}[t]||[t.replace(`${e}-`,"").replace(/-/g,"_")]}function Na(t,e,n){const r={color:{source:yo,composite:zo},number:{source:Eo,composite:yo}},i=function(t){return {"line-pattern":{source:Jo,composite:Jo},"fill-pattern":{source:Jo,composite:Jo},"fill-extrusion-pattern":{source:Jo,composite:Jo},"line-dasharray":{source:Qo,composite:Qo}}[t]}(t);return (null==i?void 0:i[n])||r[e][n]}ds("ConstantBinder",Pa),ds("CrossFadedConstantBinder",Da),ds("SourceExpressionBinder",za),ds("CrossFadedPatternBinder",Va),ds("CrossFadedDasharrayBinder",La),ds("CompositeExpressionBinder",Ba),ds("ProgramConfiguration",Oa,{omit:["_buffers"]}),ds("ProgramConfigurationSet",$a);const Ua=Math.pow(2,14)-1,ja=-Ua-1;function qa(t){const e=T/t.extent,n=t.loadGeometry();for(const t of n)for(const n of t){const t=Math.round(n.x*e),r=Math.round(n.y*e);n.x=L(t,ja,Ua),n.y=L(r,ja,Ua),(tn.x+1||rn.y+1)&&G("Geometry exceeds allowed extent, reduce your vector tile buffer size");}return n}function Ga(t,e){return {type:t.type,id:t.id,properties:t.properties,geometry:e?qa(t):[]}}const Xa=-32768;function Ya(t,e,n,r,i){t.emplaceBack(Xa+8*e+r,Xa+8*n+i);}class Za{constructor(t){this.zoom=t.zoom,this.overscaling=t.overscaling,this.layers=t.layers,this.layerIds=this.layers.map((t=>t.id)),this.index=t.index,this.hasDependencies=!1,this.layoutVertexArray=new Yo,this.indexArray=new sa,this.segments=new ua,this.programConfigurations=new $a(t.layers,t.zoom),this.stateDependentLayerIds=this.layers.filter((t=>t.isStateDependent())).map((t=>t.id));}populate(t,e,n){const r=this.layers[0],i=[];let s=null,o=!1,a="heatmap"===r.type;if("circle"===r.type){const t=r;s=t.layout.get("circle-sort-key"),o=!s.isConstant(),a||(a="map"===t.paint.get("circle-pitch-alignment"));}const l=a?e.subdivisionGranularity.circle:1;for(const{feature:e,id:r,index:a,sourceLayerIndex:l}of t){const t=this.layers[0]._featureFilter.needGeometry,u=Ga(e,t);if(!this.layers[0]._featureFilter.filter(new Cs(this.zoom),u,n))continue;const c=o?s.evaluate(u,{},n):void 0,h={id:r,properties:e.properties,type:e.type,sourceLayerIndex:l,index:a,geometry:t?u.geometry:qa(e),patterns:{},sortKey:c};i.push(h);}o&&i.sort(((t,e)=>t.sortKey-e.sortKey));for(const r of i){const{geometry:i,index:s,sourceLayerIndex:o}=r,a=t[s].feature;this.addFeature(r,i,s,n,l),e.featureIndex.insert(a,i,s,o,this.index);}}update(t,e,n){this.stateDependentLayers.length&&this.programConfigurations.updatePaintArrays(t,e,this.stateDependentLayers,{imagePositions:n});}isEmpty(){return 0===this.layoutVertexArray.length}uploadPending(){return !this.uploaded||this.programConfigurations.needsUpload}upload(t){this.uploaded||(this.layoutVertexBuffer=t.createVertexBuffer(this.layoutVertexArray,la),this.indexBuffer=t.createIndexBuffer(this.indexArray)),this.programConfigurations.upload(t),this.uploaded=!0;}destroy(){this.layoutVertexBuffer&&(this.layoutVertexBuffer.destroy(),this.indexBuffer.destroy(),this.programConfigurations.destroy(),this.segments.destroy());}addFeature(t,e,n,r,i=1){let s;switch(i){case 1:s=[0,7];break;case 3:s=[0,2,5,7];break;case 5:s=[0,1,3,4,6,7];break;case 7:s=[0,1,2,3,4,5,6,7];break;default:throw new Error(`Invalid circle bucket granularity: ${i}; valid values are 1, 3, 5, 7.`)}const o=s.length;for(const n of e)for(const e of n){const n=e.x,r=e.y;if(n<0||n>=T||r<0||r>=T)continue;const i=this.segments.prepareSegment(o*o,this.layoutVertexArray,this.indexArray,t.sortKey),a=i.vertexLength;for(let t=0;t1){if(Qa(t,e))return !0;for(const r of e)if(el(r,t,n))return !0}for(const r of t)if(el(r,e,n))return !0;return !1}function Qa(t,e){if(0===t.length||0===e.length)return !1;for(let n=0;n1?n:n.sub(e)._mult(i)._add(e))}function rl(t,e){let n,r,i,s=!1;for(const o of t){n=o;for(let t=0,o=n.length-1;te.y!=i.y>e.y&&e.x<(i.x-r.x)*(e.y-r.y)/(i.y-r.y)+r.x&&(s=!s);}return s}function il(t,e){let n=!1;for(let r=0,i=t.length-1;re.y!=o.y>e.y&&e.x<(o.x-s.x)*(e.y-s.y)/(o.y-s.y)+s.x&&(n=!n);}return n}function sl(t,e,n){const r=n[0],i=n[2];if(t.xi.x&&e.x>i.x||t.yi.y&&e.y>i.y)return !1;const s=X(t,e,n[0]);return s!==X(t,e,n[1])||s!==X(t,e,n[2])||s!==X(t,e,n[3])}function ol(t,e,n){const r=e.paint.get(t).value;return "constant"===r.kind?r.value:n.programConfigurations.get(e.id).getMaxValue(t)}function al(t){return Math.sqrt(t[0]*t[0]+t[1]*t[1])}function ll(t,e,r,i,s){if(!e[0]&&!e[1])return t;const o=n.convert(e)._mult(s);"viewport"===r&&o._rotate(-i);const a=[];for(const e of t)a.push(e.sub(o));return a}function ul(t){const e=[];for(let n=0;nyl(t,e,n,r)))}(l,i,o,a),f=u),dl({queryGeometry:p,size:f,transform:i,unwrappedTileID:o,getElevation:a,pitchAlignment:h,pitchScale:c},r)}}class bl extends Za{}let wl;ds("HeatmapBucket",bl,{omit:["layers"]});var _l={get paint(){return wl=wl||new Ks({"heatmap-radius":new Ys(wt.paint_heatmap["heatmap-radius"]),"heatmap-weight":new Ys(wt.paint_heatmap["heatmap-weight"]),"heatmap-intensity":new Xs(wt.paint_heatmap["heatmap-intensity"]),"heatmap-color":new Ws(wt.paint_heatmap["heatmap-color"]),"heatmap-opacity":new Xs(wt.paint_heatmap["heatmap-opacity"])})}};function Sl(t,{width:e,height:n},r,i){if(i){if(i instanceof Uint8ClampedArray)i=new Uint8Array(i.buffer);else if(i.length!==e*n*r)throw new RangeError(`mismatched image size. expected: ${i.length} but got: ${e*n*r}`)}else i=new Uint8Array(e*n*r);return t.width=e,t.height=n,t.data=i,t}function Al(t,{width:e,height:n},r){if(e===t.width&&n===t.height)return;const i=Sl({},{width:e,height:n},r);Ml(t,i,{x:0,y:0},{x:0,y:0},{width:Math.min(t.width,e),height:Math.min(t.height,n)},r),t.width=e,t.height=n,t.data=i.data;}function Ml(t,e,n,r,i,s){if(0===i.width||0===i.height)return e;if(i.width>t.width||i.height>t.height||n.x>t.width-i.width||n.y>t.height-i.height)throw new RangeError("out of range source coordinates for image copy");if(i.width>e.width||i.height>e.height||r.x>e.width-i.width||r.y>e.height-i.height)throw new RangeError("out of range destination coordinates for image copy");const o=t.data,a=e.data;if(o===a)throw new Error("srcData equals dstData, so image is already copied");for(let l=0;l{e[t.evaluationKey]=o;const a=t.expression.evaluate(e);i.setPixel(r/4/n,s/4,a);};if(t.clips)for(let e=0,i=0;ethis.max&&(this.max=n),n=this.dim+1||e<-1||e>=this.dim+1)throw new RangeError(`Out of range source coordinates for DEM data. x: ${t}, y: ${e}, dim: ${this.dim}`);return (e+1)*this.stride+(t+1)}unpack(t,e,n){return t*this.redFactor+e*this.greenFactor+n*this.blueFactor-this.baseShift}pack(t){return Rl(t,this.getUnpackVector())}getPixels(){return new Il({width:this.stride,height:this.stride},new Uint8Array(this.data.buffer))}backfillBorder(t,e,n){if(this.dim!==t.dim)throw new Error("dem dimension mismatch");let r=e*this.dim,i=e*this.dim+this.dim,s=n*this.dim,o=n*this.dim+this.dim;switch(e){case -1:r=i-1;break;case 1:i=r+1;}switch(n){case -1:s=o-1;break;case 1:o=s+1;}const a=-e*this.dim,l=-n*this.dim;for(let e=s;e0)for(let i=e;i=e;i-=r)s=du(i/r|0,t[i],t[i+1],s);return s&&lu(s,s.next)&&(yu(s),s=s.next),s}function Yl(t,e){if(!t)return t;e||(e=t);let n,r=t;do{if(n=!1,r.steiner||!lu(r,r.next)&&0!==au(r.prev,r,r.next))r=r.next;else {if(yu(r),r=e=r.prev,r===r.next)break;n=!0;}}while(n||r!==e);return e}function Zl(t,e,n,r,i,s,o){if(!t)return;!o&&s&&function(t,e,n,r){let i=t;do{0===i.z&&(i.z=nu(i.x,i.y,e,n,r)),i.prevZ=i.prev,i.nextZ=i.next,i=i.next;}while(i!==t);i.prevZ.nextZ=null,i.prevZ=null,function(t){let e,n=1;do{let r,i=t;t=null;let s=null;for(e=0;i;){e++;let o=i,a=0;for(let t=0;t0||l>0&&o;)0!==a&&(0===l||!o||i.z<=o.z)?(r=i,i=i.nextZ,a--):(r=o,o=o.nextZ,l--),s?s.nextZ=r:t=r,r.prevZ=s,s=r;i=o;}s.nextZ=null,n*=2;}while(e>1)}(i);}(t,r,i,s);let a=t;for(;t.prev!==t.next;){const l=t.prev,u=t.next;if(s?Wl(t,r,i,s):Hl(t))e.push(l.i,t.i,u.i),yu(t),t=u.next,a=u.next;else if((t=u)===a){o?1===o?Zl(t=Kl(Yl(t),e),e,n,r,i,s,2):2===o&&Jl(t,e,n,r,i,s):Zl(Yl(t),e,n,r,i,s,1);break}}}function Hl(t){const e=t.prev,n=t,r=t.next;if(au(e,n,r)>=0)return !1;const i=e.x,s=n.x,o=r.x,a=e.y,l=n.y,u=r.y,c=Math.min(i,s,o),h=Math.min(a,l,u),p=Math.max(i,s,o),f=Math.max(a,l,u);let d=r.next;for(;d!==e;){if(d.x>=c&&d.x<=p&&d.y>=h&&d.y<=f&&su(i,a,s,l,o,u,d.x,d.y)&&au(d.prev,d,d.next)>=0)return !1;d=d.next;}return !0}function Wl(t,e,n,r){const i=t.prev,s=t,o=t.next;if(au(i,s,o)>=0)return !1;const a=i.x,l=s.x,u=o.x,c=i.y,h=s.y,p=o.y,f=Math.min(a,l,u),d=Math.min(c,h,p),y=Math.max(a,l,u),m=Math.max(c,h,p),g=nu(f,d,e,n,r),x=nu(y,m,e,n,r);let v=t.prevZ,b=t.nextZ;for(;v&&v.z>=g&&b&&b.z<=x;){if(v.x>=f&&v.x<=y&&v.y>=d&&v.y<=m&&v!==i&&v!==o&&su(a,c,l,h,u,p,v.x,v.y)&&au(v.prev,v,v.next)>=0)return !1;if(v=v.prevZ,b.x>=f&&b.x<=y&&b.y>=d&&b.y<=m&&b!==i&&b!==o&&su(a,c,l,h,u,p,b.x,b.y)&&au(b.prev,b,b.next)>=0)return !1;b=b.nextZ;}for(;v&&v.z>=g;){if(v.x>=f&&v.x<=y&&v.y>=d&&v.y<=m&&v!==i&&v!==o&&su(a,c,l,h,u,p,v.x,v.y)&&au(v.prev,v,v.next)>=0)return !1;v=v.prevZ;}for(;b&&b.z<=x;){if(b.x>=f&&b.x<=y&&b.y>=d&&b.y<=m&&b!==i&&b!==o&&su(a,c,l,h,u,p,b.x,b.y)&&au(b.prev,b,b.next)>=0)return !1;b=b.nextZ;}return !0}function Kl(t,e){let n=t;do{const r=n.prev,i=n.next.next;!lu(r,i)&&uu(r,n,n.next,i)&&pu(r,i)&&pu(i,r)&&(e.push(r.i,n.i,i.i),yu(n),yu(n.next),n=t=i),n=n.next;}while(n!==t);return Yl(n)}function Jl(t,e,n,r,i,s){let o=t;do{let t=o.next.next;for(;t!==o.prev;){if(o.i!==t.i&&ou(o,t)){let a=fu(o,t);return o=Yl(o,o.next),a=Yl(a,a.next),Zl(o,e,n,r,i,s,0),void Zl(a,e,n,r,i,s,0)}t=t.next;}o=o.next;}while(o!==t)}function Ql(t,e){let n=t.x-e.x;return 0===n&&(n=t.y-e.y,0===n)&&(n=(t.next.y-t.y)/(t.next.x-t.x)-(e.next.y-e.y)/(e.next.x-e.x)),n}function tu(t,e){const n=function(t,e){let n=e;const r=t.x,i=t.y;let s,o=-1/0;if(lu(t,n))return n;do{if(lu(t,n.next))return n.next;if(i<=n.y&&i>=n.next.y&&n.next.y!==n.y){const t=n.x+(i-n.y)*(n.next.x-n.x)/(n.next.y-n.y);if(t<=r&&t>o&&(o=t,s=n.x=n.x&&n.x>=l&&r!==n.x&&iu(is.x||n.x===s.x&&eu(s,n)))&&(s=n,c=e);}n=n.next;}while(n!==a);return s}(t,e);if(!n)return e;const r=fu(n,t);return Yl(r,r.next),Yl(n,n.next)}function eu(t,e){return au(t.prev,t,e.prev)<0&&au(e.next,t,t.next)<0}function nu(t,e,n,r,i){return (t=1431655765&((t=858993459&((t=252645135&((t=16711935&((t=(t-n)*i|0)|t<<8))|t<<4))|t<<2))|t<<1))|(e=1431655765&((e=858993459&((e=252645135&((e=16711935&((e=(e-r)*i|0)|e<<8))|e<<4))|e<<2))|e<<1))<<1}function ru(t){let e=t,n=t;do{(e.x=(t-o)*(s-a)&&(t-o)*(r-a)>=(n-o)*(e-a)&&(n-o)*(s-a)>=(i-o)*(r-a)}function su(t,e,n,r,i,s,o,a){return !(t===o&&e===a)&&iu(t,e,n,r,i,s,o,a)}function ou(t,e){return t.next.i!==e.i&&t.prev.i!==e.i&&!function(t,e){let n=t;do{if(n.i!==t.i&&n.next.i!==t.i&&n.i!==e.i&&n.next.i!==e.i&&uu(n,n.next,t,e))return !0;n=n.next;}while(n!==t);return !1}(t,e)&&(pu(t,e)&&pu(e,t)&&function(t,e){let n=t,r=!1;const i=(t.x+e.x)/2,s=(t.y+e.y)/2;do{n.y>s!=n.next.y>s&&n.next.y!==n.y&&i<(n.next.x-n.x)*(s-n.y)/(n.next.y-n.y)+n.x&&(r=!r),n=n.next;}while(n!==t);return r}(t,e)&&(au(t.prev,t,e.prev)||au(t,e.prev,e))||lu(t,e)&&au(t.prev,t,t.next)>0&&au(e.prev,e,e.next)>0)}function au(t,e,n){return (e.y-t.y)*(n.x-e.x)-(e.x-t.x)*(n.y-e.y)}function lu(t,e){return t.x===e.x&&t.y===e.y}function uu(t,e,n,r){const i=hu(au(t,e,n)),s=hu(au(t,e,r)),o=hu(au(n,r,t)),a=hu(au(n,r,e));return i!==s&&o!==a||!(0!==i||!cu(t,n,e))||!(0!==s||!cu(t,r,e))||!(0!==o||!cu(n,t,r))||!(0!==a||!cu(n,e,r))}function cu(t,e,n){return e.x<=Math.max(t.x,n.x)&&e.x>=Math.min(t.x,n.x)&&e.y<=Math.max(t.y,n.y)&&e.y>=Math.min(t.y,n.y)}function hu(t){return t>0?1:t<0?-1:0}function pu(t,e){return au(t.prev,t,t.next)<0?au(t,e,t.next)>=0&&au(t,t.prev,e)>=0:au(t,e,t.prev)<0||au(t,t.next,e)<0}function fu(t,e){const n=mu(t.i,t.x,t.y),r=mu(e.i,e.x,e.y),i=t.next,s=e.prev;return t.next=e,e.prev=t,n.next=i,i.prev=n,r.next=n,n.prev=r,s.next=r,r.prev=s,r}function du(t,e,n,r){const i=mu(t,e,n);return r?(i.next=r.next,i.prev=r,r.next.prev=i,r.next=i):(i.prev=i,i.next=i),i}function yu(t){t.next.prev=t.prev,t.prev.next=t.next,t.prevZ&&(t.prevZ.nextZ=t.nextZ),t.nextZ&&(t.nextZ.prevZ=t.prevZ);}function mu(t,e,n){return {i:t,x:e,y:n,prev:null,next:null,z:0,prevZ:null,nextZ:null,steiner:!1}}class gu{constructor(t,e){if(e>t)throw new Error("Min granularity must not be greater than base granularity.");this._baseZoomGranularity=t,this._minGranularity=e;}getGranularityForZoomLevel(t){return Math.max(Math.floor(this._baseZoomGranularity/(1<32767||e>32767)throw new Error("Vertex coordinates are out of signed 16 bit integer range.");const n=0|Math.round(t),r=0|Math.round(e),i=this._getKey(n,r);if(this._vertexDictionary.has(i))return this._vertexDictionary.get(i);const s=this._vertexBuffer.length/2;return this._vertexDictionary.set(i,s),this._vertexBuffer.push(n,r),s}_subdivideTrianglesScanline(t){if(this._granularity<2)return function(t,e){const n=[];for(let r=0;r0?(n.push(i),n.push(o),n.push(s)):(n.push(i),n.push(s),n.push(o));}return n}(this._vertexBuffer,t);const e=[],n=t.length;for(let r=0;r=1||v<=0)||y&&(ai)){u>=r&&u<=i&&s.push(n[(t+1)%3]);continue}!y&&x>0&&s.push(this._vertexToIndex(o+p*x,a+f*x));const b=o+p*Math.max(x,0),w=o+p*Math.min(v,1);d||this._generateIntraEdgeVertices(s,o,a,l,u,b,w),!y&&v<1&&s.push(this._vertexToIndex(o+p*v,a+f*v)),(y||u>=r&&u<=i)&&s.push(n[(t+1)%3]),!y&&(u<=r||u>=i)&&this._generateInterEdgeVertices(s,o,a,l,u,c,h,w,r,i);}return s}_generateIntraEdgeVertices(t,e,n,r,i,s,o){const a=r-e,l=i-n,u=0===l,c=u?Math.min(e,r):Math.min(s,o),h=u?Math.max(e,r):Math.max(s,o),p=Math.floor(c/this._granularityCellSize)+1,f=Math.ceil(h/this._granularityCellSize)-1;if(u?e=p;r--){const i=r*this._granularityCellSize;t.push(this._vertexToIndex(i,n+l*(i-e)/a));}}_generateInterEdgeVertices(t,e,n,r,i,s,o,a,l,u){const c=i-n,h=s-r,p=o-i,f=(l-i)/p,d=(u-i)/p,y=Math.min(f,d),m=Math.max(f,d),g=r+h*y;let x=Math.floor(Math.min(g,a)/this._granularityCellSize)+1,v=Math.ceil(Math.max(g,a)/this._granularityCellSize)-1,b=a=1||m<=0){const t=n-o,r=s+(e-s)*Math.min((l-o)/t,(u-o)/t);x=Math.floor(Math.min(r,a)/this._granularityCellSize)+1,v=Math.ceil(Math.max(r,a)/this._granularityCellSize)-1,b=a0?u:l;if(b)for(let e=x;e<=v;e++)t.push(this._vertexToIndex(e*this._granularityCellSize,_));else for(let e=v;e>=x;e--)t.push(this._vertexToIndex(e*this._granularityCellSize,_));}_generateOutline(t){const e=[];for(const n of t){const t=Su(n,this._granularity,!0),r=this._pointArrayToIndices(t),i=[];for(let t=1;ti!=(s===vu)?(t.push(e),t.push(n),t.push(this._vertexToIndex(r,s)),t.push(n),t.push(this._vertexToIndex(i,s)),t.push(this._vertexToIndex(r,s))):(t.push(n),t.push(e),t.push(this._vertexToIndex(r,s)),t.push(this._vertexToIndex(i,s)),t.push(n),t.push(this._vertexToIndex(r,s)));}_fillPoles(t,e,n){const r=this._vertexBuffer,i=T,s=t.length;for(let o=2;o80*n){a=t[0],l=t[1];let e=a,r=l;for(let s=n;se&&(e=n),i>r&&(r=i);}u=Math.max(e-a,r-l),u=0!==u?32767/u:0;}return Zl(s,o,n,a,l,u,0),o}(n,r),e=this._convertIndices(n,t);i=this._subdivideTrianglesScanline(e);}catch(t){console.error(t);}let s=[];return e&&(s=this._generateOutline(t)),this._ensureNoPoleVertices(),this._handlePoles(i),{verticesFlattened:this._vertexBuffer,indicesTriangles:i,indicesLineList:s}}_convertIndices(t,e){const n=[];for(const r of e)n.push(this._vertexToIndex(t[2*r],t[2*r+1]));return n}_pointArrayToIndices(t){const e=[];for(const n of t)e.push(this._vertexToIndex(n.x,n.y));return e}}function _u(t,e,n,r=!0){return new wu(n,e).subdividePolygonInternal(t,r)}function Su(t,e,r=!1){if(!t||t.length<1)return [];if(t.length<2)return [];const i=t[0],s=t[t.length-1],o=r&&(i.x!==s.x||i.y!==s.y);if(e<2)return o?[...t,t[0]]:[...t];const a=Math.floor(T/e),l=[];l.push(new n(t[0].x,t[0].y));const u=t.length,c=o?u:u-1;for(let e=0;e0?(Math.floor(x/a)+1)*a:(Math.ceil(x/a)-1)*a,e=y>0?(Math.floor(v/a)+1)*a:(Math.ceil(v/a)-1)*a,r=Math.abs(x-t),i=Math.abs(v-e),s=Math.abs(x-c),o=Math.abs(v-h),u=p?r/m:Number.POSITIVE_INFINITY,b=f?i/g:Number.POSITIVE_INFINITY;if((s<=r||!p)&&(o<=i||!f))break;if(u=0?o-1:s-1,i=(a+1)%s,l=t[2*e[r]],u=t[2*e[i]],c=t[2*e[o]],h=t[2*e[o]+1],p=t[2*e[a]+1];let f=!1;if(lu)f=!1;else {const n=p-h,s=-(t[2*e[a]]-c),o=h((u-c)*n+(t[2*e[i]+1]-h)*s)*o&&(f=!0);}if(f){const t=e[r],i=e[o],l=e[a];t!==i&&t!==l&&i!==l&&n.push(l,i,t),o--,o<0&&(o=s-1);}else {const t=e[i],r=e[o],l=e[a];t!==r&&t!==l&&r!==l&&n.push(l,r,t),a++,a>=s&&(a=0);}if(r===i)break}}function Mu(t,e,n,r,i,s,o,a,l){const u=i.length/2,c=o&&a&&l;if(uua.MAX_VERTEX_ARRAY_LENGTH&&(u=t.createNewSegment(e,n),l=a.count,y=!0,m=!0,g=!0,c=0);const x=ku(o,r,s,a,p,y,u),v=ku(o,r,s,a,f,m,u),b=ku(o,r,s,a,d,g,u);n.emplaceBack(c+x-l,c+v-l,c+b-l),u.primitiveLength++;}}(e,n,r,i,s,t),c&&function(t,e,n,r,i,s){const o=[];for(let t=0;tua.MAX_VERTEX_ARRAY_LENGTH&&(u=t.createNewSegment(e,n),l=a.count,d=!0,y=!0,c=0);const m=ku(o,r,s,a,p,d,u),g=ku(o,r,s,a,f,y,u);n.emplaceBack(c+m-l,c+g-l),u.primitiveLength++;}}(o,n,a,i,l,t),e.forceNewSegmentOnNextPrepare(),null==o||o.forceNewSegmentOnNextPrepare();}function ku(t,e,n,r,i,s,o){if(s){const s=r.count;return n(e[2*i],e[2*i+1]),t[i]=r.count,r.count++,o.vertexLength++,s}return t[i]}class Iu{constructor(t){this.zoom=t.zoom,this.overscaling=t.overscaling,this.layers=t.layers,this.layerIds=this.layers.map((t=>t.id)),this.index=t.index,this.hasDependencies=!1,this.patternFeatures=[],this.layoutVertexArray=new Zo,this.indexArray=new sa,this.indexArray2=new oa,this.programConfigurations=new $a(t.layers,t.zoom),this.segments=new ua,this.segments2=new ua,this.stateDependentLayerIds=this.layers.filter((t=>t.isStateDependent())).map((t=>t.id));}populate(t,e,n){this.hasDependencies=ql("fill",this.layers,e);const r=this.layers[0].layout.get("fill-sort-key"),i=!r.isConstant(),s=[];for(const{feature:o,id:a,index:l,sourceLayerIndex:u}of t){const t=this.layers[0]._featureFilter.needGeometry,c=Ga(o,t);if(!this.layers[0]._featureFilter.filter(new Cs(this.zoom),c,n))continue;const h=i?r.evaluate(c,{},n,e.availableImages):void 0,p={id:a,properties:o.properties,type:o.type,sourceLayerIndex:u,index:l,geometry:t?c.geometry:qa(o),patterns:{},sortKey:h};s.push(p);}i&&s.sort(((t,e)=>t.sortKey-e.sortKey));for(const r of s){const{geometry:i,index:s,sourceLayerIndex:o}=r;if(this.hasDependencies){const t=Gl("fill",this.layers,r,{zoom:this.zoom},e);this.patternFeatures.push(t);}else this.addFeature(r,i,s,n,{},e.subdivisionGranularity);e.featureIndex.insert(t[s].feature,i,s,o,this.index);}}update(t,e,n){this.stateDependentLayers.length&&this.programConfigurations.updatePaintArrays(t,e,this.stateDependentLayers,{imagePositions:n});}addFeatures(t,e,n){for(const r of this.patternFeatures)this.addFeature(r,r.geometry,r.index,e,n,t.subdivisionGranularity);}isEmpty(){return 0===this.layoutVertexArray.length}uploadPending(){return !this.uploaded||this.programConfigurations.needsUpload}upload(t){this.uploaded||(this.layoutVertexBuffer=t.createVertexBuffer(this.layoutVertexArray,jl),this.indexBuffer=t.createIndexBuffer(this.indexArray),this.indexBuffer2=t.createIndexBuffer(this.indexArray2)),this.programConfigurations.upload(t),this.uploaded=!0;}destroy(){this.layoutVertexBuffer&&(this.layoutVertexBuffer.destroy(),this.indexBuffer.destroy(),this.indexBuffer2.destroy(),this.programConfigurations.destroy(),this.segments.destroy(),this.segments2.destroy());}addFeature(t,e,n,r,i,s){for(const t of rr(e,500)){const e=_u(t,r,s.fill.getGranularityForZoomLevel(r.z)),n=this.layoutVertexArray;Mu(((t,e)=>{n.emplaceBack(t,e);}),this.segments,this.layoutVertexArray,this.indexArray,e.verticesFlattened,e.indicesTriangles,this.segments2,this.indexArray2,e.indicesLineList);}this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length,t,n,{imagePositions:i,canonical:r});}}let Eu,Tu;ds("FillBucket",Iu,{omit:["layers","patternFeatures"]});var Fu={get paint(){return Tu=Tu||new Ks({"fill-antialias":new Xs(wt.paint_fill["fill-antialias"]),"fill-opacity":new Ys(wt.paint_fill["fill-opacity"]),"fill-color":new Ys(wt.paint_fill["fill-color"]),"fill-outline-color":new Ys(wt.paint_fill["fill-outline-color"]),"fill-translate":new Xs(wt.paint_fill["fill-translate"]),"fill-translate-anchor":new Xs(wt.paint_fill["fill-translate-anchor"]),"fill-pattern":new Zs(wt.paint_fill["fill-pattern"])})},get layout(){return Eu=Eu||new Ks({"fill-sort-key":new Ys(wt.layout_fill["fill-sort-key"])})}};class Pu extends to{constructor(t,e){super(t,Fu,e);}recalculate(t,e){super.recalculate(t,e);const n=this.paint._values["fill-outline-color"];"constant"===n.value.kind&&void 0===n.value.value&&(this.paint._values["fill-outline-color"]=this.paint._values["fill-color"]);}createBucket(t){return new Iu(t)}queryRadius(){return al(this.paint.get("fill-translate"))}queryIntersectsFeature({queryGeometry:t,geometry:e,transform:n,pixelsToTileUnits:r}){return Ka(ll(t,this.paint.get("fill-translate"),this.paint.get("fill-translate-anchor"),-n.bearingInRadians,r),e)}isTileClipped(){return !0}}const Du=ao([{name:"a_pos",components:2,type:"Int16"},{name:"a_normal_ed",components:4,type:"Int16"}],4),zu=ao([{name:"a_centroid",components:2,type:"Int16"}],4),{members:Bu}=Du;class Cu{constructor(t,e,n,r,i){this.properties={},this.extent=n,this.type=0,this.id=void 0,this._pbf=t,this._geometry=-1,this._keys=r,this._values=i,t.readFields(Vu,this,e);}loadGeometry(){const t=this._pbf;t.pos=this._geometry;const e=t.readVarint()+t.pos,r=[];let i,s=1,o=0,a=0,l=0;for(;t.pos>3;}if(o--,1===s||2===s)a+=t.readSVarint(),l+=t.readSVarint(),1===s&&(i&&r.push(i),i=[]),i&&i.push(new n(a,l));else {if(7!==s)throw new Error(`unknown command ${s}`);i&&i.push(i[0].clone());}}return i&&r.push(i),r}bbox(){const t=this._pbf;t.pos=this._geometry;const e=t.readVarint()+t.pos;let n=1,r=0,i=0,s=0,o=1/0,a=-1/0,l=1/0,u=-1/0;for(;t.pos>3;}if(r--,1===n||2===n)i+=t.readSVarint(),s+=t.readSVarint(),ia&&(a=i),su&&(u=s);else if(7!==n)throw new Error(`unknown command ${n}`)}return [o,l,a,u]}toGeoJSON(t,e,n){const r=this.extent*Math.pow(2,n),i=this.extent*t,s=this.extent*e,o=this.loadGeometry();function a(t){return [360*(t.x+i)/r-180,360/Math.PI*Math.atan(Math.exp((1-2*(t.y+s)/r)*Math.PI))-90]}function l(t){return t.map(a)}let u;if(1===this.type){const t=[];for(const e of o)t.push(e[0]);const e=l(t);u=1===t.length?{type:"Point",coordinates:e[0]}:{type:"MultiPoint",coordinates:e};}else if(2===this.type){const t=o.map(l);u=1===t.length?{type:"LineString",coordinates:t[0]}:{type:"MultiLineString",coordinates:t};}else {if(3!==this.type)throw new Error("unknown feature type");{const t=Lu(o),e=[];for(const n of t)e.push(n.map(l));u=1===e.length?{type:"Polygon",coordinates:e[0]}:{type:"MultiPolygon",coordinates:e};}}const c={type:"Feature",geometry:u,properties:this.properties};return null!=this.id&&(c.id=this.id),c}}function Vu(t,e,n){1===t?e.id=n.readVarint():2===t?function(t,e){const n=t.readVarint()+t.pos;for(;t.pos=this._features.length)throw new Error("feature index out of bounds");this._pbf.pos=this._features[t];const e=this._pbf.readVarint()+this._pbf.pos;return new Cu(this._pbf,e,this.extent,this._keys,this._values)}}function Ru(t,e,n){15===t?e.version=n.readVarint():1===t?e.name=n.readString():5===t?e.extent=n.readVarint():2===t?e._features.push(n.pos):3===t?e._keys.push(n.readString()):4===t&&e._values.push(function(t){let e=null;const n=t.readVarint()+t.pos;for(;t.pos>3;e=1===n?t.readString():2===n?t.readFloat():3===n?t.readDouble():4===n?t.readVarint64():5===n?t.readVarint():6===n?t.readSVarint():7===n?t.readBoolean():null;}if(null==e)throw new Error("unknown feature value");return e}(n));}class Nu{constructor(t,e){this.layers=t.readFields(Uu,{},e);}}function Uu(t,e,n){if(3===t){const t=new $u(n,n.readVarint()+n.pos);t.length&&(e[t.name]=t);}}const ju=Math.pow(2,13);function qu(t,e,n,r,i,s,o,a){t.emplaceBack(e,n,2*Math.floor(r*ju)+o,i*ju*2,s*ju*2,Math.round(a));}class Gu{constructor(t){this.zoom=t.zoom,this.overscaling=t.overscaling,this.layers=t.layers,this.layerIds=this.layers.map((t=>t.id)),this.index=t.index,this.hasDependencies=!1,this.layoutVertexArray=new Ho,this.centroidVertexArray=new Xo,this.indexArray=new sa,this.programConfigurations=new $a(t.layers,t.zoom),this.segments=new ua,this.stateDependentLayerIds=this.layers.filter((t=>t.isStateDependent())).map((t=>t.id));}populate(t,e,n){this.features=[],this.hasDependencies=ql("fill-extrusion",this.layers,e);for(const{feature:r,id:i,index:s,sourceLayerIndex:o}of t){const t=this.layers[0]._featureFilter.needGeometry,a=Ga(r,t);if(!this.layers[0]._featureFilter.filter(new Cs(this.zoom),a,n))continue;const l={id:i,sourceLayerIndex:o,index:s,geometry:t?a.geometry:qa(r),properties:r.properties,type:r.type,patterns:{}};this.hasDependencies?this.features.push(Gl("fill-extrusion",this.layers,l,{zoom:this.zoom},e)):this.addFeature(l,l.geometry,s,n,{},e.subdivisionGranularity),e.featureIndex.insert(r,l.geometry,s,o,this.index,!0);}}addFeatures(t,e,n){for(const r of this.features){const{geometry:i}=r;this.addFeature(r,i,r.index,e,n,t.subdivisionGranularity);}}update(t,e,n){this.stateDependentLayers.length&&this.programConfigurations.updatePaintArrays(t,e,this.stateDependentLayers,{imagePositions:n});}isEmpty(){return 0===this.layoutVertexArray.length&&0===this.centroidVertexArray.length}uploadPending(){return !this.uploaded||this.programConfigurations.needsUpload}upload(t){this.uploaded||(this.layoutVertexBuffer=t.createVertexBuffer(this.layoutVertexArray,Bu),this.centroidVertexBuffer=t.createVertexBuffer(this.centroidVertexArray,zu.members,!0),this.indexBuffer=t.createIndexBuffer(this.indexArray)),this.programConfigurations.upload(t),this.uploaded=!0;}destroy(){this.layoutVertexBuffer&&(this.layoutVertexBuffer.destroy(),this.indexBuffer.destroy(),this.programConfigurations.destroy(),this.segments.destroy(),this.centroidVertexBuffer.destroy());}addFeature(t,e,n,r,i,s){for(const n of rr(e,500)){const e={x:0,y:0,sampleCount:0},i=this.layoutVertexArray.length;this.processPolygon(e,r,t,n,s);const o=this.layoutVertexArray.length-i,a=Math.floor(e.x/e.sampleCount),l=Math.floor(e.y/e.sampleCount);for(let t=0;t{qu(u,t,e,0,0,1,1,0);}),this.segments,this.layoutVertexArray,this.indexArray,l.verticesFlattened,l.indicesTriangles);}_generateSideFaces(t,e){let n=0;for(let r=1;rua.MAX_VERTEX_ARRAY_LENGTH&&(e.segment=this.segments.prepareSegment(4,this.layoutVertexArray,this.indexArray));const o=i.sub(s)._perp()._unit(),a=s.dist(i);n+a>32768&&(n=0),qu(this.layoutVertexArray,i.x,i.y,o.x,o.y,0,0,n),qu(this.layoutVertexArray,i.x,i.y,o.x,o.y,0,1,n),n+=a,qu(this.layoutVertexArray,s.x,s.y,o.x,o.y,0,0,n),qu(this.layoutVertexArray,s.x,s.y,o.x,o.y,0,1,n);const l=e.segment.vertexLength;this.indexArray.emplaceBack(l,l+2,l+1),this.indexArray.emplaceBack(l+1,l+2,l+3),e.segment.vertexLength+=4,e.segment.primitiveLength+=2;}}}function Xu(t,e){for(let n=0;nT)||t.y===e.y&&(t.y<0||t.y>T)}function Zu(t){return t.every((t=>t.x<0))||t.every((t=>t.x>T))||t.every((t=>t.y<0))||t.every((t=>t.y>T))}let Hu;ds("FillExtrusionBucket",Gu,{omit:["layers","features"]});var Wu={get paint(){return Hu=Hu||new Ks({"fill-extrusion-opacity":new Xs(wt["paint_fill-extrusion"]["fill-extrusion-opacity"]),"fill-extrusion-color":new Ys(wt["paint_fill-extrusion"]["fill-extrusion-color"]),"fill-extrusion-translate":new Xs(wt["paint_fill-extrusion"]["fill-extrusion-translate"]),"fill-extrusion-translate-anchor":new Xs(wt["paint_fill-extrusion"]["fill-extrusion-translate-anchor"]),"fill-extrusion-pattern":new Zs(wt["paint_fill-extrusion"]["fill-extrusion-pattern"]),"fill-extrusion-height":new Ys(wt["paint_fill-extrusion"]["fill-extrusion-height"]),"fill-extrusion-base":new Ys(wt["paint_fill-extrusion"]["fill-extrusion-base"]),"fill-extrusion-vertical-gradient":new Xs(wt["paint_fill-extrusion"]["fill-extrusion-vertical-gradient"])})}};class Ku extends to{constructor(t,e){super(t,Wu,e);}createBucket(t){return new Gu(t)}queryRadius(){return al(this.paint.get("fill-extrusion-translate"))}is3D(){return !0}queryIntersectsFeature({queryGeometry:t,feature:e,featureState:r,geometry:i,transform:s,pixelsToTileUnits:o,pixelPosMatrix:a}){const l=ll(t,this.paint.get("fill-extrusion-translate"),this.paint.get("fill-extrusion-translate-anchor"),-s.bearingInRadians,o),u=this.paint.get("fill-extrusion-height").evaluate(e,r),c=this.paint.get("fill-extrusion-base").evaluate(e,r),h=function(t,e){const r=[];for(const i of t){const t=[i.x,i.y,0,1];A(t,t,e),r.push(new n(t[0]/t[3],t[1]/t[3]));}return r}(l,a),p=function(t,e,r,i){const s=[],o=[],a=i[8]*e,l=i[9]*e,u=i[10]*e,c=i[11]*e,h=i[8]*r,p=i[9]*r,f=i[10]*r,d=i[11]*r;for(const e of t){const t=[],r=[];for(const s of e){const e=s.x,o=s.y,y=i[0]*e+i[4]*o+i[12],m=i[1]*e+i[5]*o+i[13],g=i[2]*e+i[6]*o+i[14],x=i[3]*e+i[7]*o+i[15],v=g+u,b=x+c,w=y+h,_=m+p,S=g+f,A=x+d,M=new n((y+a)/b,(m+l)/b);M.z=v/b,t.push(M);const k=new n(w/A,_/A);k.z=S/A,r.push(k);}s.push(t),o.push(r);}return [s,o]}(i,c,u,a);return function(t,e,n){let r=1/0;Ka(n,e)&&(r=Qu(n,e[0]));for(let i=0;i>4;if(1!==r)throw new Error(`Got v${r} data when expected v1.`);const i=tc[15&n];if(!i)throw new Error("Unrecognized array type.");const[s]=new Uint16Array(t,2,1),[o]=new Uint32Array(t,4,1);return new ec(o,s,i,t)}constructor(t,e=64,n=Float64Array,r){if(isNaN(t)||t<0)throw new Error(`Unpexpected numItems value: ${t}.`);this.numItems=+t,this.nodeSize=Math.min(Math.max(+e,2),65535),this.ArrayType=n,this.IndexArrayType=t<65536?Uint16Array:Uint32Array;const i=tc.indexOf(this.ArrayType),s=2*t*this.ArrayType.BYTES_PER_ELEMENT,o=t*this.IndexArrayType.BYTES_PER_ELEMENT,a=(8-o%8)%8;if(i<0)throw new Error(`Unexpected typed array class: ${n}.`);r&&r instanceof ArrayBuffer?(this.data=r,this.ids=new this.IndexArrayType(this.data,8,t),this.coords=new this.ArrayType(this.data,8+o+a,2*t),this._pos=2*t,this._finished=!0):(this.data=new ArrayBuffer(8+s+o+a),this.ids=new this.IndexArrayType(this.data,8,t),this.coords=new this.ArrayType(this.data,8+o+a,2*t),this._pos=0,this._finished=!1,new Uint8Array(this.data,0,2).set([219,16+i]),new Uint16Array(this.data,2,1)[0]=e,new Uint32Array(this.data,4,1)[0]=t);}add(t,e){const n=this._pos>>1;return this.ids[n]=n,this.coords[this._pos++]=t,this.coords[this._pos++]=e,n}finish(){const t=this._pos>>1;if(t!==this.numItems)throw new Error(`Added ${t} items when expected ${this.numItems}.`);return nc(this.ids,this.coords,this.nodeSize,0,this.numItems-1,0),this._finished=!0,this}range(t,e,n,r){if(!this._finished)throw new Error("Data not yet indexed - call index.finish().");const{ids:i,coords:s,nodeSize:o}=this,a=[0,i.length-1,0],l=[];for(;a.length;){const u=a.pop()||0,c=a.pop()||0,h=a.pop()||0;if(c-h<=o){for(let o=h;o<=c;o++){const a=s[2*o],u=s[2*o+1];a>=t&&a<=n&&u>=e&&u<=r&&l.push(i[o]);}continue}const p=h+c>>1,f=s[2*p],d=s[2*p+1];f>=t&&f<=n&&d>=e&&d<=r&&l.push(i[p]),(0===u?t<=f:e<=d)&&(a.push(h),a.push(p-1),a.push(1-u)),(0===u?n>=f:r>=d)&&(a.push(p+1),a.push(c),a.push(1-u));}return l}within(t,e,n){if(!this._finished)throw new Error("Data not yet indexed - call index.finish().");const{ids:r,coords:i,nodeSize:s}=this,o=[0,r.length-1,0],a=[],l=n*n;for(;o.length;){const u=o.pop()||0,c=o.pop()||0,h=o.pop()||0;if(c-h<=s){for(let n=h;n<=c;n++)oc(i[2*n],i[2*n+1],t,e)<=l&&a.push(r[n]);continue}const p=h+c>>1,f=i[2*p],d=i[2*p+1];oc(f,d,t,e)<=l&&a.push(r[p]),(0===u?t-n<=f:e-n<=d)&&(o.push(h),o.push(p-1),o.push(1-u)),(0===u?t+n>=f:e+n>=d)&&(o.push(p+1),o.push(c),o.push(1-u));}return a}}function nc(t,e,n,r,i,s){if(i-r<=n)return;const o=r+i>>1;rc(t,e,o,r,i,s),nc(t,e,n,r,o-1,1-s),nc(t,e,n,o+1,i,1-s);}function rc(t,e,n,r,i,s){for(;i>r;){if(i-r>600){const o=i-r+1,a=n-r+1,l=Math.log(o),u=.5*Math.exp(2*l/3),c=.5*Math.sqrt(l*u*(o-u)/o)*(a-o/2<0?-1:1);rc(t,e,n,Math.max(r,Math.floor(n-a*u/o+c)),Math.min(i,Math.floor(n+(o-a)*u/o+c)),s);}const o=e[2*n+s];let a=r,l=i;for(ic(t,e,r,n),e[2*i+s]>o&&ic(t,e,r,i);ao;)l--;}e[2*r+s]===o?ic(t,e,r,l):(l++,ic(t,e,l,i)),l<=n&&(r=l+1),n<=l&&(i=l-1);}}function ic(t,e,n,r){sc(t,n,r),sc(e,2*n,2*r),sc(e,2*n+1,2*r+1);}function sc(t,e,n){const r=t[e];t[e]=t[n],t[n]=r;}function oc(t,e,n,r){const i=t-n,s=e-r;return i*i+s*s}function ac(t,e,n,r){let i=r;const s=e+(n-e>>1);let o,a=n-e;const l=t[e],u=t[e+1],c=t[n],h=t[n+1];for(let r=e+3;ri)o=r,i=e;else if(e===i){const t=Math.abs(r-s);tr&&(o-e>3&&ac(t,e,o,r),t[o+2]=i,n-o>3&&ac(t,o,n,r));}function lc(t,e,n,r,i,s){let o=i-n,a=s-r;if(0!==o||0!==a){const l=((t-n)*o+(e-r)*a)/(o*o+a*a);l>1?(n=i,r=s):l>0&&(n+=o*l,r+=a*l);}return o=t-n,a=e-r,o*o+a*a}function uc(t,e,n,r){const i={type:e,geom:n},s={id:null==t?null:t,type:i.type,geometry:i.geom,tags:r,minX:1/0,minY:1/0,maxX:-1/0,maxY:-1/0};switch(i.type){case "Point":case "MultiPoint":hc(s,i.geom);break;case "LineString":hc(s,i.geom.points);break;case "Polygon":hc(s,i.geom[0].points);break;case "MultiLineString":for(const t of i.geom)hc(s,t.points);break;case "MultiPolygon":for(const t of i.geom)hc(s,t[0].points);}return s}function cc(t){t.points.length>64&&(t.points=new Float64Array(t.points));}function hc(t,e){for(let n=0;n0&&(o+=r?(i*l-a*s)/2:Math.sqrt(Math.pow(a-i,2)+Math.pow(l-s,2))),i=a,s=l;}const a=e.points.length-3;e.points[2]=1,n>0&&ac(e.points,0,a,n),e.points[a+2]=1,cc(e),e.size=Math.abs(o),e.start=0,e.end=e.size;}function yc(t,e,n,r){for(let i=0;i1?1:n}function xc(t){const e={type:"Feature",geometry:vc(t),properties:t.tags};return null!=t.id&&(e.id=t.id),e}function vc(t){const{type:e,geometry:n}=t;switch(e){case "Point":return {type:e,coordinates:wc(n[0],n[1])};case "MultiPoint":return {type:e,coordinates:bc(n)};case "LineString":return {type:e,coordinates:bc(n.points)};case "MultiLineString":case "Polygon":return {type:e,coordinates:n.map((t=>bc(t.points)))};case "MultiPolygon":return {type:e,coordinates:n.map((t=>t.map((t=>bc(t.points)))))}}}function bc(t){const e=[];for(let n=0;n=(n/=e)&&o=r)return null;const l=[];for(const e of t){const t=i===Ac.X?e.minX:e.minY,s=i===Ac.X?e.maxX:e.maxY;if(t>=n&&s=r))switch(e.type){case "Point":case "MultiPoint":kc(e,l,n,r,i);continue;case "LineString":Ic(e,l,n,r,i,a);continue;case "MultiLineString":Ec(e,l,n,r,i);continue;case "Polygon":Tc(e,l,n,r,i);continue;case "MultiPolygon":Fc(e,l,n,r,i);continue}}return l.length?l:null}function kc(t,e,n,r,i){const s=[];((function(t,e,n,r,i){for(let s=0;s=n&&o<=r&&Bc(e,t[s],t[s+1],t[s+2]);}}))(t.geometry,s,n,r,i),s.length&&e.push(uc(t.id,3===s.length?"Point":"MultiPoint",s,t.tags));}function Ic(t,e,n,r,i,s){const o=[];if(Pc(t.geometry,o,n,r,i,!1,s.lineMetrics),o.length)if(s.lineMetrics)for(const n of o)e.push(uc(t.id,"LineString",n,t.tags));else e.push(o.length>1?uc(t.id,"MultiLineString",o,t.tags):uc(t.id,"LineString",o[0],t.tags));}function Ec(t,e,n,r,i){const s=[];zc(t.geometry,s,n,r,i,!1),s.length&&e.push(1!==s.length?uc(t.id,"MultiLineString",s,t.tags):uc(t.id,"LineString",s[0],t.tags));}function Tc(t,e,n,r,i){const s=[];zc(t.geometry,s,n,r,i,!0),s.length&&e.push(uc(t.id,"Polygon",s,t.tags));}function Fc(t,e,n,r,i){const s=[];for(const e of t.geometry){const t=[];zc(e,t,n,r,i,!0),t.length&&s.push(t);}s.length&&e.push(uc(t.id,"MultiPolygon",s,t.tags));}function Pc(t,e,n,r,i,s,o){let a=Dc(t);const l=i===Ac.X?Cc:Vc;let u,c,h=t.start;for(let p=0;pn&&(c=l(a,f,d,m,g,n),o&&(a.start=h+u*c)):x>r?v=n&&(c=l(a,f,d,m,g,n),b=!0),v>r&&x<=r&&(c=l(a,f,d,m,g,r),b=!0),!s&&b&&(o&&(a.end=h+u*c),e.push(a),a=Dc(t)),o&&(h+=u);}let p=t.points.length-3;const f=t.points[p],d=t.points[p+1],y=i===Ac.X?f:d;y>=n&&y<=r&&Bc(a.points,f,d,t.points[p+2]),p=a.points.length-3,s&&p>=3&&(a.points[p]!==a.points[0]||a.points[p+1]!==a.points[1])&&Bc(a.points,a.points[0],a.points[1],a.points[2]),a.points.length&&(cc(a),e.push(a));}function Dc(t){return {points:[],size:t.size,start:t.start,end:t.end}}function zc(t,e,n,r,i,s){for(const o of t)Pc(o,e,n,r,i,s,!1);}function Bc(t,e,n,r){t.push(e,n,r);}function Cc(t,e,n,r,i,s){const o=(s-e)/(r-e);return Bc(t.points,s,n+(i-n)*o,1),o}function Vc(t,e,n,r,i,s){const o=(s-n)/(i-n);return Bc(t.points,e+(r-e)*o,s,1),o}function Lc(t,e){const n=e.buffer/e.extent;let r=t;const i=Mc(t,1,-1-n,n,Ac.X,-1,2,e),s=Mc(t,1,1-n,2+n,Ac.X,-1,2,e);return i||s?(r=Mc(t,1,-n,1+n,Ac.X,-1,2,e)||[],i&&(r=Oc(i,1).concat(r)),s&&(r=r.concat(Oc(s,-1))),r):r}function Oc(t,e){const n=[];for(const r of t)switch(r.type){case "Point":case "MultiPoint":{const t=$c(r.geometry,e);n.push(uc(r.id,r.type,t,r.tags));continue}case "LineString":{const t=Rc(r.geometry,e);n.push(uc(r.id,r.type,t,r.tags));continue}case "MultiLineString":case "Polygon":{const t=[];for(const n of r.geometry)t.push(Rc(n,e));n.push(uc(r.id,r.type,t,r.tags));continue}case "MultiPolygon":{const t=[];for(const n of r.geometry){const r=[];for(const t of n)r.push(Rc(t,e));t.push(r);}n.push(uc(r.id,r.type,t,r.tags));continue}}return n}function $c(t,e){const n=[];for(let r=0;r0||e.addOrUpdateProperties?.length>0;if(r){const r=t[0];let s=pc({type:"FeatureCollection",features:[{type:"Feature",id:r.id,geometry:e.newGeometry,properties:i?Uc(r.tags,e):r.tags}]},n);return s=Lc(s,n),s}if(i){const n=[];for(const r of t){const t={...r};t.tags=Uc(t.tags,e),n.push(t);}return n}return t}function Uc(t,e){if(e.removeAllProperties)return {};const n={...t||{}};if(e.removeProperties)for(const t of e.removeProperties)delete n[t];if(e.addOrUpdateProperties)for(const{key:t,value:r}of e.addOrUpdateProperties)n[t]=r;return n}!function(t){t[t.X=0]="X",t[t.Y=1]="Y";}(Ac||(Ac={}));const jc={minZoom:0,maxZoom:16,minPoints:2,radius:40,extent:512,nodeSize:64,log:!1,generateId:!1,reduce:null,map:t=>t};class qc{constructor(t){this.options=Object.assign(Object.create(jc),t),this.trees=new Array(this.options.maxZoom+1),this.stride=this.options.reduce?7:6,this.clusterProps=[],this.points=[];}load(t){const e=[];for(const n of t){if(!n.geometry)continue;const[t,r]=n.geometry.coordinates,[i,s]=[mc(t),gc(r)];e.push({id:n.id,type:"Point",geometry:[i,s],tags:n.properties});}this.createIndex(e);}initialize(t){const e=[];for(const n of t)"Point"===n.type&&e.push(n);this.createIndex(e);}updateIndex(t,e,n){this.options=Object.assign(Object.create(jc),n.clusterOptions),this.initialize(t);}createIndex(t){const{log:e,minZoom:n,maxZoom:r}=this.options;e&&console.time("total time");const i=`prepare ${t.length} points`;e&&console.time(i),this.points=t;const s=[];for(let e=0;e=n;t--){const n=Date.now();o=this.trees[t]=this.createTree(this.cluster(o,t)),e&&console.log("z%d: %d clusters in %dms",t,o.numItems,Date.now()-n);}e&&console.timeEnd("total time");}getClusters(t,e){return this.getClustersInternal(t,e).map((t=>xc(t)))}getClustersInternal(t,e){let n=((t[0]+180)%360+360)%360-180;const r=Math.max(-90,Math.min(90,t[1]));let i=180===t[2]?180:((t[2]+180)%360+360)%360-180;const s=Math.max(-90,Math.min(90,t[3]));if(t[2]-t[0]>=360)n=-180,i=180;else if(n>i){const t=this.getClustersInternal([n,r,180,s],e),o=this.getClustersInternal([-180,r,i,s],e);return t.concat(o)}const o=this.trees[this.limitZoom(e)],a=o.range(mc(n),gc(s),mc(i),gc(r)),l=o.flatData,u=[];for(const t of a){const e=this.stride*t;u.push(l[e+5]>1?Gc(l,e,this.clusterProps):this.points[l[e+3]]);}return u}getChildren(t){const e=this.getOriginId(t),n=this.getOriginZoom(t),r=new Error("No cluster with the specified id: "+t),i=this.trees[n];if(!i)throw r;const s=i.flatData;if(e*this.stride>=s.length)throw r;const o=this.options.radius/(this.options.extent*Math.pow(2,n-1)),a=i.within(s[e*this.stride],s[e*this.stride+1],o),l=[];for(const e of a){const n=e*this.stride;s[n+4]===t&&l.push(s[n+5]>1?Xc(s,n,this.clusterProps):xc(this.points[s[n+3]]));}if(0===l.length)throw r;return l}getLeaves(t,e,n){const r=[];return this.appendLeaves(r,t,e=e||10,n=n||0,0),r}getTile(t,e,n){const r=this.trees[this.limitZoom(t)];if(!r)return null;const i=Math.pow(2,t),{extent:s,radius:o}=this.options,a=o/s,l=(n-a)/i,u=(n+1+a)/i,c={transformed:!0,features:[],source:null,x:e,y:n,z:t};return this.addTileFeatures(r.range((e-a)/i,l,(e+1+a)/i,u),r.flatData,e,n,i,c),0===e&&this.addTileFeatures(r.range(1-a/i,l,1,u),r.flatData,i,n,i,c),e===i-1&&this.addTileFeatures(r.range(0,l,a/i,u),r.flatData,-1,n,i,c),c}getClusterExpansionZoom(t){return this.getOriginZoom(t)}appendLeaves(t,e,n,r,i){const s=this.getChildren(e);for(const e of s){const s=e.properties;if(s?.cluster?i+s.point_count<=r?i+=s.point_count:i=this.appendLeaves(t,s.cluster_id,n,r,i):i1;let l,u,c;if(a)l=Yc(e,t,this.clusterProps),u=e[t],c=e[t+1];else {const n=this.points[e[t+3]];l=n.tags,[u,c]=n.geometry;}const h={type:1,geometry:[[Math.round(this.options.extent*(u*i-n)),Math.round(this.options.extent*(c*i-r))]],tags:l};let p;p=a||this.options.generateId?e[t+3]:this.points[e[t+3]].id,void 0!==p&&(h.id=p),s.features.push(h);}}limitZoom(t){return Math.max(this.options.minZoom,Math.min(Math.floor(+t),this.options.maxZoom+1))}cluster(t,e){const{radius:n,extent:r,reduce:i,minPoints:s}=this.options,o=n/(r*Math.pow(2,e)),a=t.flatData,l=[],u=this.stride;for(let n=0;ne&&(f+=a[n+5]);}if(f>p&&f>=s){let t,s=r*p,o=c*p,d=-1;const y=(n/u<<5)+(e+1)+this.points.length;for(const r of h){const l=r*u;if(a[l+2]<=e)continue;a[l+2]=e;const c=a[l+5];s+=a[l]*c,o+=a[l+1]*c,a[l+4]=y,i&&(t||(t=this.map(a,n,!0),d=this.clusterProps.length,this.clusterProps.push(t)),i(t,this.map(a,l)));}a[n+4]=y,l.push(s/f,o/f,1/0,y,-1,f),i&&l.push(d);}else {for(let t=0;t1)for(const t of h){const n=t*u;if(!(a[n+2]<=e)){a[n+2]=e;for(let t=0;t>5}getOriginZoom(t){return (t-this.points.length)%32}map(t,e,n){if(t[e+5]>1){const r=this.clusterProps[t[e+6]];return n?Object.assign({},r):r}const r=this.points[t[e+3]].tags,i=this.options.map(r);return n&&i===r?Object.assign({},i):i}}function Gc(t,e,n){return {id:t[e+3],type:"Point",tags:Yc(t,e,n),geometry:[t[e],t[e+1]]}}function Xc(t,e,n){return {type:"Feature",id:t[e+3],properties:Yc(t,e,n),geometry:{type:"Point",coordinates:[_c(t[e]),Sc(t[e+1])]}}}function Yc(t,e,n){const r=t[e+5],i=r>=1e4?`${Math.round(r/1e3)}k`:r>=1e3?Math.round(r/100)/10+"k":r,s=t[e+6],o=-1===s?{}:Object.assign({},n[s]);return Object.assign(o,{cluster:!0,cluster_id:t[e+3],point_count:r,point_count_abbreviated:i})}const Zc="geojsonvt_clip_start",Hc="geojsonvt_clip_end";function Wc(t,e,n,r,i){const s=e===i.maxZoom?0:i.tolerance/((1<0&&e.size<(i?o:r))return void(n.numPoints+=e.points.length/3);const a=[];for(let t=0;to)&&(n.numSimplified++,a.push(e.points[t],e.points[t+1])),n.numPoints++;i&&function(t,e){let n=0;for(let e=0,r=t.length,i=r-2;e0===e)for(let e=0,n=t.length;e1&&(console.log("invalidating tiles"),console.time("invalidating")),this.invalidateTiles(e),n.debug>1&&console.timeEnd("invalidating");const[r,i,s]=[0,0,0],o=Wc(t,r,i,s,n);o.source=t;const a=ih(r,i,s);if(this.tiles[a]=o,this.tileCoords.push({z:r,x:i,y:s,id:a}),n.debug){const t=`z${r}`;this.stats[t]=(this.stats[t]||0)+1,this.total++;}}getClusterExpansionZoom(t){return null}getChildren(t){return null}getLeaves(t,e,n){return null}getTile(t,e,n){const{extent:r,debug:i}=this.options,s=1<1&&console.log("drilling down to z%d-%d-%d",t,e,n);let a,l=t,u=e,c=n;for(;!a&&l>0;)l--,u>>=1,c>>=1,a=this.tiles[ih(l,u,c)];return a?.source?(i>1&&(console.log("found parent tile z%d-%d-%d",l,u,c),console.time("drilling down")),this.splitTile(a.source,l,u,c,t,e,n),i>1&&console.timeEnd("drilling down"),this.tiles[o]?Qc(this.tiles[o],r):null):null}splitTile(t,e,n,r,i,s,o){const a=[t,e,n,r],l=this.options,u=l.debug;for(;a.length;){r=a.pop(),n=a.pop(),e=a.pop(),t=a.pop();const c=1<1&&console.time("creation"),p=this.tiles[h]=Wc(t,e,n,r,l),this.tileCoords.push({z:e,x:n,y:r,id:h}),u)){u>1&&(console.log("tile z%d-%d-%d (features: %d, points: %d, simplified: %d)",e,n,r,p.numFeatures,p.numPoints,p.numSimplified),console.timeEnd("creation"));const t=`z${e}`;this.stats[t]=(this.stats[t]||0)+1,this.total++;}if(p.source=t,null==i){if(e===l.indexMaxZoom||p.numPoints<=l.indexMaxPoints)continue}else {if(e===l.maxZoom||e===i)continue;if(null!=i){const t=i-e;if(n!==s>>t||r!==o>>t)continue}}if(p.source=null,!t.length)continue;u>1&&console.time("clipping");const f=.5*l.buffer/l.extent,d=.5-f,y=.5+f,m=1+f;let g=null,x=null,v=null,b=null;const w=Mc(t,c,n-f,n+y,Ac.X,p.minX,p.maxX,l),_=Mc(t,c,n+d,n+m,Ac.X,p.minX,p.maxX,l);w&&(g=Mc(w,c,r-f,r+y,Ac.Y,p.minY,p.maxY,l),x=Mc(w,c,r+d,r+m,Ac.Y,p.minY,p.maxY,l)),_&&(v=Mc(_,c,r-f,r+y,Ac.Y,p.minY,p.maxY,l),b=Mc(_,c,r+d,r+m,Ac.Y,p.minY,p.maxY,l)),u>1&&console.timeEnd("clipping"),a.push(g||[],e+1,2*n,2*r),a.push(x||[],e+1,2*n,2*r+1),a.push(v||[],e+1,2*n+1,2*r),a.push(b||[],e+1,2*n+1,2*r+1);}}invalidateTiles(t){if(!t.length)return;const e=this.options,{debug:n}=e;let r=1/0,i=-1/0,s=1/0,o=-1/0;for(const e of t)r=Math.min(r,e.minX),i=Math.max(i,e.maxX),s=Math.min(s,e.minY),o=Math.max(o,e.maxY);const a=e.buffer/e.extent,l=new Set;for(const e in this.tiles){const u=this.tiles[e],c=1<=p||o=d)continue;let y=!1;for(const e of t)if(e.maxX>=h&&e.minX=f&&e.minY1&&console.log("invalidate tile z%d-%d-%d (features: %d, points: %d, simplified: %d)",u.z,u.x,u.y,u.numFeatures,u.numPoints,u.numSimplified);const t=`z${u.z}`;this.stats[t]=(this.stats[t]||0)-1,this.total--;}delete this.tiles[e],l.add(e);}}l.size&&(this.tileCoords=this.tileCoords.filter((t=>!l.has(t.id))));}}function ih(t,e,n){return 32*((1<t.id)),this.index=t.index,this.hasDependencies=!1,this.patternFeatures=[],this.lineClipsArray=[],this.gradients={};for(const t of this.layers)this.gradients[t.id]={};this.layoutVertexArray=new Wo,this.layoutVertexArray2=new Ko,this.indexArray=new sa,this.programConfigurations=new $a(t.layers,t.zoom),this.segments=new ua,this.maxLineLength=0,this.stateDependentLayerIds=this.layers.filter((t=>t.isStateDependent())).map((t=>t.id));}populate(t,e,n){this.hasDependencies=ql("line",this.layers,e)||this.hasLineDasharray(this.layers);const r=this.layers[0].layout.get("line-sort-key"),i=!r.isConstant(),s=[];for(const{feature:e,id:o,index:a,sourceLayerIndex:l}of t){const t=this.layers[0]._featureFilter.needGeometry,u=Ga(e,t);if(!this.layers[0]._featureFilter.filter(new Cs(this.zoom),u,n))continue;const c=i?r.evaluate(u,{},n):void 0,h={id:o,properties:e.properties,type:e.type,sourceLayerIndex:l,index:a,geometry:t?u.geometry:qa(e),patterns:{},dashes:{},sortKey:c};s.push(h);}i&&s.sort(((t,e)=>t.sortKey-e.sortKey));for(const r of s){const{geometry:i,index:s,sourceLayerIndex:o}=r;this.hasDependencies?(ql("line",this.layers,e)?Gl("line",this.layers,r,{zoom:this.zoom},e):this.hasLineDasharray(this.layers)&&this.addLineDashDependencies(this.layers,r,this.zoom,e),this.patternFeatures.push(r)):this.addFeature(r,i,s,n,{},{},e.subdivisionGranularity),e.featureIndex.insert(t[s].feature,i,s,o,this.index);}}update(t,e,n,r){this.stateDependentLayers.length&&this.programConfigurations.updatePaintArrays(t,e,this.stateDependentLayers,{imagePositions:n,dashPositions:r});}addFeatures(t,e,n,r){for(const i of this.patternFeatures)this.addFeature(i,i.geometry,i.index,e,n,r,t.subdivisionGranularity);}isEmpty(){return 0===this.layoutVertexArray.length}uploadPending(){return !this.uploaded||this.programConfigurations.needsUpload}upload(t){this.uploaded||(0!==this.layoutVertexArray2.length&&(this.layoutVertexBuffer2=t.createVertexBuffer(this.layoutVertexArray2,uh)),this.layoutVertexBuffer=t.createVertexBuffer(this.layoutVertexArray,ah),this.indexBuffer=t.createIndexBuffer(this.indexArray)),this.programConfigurations.upload(t),this.uploaded=!0;}destroy(){this.layoutVertexBuffer&&(this.layoutVertexBuffer.destroy(),this.indexBuffer.destroy(),this.programConfigurations.destroy(),this.segments.destroy());}lineFeatureClips(t){if(t.properties&&Object.hasOwn(t.properties,Zc)&&Object.hasOwn(t.properties,Hc))return {start:+t.properties[Zc],end:+t.properties[Hc]}}addFeature(t,e,n,r,i,s,o){const a=this.layers[0].layout,l=a.get("line-join").evaluate(t,{}),u=a.get("line-cap").evaluate(t,{}),c=a.get("line-miter-limit").evaluate(t,{}),h=a.get("line-round-limit").evaluate(t,{});this.lineClips=this.lineFeatureClips(t);for(const n of e)this.addLine(n,t,l,u,c,h,r,o);this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length,t,n,{imagePositions:i,dashPositions:s,canonical:r});}addLine(t,e,n,r,i,s,o,a){if(this.distance=0,this.scaledDistance=0,this.totalDistance=0,t=Su(t,o?a.line.getGranularityForZoomLevel(o.z):1),this.lineClips){this.lineClipsArray.push(this.lineClips);for(let e=0;e=2&&t[u-1].equals(t[u-2]);)u--;let c=0;for(;c0;if(w&&e>c){const t=f.dist(d);if(t>2*h){const e=f.sub(f.sub(d)._mult(h/t)._round());this.updateDistance(d,e),this.addCurrentVertex(e,m,0,0,p),d=e;}}const S=d&&y;let A=S?n:l?"butt":r;if(S&&"round"===A&&(vi&&(A="bevel"),"bevel"===A&&(v>2&&(A="flipbevel"),v100)o=g.mult(-1);else {const t=v*m.add(g).mag()/m.sub(g).mag();o._perp()._mult(t*(_?-1:1));}this.addCurrentVertex(f,o,0,0,p),this.addCurrentVertex(f,o.mult(-1),0,0,p);}else if("bevel"===A||"fakeround"===A){const t=-Math.sqrt(v*v-1),e=_?t:0,n=_?0:t;if(d&&this.addCurrentVertex(f,m,e,n,p),"fakeround"===A){const t=Math.round(180*b/Math.PI/20);for(let e=1;e2*h){const e=f.add(y.sub(f)._mult(h/t)._round());this.updateDistance(f,e),this.addCurrentVertex(e,g,0,0,p),f=e;}}}}addCurrentVertex(t,e,n,r,i,s=!1){const o=e.y*r-e.x,a=-e.y-e.x*r;this.addHalfVertex(t,e.x+e.y*n,e.y-e.x*n,s,!1,n,i),this.addHalfVertex(t,o,a,s,!0,-r,i),this.distance>hh/2&&0===this.totalDistance&&(this.distance=0,this.updateScaledDistance(),this.addCurrentVertex(t,e,n,r,i,s));}addHalfVertex({x:t,y:e},n,r,i,s,o,a){const l=.5*(this.lineClips?this.scaledDistance*(hh-1):this.scaledDistance);this.layoutVertexArray.emplaceBack((t<<1)+(i?1:0),(e<<1)+(s?1:0),Math.round(63*n)+128,Math.round(63*r)+128,1+(0===o?0:o<0?-1:1)|(63&l)<<2,l>>6),this.lineClips&&this.layoutVertexArray2.emplaceBack((this.scaledDistance-this.lineClips.start)/(this.lineClips.end-this.lineClips.start),this.lineClipsArray.length);const u=a.vertexLength++;this.e1>=0&&this.e2>=0&&(this.indexArray.emplaceBack(this.e1,u,this.e2),a.primitiveLength++),s?this.e2=u:this.e1=u;}updateScaledDistance(){this.scaledDistance=this.lineClips?this.lineClips.start+(this.lineClips.end-this.lineClips.start)*this.distance/this.totalDistance:this.distance;}updateDistance(t,e){this.distance+=t.dist(e),this.updateScaledDistance();}hasLineDasharray(t){for(const e of t){const t=e.paint.get("line-dasharray");if(t&&!t.isConstant())return !0}return !1}addLineDashDependencies(t,e,n,r){for(const i of t){const t=i.paint.get("line-dasharray");if(!t||"constant"===t.value.kind)continue;const s="round"===i.layout.get("line-cap").evaluate(e,{}),o={dasharray:t.value.evaluate({zoom:n-1},e,{}),round:s},a={dasharray:t.value.evaluate({zoom:n},e,{}),round:s},l={dasharray:t.value.evaluate({zoom:n+1},e,{}),round:s},u=`${o.dasharray.join(",")},${o.round}`,c=`${a.dasharray.join(",")},${a.round}`,h=`${l.dasharray.join(",")},${l.round}`;r.dashDependencies[u]=o,r.dashDependencies[c]=a,r.dashDependencies[h]=l,e.dashes[i.id]={min:u,mid:c,max:h};}}}let fh,dh;ds("LineBucket",ph,{omit:["layers","patternFeatures"]});var yh={get paint(){return dh=dh||new Ks({"line-opacity":new Ys(wt.paint_line["line-opacity"]),"line-color":new Ys(wt.paint_line["line-color"]),"line-translate":new Xs(wt.paint_line["line-translate"]),"line-translate-anchor":new Xs(wt.paint_line["line-translate-anchor"]),"line-width":new Ys(wt.paint_line["line-width"]),"line-gap-width":new Ys(wt.paint_line["line-gap-width"]),"line-offset":new Ys(wt.paint_line["line-offset"]),"line-blur":new Ys(wt.paint_line["line-blur"]),"line-dasharray":new Zs(wt.paint_line["line-dasharray"]),"line-pattern":new Zs(wt.paint_line["line-pattern"]),"line-gradient":new Ws(wt.paint_line["line-gradient"])})},get layout(){return fh=fh||new Ks({"line-cap":new Ys(wt.layout_line["line-cap"]),"line-join":new Ys(wt.layout_line["line-join"]),"line-miter-limit":new Ys(wt.layout_line["line-miter-limit"]),"line-round-limit":new Ys(wt.layout_line["line-round-limit"]),"line-sort-key":new Ys(wt.layout_line["line-sort-key"])})}};class mh extends Ys{possiblyEvaluate(t,e){return e=new Cs(Math.floor(e.zoom),{now:e.now,fadeDuration:e.fadeDuration,zoomHistory:e.zoomHistory,transition:e.transition}),super.possiblyEvaluate(t,e)}evaluate(t,e,n,r){return e=$({},e,{zoom:Math.floor(e.zoom)}),super.evaluate(t,e,n,r)}}let gh;class xh extends to{constructor(t,e){super(t,yh,e),this.gradientVersion=0,gh||(gh=new mh(yh.paint.properties["line-width"].specification),gh.useIntegerZoom=!0);}_handleSpecialPaintPropertyUpdate(t){if("line-gradient"===t){const t=this.gradientExpression();this.stepInterpolant=!!function(t){return void 0!==t._styleExpression}(t)&&t._styleExpression.expression instanceof cn,this.gradientVersion=(this.gradientVersion+1)%Number.MAX_SAFE_INTEGER;}}gradientExpression(){return this._transitionablePaint._values["line-gradient"].value.expression}recalculate(t,e){super.recalculate(t,e),this.paint._values["line-floorwidth"]=gh.possiblyEvaluate(this._transitioningPaint._values["line-width"].value,t);}createBucket(t){return new ph(t)}queryRadius(t){const e=t,n=vh(ol("line-width",this,e),ol("line-gap-width",this,e)),r=ol("line-offset",this,e);return n/2+Math.abs(r)+al(this.paint.get("line-translate"))}queryIntersectsFeature({queryGeometry:t,feature:e,featureState:r,geometry:i,transform:s,pixelsToTileUnits:o}){const a=ll(t,this.paint.get("line-translate"),this.paint.get("line-translate-anchor"),-s.bearingInRadians,o),l=o/2*vh(this.paint.get("line-width").evaluate(e,r),this.paint.get("line-gap-width").evaluate(e,r)),u=this.paint.get("line-offset").evaluate(e,r);return u&&(i=function(t,e){const r=[];for(const i of t){const t=ul(i),s=[];for(let r=0;r=3)for(const e of r)if(il(t,e))return !0;if(Ja(t,r,n))return !0}return !1}(a,i,l)}isTileClipped(){return !0}}function vh(t,e){return e>0?e+2*t:t}const bh=ao([{name:"a_pos_offset",components:4,type:"Int16"},{name:"a_data",components:4,type:"Uint16"},{name:"a_pixeloffset",components:4,type:"Int16"}],4),wh=ao([{name:"a_projected_pos",components:3,type:"Float32"}],4);ao([{name:"a_fade_opacity",components:1,type:"Uint32"}],4);const _h=ao([{name:"a_placed",components:2,type:"Uint8"},{name:"a_shift",components:2,type:"Float32"},{name:"a_box_real",components:2,type:"Int16"}]);ao([{type:"Int16",name:"anchorPointX"},{type:"Int16",name:"anchorPointY"},{type:"Int16",name:"x1"},{type:"Int16",name:"y1"},{type:"Int16",name:"x2"},{type:"Int16",name:"y2"},{type:"Uint32",name:"featureIndex"},{type:"Uint16",name:"sourceLayerIndex"},{type:"Uint16",name:"bucketIndex"}]);const Sh=ao([{name:"a_pos",components:2,type:"Int16"},{name:"a_anchor_pos",components:2,type:"Int16"},{name:"a_extrude",components:2,type:"Int16"}],4),Ah=ao([{name:"a_pos",components:2,type:"Float32"},{name:"a_radius",components:1,type:"Float32"},{name:"a_flags",components:2,type:"Int16"}],4);function Mh(t,e,n){const r=e.layout.get("text-transform").evaluate(n,{});return "uppercase"===r?t=t.toLocaleUpperCase():"lowercase"===r&&(t=t.toLocaleLowerCase()),Bs.applyArabicShaping&&(t=Bs.applyArabicShaping(t)),t}function kh(t,e,n){for(const r of t.sections)r.text=Mh(r.text,e,n);return t}ao([{name:"triangle",components:3,type:"Uint16"}]),ao([{type:"Int16",name:"anchorX"},{type:"Int16",name:"anchorY"},{type:"Uint16",name:"glyphStartIndex"},{type:"Uint16",name:"numGlyphs"},{type:"Uint32",name:"vertexStartIndex"},{type:"Uint32",name:"lineStartIndex"},{type:"Uint32",name:"lineLength"},{type:"Uint16",name:"segment"},{type:"Uint16",name:"lowerSize"},{type:"Uint16",name:"upperSize"},{type:"Float32",name:"lineOffsetX"},{type:"Float32",name:"lineOffsetY"},{type:"Uint8",name:"writingMode"},{type:"Uint8",name:"placedOrientation"},{type:"Uint8",name:"hidden"},{type:"Uint32",name:"crossTileID"},{type:"Int16",name:"associatedIconIndex"}]),ao([{type:"Int16",name:"anchorX"},{type:"Int16",name:"anchorY"},{type:"Int16",name:"rightJustifiedTextSymbolIndex"},{type:"Int16",name:"centerJustifiedTextSymbolIndex"},{type:"Int16",name:"leftJustifiedTextSymbolIndex"},{type:"Int16",name:"verticalPlacedTextSymbolIndex"},{type:"Int16",name:"placedIconSymbolIndex"},{type:"Int16",name:"verticalPlacedIconSymbolIndex"},{type:"Uint16",name:"key"},{type:"Uint16",name:"textBoxStartIndex"},{type:"Uint16",name:"textBoxEndIndex"},{type:"Uint16",name:"verticalTextBoxStartIndex"},{type:"Uint16",name:"verticalTextBoxEndIndex"},{type:"Uint16",name:"iconBoxStartIndex"},{type:"Uint16",name:"iconBoxEndIndex"},{type:"Uint16",name:"verticalIconBoxStartIndex"},{type:"Uint16",name:"verticalIconBoxEndIndex"},{type:"Uint16",name:"featureIndex"},{type:"Uint16",name:"numHorizontalGlyphVertices"},{type:"Uint16",name:"numVerticalGlyphVertices"},{type:"Uint16",name:"numIconVertices"},{type:"Uint16",name:"numVerticalIconVertices"},{type:"Uint16",name:"useRuntimeCollisionCircles"},{type:"Uint32",name:"crossTileID"},{type:"Float32",name:"textBoxScale"},{type:"Float32",name:"collisionCircleDiameter"},{type:"Uint16",name:"textAnchorOffsetStartIndex"},{type:"Uint16",name:"textAnchorOffsetEndIndex"}]),ao([{type:"Float32",name:"offsetX"}]),ao([{type:"Int16",name:"x"},{type:"Int16",name:"y"},{type:"Int16",name:"tileUnitDistanceFromAnchor"}]),ao([{type:"Uint16",name:"textAnchor"},{type:"Float32",components:2,name:"textOffset"}]);var Ih=24;const Eh={"!":"︕","#":"#",$:"$","%":"%","&":"&","(":"︵",")":"︶","*":"*","+":"+",",":"︐","-":"︲",".":"・","/":"/",":":"︓",";":"︔","<":"︿","=":"=",">":"﹀","?":"︖","@":"@","[":"﹇","\\":"\","]":"﹈","^":"^",_:"︳","`":"`","{":"︷","|":"―","}":"︸","~":"~","¢":"¢","£":"£","¥":"¥","¦":"¦","¬":"¬","¯":" ̄","–":"︲","—":"︱","‘":"﹃","’":"﹄","“":"﹁","”":"﹂","…":"︙","⋯":"︙","‧":"・","₩":"₩","、":"︑","。":"︒","〈":"︿","〉":"﹀","《":"︽","》":"︾","「":"﹁","」":"﹂","『":"﹃","』":"﹄","【":"︻","】":"︼","〔":"︹","〕":"︺","〖":"︗","〗":"︘","!":"︕","(":"︵",")":"︶",",":"︐","-":"︲",".":"・",":":"︓",";":"︔","<":"︿",">":"﹀","?":"︖","[":"﹇","]":"﹈","_":"︳","{":"︷","|":"―","}":"︸","⦅":"︵","⦆":"︶","。":"︒","「":"﹁","」":"﹂"},Th={10:!0,32:!0,38:!0,41:!0,43:!0,45:!0,47:!0,173:!0,183:!0,8203:!0,8208:!0,8211:!0,8231:!0},Fh={40:!0};function Ph(t,e,n,r,i,s){if("fontStack"in e){const r=n[e.fontStack],s=null==r?void 0:r[t];return s?s.metrics.advance*e.scale+i:0}{const t=r[e.imageName];return t?t.displaySize[0]*e.scale*Ih/s+i:0}}function Dh(t,e,n,r){const i=Math.pow(t-e,2);return r?tMath.max(t,this.sections[e].scale)),0)}getMaxImageSize(t){let e=0,n=0;for(let r=0;rn)));}addImageSection(t){const e=t.image?t.image.name:"";if(0===e.length)return void G("Can't add FormattedSection with an empty image.");const n=this.getNextImageSectionCharCode();n?(this.text+=String.fromCharCode(n),this.sections.push({scale:1,verticalAlign:t.verticalAlign||"bottom",imageName:e}),this.sectionIndex.push(this.sections.length-1)):G("Reached maximum number of images 6401");}getNextImageSectionCharCode(){return this.imageSectionID?this.imageSectionID>=63743?null:++this.imageSectionID:(this.imageSectionID=57344,this.imageSectionID)}determineLineBreaks(t,e,n,r,i){const s=[],o=this.determineAverageLineWidth(t,e,n,r,i),a=this.hasZeroWidthSpaces();let l=0,u=0;const c=this.text[Symbol.iterator]();let h=c.next();const p=this.text[Symbol.iterator]();p.next();let f=p.next();const d=this.text[Symbol.iterator]();d.next(),d.next();let y=d.next();for(;!h.done;){const e=this.getSection(u),m=h.value.codePointAt(0);if(Ss(m)||(l+=Ph(m,e,n,r,t,i)),!f.done){const t=ws(m),n=f.value.codePointAt(0);(Th[m]||t||"imageName"in e||!y.done&&Fh[n])&&s.push(Bh(u+1,l,o,s,zh(m,n,t&&a),!1));}u++,h=c.next(),f=p.next(),y=d.next();}return Ch(Bh(this.length(),l,o,s,0,!0))}determineAverageLineWidth(t,e,n,r,i){let s=0,o=0;for(const e of this.text){const a=this.getSection(o);s+=Ph(e.codePointAt(0),a,n,r,t,i),o++;}return s/Math.max(1,Math.ceil(s/e))}}const Lh=4294967296,Oh=1/Lh,$h="undefined"==typeof TextDecoder?null:new TextDecoder("utf-8");class Rh{constructor(t=new Uint8Array(16)){this.buf=ArrayBuffer.isView(t)?t:new Uint8Array(t),this.dataView=new DataView(this.buf.buffer),this.pos=0,this.type=0,this.length=this.buf.length;}readFields(t,e,n=this.length){for(;this.pos>3,i=this.pos;this.type=7&n,t(r,e,this),this.pos===i&&this.skip(n);}return e}readMessage(t,e){return this.readFields(t,e,this.readVarint()+this.pos)}readFixed32(){const t=this.dataView.getUint32(this.pos,!0);return this.pos+=4,t}readSFixed32(){const t=this.dataView.getInt32(this.pos,!0);return this.pos+=4,t}readFixed64(){const t=this.dataView.getUint32(this.pos,!0)+this.dataView.getUint32(this.pos+4,!0)*Lh;return this.pos+=8,t}readSFixed64(){const t=this.dataView.getUint32(this.pos,!0)+this.dataView.getInt32(this.pos+4,!0)*Lh;return this.pos+=8,t}readFloat(){const t=this.dataView.getFloat32(this.pos,!0);return this.pos+=4,t}readDouble(){const t=this.dataView.getFloat64(this.pos,!0);return this.pos+=8,t}readVarint(t){const e=this.buf;let n,r;return r=e[this.pos++],n=127&r,r<128?n:(r=e[this.pos++],n|=(127&r)<<7,r<128?n:(r=e[this.pos++],n|=(127&r)<<14,r<128?n:(r=e[this.pos++],n|=(127&r)<<21,r<128?n:(r=e[this.pos],n|=(15&r)<<28,function(t,e,n){const r=n.buf;let i,s;if(s=r[n.pos++],i=(112&s)>>4,s<128)return Nh(t,i,e);if(s=r[n.pos++],i|=(127&s)<<3,s<128)return Nh(t,i,e);if(s=r[n.pos++],i|=(127&s)<<10,s<128)return Nh(t,i,e);if(s=r[n.pos++],i|=(127&s)<<17,s<128)return Nh(t,i,e);if(s=r[n.pos++],i|=(127&s)<<24,s<128)return Nh(t,i,e);if(s=r[n.pos++],i|=(1&s)<<31,s<128)return Nh(t,i,e);throw new Error("Expected varint not more than 10 bytes")}(n,t,this)))))}readVarint64(){return this.readVarint(!0)}readSVarint(){const t=this.readVarint();return t%2==1?(t+1)/-2:t/2}readBoolean(){return Boolean(this.readVarint())}readString(){const t=this.readVarint()+this.pos,e=this.pos;return this.pos=t,t-e>=12&&$h?$h.decode(this.buf.subarray(e,t)):function(t,e,n){let r="",i=e;for(;i239?4:e>223?3:e>191?2:1;if(i+u>n)break;1===u?e<128&&(l=e):2===u?(s=t[i+1],128==(192&s)&&(l=(31&e)<<6|63&s,l<=127&&(l=null))):3===u?(s=t[i+1],o=t[i+2],128==(192&s)&&128==(192&o)&&(l=(15&e)<<12|(63&s)<<6|63&o,(l<=2047||l>=55296&&l<=57343)&&(l=null))):4===u&&(s=t[i+1],o=t[i+2],a=t[i+3],128==(192&s)&&128==(192&o)&&128==(192&a)&&(l=(15&e)<<18|(63&s)<<12|(63&o)<<6|63&a,(l<=65535||l>=1114112)&&(l=null))),null===l?(l=65533,u=1):l>65535&&(l-=65536,r+=String.fromCharCode(l>>>10&1023|55296),l=56320|1023&l),r+=String.fromCharCode(l),i+=u;}return r}(this.buf,e,t)}readBytes(){const t=this.readVarint()+this.pos,e=this.buf.subarray(this.pos,t);return this.pos=t,e}readPackedVarint(t=[],e){const n=this.readPackedEnd();for(;this.pos127;);else if(2===e)this.pos=this.readVarint()+this.pos;else if(5===e)this.pos+=4;else {if(1!==e)throw new Error(`Unimplemented type: ${e}`);this.pos+=8;}}writeTag(t,e){this.writeVarint(t<<3|e);}realloc(t){let e=this.length||16;for(;e268435455||t<0?function(t,e){let n,r;if(t>=0?(n=t%4294967296|0,r=t/4294967296|0):(n=~(-t%4294967296),r=~(-t/4294967296),4294967295^n?n=n+1|0:(n=0,r=r+1|0)),t>=0x10000000000000000||t<-0x10000000000000000)throw new Error("Given varint doesn't fit into 10 bytes");e.realloc(10),function(t,e,n){n.buf[n.pos++]=127&t|128,t>>>=7,n.buf[n.pos++]=127&t|128,t>>>=7,n.buf[n.pos++]=127&t|128,t>>>=7,n.buf[n.pos++]=127&t|128,n.buf[n.pos]=127&(t>>>=7);}(n,0,e),function(t,e){const n=(7&t)<<4;e.buf[e.pos++]|=n|((t>>>=3)?128:0),t&&(e.buf[e.pos++]=127&t|((t>>>=7)?128:0),t&&(e.buf[e.pos++]=127&t|((t>>>=7)?128:0),t&&(e.buf[e.pos++]=127&t|((t>>>=7)?128:0),t&&(e.buf[e.pos++]=127&t|((t>>>=7)?128:0),t&&(e.buf[e.pos++]=127&t)))));}(r,e);}(t,this):(this.realloc(4),this.buf[this.pos++]=127&t|(t>127?128:0),t<=127||(this.buf[this.pos++]=127&(t>>>=7)|(t>127?128:0),t<=127||(this.buf[this.pos++]=127&(t>>>=7)|(t>127?128:0),t<=127||(this.buf[this.pos++]=t>>>7&127))));}writeSVarint(t){this.writeVarint(t<0?2*-t-1:2*t);}writeBoolean(t){this.writeVarint(+t);}writeString(t){t=String(t),this.realloc(4*t.length),this.pos++;const e=this.pos;this.pos=function(t,e,n){for(let r,i,s=0;s55295&&r<57344){if(!i){r>56319||s+1===e.length?(t[n++]=239,t[n++]=191,t[n++]=189):i=r;continue}if(r<56320){t[n++]=239,t[n++]=191,t[n++]=189,i=r;continue}r=i-55296<<10|r-56320|65536,i=null;}else i&&(t[n++]=239,t[n++]=191,t[n++]=189,i=null);r<128?t[n++]=r:(r<2048?t[n++]=r>>6|192:(r<65536?t[n++]=r>>12|224:(t[n++]=r>>18|240,t[n++]=r>>12&63|128),t[n++]=r>>6&63|128),t[n++]=63&r|128);}return n}(this.buf,t,this.pos);const n=this.pos-e;n>=128&&Uh(e,n,this),this.pos=e-1,this.writeVarint(n),this.pos+=n;}writeFloat(t){this.realloc(4),this.dataView.setFloat32(this.pos,t,!0),this.pos+=4;}writeDouble(t){this.realloc(8),this.dataView.setFloat64(this.pos,t,!0),this.pos+=8;}writeBytes(t){const e=t.length;this.writeVarint(e),this.realloc(e);for(let n=0;n=128&&Uh(n,r,this),this.pos=n-1,this.writeVarint(r),this.pos+=r;}writeMessage(t,e,n){this.writeTag(t,2),this.writeRawMessage(e,n);}writePackedVarint(t,e){e.length&&this.writeMessage(t,jh,e);}writePackedSVarint(t,e){e.length&&this.writeMessage(t,qh,e);}writePackedBoolean(t,e){e.length&&this.writeMessage(t,Yh,e);}writePackedFloat(t,e){e.length&&this.writeMessage(t,Gh,e);}writePackedDouble(t,e){e.length&&this.writeMessage(t,Xh,e);}writePackedFixed32(t,e){e.length&&this.writeMessage(t,Zh,e);}writePackedSFixed32(t,e){e.length&&this.writeMessage(t,Hh,e);}writePackedFixed64(t,e){e.length&&this.writeMessage(t,Wh,e);}writePackedSFixed64(t,e){e.length&&this.writeMessage(t,Kh,e);}writeBytesField(t,e){this.writeTag(t,2),this.writeBytes(e);}writeFixed32Field(t,e){this.writeTag(t,5),this.writeFixed32(e);}writeSFixed32Field(t,e){this.writeTag(t,5),this.writeSFixed32(e);}writeFixed64Field(t,e){this.writeTag(t,1),this.writeFixed64(e);}writeSFixed64Field(t,e){this.writeTag(t,1),this.writeSFixed64(e);}writeVarintField(t,e){this.writeTag(t,0),this.writeVarint(e);}writeSVarintField(t,e){this.writeTag(t,0),this.writeSVarint(e);}writeStringField(t,e){this.writeTag(t,2),this.writeString(e);}writeFloatField(t,e){this.writeTag(t,5),this.writeFloat(e);}writeDoubleField(t,e){this.writeTag(t,1),this.writeDouble(e);}writeBooleanField(t,e){this.writeVarintField(t,+e);}}function Nh(t,e,n){return n?4294967296*e+(t>>>0):4294967296*(e>>>0)+(t>>>0)}function Uh(t,e,n){const r=e<=16383?1:e<=2097151?2:e<=268435455?3:Math.floor(Math.log(e)/(7*Math.LN2));n.realloc(r);for(let e=n.pos-1;e>=t;e--)n.buf[e+r]=n.buf[e];}function jh(t,e){for(let n=0;ne.h-t.h));const r=[{x:0,y:0,w:Math.max(Math.ceil(Math.sqrt(e/.95)),n),h:1/0}];let i=0,s=0;for(const e of t)for(let t=r.length-1;t>=0;t--){const n=r[t];if(!(e.w>n.w||e.h>n.h)){if(e.x=n.x,e.y=n.y,s=Math.max(s,e.y+e.h),i=Math.max(i,e.x+e.w),e.w===n.w&&e.h===n.h){const e=r.pop();e&&tm.toCodeUnitIndex(t)));const t=v(m.toString(),x);for(const e of t){const t=[...e].map((()=>0));g.push(new Vh(e,m.sections,t));}}else if(b){g=[],x=x.map((t=>m.toCodeUnitIndex(t)));let t=0;const e=[];for(const n of m.text)e.push(...Array(n.length).fill(m.sectionIndex[t])),t++;const n=b(m.text,e,x);for(const t of n){const e=[];let n="";for(const r of t[0])e.push(t[1][n.length]),n+=r;g.push(new Vh(t[0],m.sections,e));}}else g=function(t,e){const n=[];let r=0;for(const i of e)n.push(t.substring(r,i)),r=i;return ru){const t=Math.ceil(s/u);i*=t/o,o=t;}return {x1:r,y1:i,x2:r+s,y2:i+o}}function yp(t,e,n,r,i,s){const o=t.image;let a;if(o.content){const t=o.content,e=o.pixelRatio||1;a=[t[0]/e,t[1]/e,o.displaySize[0]-t[2]/e,o.displaySize[1]-t[3]/e];}const l=e.left*s,u=e.right*s;let c,h,p,f;"width"===n||"both"===n?(f=i[0]+l-r[3],h=i[0]+u+r[1]):(f=i[0]+(l+u-o.displaySize[0])/2,h=f+o.displaySize[0]);const d=e.top*s,y=e.bottom*s;return "height"===n||"both"===n?(c=i[1]+d-r[0],p=i[1]+y+r[2]):(c=i[1]+(d+y-o.displaySize[1])/2,p=c+o.displaySize[1]),{image:o,top:c,right:h,bottom:p,left:f,collisionPadding:a}}ds("ImagePosition",np),ds("ImageAtlas",rp),t.ax=void 0,(ip=t.ax||(t.ax={}))[ip.none=0]="none",ip[ip.horizontal=1]="horizontal",ip[ip.vertical=2]="vertical",ip[ip.horizontalOnly=3]="horizontalOnly";const mp=128,gp=32640;function xp(t,e){const{expression:n}=e;if("constant"===n.kind)return {kind:"constant",layoutSize:n.evaluate(new Cs(t+1))};if("source"===n.kind)return {kind:"source"};{const{zoomStops:e,interpolationType:r}=n;let i=0;for(;it.id)),this.index=e.index,this.pixelRatio=e.pixelRatio,this.sourceLayerIndex=e.sourceLayerIndex,this.hasDependencies=!1,this.hasRTLText=!1,this.sortKeyRanges=[],this.collisionCircleArray=[];const n=this.layers[0]._unevaluatedLayout._values;this.textSizeData=xp(this.zoom,n["text-size"]),this.iconSizeData=xp(this.zoom,n["icon-size"]);const r=this.layers[0].layout,i=r.get("symbol-sort-key"),s=r.get("symbol-z-order");this.canOverlap="never"!==vp(r,"text-overlap","text-allow-overlap")||"never"!==vp(r,"icon-overlap","icon-allow-overlap")||r.get("text-ignore-placement")||r.get("icon-ignore-placement"),this.sortFeaturesByKey="viewport-y"!==s&&!i.isConstant(),this.sortFeaturesByY=("viewport-y"===s||"auto"===s&&!this.sortFeaturesByKey)&&this.canOverlap,"point"===r.get("symbol-placement")&&(this.writingModes=r.get("text-writing-mode").map((e=>t.ax[e]))),this.stateDependentLayerIds=this.layers.filter((t=>t.isStateDependent())).map((t=>t.id)),this.sourceID=e.sourceID;}createArrays(){this.text=new Ap(new $a(this.layers,this.zoom,(t=>t.startsWith("text")))),this.icon=new Ap(new $a(this.layers,this.zoom,(t=>t.startsWith("icon")))),this.glyphOffsetArray=new Ro,this.lineVertexArray=new No,this.symbolInstances=new $o,this.textAnchorOffsets=new jo;}calculateGlyphDependencies(t,e,n,r,i){for(const s of t)if(e[s.codePointAt(0)]=!0,(n||r)&&i){const t=Eh[s];t&&(e[t.codePointAt(0)]=!0);}}populate(e,n,r){var i;const s=this.layers[0],o=s.layout,a=o.get("text-font"),l=o.get("text-field"),u=o.get("icon-image"),c=("constant"!==l.value.kind||l.value.value instanceof ze&&!l.value.value.isEmpty()||l.value.value.toString().length>0)&&("constant"!==a.value.kind||a.value.value.length>0),h="constant"!==u.value.kind||!!u.value.value||Object.keys(u.parameters).length>0,p=o.get("symbol-sort-key");if(this.features=[],!c&&!h)return;const f=n.iconDependencies,d=n.glyphDependencies,y=n.availableImages,m=new Cs(this.zoom);for(const{feature:n,id:l,index:u,sourceLayerIndex:g}of e){const e=s._featureFilter.needGeometry,x=Ga(n,e);if(!s._featureFilter.filter(m,x,r))continue;let v,b;if(e||(x.geometry=qa(n)),c){const t=s.getValueAndResolveTokens("text-field",x,r,y),e=ze.factory(t);this.hasRTLText||(this.hasRTLText=Sp(e)),(!this.hasRTLText||"unavailable"===Bs.getRTLTextPluginStatus()||this.hasRTLText&&Bs.isParsed())&&(v=kh(e,s,x));}if(h){const t=s.getValueAndResolveTokens("icon-image",x,r,y);b=t instanceof Re?t:Re.fromString(t);}if(!v&&!b)continue;const w=this.sortFeaturesByKey?p.evaluate(x,{},r):void 0;if(this.features.push({id:l,text:v,icon:b,index:u,sourceLayerIndex:g,geometry:x.geometry,properties:n.properties,type:Cu.types[n.type],sortKey:w}),b&&(f[b.name]=!0),v){const e=a.evaluate(x,{},r).join(","),n="viewport"!==o.get("text-rotation-alignment")&&"point"!==o.get("symbol-placement");this.allowVerticalPlacement=null===(i=this.writingModes)||void 0===i?void 0:i.includes(t.ax.vertical);for(const t of v.sections)if(t.image)f[t.image.name]=!0;else {const r=As(v.toString()),i=t.fontStack||e;d[i]||(d[i]={}),this.calculateGlyphDependencies(t.text,d[i],n,this.allowVerticalPlacement,r);}}}"line"===o.get("symbol-placement")&&(this.features=function(t){const e={},n={},r=[];let i=0;function s(e){r.push(t[e]),i++;}function o(t,e,i){const s=n[t];return delete n[t],n[e]=s,r[s].geometry[0].pop(),r[s].geometry[0]=r[s].geometry[0].concat(i[0]),s}function a(t,n,i){const s=e[n];return delete e[n],e[t]=s,r[s].geometry[0].shift(),r[s].geometry[0]=i[0].concat(r[s].geometry[0]),s}function l(t,e,n){const r=n?e[0][e[0].length-1]:e[0][0];return `${t}:${r.x}:${r.y}`}for(let u=0;ut.geometry))}(this.features)),this.sortFeaturesByKey&&this.features.sort(((t,e)=>t.sortKey-e.sortKey));}update(t,e,n){this.stateDependentLayers.length&&(this.text.programConfigurations.updatePaintArrays(t,e,this.layers,{imagePositions:n}),this.icon.programConfigurations.updatePaintArrays(t,e,this.layers,{imagePositions:n}));}isEmpty(){return 0===this.symbolInstances.length&&!this.hasRTLText}uploadPending(){return !this.uploaded||this.text.programConfigurations.needsUpload||this.icon.programConfigurations.needsUpload}upload(t){!this.uploaded&&this.hasDebugData()&&(this.textCollisionBox.upload(t),this.iconCollisionBox.upload(t)),this.text.upload(t,this.sortFeaturesByY,!this.uploaded,this.text.programConfigurations.needsUpload),this.icon.upload(t,this.sortFeaturesByY,!this.uploaded,this.icon.programConfigurations.needsUpload),this.uploaded=!0;}destroyDebugData(){this.textCollisionBox.destroy(),this.iconCollisionBox.destroy();}destroy(){this.text.destroy(),this.icon.destroy(),this.hasDebugData()&&this.destroyDebugData();}addToLineVertexArray(t,e){const n=this.lineVertexArray.length;if(void 0!==t.segment){let n=t.dist(e[t.segment+1]),r=t.dist(e[t.segment]);const i={};for(let r=t.segment+1;r=0;n--)i[n]={x:e[n].x,y:e[n].y,tileUnitDistanceFromAnchor:r},n>0&&(r+=e[n-1].dist(e[n]));for(let t=0;t0}hasIconData(){return this.icon.segments.get().length>0}hasDebugData(){return this.textCollisionBox&&this.iconCollisionBox}hasTextCollisionBoxData(){return this.hasDebugData()&&this.textCollisionBox.segments.get().length>0}hasIconCollisionBoxData(){return this.hasDebugData()&&this.iconCollisionBox.segments.get().length>0}addIndicesForPlacedSymbol(t,e){const n=t.placedSymbolArray.get(e),r=n.vertexStartIndex+4*n.numGlyphs;for(let e=n.vertexStartIndex;er[t]-r[e]||i[e]-i[t])),s}addToSortKeyRanges(t,e){const n=this.sortKeyRanges[this.sortKeyRanges.length-1];(null==n?void 0:n.sortKey)===e?n.symbolInstanceEnd=t+1:this.sortKeyRanges.push({sortKey:e,symbolInstanceStart:t,symbolInstanceEnd:t+1});}sortFeatures(t){if(this.sortFeaturesByY&&this.sortedAngle!==t&&!(this.text.segments.get().length>1||this.icon.segments.get().length>1)){this.symbolInstanceIndexes=this.getSortedSymbolIndexes(t),this.sortedAngle=t,this.text.indexArray.clear(),this.icon.indexArray.clear(),this.featureSortOrder=[];for(const t of this.symbolInstanceIndexes){const e=this.symbolInstances.get(t);this.featureSortOrder.push(e.featureIndex);const n=[e.rightJustifiedTextSymbolIndex,e.centerJustifiedTextSymbolIndex,e.leftJustifiedTextSymbolIndex];for(let t=0;t=0&&n.indexOf(e)===t&&this.addIndicesForPlacedSymbol(this.text,e);}e.verticalPlacedTextSymbolIndex>=0&&this.addIndicesForPlacedSymbol(this.text,e.verticalPlacedTextSymbolIndex),e.placedIconSymbolIndex>=0&&this.addIndicesForPlacedSymbol(this.icon,e.placedIconSymbolIndex),e.verticalPlacedIconSymbolIndex>=0&&this.addIndicesForPlacedSymbol(this.icon,e.verticalPlacedIconSymbolIndex);}this.text.indexBuffer&&this.text.indexBuffer.updateData(this.text.indexArray),this.icon.indexBuffer&&this.icon.indexBuffer.updateData(this.icon.indexArray);}}}let Ip,Ep;ds("SymbolBucket",kp,{omit:["layers","collisionBoxArray","features","compareText"]}),kp.MAX_GLYPHS=65535,kp.addDynamicAttributes=_p;var Tp={get paint(){return Ep=Ep||new Ks({"icon-opacity":new Ys(wt.paint_symbol["icon-opacity"]),"icon-color":new Ys(wt.paint_symbol["icon-color"]),"icon-halo-color":new Ys(wt.paint_symbol["icon-halo-color"]),"icon-halo-width":new Ys(wt.paint_symbol["icon-halo-width"]),"icon-halo-blur":new Ys(wt.paint_symbol["icon-halo-blur"]),"icon-translate":new Xs(wt.paint_symbol["icon-translate"]),"icon-translate-anchor":new Xs(wt.paint_symbol["icon-translate-anchor"]),"text-opacity":new Ys(wt.paint_symbol["text-opacity"]),"text-color":new Ys(wt.paint_symbol["text-color"],{runtimeType:Nt,getOverride:t=>t.textColor,hasOverride:t=>!!t.textColor}),"text-halo-color":new Ys(wt.paint_symbol["text-halo-color"]),"text-halo-width":new Ys(wt.paint_symbol["text-halo-width"]),"text-halo-blur":new Ys(wt.paint_symbol["text-halo-blur"]),"text-translate":new Xs(wt.paint_symbol["text-translate"]),"text-translate-anchor":new Xs(wt.paint_symbol["text-translate-anchor"])})},get layout(){return Ip=Ip||new Ks({"symbol-placement":new Xs(wt.layout_symbol["symbol-placement"]),"symbol-spacing":new Xs(wt.layout_symbol["symbol-spacing"]),"symbol-avoid-edges":new Xs(wt.layout_symbol["symbol-avoid-edges"]),"symbol-sort-key":new Ys(wt.layout_symbol["symbol-sort-key"]),"symbol-z-order":new Xs(wt.layout_symbol["symbol-z-order"]),"icon-allow-overlap":new Xs(wt.layout_symbol["icon-allow-overlap"]),"icon-overlap":new Xs(wt.layout_symbol["icon-overlap"]),"icon-ignore-placement":new Xs(wt.layout_symbol["icon-ignore-placement"]),"icon-optional":new Xs(wt.layout_symbol["icon-optional"]),"icon-rotation-alignment":new Xs(wt.layout_symbol["icon-rotation-alignment"]),"icon-size":new Ys(wt.layout_symbol["icon-size"]),"icon-text-fit":new Xs(wt.layout_symbol["icon-text-fit"]),"icon-text-fit-padding":new Xs(wt.layout_symbol["icon-text-fit-padding"]),"icon-image":new Ys(wt.layout_symbol["icon-image"]),"icon-rotate":new Ys(wt.layout_symbol["icon-rotate"]),"icon-padding":new Ys(wt.layout_symbol["icon-padding"]),"icon-keep-upright":new Xs(wt.layout_symbol["icon-keep-upright"]),"icon-offset":new Ys(wt.layout_symbol["icon-offset"]),"icon-anchor":new Ys(wt.layout_symbol["icon-anchor"]),"icon-pitch-alignment":new Xs(wt.layout_symbol["icon-pitch-alignment"]),"text-pitch-alignment":new Xs(wt.layout_symbol["text-pitch-alignment"]),"text-rotation-alignment":new Xs(wt.layout_symbol["text-rotation-alignment"]),"text-field":new Ys(wt.layout_symbol["text-field"]),"text-font":new Ys(wt.layout_symbol["text-font"]),"text-size":new Ys(wt.layout_symbol["text-size"]),"text-max-width":new Ys(wt.layout_symbol["text-max-width"]),"text-line-height":new Xs(wt.layout_symbol["text-line-height"]),"text-letter-spacing":new Ys(wt.layout_symbol["text-letter-spacing"]),"text-justify":new Ys(wt.layout_symbol["text-justify"]),"text-radial-offset":new Ys(wt.layout_symbol["text-radial-offset"]),"text-variable-anchor":new Xs(wt.layout_symbol["text-variable-anchor"]),"text-variable-anchor-offset":new Ys(wt.layout_symbol["text-variable-anchor-offset"]),"text-anchor":new Ys(wt.layout_symbol["text-anchor"]),"text-max-angle":new Xs(wt.layout_symbol["text-max-angle"]),"text-writing-mode":new Xs(wt.layout_symbol["text-writing-mode"]),"text-rotate":new Ys(wt.layout_symbol["text-rotate"]),"text-padding":new Xs(wt.layout_symbol["text-padding"]),"text-keep-upright":new Xs(wt.layout_symbol["text-keep-upright"]),"text-transform":new Ys(wt.layout_symbol["text-transform"]),"text-offset":new Ys(wt.layout_symbol["text-offset"]),"text-allow-overlap":new Xs(wt.layout_symbol["text-allow-overlap"]),"text-overlap":new Xs(wt.layout_symbol["text-overlap"]),"text-ignore-placement":new Xs(wt.layout_symbol["text-ignore-placement"]),"text-optional":new Xs(wt.layout_symbol["text-optional"])})}};class Fp{constructor(t){if(void 0===t.property.overrides)throw new Error("overrides must be provided to instantiate FormatSectionOverride class");this.type=t.property.overrides?t.property.overrides.runtimeType:Lt,this.defaultValue=t;}evaluate(t){if(t.formattedSection){const e=this.defaultValue.property.overrides;if(null==e?void 0:e.hasOverride(t.formattedSection))return e.getOverride(t.formattedSection)}return t.feature&&t.featureState?this.defaultValue.evaluate(t.feature,t.featureState):this.defaultValue.property.specification.default}eachChild(t){this.defaultValue.isConstant()||t(this.defaultValue.value._styleExpression.expression);}outputDefined(){return !1}serialize(){return null}}ds("FormatSectionOverride",Fp,{omit:["defaultValue"]});class Pp extends to{constructor(t,e){super(t,Tp,e);}recalculate(t,e){if(super.recalculate(t,e),"auto"===this.layout.get("icon-rotation-alignment")&&(this.layout._values["icon-rotation-alignment"]="point"!==this.layout.get("symbol-placement")?"map":"viewport"),"auto"===this.layout.get("text-rotation-alignment")&&(this.layout._values["text-rotation-alignment"]="point"!==this.layout.get("symbol-placement")?"map":"viewport"),"auto"===this.layout.get("text-pitch-alignment")&&(this.layout._values["text-pitch-alignment"]="map"===this.layout.get("text-rotation-alignment")?"map":"viewport"),"auto"===this.layout.get("icon-pitch-alignment")&&(this.layout._values["icon-pitch-alignment"]=this.layout.get("icon-rotation-alignment")),"point"===this.layout.get("symbol-placement")){const t=this.layout.get("text-writing-mode");if(t){const e=[];for(const n of t)e.includes(n)||e.push(n);this.layout._values["text-writing-mode"]=e;}else this.layout._values["text-writing-mode"]=["horizontal"];}this._setPaintOverrides();}getValueAndResolveTokens(t,e,n,r){const i=this.layout.get(t).evaluate(e,{},n,r),s=this._unevaluatedLayout._values[t];return s.isDataDriven()||ii(s.value)||!i?i:function(t,e){return e.replace(/{([^{}]+)}/g,((e,n)=>t&&n in t?String(t[n]):""))}(e.properties,i)}createBucket(t){return new kp(t)}queryRadius(){return 0}queryIntersectsFeature(){throw new Error("Should take a different path in FeatureIndex")}_setPaintOverrides(){for(const t of Tp.paint.overridableProperties){if(!Pp.hasPaintOverride(this.layout,t))continue;const e=this.paint.get(t),n=new Fp(e),r=new ri(n,e.property.specification);let i=null;i="constant"===e.value.kind||"source"===e.value.kind?new oi("source",r):new ai("composite",r,e.value.zoomStops),this.paint._values[t]=new qs(e.property,i,e.parameters);}}_handleOverridablePaintPropertyUpdate(t,e,n){return !(!this.layout||e.isDataDriven()||n.isDataDriven())&&Pp.hasPaintOverride(this.layout,t)}static hasPaintOverride(t,e){const n=t.get("text-field"),r=Tp.paint.properties[e];let i=!1;const s=t=>{var e;for(const n of t)if(null===(e=r.overrides)||void 0===e?void 0:e.hasOverride(n))return void(i=!0)};if("constant"===n.value.kind&&n.value.value instanceof ze)s(n.value.value.sections);else if("source"===n.value.kind||"composite"===n.value.kind){const t=e=>{i||(e instanceof Xe&&qe(e.value)===Xt?s(e.value.sections):e instanceof Pn?s(e.sections):e.eachChild(t));},e=n.value;e._styleExpression&&t(e._styleExpression.expression);}return i}}let Dp;var zp={get paint(){return Dp=Dp||new Ks({"background-color":new Xs(wt.paint_background["background-color"]),"background-pattern":new Hs(wt.paint_background["background-pattern"]),"background-opacity":new Xs(wt.paint_background["background-opacity"])})}};class Bp extends to{constructor(t,e){super(t,zp,e);}}class Cp extends to{constructor(t,e){super(t,{},e),this.onAdd=t=>{this.implementation.onAdd&&this.implementation.onAdd(t,t.painter.context.gl);},this.onRemove=t=>{this.implementation.onRemove&&this.implementation.onRemove(t,t.painter.context.gl);},this.implementation=t;}is3D(){return "3d"===this.implementation.renderingMode}hasOffscreenPass(){return void 0!==this.implementation.prerender}recalculate(){}updateTransitions(){}hasTransition(){return !1}serialize(){throw new Error("Custom layers cannot be serialized")}}class Vp{constructor(t){this._methodToThrottle=t,this._triggered=!1,this._channel=new MessageChannel,this._channel.port2.onmessage=()=>{this._triggered=!1,this._methodToThrottle();};}trigger(){var t;this._triggered||(this._triggered=!0,null===(t=this._channel)||void 0===t||t.port1.postMessage(!0));}remove(){delete this._channel,this._methodToThrottle=()=>{};}}const Lp={once:!0},Op=6371008.8;class $p{constructor(t,e){if(isNaN(t)||isNaN(e))throw new Error(`Invalid LngLat object: (${t}, ${e})`);if(this.lng=+t,this.lat=+e,this.lat>90||this.lat<-90)throw new Error("Invalid LngLat latitude value: must be between -90 and 90")}wrap(){return new $p(O(this.lng,-180,180),this.lat)}toArray(){return [this.lng,this.lat]}toString(){return `LngLat(${this.lng}, ${this.lat})`}distanceTo(t){const e=Math.PI/180,n=this.lat*e,r=t.lat*e,i=Math.sin(n)*Math.sin(r)+Math.cos(n)*Math.cos(r)*Math.cos((t.lng-this.lng)*e);return Op*Math.acos(Math.min(i,1))}static convert(t){if(t instanceof $p)return t;if(Array.isArray(t)&&(2===t.length||3===t.length))return new $p(Number(t[0]),Number(t[1]));if(!Array.isArray(t)&&"object"==typeof t&&null!==t)return new $p(Number("lng"in t?t.lng:t.lon),Number(t.lat));throw new Error("`LngLatLike` argument must be specified as a LngLat instance, an object {lng: , lat: }, an object {lon: , lat: }, or an array of [, ]")}}const Rp=2*Math.PI*Op;function Np(t){return Rp*Math.cos(t*Math.PI/180)}function Up(t){return (180+t)/360}function jp(t){return (180-180/Math.PI*Math.log(Math.tan(Math.PI/4+t*Math.PI/360)))/360}function qp(t,e){return t/Np(e)}function Gp(t){return 360*t-180}function Xp(t){return 360/Math.PI*Math.atan(Math.exp((180-360*t)*Math.PI/180))-90}function Yp(t,e){return t*Np(Xp(e))}class Zp{constructor(t,e,n=0){this.x=+t,this.y=+e,this.z=+n;}static fromLngLat(t,e=0){const n=$p.convert(t);return new Zp(Up(n.lng),jp(n.lat),qp(e,n.lat))}toLngLat(){return new $p(Gp(this.x),Xp(this.y))}toAltitude(){return Yp(this.z,this.y)}meterInMercatorCoordinateUnits(){return 1/Rp*(t=Xp(this.y),1/Math.cos(t*Math.PI/180));var t;}}function Hp(t,e,n){var r=2*Math.PI*6378137/256/Math.pow(2,n);return [t*r-2*Math.PI*6378137/2,e*r-2*Math.PI*6378137/2]}class Wp{constructor(t,e,n){if(!function(t,e,n){return !(t<0||t>25||n<0||n>=Math.pow(2,t)||e<0||e>=Math.pow(2,t))}(t,e,n))throw new Error(`x=${e}, y=${n}, z=${t} outside of bounds. 0<=x<${Math.pow(2,t)}, 0<=y<${Math.pow(2,t)} 0<=z<=25 `);this.z=t,this.x=e,this.y=n,this.key=Qp(0,t,t,e,n);}equals(t){return this.z===t.z&&this.x===t.x&&this.y===t.y}url(t,e,n){const r=(s=this.y,o=this.z,a=Hp(256*(i=this.x),256*(s=Math.pow(2,o)-s-1),o),l=Hp(256*(i+1),256*(s+1),o),a[0]+","+a[1]+","+l[0]+","+l[1]);var i,s,o,a,l;const u=function(t,e,n){let r="";for(let i=t;i>0;i--){const t=1<1?"@2x":"").replace(/{quadkey}/g,u).replace(/{bbox-epsg-3857}/g,r)}isChildOf(t){const e=this.z-t.z;return e>0&&t.x===this.x>>e&&t.y===this.y>>e}getTilePoint(t){const e=Math.pow(2,this.z);return new n((t.x*e-this.x)*T,(t.y*e-this.y)*T)}toString(){return `${this.z}/${this.x}/${this.y}`}}class Kp{constructor(t,e){this.wrap=t,this.canonical=e,this.key=Qp(t,e.z,e.z,e.x,e.y);}}class Jp{constructor(t,e,n,r,i){if(this.terrainRttPosMatrix32f=null,t= z; overscaledZ = ${t}; z = ${n}`);this.overscaledZ=t,this.wrap=e,this.canonical=new Wp(n,+r,+i),this.key=Qp(e,t,n,r,i);}clone(){return new Jp(this.overscaledZ,this.wrap,this.canonical.z,this.canonical.x,this.canonical.y)}equals(t){return this.overscaledZ===t.overscaledZ&&this.wrap===t.wrap&&this.canonical.equals(t.canonical)}scaledTo(t){if(t>this.overscaledZ)throw new Error(`targetZ > this.overscaledZ; targetZ = ${t}; overscaledZ = ${this.overscaledZ}`);const e=this.canonical.z-t;return t>this.canonical.z?new Jp(t,this.wrap,this.canonical.z,this.canonical.x,this.canonical.y):new Jp(t,this.wrap,t,this.canonical.x>>e,this.canonical.y>>e)}isOverscaled(){return this.overscaledZ>this.canonical.z}calculateScaledKey(t,e){if(t>this.overscaledZ)throw new Error(`targetZ > this.overscaledZ; targetZ = ${t}; overscaledZ = ${this.overscaledZ}`);const n=this.canonical.z-t;return t>this.canonical.z?Qp(this.wrap*+e,t,this.canonical.z,this.canonical.x,this.canonical.y):Qp(this.wrap*+e,t,t,this.canonical.x>>n,this.canonical.y>>n)}isChildOf(t){if(t.wrap!==this.wrap)return !1;if(this.overscaledZ-t.overscaledZ<=0)return !1;if(0===t.overscaledZ)return this.overscaledZ>0;const e=this.canonical.z-t.canonical.z;return !(e<0)&&t.canonical.x===this.canonical.x>>e&&t.canonical.y===this.canonical.y>>e}children(t){if(this.overscaledZ>=t)return [new Jp(this.overscaledZ+1,this.wrap,this.canonical.z,this.canonical.x,this.canonical.y)];const e=this.canonical.z+1,n=2*this.canonical.x,r=2*this.canonical.y;return [new Jp(e,this.wrap,e,n,r),new Jp(e,this.wrap,e,n+1,r),new Jp(e,this.wrap,e,n,r+1),new Jp(e,this.wrap,e,n+1,r+1)]}isLessThan(t){return this.wrapt.wrap)&&(this.overscaledZt.overscaledZ)&&(this.canonical.xt.canonical.x)&&this.canonical.y=0&&t=0&&e=l)return null;let c=this.canonical.x+r,h=this.wrap;return c<0?(h-=Math.ceil(-c/l),c=(c%l+l)%l):c>=l&&(h+=Math.floor(c/l),c%=l),{tileID:new Jp(this.overscaledZ,h,a,c,u),x:s,y:o}}}function Qp(t,e,n,r,i){(t*=2)<0&&(t=-1*t-1);const s=1<this.maxX||this.minY>this.maxY)&&(this.minX=1/0,this.maxX=-1/0,this.minY=1/0,this.maxY=-1/0),this}shrinkBy(t){return this.expandBy(-t)}map(t){const e=new tf;return e.extend(t(new n(this.minX,this.minY))),e.extend(t(new n(this.maxX,this.minY))),e.extend(t(new n(this.minX,this.maxY))),e.extend(t(new n(this.maxX,this.maxY))),e}static fromPoints(t){const e=new tf;for(const n of t)e.extend(n);return e}contains(t){return t.x>=this.minX&&t.x<=this.maxX&&t.y>=this.minY&&t.y<=this.maxY}empty(){return this.minX>this.maxX}width(){return this.maxX-this.minX}height(){return this.maxY-this.minY}covers(t){return !this.empty()&&!t.empty()&&t.minX>=this.minX&&t.maxX<=this.maxX&&t.minY>=this.minY&&t.maxY<=this.maxY}intersects(t){return !this.empty()&&!t.empty()&&t.minX<=this.maxX&&t.maxX>=this.minX&&t.minY<=this.maxY&&t.maxY>=this.minY}}class ef{constructor(t){this._stringToNumber={},this._numberToString=[];for(let e=0;e=this._numberToString.length)throw new Error(`Out of bounds. Index requested n=${t} can't be >= this._numberToString.length ${this._numberToString.length}`);return this._numberToString[t]}}class nf{constructor(t,e,n,r,i){this.type="Feature",this._vectorTileFeature=t,this._x=n,this._y=r,this._z=e,this.properties=t.properties,this.id=i;}projectPoint(t,e,n,r){return [360*(t.x+e)/r-180,360/Math.PI*Math.atan(Math.exp((1-2*(t.y+n)/r)*Math.PI))-90]}projectLine(t,e,n,r){return t.map((t=>this.projectPoint(t,e,n,r)))}get geometry(){if(this._geometry)return this._geometry;const t=this._vectorTileFeature,e=t.extent*Math.pow(2,this._z),n=t.extent*this._x,r=t.extent*this._y,i=t.loadGeometry();switch(t.type){case 1:{const t=[];for(const e of i)t.push(e[0]);const s=this.projectLine(t,n,r,e);this._geometry=1===t.length?{type:"Point",coordinates:s[0]}:{type:"MultiPoint",coordinates:s};break}case 2:{const t=i.map((t=>this.projectLine(t,n,r,e)));this._geometry=1===t.length?{type:"LineString",coordinates:t[0]}:{type:"MultiLineString",coordinates:t};break}case 3:{const t=Lu(i),s=[];for(const i of t)s.push(i.map((t=>this.projectLine(t,n,r,e))));this._geometry=1===s.length?{type:"Polygon",coordinates:s[0]}:{type:"MultiPolygon",coordinates:s};break}default:throw new Error(`unknown feature type: ${t.type}`)}return this._geometry}set geometry(t){this._geometry=t;}toJSON(){const t={geometry:this.geometry};for(const e in this)"_geometry"!==e&&"_vectorTileFeature"!==e&&"_x"!==e&&"_y"!==e&&"_z"!==e&&(t[e]=this[e]);return t}}class rf{constructor(t,e,n){this._name=t,this.dataBuffer=e,"number"==typeof n?this._size=n:(this.nullabilityBuffer=n,this._size=n.size());}getValue(t){return this.nullabilityBuffer&&!this.nullabilityBuffer.get(t)?null:this.getValueFromBuffer(t)}has(t){return this.nullabilityBuffer?.get(t)||!this.nullabilityBuffer}get name(){return this._name}get size(){return this._size}}class sf extends rf{}class of extends sf{getValueFromBuffer(t){return this.dataBuffer[t]}}class af extends sf{getValueFromBuffer(t){return this.dataBuffer[t]}}class lf extends rf{constructor(t,e,n,r){super(t,e,r),this.delta=n;}}class uf extends lf{constructor(t,e,n,r){super(t,Int32Array.of(e),n,r);}getValueFromBuffer(t){return this.dataBuffer[0]+t*this.delta}}class cf extends rf{constructor(t,e,n,r){super(t,r?Int32Array.of(e):Uint32Array.of(e),n);}getValueFromBuffer(t){return this.dataBuffer[0]}}class hf{constructor(t,e,n,r,i=4096){this._name=t,this._geometryVector=e,this._idVector=n,this._propertyVectors=r,this._extent=i;}get name(){return this._name}get idVector(){return this._idVector}get geometryVector(){return this._geometryVector}get propertyVectors(){return this._propertyVectors}getPropertyVector(t){return this.propertyVectorsMap||(this.propertyVectorsMap=new Map(this._propertyVectors.map((t=>[t.name,t])))),this.propertyVectorsMap.get(t)}get numFeatures(){return this.geometryVector.numGeometries}get extent(){return this._extent}getFeatures(){const t=[],e=this.geometryVector.getGeometries();for(let n=0;n>>32-t;const mf=yf,gf=256;function xf(t,e){return t-t%e}function vf(t){const e=t>>>0;return ((255&e)<<24|(65280&e)<<8|e>>>8&65280|e>>>24&255)>>>0}const bf=function(){if(!Number.isFinite(65536))return 65536;const t=xf(Math.floor(65536),gf);return 0===t?gf:t}(),wf=3*bf/gf+bf|0;function _f(){const t=new Uint8Array(wf);return {dataToBePacked:new Array(33),dataPointers:new Int32Array(33),byteContainer:t,byteContainerI32:new Int32Array(t.buffer,t.byteOffset,t.byteLength>>>2),exceptionSizes:new Int32Array(33)}}function Sf(t,e,n,r,i){switch(i){case 1:!function(t,e,n,r){let i=r,s=e;for(let e=0;e<8;e++){const e=t[s++]>>>0;n[i++]=e>>>0&1,n[i++]=e>>>1&1,n[i++]=e>>>2&1,n[i++]=e>>>3&1,n[i++]=e>>>4&1,n[i++]=e>>>5&1,n[i++]=e>>>6&1,n[i++]=e>>>7&1,n[i++]=e>>>8&1,n[i++]=e>>>9&1,n[i++]=e>>>10&1,n[i++]=e>>>11&1,n[i++]=e>>>12&1,n[i++]=e>>>13&1,n[i++]=e>>>14&1,n[i++]=e>>>15&1,n[i++]=e>>>16&1,n[i++]=e>>>17&1,n[i++]=e>>>18&1,n[i++]=e>>>19&1,n[i++]=e>>>20&1,n[i++]=e>>>21&1,n[i++]=e>>>22&1,n[i++]=e>>>23&1,n[i++]=e>>>24&1,n[i++]=e>>>25&1,n[i++]=e>>>26&1,n[i++]=e>>>27&1,n[i++]=e>>>28&1,n[i++]=e>>>29&1,n[i++]=e>>>30&1,n[i++]=e>>>31&1;}}(t,e,n,r);break;case 2:!function(t,e,n,r){let i=r,s=e;for(let e=0;e<8;e++){const e=t[s++]>>>0,r=t[s++]>>>0;n[i++]=e>>>0&3,n[i++]=e>>>2&3,n[i++]=e>>>4&3,n[i++]=e>>>6&3,n[i++]=e>>>8&3,n[i++]=e>>>10&3,n[i++]=e>>>12&3,n[i++]=e>>>14&3,n[i++]=e>>>16&3,n[i++]=e>>>18&3,n[i++]=e>>>20&3,n[i++]=e>>>22&3,n[i++]=e>>>24&3,n[i++]=e>>>26&3,n[i++]=e>>>28&3,n[i++]=e>>>30&3,n[i++]=r>>>0&3,n[i++]=r>>>2&3,n[i++]=r>>>4&3,n[i++]=r>>>6&3,n[i++]=r>>>8&3,n[i++]=r>>>10&3,n[i++]=r>>>12&3,n[i++]=r>>>14&3,n[i++]=r>>>16&3,n[i++]=r>>>18&3,n[i++]=r>>>20&3,n[i++]=r>>>22&3,n[i++]=r>>>24&3,n[i++]=r>>>26&3,n[i++]=r>>>28&3,n[i++]=r>>>30&3;}}(t,e,n,r);break;case 3:!function(t,e,n,r){let i=r,s=e;for(let e=0;e<8;e++){const e=t[s++]>>>0,r=t[s++]>>>0,o=t[s++]>>>0;n[i++]=e>>>0&7,n[i++]=e>>>3&7,n[i++]=e>>>6&7,n[i++]=e>>>9&7,n[i++]=e>>>12&7,n[i++]=e>>>15&7,n[i++]=e>>>18&7,n[i++]=e>>>21&7,n[i++]=e>>>24&7,n[i++]=e>>>27&7,n[i++]=7&(e>>>30|(1&r)<<2),n[i++]=r>>>1&7,n[i++]=r>>>4&7,n[i++]=r>>>7&7,n[i++]=r>>>10&7,n[i++]=r>>>13&7,n[i++]=r>>>16&7,n[i++]=r>>>19&7,n[i++]=r>>>22&7,n[i++]=r>>>25&7,n[i++]=r>>>28&7,n[i++]=7&(r>>>31|(3&o)<<1),n[i++]=o>>>2&7,n[i++]=o>>>5&7,n[i++]=o>>>8&7,n[i++]=o>>>11&7,n[i++]=o>>>14&7,n[i++]=o>>>17&7,n[i++]=o>>>20&7,n[i++]=o>>>23&7,n[i++]=o>>>26&7,n[i++]=o>>>29&7;}}(t,e,n,r);break;case 4:!function(t,e,n,r){let i=r,s=e;for(let e=0;e<8;e++){const e=t[s++]>>>0,r=t[s++]>>>0,o=t[s++]>>>0,a=t[s++]>>>0;n[i++]=e>>>0&15,n[i++]=e>>>4&15,n[i++]=e>>>8&15,n[i++]=e>>>12&15,n[i++]=e>>>16&15,n[i++]=e>>>20&15,n[i++]=e>>>24&15,n[i++]=e>>>28&15,n[i++]=r>>>0&15,n[i++]=r>>>4&15,n[i++]=r>>>8&15,n[i++]=r>>>12&15,n[i++]=r>>>16&15,n[i++]=r>>>20&15,n[i++]=r>>>24&15,n[i++]=r>>>28&15,n[i++]=o>>>0&15,n[i++]=o>>>4&15,n[i++]=o>>>8&15,n[i++]=o>>>12&15,n[i++]=o>>>16&15,n[i++]=o>>>20&15,n[i++]=o>>>24&15,n[i++]=o>>>28&15,n[i++]=a>>>0&15,n[i++]=a>>>4&15,n[i++]=a>>>8&15,n[i++]=a>>>12&15,n[i++]=a>>>16&15,n[i++]=a>>>20&15,n[i++]=a>>>24&15,n[i++]=a>>>28&15;}}(t,e,n,r);break;case 5:!function(t,e,n,r){let i=r,s=e;for(let e=0;e<8;e++){const e=t[s++]>>>0,r=t[s++]>>>0,o=t[s++]>>>0,a=t[s++]>>>0,l=t[s++]>>>0;n[i++]=e>>>0&31,n[i++]=e>>>5&31,n[i++]=e>>>10&31,n[i++]=e>>>15&31,n[i++]=e>>>20&31,n[i++]=e>>>25&31,n[i++]=31&(e>>>30|(7&r)<<2),n[i++]=r>>>3&31,n[i++]=r>>>8&31,n[i++]=r>>>13&31,n[i++]=r>>>18&31,n[i++]=r>>>23&31,n[i++]=31&(r>>>28|(1&o)<<4),n[i++]=o>>>1&31,n[i++]=o>>>6&31,n[i++]=o>>>11&31,n[i++]=o>>>16&31,n[i++]=o>>>21&31,n[i++]=o>>>26&31,n[i++]=31&(o>>>31|(15&a)<<1),n[i++]=a>>>4&31,n[i++]=a>>>9&31,n[i++]=a>>>14&31,n[i++]=a>>>19&31,n[i++]=a>>>24&31,n[i++]=31&(a>>>29|(3&l)<<3),n[i++]=l>>>2&31,n[i++]=l>>>7&31,n[i++]=l>>>12&31,n[i++]=l>>>17&31,n[i++]=l>>>22&31,n[i++]=l>>>27&31;}}(t,e,n,r);break;case 6:!function(t,e,n,r){let i=r,s=e;for(let e=0;e<8;e++){const e=t[s++]>>>0,r=t[s++]>>>0,o=t[s++]>>>0,a=t[s++]>>>0,l=t[s++]>>>0,u=t[s++]>>>0;n[i++]=e>>>0&63,n[i++]=e>>>6&63,n[i++]=e>>>12&63,n[i++]=e>>>18&63,n[i++]=e>>>24&63,n[i++]=63&(e>>>30|(15&r)<<2),n[i++]=r>>>4&63,n[i++]=r>>>10&63,n[i++]=r>>>16&63,n[i++]=r>>>22&63,n[i++]=63&(r>>>28|(3&o)<<4),n[i++]=o>>>2&63,n[i++]=o>>>8&63,n[i++]=o>>>14&63,n[i++]=o>>>20&63,n[i++]=o>>>26&63,n[i++]=a>>>0&63,n[i++]=a>>>6&63,n[i++]=a>>>12&63,n[i++]=a>>>18&63,n[i++]=a>>>24&63,n[i++]=63&(a>>>30|(15&l)<<2),n[i++]=l>>>4&63,n[i++]=l>>>10&63,n[i++]=l>>>16&63,n[i++]=l>>>22&63,n[i++]=63&(l>>>28|(3&u)<<4),n[i++]=u>>>2&63,n[i++]=u>>>8&63,n[i++]=u>>>14&63,n[i++]=u>>>20&63,n[i++]=u>>>26&63;}}(t,e,n,r);break;case 7:!function(t,e,n,r){let i=r,s=e;for(let e=0;e<8;e++){const e=t[s++]>>>0,r=t[s++]>>>0,o=t[s++]>>>0,a=t[s++]>>>0,l=t[s++]>>>0,u=t[s++]>>>0,c=t[s++]>>>0;n[i++]=e>>>0&127,n[i++]=e>>>7&127,n[i++]=e>>>14&127,n[i++]=e>>>21&127,n[i++]=127&(e>>>28|(7&r)<<4),n[i++]=r>>>3&127,n[i++]=r>>>10&127,n[i++]=r>>>17&127,n[i++]=r>>>24&127,n[i++]=127&(r>>>31|(63&o)<<1),n[i++]=o>>>6&127,n[i++]=o>>>13&127,n[i++]=o>>>20&127,n[i++]=127&(o>>>27|(3&a)<<5),n[i++]=a>>>2&127,n[i++]=a>>>9&127,n[i++]=a>>>16&127,n[i++]=a>>>23&127,n[i++]=127&(a>>>30|(31&l)<<2),n[i++]=l>>>5&127,n[i++]=l>>>12&127,n[i++]=l>>>19&127,n[i++]=127&(l>>>26|(1&u)<<6),n[i++]=u>>>1&127,n[i++]=u>>>8&127,n[i++]=u>>>15&127,n[i++]=u>>>22&127,n[i++]=127&(u>>>29|(15&c)<<3),n[i++]=c>>>4&127,n[i++]=c>>>11&127,n[i++]=c>>>18&127,n[i++]=c>>>25&127;}}(t,e,n,r);break;case 8:!function(t,e,n,r){let i=r,s=e;for(let e=0;e<8;e++){const e=t[s++]>>>0,r=t[s++]>>>0,o=t[s++]>>>0,a=t[s++]>>>0,l=t[s++]>>>0,u=t[s++]>>>0,c=t[s++]>>>0,h=t[s++]>>>0;n[i++]=e>>>0&255,n[i++]=e>>>8&255,n[i++]=e>>>16&255,n[i++]=e>>>24&255,n[i++]=r>>>0&255,n[i++]=r>>>8&255,n[i++]=r>>>16&255,n[i++]=r>>>24&255,n[i++]=o>>>0&255,n[i++]=o>>>8&255,n[i++]=o>>>16&255,n[i++]=o>>>24&255,n[i++]=a>>>0&255,n[i++]=a>>>8&255,n[i++]=a>>>16&255,n[i++]=a>>>24&255,n[i++]=l>>>0&255,n[i++]=l>>>8&255,n[i++]=l>>>16&255,n[i++]=l>>>24&255,n[i++]=u>>>0&255,n[i++]=u>>>8&255,n[i++]=u>>>16&255,n[i++]=u>>>24&255,n[i++]=c>>>0&255,n[i++]=c>>>8&255,n[i++]=c>>>16&255,n[i++]=c>>>24&255,n[i++]=h>>>0&255,n[i++]=h>>>8&255,n[i++]=h>>>16&255,n[i++]=h>>>24&255;}}(t,e,n,r);break;case 16:!function(t,e,n,r){let i=r,s=e;for(let e=0;e<128;e++){const e=t[s++]>>>0;n[i++]=65535&e,n[i++]=e>>>16&65535;}}(t,e,n,r);break;default:!function(t,e,n,r,i){const s=mf[i]>>>0;let o=e,a=0,l=t[o]>>>0,u=r;for(let e=0;e<8;e++){for(let e=0;e<32;e++)if(a+i<=32)n[u+e]=l>>>a&s,a+=i,32===a&&(a=0,o++,31!==e&&(l=t[o]>>>0));else {const r=32-a,c=l>>>a;o++,l=t[o]>>>0;const h=i-r;n[u+e]=(c|(l&-1>>>32-h>>>0)<>>0);}}(t,e,n,r,i);}return e+(i<<3)|0}function Af(t,e,n,r){if(n+2>e)throw new Error(`FastPFOR decode: byteContainer underflow at block=${r} (need 2 bytes for [bitWidth, exceptionCount], bytePos=${n}, byteSize=${e})`);const i=t[n++],s=t[n++];if(i>32)throw new Error(`FastPFOR decode: invalid bitWidth=${i} at block=${r} (expected 0..32). This likely indicates corrupted or truncated input.`);return {bitWidth:i,exceptionCount:s,bytePosIn:n}}function Mf(t,e,n,r,i,s,o,a,l){const{maxBits:u,exceptionBitWidth:c,bytePosIn:h}=function(t,e,n,r,i,s){if(n+1>e)throw new Error(`FastPFOR decode: exception header underflow at block=${s} (need 1 byte for maxBits, bytePos=${n}, byteSize=${e})`);const o=t[n++];if(o32)throw new Error(`FastPFOR decode: invalid maxBits=${o} at block=${s} (bitWidth=${r}, expected ${r}..32)`);const a=o-r|0;if(a<1||a>32)throw new Error(`FastPFOR decode: invalid exceptionBitWidth=${a} at block=${s} (bitWidth=${r}, maxBits=${o})`);if(n+i>e)throw new Error(`FastPFOR decode: exception positions underflow at block=${s} (need=${i}, have=${e-n})`);return {maxBits:o,exceptionBitWidth:a,bytePosIn:n}}(i,s,o,n,r,l);if(o=h,1===c){const s=1<y)throw new Error(`FastPFOR decode: exception stream overflow for exceptionBitWidth=${c} (ptr=${d}, need ${r}, size=${y}) at block ${l}`);for(let s=0;st.length-1)throw new Error(`FastPFOR decode: invalid whereMeta=${a} at pageStart=${o} (expected > 0 and pageStart+whereMeta < encoded.length=${t.length})`);const l=o+1|0,u=o+a|0,c=t[u]>>>0,h=c+3>>>2,p=u+1,f=p+h;if(f>=t.length)throw new Error(`FastPFOR decode: invalid byteSize=${c} (metaInts=${h}, pageStart=${o}, packedEnd=${u}, byteContainerStart=${p}) causes bitmapPos=${f} out of bounds (encoded.length=${t.length})`);const d=function(t,e,n,r){r.byteContainer.length>>2;if(3&i.byteOffset)for(let n=0;n>>8&255,i[s+2|0]=r>>>16&255,i[s+3|0]=r>>>24&255;}else {let n=r.byteContainerI32;(!n||n.buffer!==i.buffer||n.byteOffset!==i.byteOffset||n.length>>2)),n.set(t.subarray(e,e+s));}const o=3&n;if(o>0){const n=0|t[e+s|0],r=s<<2;for(let t=0;t>>(t<<3)&255;}return i}(t,p,c,s),y=c,m=function(t,e,n){const r=0|t[e++],i=n.dataToBePacked;for(let s=2;s<=32;s=s+1|0){if(!(r>>>s-1&1))continue;if(e>=t.length)throw new Error(`FastPFOR decode: truncated exception stream header (bitWidth=${s}, streamWordIndex=${e}, needWords=1, availableWords=${t.length-e}, encodedWords=${t.length})`);const o=t[e++]>>>0,a=xf(o+31,32),l=o*s+31>>>5;if(e+l>t.length)throw new Error(`FastPFOR decode: truncated exception stream (bitWidth=${s}, size=${o}, streamWordIndex=${e}, needWords=${l}, availableWords=${t.length-e}, encodedWords=${t.length})`);let u=i[s];(!u||u.length>>5)|0,n.exceptionSizes[s]=o;}return e}(t,f,s);return s.dataPointers.fill(0),function(t,e,n,r,i,s,o,a,l,u){let c=0|n,h=0;for(let e=0;e0&&(h=Mf(i,p,r,o,a,l,h,u,e));}if(c!==r)throw new Error(`FastPFOR decode: packed region mismatch (pageStart=${e}, packedStart=${n}, consumedPackedEnd=${c}, expectedPackedEnd=${r}, packedWords=${r-n}, encoded.length=${t.length})`)}(t,o,l,u,e,0|r,i/gf|0,d,y,s),m}function If(t,e,n,r,i){switch(i){case 2:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0;n[i++]=s>>>0&3,n[i++]=s>>>2&3,n[i++]=s>>>4&3,n[i++]=s>>>6&3,n[i++]=s>>>8&3,n[i++]=s>>>10&3,n[i++]=s>>>12&3,n[i++]=s>>>14&3,n[i++]=s>>>16&3,n[i++]=s>>>18&3,n[i++]=s>>>20&3,n[i++]=s>>>22&3,n[i++]=s>>>24&3,n[i++]=s>>>26&3,n[i++]=s>>>28&3,n[i++]=s>>>30&3,n[i++]=o>>>0&3,n[i++]=o>>>2&3,n[i++]=o>>>4&3,n[i++]=o>>>6&3,n[i++]=o>>>8&3,n[i++]=o>>>10&3,n[i++]=o>>>12&3,n[i++]=o>>>14&3,n[i++]=o>>>16&3,n[i++]=o>>>18&3,n[i++]=o>>>20&3,n[i++]=o>>>22&3,n[i++]=o>>>24&3,n[i++]=o>>>26&3,n[i++]=o>>>28&3,n[i]=o>>>30&3;}(t,e,n,r);case 3:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0;n[i++]=s>>>0&7,n[i++]=s>>>3&7,n[i++]=s>>>6&7,n[i++]=s>>>9&7,n[i++]=s>>>12&7,n[i++]=s>>>15&7,n[i++]=s>>>18&7,n[i++]=s>>>21&7,n[i++]=s>>>24&7,n[i++]=s>>>27&7,n[i++]=7&(s>>>30|(1&o)<<2),n[i++]=o>>>1&7,n[i++]=o>>>4&7,n[i++]=o>>>7&7,n[i++]=o>>>10&7,n[i++]=o>>>13&7,n[i++]=o>>>16&7,n[i++]=o>>>19&7,n[i++]=o>>>22&7,n[i++]=o>>>25&7,n[i++]=o>>>28&7,n[i++]=7&(o>>>31|(3&a)<<1),n[i++]=a>>>2&7,n[i++]=a>>>5&7,n[i++]=a>>>8&7,n[i++]=a>>>11&7,n[i++]=a>>>14&7,n[i++]=a>>>17&7,n[i++]=a>>>20&7,n[i++]=a>>>23&7,n[i++]=a>>>26&7,n[i]=a>>>29&7;}(t,e,n,r);case 4:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0,l=t[e+3]>>>0;n[i++]=s>>>0&15,n[i++]=s>>>4&15,n[i++]=s>>>8&15,n[i++]=s>>>12&15,n[i++]=s>>>16&15,n[i++]=s>>>20&15,n[i++]=s>>>24&15,n[i++]=s>>>28&15,n[i++]=o>>>0&15,n[i++]=o>>>4&15,n[i++]=o>>>8&15,n[i++]=o>>>12&15,n[i++]=o>>>16&15,n[i++]=o>>>20&15,n[i++]=o>>>24&15,n[i++]=o>>>28&15,n[i++]=a>>>0&15,n[i++]=a>>>4&15,n[i++]=a>>>8&15,n[i++]=a>>>12&15,n[i++]=a>>>16&15,n[i++]=a>>>20&15,n[i++]=a>>>24&15,n[i++]=a>>>28&15,n[i++]=l>>>0&15,n[i++]=l>>>4&15,n[i++]=l>>>8&15,n[i++]=l>>>12&15,n[i++]=l>>>16&15,n[i++]=l>>>20&15,n[i++]=l>>>24&15,n[i]=l>>>28&15;}(t,e,n,r);case 5:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0,l=t[e+3]>>>0,u=t[e+4]>>>0;n[i++]=s>>>0&31,n[i++]=s>>>5&31,n[i++]=s>>>10&31,n[i++]=s>>>15&31,n[i++]=s>>>20&31,n[i++]=s>>>25&31,n[i++]=31&(s>>>30|(7&o)<<2),n[i++]=o>>>3&31,n[i++]=o>>>8&31,n[i++]=o>>>13&31,n[i++]=o>>>18&31,n[i++]=o>>>23&31,n[i++]=31&(o>>>28|(1&a)<<4),n[i++]=a>>>1&31,n[i++]=a>>>6&31,n[i++]=a>>>11&31,n[i++]=a>>>16&31,n[i++]=a>>>21&31,n[i++]=a>>>26&31,n[i++]=31&(a>>>31|(15&l)<<1),n[i++]=l>>>4&31,n[i++]=l>>>9&31,n[i++]=l>>>14&31,n[i++]=l>>>19&31,n[i++]=l>>>24&31,n[i++]=31&(l>>>29|(3&u)<<3),n[i++]=u>>>2&31,n[i++]=u>>>7&31,n[i++]=u>>>12&31,n[i++]=u>>>17&31,n[i++]=u>>>22&31,n[i]=u>>>27&31;}(t,e,n,r);case 6:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0,l=t[e+3]>>>0,u=t[e+4]>>>0,c=t[e+5]>>>0;n[i++]=s>>>0&63,n[i++]=s>>>6&63,n[i++]=s>>>12&63,n[i++]=s>>>18&63,n[i++]=s>>>24&63,n[i++]=63&(s>>>30|(15&o)<<2),n[i++]=o>>>4&63,n[i++]=o>>>10&63,n[i++]=o>>>16&63,n[i++]=o>>>22&63,n[i++]=63&(o>>>28|(3&a)<<4),n[i++]=a>>>2&63,n[i++]=a>>>8&63,n[i++]=a>>>14&63,n[i++]=a>>>20&63,n[i++]=a>>>26&63,n[i++]=l>>>0&63,n[i++]=l>>>6&63,n[i++]=l>>>12&63,n[i++]=l>>>18&63,n[i++]=l>>>24&63,n[i++]=63&(l>>>30|(15&u)<<2),n[i++]=u>>>4&63,n[i++]=u>>>10&63,n[i++]=u>>>16&63,n[i++]=u>>>22&63,n[i++]=63&(u>>>28|(3&c)<<4),n[i++]=c>>>2&63,n[i++]=c>>>8&63,n[i++]=c>>>14&63,n[i++]=c>>>20&63,n[i]=c>>>26&63;}(t,e,n,r);case 7:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0,l=t[e+3]>>>0,u=t[e+4]>>>0,c=t[e+5]>>>0,h=t[e+6]>>>0;n[i++]=s>>>0&127,n[i++]=s>>>7&127,n[i++]=s>>>14&127,n[i++]=s>>>21&127,n[i++]=127&(s>>>28|(7&o)<<4),n[i++]=o>>>3&127,n[i++]=o>>>10&127,n[i++]=o>>>17&127,n[i++]=o>>>24&127,n[i++]=127&(o>>>31|(63&a)<<1),n[i++]=a>>>6&127,n[i++]=a>>>13&127,n[i++]=a>>>20&127,n[i++]=127&(a>>>27|(3&l)<<5),n[i++]=l>>>2&127,n[i++]=l>>>9&127,n[i++]=l>>>16&127,n[i++]=l>>>23&127,n[i++]=127&(l>>>30|(31&u)<<2),n[i++]=u>>>5&127,n[i++]=u>>>12&127,n[i++]=u>>>19&127,n[i++]=127&(u>>>26|(1&c)<<6),n[i++]=c>>>1&127,n[i++]=c>>>8&127,n[i++]=c>>>15&127,n[i++]=c>>>22&127,n[i++]=127&(c>>>29|(15&h)<<3),n[i++]=h>>>4&127,n[i++]=h>>>11&127,n[i++]=h>>>18&127,n[i]=h>>>25&127;}(t,e,n,r);case 8:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0,l=t[e+3]>>>0,u=t[e+4]>>>0,c=t[e+5]>>>0,h=t[e+6]>>>0,p=t[e+7]>>>0;n[i++]=s>>>0&255,n[i++]=s>>>8&255,n[i++]=s>>>16&255,n[i++]=s>>>24&255,n[i++]=o>>>0&255,n[i++]=o>>>8&255,n[i++]=o>>>16&255,n[i++]=o>>>24&255,n[i++]=a>>>0&255,n[i++]=a>>>8&255,n[i++]=a>>>16&255,n[i++]=a>>>24&255,n[i++]=l>>>0&255,n[i++]=l>>>8&255,n[i++]=l>>>16&255,n[i++]=l>>>24&255,n[i++]=u>>>0&255,n[i++]=u>>>8&255,n[i++]=u>>>16&255,n[i++]=u>>>24&255,n[i++]=c>>>0&255,n[i++]=c>>>8&255,n[i++]=c>>>16&255,n[i++]=c>>>24&255,n[i++]=h>>>0&255,n[i++]=h>>>8&255,n[i++]=h>>>16&255,n[i++]=h>>>24&255,n[i++]=p>>>0&255,n[i++]=p>>>8&255,n[i++]=p>>>16&255,n[i]=p>>>24&255;}(t,e,n,r);case 9:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0,l=t[e+3]>>>0,u=t[e+4]>>>0,c=t[e+5]>>>0,h=t[e+6]>>>0,p=t[e+7]>>>0,f=t[e+8]>>>0;n[i++]=s>>>0&511,n[i++]=s>>>9&511,n[i++]=s>>>18&511,n[i++]=511&(s>>>27|(15&o)<<5),n[i++]=o>>>4&511,n[i++]=o>>>13&511,n[i++]=o>>>22&511,n[i++]=511&(o>>>31|(255&a)<<1),n[i++]=a>>>8&511,n[i++]=a>>>17&511,n[i++]=511&(a>>>26|(7&l)<<6),n[i++]=l>>>3&511,n[i++]=l>>>12&511,n[i++]=l>>>21&511,n[i++]=511&(l>>>30|(127&u)<<2),n[i++]=u>>>7&511,n[i++]=u>>>16&511,n[i++]=511&(u>>>25|(3&c)<<7),n[i++]=c>>>2&511,n[i++]=c>>>11&511,n[i++]=c>>>20&511,n[i++]=511&(c>>>29|(63&h)<<3),n[i++]=h>>>6&511,n[i++]=h>>>15&511,n[i++]=511&(h>>>24|(1&p)<<8),n[i++]=p>>>1&511,n[i++]=p>>>10&511,n[i++]=p>>>19&511,n[i++]=511&(p>>>28|(31&f)<<4),n[i++]=f>>>5&511,n[i++]=f>>>14&511,n[i]=f>>>23&511;}(t,e,n,r);case 10:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0,l=t[e+3]>>>0,u=t[e+4]>>>0,c=t[e+5]>>>0,h=t[e+6]>>>0,p=t[e+7]>>>0,f=t[e+8]>>>0,d=t[e+9]>>>0;n[i++]=s>>>0&1023,n[i++]=s>>>10&1023,n[i++]=s>>>20&1023,n[i++]=1023&(s>>>30|(255&o)<<2),n[i++]=o>>>8&1023,n[i++]=o>>>18&1023,n[i++]=1023&(o>>>28|(63&a)<<4),n[i++]=a>>>6&1023,n[i++]=a>>>16&1023,n[i++]=1023&(a>>>26|(15&l)<<6),n[i++]=l>>>4&1023,n[i++]=l>>>14&1023,n[i++]=1023&(l>>>24|(3&u)<<8),n[i++]=u>>>2&1023,n[i++]=u>>>12&1023,n[i++]=u>>>22&1023,n[i++]=c>>>0&1023,n[i++]=c>>>10&1023,n[i++]=c>>>20&1023,n[i++]=1023&(c>>>30|(255&h)<<2),n[i++]=h>>>8&1023,n[i++]=h>>>18&1023,n[i++]=1023&(h>>>28|(63&p)<<4),n[i++]=p>>>6&1023,n[i++]=p>>>16&1023,n[i++]=1023&(p>>>26|(15&f)<<6),n[i++]=f>>>4&1023,n[i++]=f>>>14&1023,n[i++]=1023&(f>>>24|(3&d)<<8),n[i++]=d>>>2&1023,n[i++]=d>>>12&1023,n[i]=d>>>22&1023;}(t,e,n,r);case 11:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0,l=t[e+3]>>>0,u=t[e+4]>>>0,c=t[e+5]>>>0,h=t[e+6]>>>0,p=t[e+7]>>>0,f=t[e+8]>>>0,d=t[e+9]>>>0,y=t[e+10]>>>0;n[i++]=s>>>0&2047,n[i++]=s>>>11&2047,n[i++]=2047&(s>>>22|(1&o)<<10),n[i++]=o>>>1&2047,n[i++]=o>>>12&2047,n[i++]=2047&(o>>>23|(3&a)<<9),n[i++]=a>>>2&2047,n[i++]=a>>>13&2047,n[i++]=2047&(a>>>24|(7&l)<<8),n[i++]=l>>>3&2047,n[i++]=l>>>14&2047,n[i++]=2047&(l>>>25|(15&u)<<7),n[i++]=u>>>4&2047,n[i++]=u>>>15&2047,n[i++]=2047&(u>>>26|(31&c)<<6),n[i++]=c>>>5&2047,n[i++]=c>>>16&2047,n[i++]=2047&(c>>>27|(63&h)<<5),n[i++]=h>>>6&2047,n[i++]=h>>>17&2047,n[i++]=2047&(h>>>28|(127&p)<<4),n[i++]=p>>>7&2047,n[i++]=p>>>18&2047,n[i++]=2047&(p>>>29|(255&f)<<3),n[i++]=f>>>8&2047,n[i++]=f>>>19&2047,n[i++]=2047&(f>>>30|(511&d)<<2),n[i++]=d>>>9&2047,n[i++]=d>>>20&2047,n[i++]=2047&(d>>>31|(1023&y)<<1),n[i++]=y>>>10&2047,n[i]=y>>>21&2047;}(t,e,n,r);case 12:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0,l=t[e+3]>>>0,u=t[e+4]>>>0,c=t[e+5]>>>0,h=t[e+6]>>>0,p=t[e+7]>>>0,f=t[e+8]>>>0,d=t[e+9]>>>0,y=t[e+10]>>>0,m=t[e+11]>>>0;n[i++]=s>>>0&4095,n[i++]=s>>>12&4095,n[i++]=4095&(s>>>24|(15&o)<<8),n[i++]=o>>>4&4095,n[i++]=o>>>16&4095,n[i++]=4095&(o>>>28|(255&a)<<4),n[i++]=a>>>8&4095,n[i++]=a>>>20&4095,n[i++]=l>>>0&4095,n[i++]=l>>>12&4095,n[i++]=4095&(l>>>24|(15&u)<<8),n[i++]=u>>>4&4095,n[i++]=u>>>16&4095,n[i++]=4095&(u>>>28|(255&c)<<4),n[i++]=c>>>8&4095,n[i++]=c>>>20&4095,n[i++]=h>>>0&4095,n[i++]=h>>>12&4095,n[i++]=4095&(h>>>24|(15&p)<<8),n[i++]=p>>>4&4095,n[i++]=p>>>16&4095,n[i++]=4095&(p>>>28|(255&f)<<4),n[i++]=f>>>8&4095,n[i++]=f>>>20&4095,n[i++]=d>>>0&4095,n[i++]=d>>>12&4095,n[i++]=4095&(d>>>24|(15&y)<<8),n[i++]=y>>>4&4095,n[i++]=y>>>16&4095,n[i++]=4095&(y>>>28|(255&m)<<4),n[i++]=m>>>8&4095,n[i]=m>>>20&4095;}(t,e,n,r);case 16:return void function(t,e,n,r){let i=r;const s=t[e]>>>0,o=t[e+1]>>>0,a=t[e+2]>>>0,l=t[e+3]>>>0,u=t[e+4]>>>0,c=t[e+5]>>>0,h=t[e+6]>>>0,p=t[e+7]>>>0,f=t[e+8]>>>0,d=t[e+9]>>>0,y=t[e+10]>>>0,m=t[e+11]>>>0,g=t[e+12]>>>0,x=t[e+13]>>>0,v=t[e+14]>>>0,b=t[e+15]>>>0;n[i++]=s>>>0&65535,n[i++]=s>>>16&65535,n[i++]=o>>>0&65535,n[i++]=o>>>16&65535,n[i++]=a>>>0&65535,n[i++]=a>>>16&65535,n[i++]=l>>>0&65535,n[i++]=l>>>16&65535,n[i++]=u>>>0&65535,n[i++]=u>>>16&65535,n[i++]=c>>>0&65535,n[i++]=c>>>16&65535,n[i++]=h>>>0&65535,n[i++]=h>>>16&65535,n[i++]=p>>>0&65535,n[i++]=p>>>16&65535,n[i++]=f>>>0&65535,n[i++]=f>>>16&65535,n[i++]=d>>>0&65535,n[i++]=d>>>16&65535,n[i++]=y>>>0&65535,n[i++]=y>>>16&65535,n[i++]=m>>>0&65535,n[i++]=m>>>16&65535,n[i++]=g>>>0&65535,n[i++]=g>>>16&65535,n[i++]=x>>>0&65535,n[i++]=x>>>16&65535,n[i++]=v>>>0&65535,n[i++]=v>>>16&65535,n[i++]=b>>>0&65535,n[i]=b>>>16&65535;}(t,e,n,r);case 32:for(let i=0;i<32;i=i+1|0)n[r+i|0]=0|t[e+i|0];return}const s=mf[i]>>>0;let o=e,a=0,l=t[o]>>>0;for(let e=0;e<32;e++)if(a+i<=32)n[r+e]=l>>>a&s,a+=i,32===a&&(a=0,o++,31!==e&&(l=t[o]>>>0));else {const u=32-a,c=l>>>a;o++,l=t[o]>>>0,n[r+e]=(c|(l&mf[i-u]>>>0)<=64)throw new Error("Varint too long")}return e.set(i),n}function Pf(t,e){let n,r;return r=t[e.get()],e.increment(),n=127&r,r<128?n:(r=t[e.get()],e.increment(),n|=(127&r)<<7,r<128?n:(r=t[e.get()],e.increment(),n|=(127&r)<<14,r<128?n:(r=t[e.get()],e.increment(),n|=(127&r)<<21,r<128?n:(r=t[e.get()],n|=(15&r)<<28,function(t,e,n){let r,i;if(i=e[n.get()],n.increment(),r=(112&i)>>4,i<128)return 4294967296*r+(t>>>0);if(i=e[n.get()],n.increment(),r|=(127&i)<<3,i<128)return 4294967296*r+(t>>>0);if(i=e[n.get()],n.increment(),r|=(127&i)<<10,i<128)return 4294967296*r+(t>>>0);if(i=e[n.get()],n.increment(),r|=(127&i)<<17,i<128)return 4294967296*r+(t>>>0);if(i=e[n.get()],n.increment(),r|=(127&i)<<24,i<128)return 4294967296*r+(t>>>0);if(i=e[n.get()],n.increment(),r|=(1&i)<<31,i<128)return 4294967296*r+(t>>>0);throw new Error("Expected varint not more than 10 bytes")}(n,t,e)))))}function Df(t){return t>>>1^-(1&t)}function zf(t){return t>>1n^-(1n&t)}function Bf(t){return t%2==1?(t+1)/-2:t/2}function Cf(t,e,n){if(void 0===n){n=0;for(let r=0;r=4)for(;r=4)for(;r=4)for(let r=t[0];n>4];let i=null;switch(r){case Nf.DATA:i={dictionaryType:Object.values(Uf)[15&n]};break;case Nf.OFFSET:i={offsetType:Object.values(jf)[15&n]};break;case Nf.LENGTH:i={lengthType:Object.values(qf)[15&n]};}e.increment();const s=t[e.get()],o=Object.values(ff)[s>>5],a=Object.values(ff)[s>>2&7],l=Object.values(df)[3&s];e.increment();const u=Ef(t,e,2),c=u[0];return {physicalStreamType:r,logicalStreamType:i,logicalLevelTechnique1:o,logicalLevelTechnique2:a,physicalLevelTechnique:l,numValues:c,byteLength:u[1],decompressedCount:c}}(t,e);return n.logicalLevelTechnique1===ff.MORTON?function(t,e,n){const r=Ef(e,n,2);return {physicalStreamType:t.physicalStreamType,logicalStreamType:t.logicalStreamType,logicalLevelTechnique1:t.logicalLevelTechnique1,logicalLevelTechnique2:t.logicalLevelTechnique2,physicalLevelTechnique:t.physicalLevelTechnique,numValues:t.numValues,byteLength:t.byteLength,decompressedCount:t.decompressedCount,numBits:r[0],coordinateShift:r[1]}}(n,t,e):ff.RLE!==n.logicalLevelTechnique1&&ff.RLE!==n.logicalLevelTechnique2||df.NONE===n.physicalLevelTechnique?n:function(t,e,n){const r=Ef(e,n,2);return {physicalStreamType:t.physicalStreamType,logicalStreamType:t.logicalStreamType,logicalLevelTechnique1:t.logicalLevelTechnique1,logicalLevelTechnique2:t.logicalLevelTechnique2,physicalLevelTechnique:t.physicalLevelTechnique,numValues:t.numValues,byteLength:t.byteLength,decompressedCount:r[1],runs:r[0],numRleValues:r[1]}}(n,t,e)}!function(t){t.PRESENT="PRESENT",t.DATA="DATA",t.OFFSET="OFFSET",t.LENGTH="LENGTH";}(Nf||(Nf={})),function(t){t.NONE="NONE",t.SINGLE="SINGLE",t.SHARED="SHARED",t.VERTEX="VERTEX",t.MORTON="MORTON",t.FSST="FSST";}(Uf||(Uf={})),function(t){t.VERTEX="VERTEX",t.INDEX="INDEX",t.STRING="STRING",t.KEY="KEY";}(jf||(jf={})),function(t){t.VAR_BINARY="VAR_BINARY",t.GEOMETRIES="GEOMETRIES",t.PARTS="PARTS",t.RINGS="RINGS",t.TRIANGLES="TRIANGLES",t.SYMBOL="SYMBOL",t.DICTIONARY="DICTIONARY";}(qf||(qf={})),function(t){t[t.FLAT=0]="FLAT",t[t.CONST=1]="CONST",t[t.SEQUENCE=2]="SEQUENCE",t[t.DICTIONARY=3]="DICTIONARY",t[t.FSST_DICTIONARY=4]="FSST_DICTIONARY";}(Gf||(Gf={}));class Wf{constructor(t,e){this.values=t,this._size=e;}get(t){const e=Math.floor(t/8);return 1==(this.values[e]>>t%8&1)}set(t,e){const n=Math.floor(t/8);this.values[n]=this.values[n]|(e?1:0)<>t%8&1}size(){return this._size}getBuffer(){return this.values}}function Kf(t,e,n){if(!e)return t;const r=e.size(),i=new(0,t.constructor)(r);let s=0;for(let o=0;o=4)for(;r>>0;for(let n=1;n>>0;return e}(e.logicalLevelTechnique2===ff.RLE?Cf(t,e.runs,e.numRleValues):t);break;case ff.RLE:i=Cf(t,e.runs,e.numRleValues);break;case ff.MORTON:Rf(t),i=t;break;case ff.COMPONENTWISE_DELTA:i=function(t){if(t.length<2)return new Uint32Array(t);const e=new Uint32Array(t.length);e[0]=Df(t[0])>>>0,e[1]=Df(t[1])>>>0;for(let n=2;n>>0,e[n+1]=e[n-1]+Df(t[n+1])>>>0;return e}(t);break;case ff.NONE:i=t;break;default:throw new Error(`The specified Logical level technique is not supported: ${e.logicalLevelTechnique1}`)}return r?Kf(i,r,0):i}(ed(t,e,n),n,0,i)}function td(t,e,n){return function(t,e){if(e.logicalLevelTechnique1===ff.DELTA&&e.logicalLevelTechnique2===ff.NONE)return function(t){const e=new Int32Array(t.length+1);e[0]=0,e[1]=Df(t[0]);let n=e[1];for(let r=2;r!==e.length;++r)n+=Df(t[r-1]),e[r]=e[r-1]+n;return new Uint32Array(e)}(t);if(e.logicalLevelTechnique1===ff.RLE&&e.logicalLevelTechnique2===ff.NONE)return function(t,e,n){const r=new Uint32Array(n+1);r[0]=0;let i=1,s=r[0];for(let n=0;n>>2,a=function(t,e){if(e<=t.encodedWords.length)return t.encodedWords;const n=new Uint32Array(Math.max(16,2*e));return t.encodedWords=n,n}(i,o);!function(t,e,n,r){if(e<0||n<0||e+n>t.length)throw new RangeError(`decodeBigEndianInt32sInto: out of bounds (offset=${e}, byteLength=${n}, bytes.length=${t.length})`);const i=Math.floor(n/4),s=n%4!=0,o=s?i+1:i;if(r.length0){const n=t.byteOffset+e;if(3&n)for(let n=0;n0){const e=0|t[r];if(r=r+1|0,255&e)throw new Error(`FastPFOR decode: invalid alignedLength=${e} (expected multiple of 256)`);if(i+e>s.length)throw new Error(`FastPFOR decode: output buffer too small (outPos=${i}, alignedLength=${e}, out.length=${s.length})`);r=function(t,e,n,r,i,s){const o=r+xf(i,gf);let a=r,l=n;for(;a!==o;){const n=Math.min(bf,o-a);l=kf(t,e,l,a,n,s),a=a+n|0;}return l}(t,s,r,i,e,o),i=i+e|0;}return function(t,e,n,r,i,s){if(0===s)return e;let o=0,a=e;const l=e+n,u=i;let c=i;const h=i+s;let p=0,f=0;for(;a>>o&255;if(o+=8,a+=o>>>5,o&=31,p|=(127&e)<28)throw new Error(`FastPFOR VByte: unterminated value (expected MSB=1 terminator within 5 bytes; shift=${f}, partial=${p}, decoded=${c-u}/${s}, inPos=${a}, inEnd=${l})`)}if(c!==h)throw new Error(`FastPFOR VByte: truncated stream (decoded=${c-u}, expected=${s}, consumedWords=${a-e}/${n}, vbyteStart=${e}, vbyteEnd=${l})`)}(t,r,t.length-r|0,s,i,e-i|0),s}(a.subarray(0,o),e,i.decoderWorkspace);return r.add(n),l}(t,e,n,r,function(t=16){if(t<0)throw new RangeError(`initialEncodedWordCapacity must be >= 0, got ${t}`);const e=Math.max(16,0|t);return {encodedWords:new Uint32Array(e),decoderWorkspace:_f()}}(n>>>2))}(t,n.numValues,n.byteLength,e);case df.VARINT:return Ef(t,e,n.numValues);case df.NONE:{const r=e.get();e.add(n.byteLength);const i=t.subarray(r,e.get());return new Uint32Array(i)}default:throw new Error(`Specified physicalLevelTechnique ${r} is not supported (yet).`)}}function nd(t,e,n){const r=ed(t,e,n);return 1===r.length?r[0]:function(t){return t[1]}(r)}function rd(t,e,n){return function(t){if(2===t.length){const e=Df(t[1]);return [e,e]}return [Df(t[2]),Df(t[3])]}(ed(t,e,n))}function id(t,e,n){return function(t){if(2===t.length){const e=zf(t[1]);return [e,e]}return [zf(t[2]),zf(t[3])]}(Tf(t,e,n.numValues))}function sd(t,e,n,r){return function(t,e,n){let r;switch(e.logicalLevelTechnique1){case ff.DELTA:r=function(t){const e=new BigUint64Array(t.length);e[0]=BigInt.asUintN(64,zf(t[0]));for(let n=1;n>1,e)-n}}function hd(t,e){let n=0;for(let r=0;r>r;return n}function pd(t,e,r,i,s,o,a){return t===Zf.MORTON?function(t,e,r,i,s,o){const a=new Array(s?i+1:i);for(let s=0;s[t])),r+=t,i+=t;}break;case Xf.LINESTRING:{let n,c;m?(n=f[i]-f[i-1],i++):n=p[r]-p[r-1],r++,y?(c=fd(g,a,n,!1),a+=2*n):(c=pd(t.vertexBufferType,g,d,l,n,!1,u),l+=n),e[o++]=[c],h&&s++;}break;case Xf.POLYGON:{const n=p[r]-p[r-1];r++;const c=new Array(n-1);let m,x=f[i]-f[i-1];if(i++,y){m=fd(g,a,x,!0),a+=2*x;for(let t=0;t0&&e.push(e[0]),h.push(e);}t[e]=h,s&&u++;}break;case Xf.MULTIPOLYGON:{const c=s[u]-s[u-1];u++;const h=[];for(let t=0;t0&&e.push(e[0]),h.push(e);}}t[e]=h;}}return t}[Symbol.iterator](){return null}}function xd(t,e,n,r,i,s){return new vd(t,e,n,r,i,s)}class vd extends gd{constructor(t,e,n,r,i,s){super(n,r,i,s),this._numGeometries=t,this._geometryType=e;}geometryType(t){return this._geometryType}get numGeometries(){return this._numGeometries}containsSingleGeometryType(){return !0}}function bd(t,e,n,r,i){return new wd(t,e,n,r,i)}class wd extends gd{constructor(t,e,n,r,i){super(e,n,r,i),this._geometryTypes=t;}geometryType(t){return this._geometryTypes[t]}get numGeometries(){return this._geometryTypes.length}containsSingleGeometryType(){return !1}}function _d(t,e,n,r,i){const s=Hf(t,n);let o,a,l,u;if(ad(s,r,t,n)===Gf.CONST){const i=nd(t,n,s);let c,h,p,f;for(let r=0;rn?e[s++]:1);return r}function Ad(t,e,n,r){const i=new Uint32Array(e[e.length-1]+1);let s=0;i[0]=s;let o=1,a=0;for(let l=0;l=o);){const n=t[r.increment()];if(n<=127){const o=n+3,a=t[r.increment()],l=Math.min(s+o,e);i.fill(a,s,l),s=l;}else {const o=256-n;for(let n=0;n=12?Td.decode(t.subarray(e,n)):function(t,e,n){let r="",i=e;for(;i239?4:e>223?3:e>191?2:1;if(i+u>n)break;1===u?e<128&&(l=e):2===u?(s=t[i+1],128==(192&s)&&(l=(31&e)<<6|63&s,l<=127&&(l=null))):3===u?(s=t[i+1],o=t[i+2],128==(192&s)&&128==(192&o)&&(l=(15&e)<<12|(63&s)<<6|63&o,(l<=2047||l>=55296&&l<=57343)&&(l=null))):4===u&&(s=t[i+1],o=t[i+2],a=t[i+3],128==(192&s)&&128==(192&o)&&128==(192&a)&&(l=(15&e)<<18|(63&s)<<12|(63&o)<<6|63&a,(l<=65535||l>=1114112)&&(l=null))),null===l?(l=65533,u=1):l>65535&&(l-=65536,r+=String.fromCharCode(l>>>10&1023|55296),l=56320|1023&l),r+=String.fromCharCode(l),i+=u;}return r}(t,e,n)}class Pd extends rf{constructor(t,e,n,r){super(t,n,r),this.offsetBuffer=e;}}class Dd extends Pd{constructor(t,e,n,r){super(t,e,n,r??e.length-1);}getValueFromBuffer(t){return Fd(this.dataBuffer,this.offsetBuffer[t],this.offsetBuffer[t+1])}}class zd extends Pd{constructor(t,e,n,r,i){super(t,n,r,i??e.length),this.indexBuffer=e,this.indexBuffer=e;}getValueFromBuffer(t){const e=this.indexBuffer[t];return Fd(this.dataBuffer,this.offsetBuffer[e],this.offsetBuffer[e+1])}}class Bd extends Pd{constructor(t,e,n,r,i,s,o){super(t,n,r,o),this.indexBuffer=e,this.symbolOffsetBuffer=i,this.symbolTableBuffer=s;}getValueFromBuffer(t){null==this.decodedDictionary&&(null==this.symbolLengthBuffer&&(this.symbolLengthBuffer=this.offsetToLengthBuffer(this.symbolOffsetBuffer)),this.decodedDictionary=function(t,e,n){const r=[],i=new Array(e.length).fill(0);for(let t=1;t=10}function $d(t){return 30===t}function Rd(t){if("scalarType"===t.type){const e=t.scalarType;if("physicalType"===e.type)switch(e.physicalType){case 0:case 1:case 2:case 3:case 4:case 5:case 6:case 7:case 8:default:return !1;case 9:return !0}if("logicalType"===e.type)return !1}else if("complexType"===t.type){const e=t.complexType;if("physicalType"===e.type)switch(e.physicalType){case 0:case 1:return !0;default:return !1}}return console.warn("Unexpected column type in hasStreamCount",t),!1}function Nd(t){return "complexType"===t.type&&"physicalType"===t.complexType?.type&&0===t.complexType.physicalType}const Ud=new TextDecoder;function jd(t,e){const n=Ef(t,e,1)[0];if(0===n)return "";const r=e.get(),i=t.subarray(r,r+n);return e.add(n),Ud.decode(i)}function qd(t,e){const n=Ef(t,e,1)[0]>>>0;if(n<10||n>30)throw new Error(`Unsupported field type code ${n}. Supported: 10-29(scalars), 30(STRUCT)`);const r=Ld(n);if(Od(n)&&(r.name=jd(t,e)),$d(n)){const n=Ef(t,e,1)[0]>>>0;r.complexType.children=new Array(n);for(let i=0;i>>0,r=Ld(n);if(!r)throw new Error(`Unsupported column type code ${n}. Supported: 0-3(ID), 4(GEOMETRY), 10-29(scalars), 30(STRUCT)`);if(Od(n)?r.name=jd(t,e):n>=0&&n<=3?r.name="id":4===n&&(r.name="geometry"),$d(n)){const n=Ef(t,e,1)[0]>>>0,i=r.complexType;i.children=new Array(n);for(let r=0;r>>0,s=Ef(t,e,1)[0]>>>0;r.columns=new Array(s);for(let n=0;n=4)for(;n>>0,o=r.get()+e;if(o>t.length)throw new Error(`Block overruns tile: ${o} > ${t.length}`);if(1!=Ef(t,r,1)[0]>>>0){r.set(o);continue}const[a,l]=Xd(t,r),u=a.featureTables[0];let c=null,h=null;const p=[];let f=0;for(const e of u.columns){const i=e.name;if("scalarType"===(s=e).type&&"logicalType"===s.scalarType?.type&&0===s.scalarType.logicalType){let s=null;if(e.nullable){const e=Hf(t,r),n=r.get(),i=Ed(t,e.numValues,e.byteLength,r);r.set(n+e.byteLength),s=new Wf(i,e.numValues);}const o=Hf(t,r);f=s?s.size():o.decompressedCount,c=Yd(t,e,r,i,o,s??f,n);}else if(Nd(e)){const e=Ef(t,r,1)[0];if(0===f){const e=r.get();f=Hf(t,r).decompressedCount,r.set(e);}h=_d(t,e,r,f);}else {const n=Rd(e)?Ef(t,r,1)[0]:1;if(0===n)continue;const i=Cd(t,r,e,n,f);if(i)if(Array.isArray(i))for(const t of i)p.push(t);else p.push(i);}}const d=new hf(u.name,h,c,p,l);i.push(d),r.set(o);}var s;return i}(new Uint8Array(t));this.layers=e.reduce(((t,e)=>Object.assign(Object.assign({},t),{[e.name]:new Hd(e)})),{});}}class Kd{constructor(t,e){this.feature=t,this.type=t.type,this.properties=t.tags?t.tags:{},this.extent=e,"id"in t&&("string"==typeof t.id?this.id=parseInt(t.id,10):"number"!=typeof t.id||isNaN(t.id)||(this.id=t.id));}loadGeometry(){const t=[],e=1===this.feature.type?[this.feature.geometry]:this.feature.geometry;for(const r of e){const e=[];for(const t of r)e.push(new n(t[0],t[1]));t.push(e);}return t}}const Jd="_geojsonTileLayer";function Qd(t,e,n=""){e.writeVarintField(15,t.version||1),e.writeStringField(1,t.name||""),e.writeVarintField(5,t.extent||4096);const r={jsonPrefix:n,keys:[],values:[],keycache:{},valuecache:{}};for(let n=0;n>31}function iy(t,e){const n=t.loadGeometry(),r=t.type;let i=0,s=0;for(const o of n){let n=1;1===r&&(n=o.length),e.writeVarint(ny(1,n));const a=3===r?o.length-1:o.length;for(let t=0;t=0&&e[3]>=0&&a.insert(o,e[0],e[1],e[2],e[3]);}}loadVTLayers(){return this.vtLayers||(this.vtLayers="mlt"!==this.encoding?new Nu(new Rh(this.rawTileData)).layers:new Wd(this.rawTileData).layers,this.sourceLayerCoder=new ef(this.vtLayers?Object.keys(this.vtLayers).sort():[Jd])),this.vtLayers}query(t,e,r,i){this.loadVTLayers();const s=t.params,o=T/t.tileSize/t.scale,a=yi(s.filter,s.globalState),l=t.queryGeometry,u=t.queryPadding*o,c=tf.fromPoints(l),h=this.grid.query(c.minX-u,c.minY-u,c.maxX+u,c.maxY+u),p=tf.fromPoints(t.cameraQueryGeometry).expandBy(u),f=this.grid3D.query(p.minX,p.minY,p.maxX,p.maxY,((e,r,i,s)=>function(t,e,r,i,s){for(const n of t)if(e<=n.x&&r<=n.y&&i>=n.x&&s>=n.y)return !0;const o=[new n(e,r),new n(e,s),new n(i,s),new n(i,r)];if(t.length>2)for(const e of o)if(il(t,e))return !0;for(let e=0;e(c||(c=qa(e)),n.queryIntersectsFeature({queryGeometry:l,feature:e,featureState:r,geometry:c,zoom:this.z,transform:t.transform,pixelsToTileUnits:o,pixelPosMatrix:t.pixelPosMatrix,unwrappedTileID:this.tileID.toUnwrapped(),getElevation:t.getElevation}))));}return d}loadMatchingFeature(t,e,n,r,i,s,o,a,l,u,c){const h=this.bucketLayerIDs[e];if(s&&!h.some((t=>s.has(t))))return;const p=this.sourceLayerCoder.decode(n),f=this.vtLayers[p].feature(r);if(i.needGeometry){const t=Ga(f,!0);if(!i.filter(new Cs(this.tileID.overscaledZ),t,this.tileID.canonical))return}else if(!i.filter(new Cs(this.tileID.overscaledZ),f))return;const d=this.getId(f,p);for(const e of h){if(s&&!s.has(e))continue;const n=a[e];if(!n)continue;let i={};d&&u&&(i=u.getState(n.sourceLayer||Jd,d));const h=$({},l[e]);h.paint=ay(h.paint,n.paint,f,i,o),h.layout=ay(h.layout,n.layout,f,i,o);const p=!c||c(f,n,i);if(!p)continue;const y=new nf(f,this.z,this.x,this.y,d);y.layer=h;let m=t[e];void 0===m&&(m=t[e]=[]),m.push({featureIndex:r,feature:y,intersectionZ:p});}}lookupSymbolFeatures(t,e,n,r,i,s,o,a){const l={};this.loadVTLayers();const u=yi(i.filterSpec,i.globalState);for(const i of t)this.loadMatchingFeature(l,n,r,i,u,s,o,a,e);return l}hasLayer(t){for(const e of this.bucketLayerIDs)for(const n of e)if(t===n)return !0;return !1}getId(t,e){var n;let r=t.id;return this.promoteId&&(r=t.properties["string"==typeof this.promoteId?this.promoteId:this.promoteId[e]],"boolean"==typeof r&&(r=Number(r)),void 0===r&&(null===(n=t.properties)||void 0===n?void 0:n.cluster)&&this.promoteId&&(r=Number(t.properties.cluster_id))),r}}function ay(t,e,n,r,i){return N(t,((t,s)=>{const o=e instanceof Gs?e.get(s):null;return (null==o?void 0:o.evaluate)?o.evaluate(n,r,i):o}))}function ly(t,e){return e-t}function uy(t,e,r,i,s){const o=[];for(const a of t){let t;for(let l=0;l=i&&c.x>=i||(u.x>=i?u=new n(i,u.y+(i-u.x)/(c.x-u.x)*(c.y-u.y))._round():c.x>=i&&(c=new n(i,u.y+(i-u.x)/(c.x-u.x)*(c.y-u.y))._round()),u.y>=s&&c.y>=s||(u.y>=s?u=new n(u.x+(s-u.y)/(c.y-u.y)*(c.x-u.x),s)._round():c.y>=s&&(c=new n(u.x+(s-u.y)/(c.y-u.y)*(c.x-u.x),s)._round()),t&&u.equals(t[t.length-1])||(t=[u],o.push(t)),t.push(c)))));}}return o}function cy(t,e,n,r,i){switch(e){case 1:return function(t,e,n,r){const i=[];for(const s of t)for(const t of s){const s=0===r?t.x:t.y;s>=e&&s<=n&&i.push([t]);}return i}(t,n,r,i);case 2:return py(t,n,r,i,!1);case 3:return py(t,n,r,i,!0)}return []}function hy(t,e,r,i,s){const o=0===i?fy:dy;let a=[];const l=[];for(let n=0;ne&&a.push(o(u,c,e)):h>r?p=e&&(a.push(o(u,c,e)),f=!0),p>r&&h<=r&&(a.push(o(u,c,r)),f=!0),!s&&f&&(l.push(a),a=[]);}const u=t.length-1,c=0===i?t[u].x:t[u].y;return c>=e&&c<=r&&a.push(t[u]),s&&a.length>0&&!a[0].equals(a[a.length-1])&&a.push(new n(a[0].x,a[0].y)),a.length>0&&l.push(a),l}function py(t,e,n,r,i){const s=[];for(const o of t){const t=hy(o,e,n,r,i);t.length>0&&s.push(...t);}return s}function fy(t,e,r){return new n(r,t.y+(r-t.x)/(e.x-t.x)*(e.y-t.y))}function dy(t,e,r){return new n(t.x+(r-t.y)/(e.y-t.y)*(e.x-t.x),r)}ds("FeatureIndex",oy,{omit:["rawTileData","sourceLayerCoder"]});class yy extends n{constructor(t,e,n,r){super(t,e),this.angle=n,void 0!==r&&(this.segment=r);}clone(){return new yy(this.x,this.y,this.angle,this.segment)}}function my(t,e,n,r,i){if(void 0===e.segment||0===n)return !0;let s=e,o=e.segment+1,a=0;for(;a>-n/2;){if(o--,o<0)return !1;a-=t[o].dist(s),s=t[o];}a+=t[o].dist(t[o+1]),o++;const l=[];let u=0;for(;ar;)u-=l.shift().angleDelta;if(u>i)return !1;o++,a+=e.dist(n);}return !0}function gy(t){let e=0;for(let n=0;nu){const c=(u-l)/s,h=xn.number(r.x,i.x,c),p=xn.number(r.y,i.y,c),f=new yy(h,p,i.angleTo(r),n);return f._round(),!o||my(t,f,a,o,e)?f:void 0}l+=s;}}function wy(t,e,n,r,i,s,o,a,l){const u=xy(r,s,o),c=vy(r,i),h=c*o,p=0===t[0].x||t[0].x===l||0===t[0].y||t[0].y===l;return e-h=0&&g=0&&x=0&&p+u<=c){const n=new yy(g,x,y,e);n._round(),r&&!my(t,n,s,r,i)||f.push(n);}}h+=d;}return a||f.length||o||(f=_y(t,h/2,n,r,i,s,o,!0,l)),f}function Sy(t,e,r,i){const s=[],o=t.image,a=o.pixelRatio,l=o.paddedRect.w-2,u=o.paddedRect.h-2;let c={x1:t.left,y1:t.top,x2:t.right,y2:t.bottom};const h=o.stretchX||[[0,l]],p=o.stretchY||[[0,u]],f=(t,e)=>t+e[1]-e[0],d=h.reduce(f,0),y=p.reduce(f,0),m=l-d,g=u-y;let x=0,v=d,b=0,w=y,_=0,S=m,A=0,M=g;if(o.content&&i){const e=o.content,n=e[2]-e[0],r=e[3]-e[1];(o.textFitWidth||o.textFitHeight)&&(c=dp(t)),x=Ay(h,0,e[0]),b=Ay(p,0,e[1]),v=Ay(h,e[0],e[2]),w=Ay(p,e[1],e[3]),_=e[0]-x,A=e[1]-b,S=n-v,M=r-w;}const k=c.x1,I=c.y1,E=c.x2-k,T=c.y2-I,F=(t,i,s,l)=>{const u=ky(t.stretch-x,v,E,k),c=Iy(t.fixed-_,S,t.stretch,d),h=ky(i.stretch-b,w,T,I),p=Iy(i.fixed-A,M,i.stretch,y),f=ky(s.stretch-x,v,E,k),m=Iy(s.fixed-_,S,s.stretch,d),g=ky(l.stretch-b,w,T,I),F=Iy(l.fixed-A,M,l.stretch,y),P=new n(u,h),D=new n(f,h),z=new n(f,g),B=new n(u,g),C=new n(c/a,p/a),V=new n(m/a,F/a),L=e*Math.PI/180;if(L){const t=Math.sin(L),e=Math.cos(L),n=[e,-t,t,e];P._matMult(n),D._matMult(n),B._matMult(n),z._matMult(n);}const O=t.stretch+t.fixed,$=i.stretch+i.fixed;return {tl:P,tr:D,bl:B,br:z,tex:{x:o.paddedRect.x+1+O,y:o.paddedRect.y+1+$,w:s.stretch+s.fixed-O,h:l.stretch+l.fixed-$},writingMode:void 0,glyphOffset:[0,0],sectionIndex:0,pixelOffsetTL:C,pixelOffsetBR:V,minFontScaleX:S/a/E,minFontScaleY:M/a/T,isSDF:r}};if(i&&(o.stretchX||o.stretchY)){const t=My(h,m,d),e=My(p,g,y);for(let n=0;n0&&(r=Math.max(10,r),this.circleDiameter=r);}else {const u=(null===(h=o.image)||void 0===h?void 0:h.content)&&(o.image.textFitWidth||o.image.textFitHeight)?dp(o):{x1:o.left,y1:o.top,x2:o.right,y2:o.bottom};u.y1=u.y1*a-l[0],u.y2=u.y2*a+l[2],u.x1=u.x1*a-l[3],u.x2=u.x2*a+l[1];const p=o.collisionPadding;if(p&&(u.x1-=p[0]*a,u.y1-=p[1]*a,u.x2+=p[2]*a,u.y2+=p[3]*a),c){const t=new n(u.x1,u.y1),e=new n(u.x2,u.y1),r=new n(u.x1,u.y2),i=new n(u.x2,u.y2),s=c*Math.PI/180;t._rotate(s),e._rotate(s),r._rotate(s),i._rotate(s),u.x1=Math.min(t.x,e.x,r.x,i.x),u.x2=Math.max(t.x,e.x,r.x,i.x),u.y1=Math.min(t.y,e.y,r.y,i.y),u.y2=Math.max(t.y,e.y,r.y,i.y);}t.emplaceBack(e.x,e.y,u.x1,u.y1,u.x2,u.y2,r,i,s);}this.boxEndIndex=t.length;}}class Ty{constructor(t=[],e=(t,e)=>te?1:0){if(this.data=t,this.length=this.data.length,this.compare=e,this.length>0)for(let t=(this.length>>1)-1;t>=0;t--)this._down(t);}push(t){this.data.push(t),this._up(this.length++);}pop(){if(0===this.length)return;const t=this.data[0],e=this.data.pop();return --this.length>0&&(this.data[0]=e,this._down(0)),t}peek(){return this.data[0]}_up(t){const{data:e,compare:n}=this,r=e[t];for(;t>0;){const i=t-1>>1,s=e[i];if(n(r,s)>=0)break;e[t]=s,t=i;}e[t]=r;}_down(t){const{data:e,compare:n}=this,r=this.length>>1,i=e[t];for(;t=0)break;e[t]=e[r],t=r;}e[t]=i;}}function Fy(t,e=1){const r=tf.fromPoints(t[0]),i=Math.min(r.width(),r.height());let s=i/2;const o=new Ty([],Py),{minX:a,minY:l,maxX:u,maxY:c}=r;if(0===i)return new n(a,l);for(let e=a;ep.d||!p.d)&&(p=n),n.max-p.d<=e||(s=n.h/2,o.push(new Dy(n.p.x-s,n.p.y-s,s,t)),o.push(new Dy(n.p.x+s,n.p.y-s,s,t)),o.push(new Dy(n.p.x-s,n.p.y+s,s,t)),o.push(new Dy(n.p.x+s,n.p.y+s,s,t)));}return h.d>0&&p.d-h.d<=e?h.p:p.p}function Py(t,e){return e.max-t.max}class Dy{constructor(t,e,r,i){this.p=new n(t,e),this.h=r,this.d=function(t,e){let n=!1,r=1/0;for(const i of e)for(let e=0,s=i.length,o=s-1;et.y!=a.y>t.y&&t.x<(a.x-s.x)*(t.y-s.y)/(a.y-s.y)+s.x&&(n=!n),r=Math.min(r,nl(t,s,a));}return (n?1:-1)*Math.sqrt(r)}(this.p,i),this.max=this.d+this.h*Math.SQRT2;}}var zy;t.aM=void 0,(zy=t.aM||(t.aM={}))[zy.center=1]="center",zy[zy.left=2]="left",zy[zy.right=3]="right",zy[zy.top=4]="top",zy[zy.bottom=5]="bottom",zy[zy["top-left"]=6]="top-left",zy[zy["top-right"]=7]="top-right",zy[zy["bottom-left"]=8]="bottom-left",zy[zy["bottom-right"]=9]="bottom-right";const By=Number.POSITIVE_INFINITY;function Cy(t,e){return e[1]!==By?function(t,e,n){let r=0,i=0;switch(e=Math.abs(e),n=Math.abs(n),t){case "top-right":case "top-left":case "top":i=n-7;break;case "bottom-right":case "bottom-left":case "bottom":i=7-n;}switch(t){case "top-right":case "bottom-right":case "right":r=-e;break;case "top-left":case "bottom-left":case "left":r=e;}return [r,i]}(t,e[0],e[1]):function(t,e){let n=0,r=0;e<0&&(e=0);const i=e/Math.SQRT2;switch(t){case "top-right":case "top-left":r=i-7;break;case "bottom-right":case "bottom-left":r=7-i;break;case "bottom":r=7-e;break;case "top":r=e-7;}switch(t){case "top-right":case "bottom-right":n=-i;break;case "top-left":case "bottom-left":n=i;break;case "left":n=e;break;case "right":n=-e;}return [n,r]}(t,e[0])}function Vy(t,e,n){var r;const i=t.layout,s=null===(r=i.get("text-variable-anchor-offset"))||void 0===r?void 0:r.evaluate(e,{},n);if(s){const t=s.values,e=[];for(let n=0;nt*Ih));r.startsWith("top")?i[1]-=7:r.startsWith("bottom")&&(i[1]+=7),e[n+1]=i;}return new $e(e)}const o=i.get("text-variable-anchor");if(o){let r;r=void 0!==t._unevaluatedLayout.getValue("text-radial-offset")?[i.get("text-radial-offset").evaluate(e,{},n)*Ih,By]:i.get("text-offset").evaluate(e,{},n).map((t=>t*Ih));const s=[];for(const t of o)s.push(t,Cy(t,r));return new $e(s)}return null}function Ly(t){switch(t){case "right":case "top-right":case "bottom-right":return "right";case "left":case "top-left":case "bottom-left":return "left"}return "center"}function Oy(e,n,r,i,s,o,a,l,u,c,h,p){let f=o.textMaxSize.evaluate(n,{});void 0===f&&(f=a);const d=e.layers[0].layout,y=d.get("icon-offset").evaluate(n,{},h),m=Ry(r.horizontal),g=a/24,x=e.tilePixelRatio*g,v=e.tilePixelRatio*f/24,b=e.tilePixelRatio*l,w=e.tilePixelRatio*d.get("symbol-spacing"),_=d.get("text-padding")*e.tilePixelRatio,S=function(t,e,n,r=1){const i=t.get("icon-padding").evaluate(e,{},n),s=null==i?void 0:i.values;return [s[0]*r,s[1]*r,s[2]*r,s[3]*r]}(d,n,h,e.tilePixelRatio),A=d.get("text-max-angle")/180*Math.PI,M="viewport"!==d.get("text-rotation-alignment")&&"point"!==d.get("symbol-placement"),k="map"===d.get("icon-rotation-alignment")&&"point"!==d.get("symbol-placement"),I=d.get("symbol-placement"),E=w/2,F=d.get("icon-text-fit");let P;i&&"none"!==F&&(e.allowVerticalPlacement&&r.vertical&&(P=yp(i,r.vertical,F,d.get("icon-text-fit-padding"),y,g)),m&&(i=yp(i,m,F,d.get("icon-text-fit-padding"),y,g)));const D=h?p.line.getGranularityForZoomLevel(h.z):1,z=(l,p)=>{p.x<0||p.x>=T||p.y<0||p.y>=T||function(e,n,r,i,s,o,a,l,u,c,h,p,f,d,y,m,g,x,v,b,w,_,S,A,M){const k=e.addToLineVertexArray(n,r);let I,E,T,F,P=0,D=0,z=0,B=0,C=-1,V=-1;const L={};let O=ba("");if(e.allowVerticalPlacement&&i.vertical){const t=l.layout.get("text-rotate").evaluate(w,{},A)+90;T=new Ey(u,n,c,h,p,i.vertical,f,d,y,t),a&&(F=new Ey(u,n,c,h,p,a,g,x,y,t));}if(s){const r=l.layout.get("icon-rotate").evaluate(w,{}),i="none"!==l.layout.get("icon-text-fit"),o=Sy(s,r,S,i),f=a?Sy(a,r,S,i):void 0;E=new Ey(u,n,c,h,p,s,g,x,!1,r),P=4*o.length;const d=e.iconSizeData;let y=null;"source"===d.kind?(y=[mp*l.layout.get("icon-size").evaluate(w,{})],y[0]>gp&&G(`${e.layerIds[0]}: Value for "icon-size" is >= 255. Reduce your "icon-size".`)):"composite"===d.kind&&(y=[mp*_.compositeIconSizes[0].evaluate(w,{},A),mp*_.compositeIconSizes[1].evaluate(w,{},A)],(y[0]>gp||y[1]>gp)&&G(`${e.layerIds[0]}: Value for "icon-size" is >= 255. Reduce your "icon-size".`)),e.addSymbols(e.icon,o,y,b,v,w,t.ax.none,n,k.lineStartIndex,k.lineLength,-1,A),C=e.icon.placedSymbolArray.length-1,f&&(D=4*f.length,e.addSymbols(e.icon,f,y,b,v,w,t.ax.vertical,n,k.lineStartIndex,k.lineLength,-1,A),V=e.icon.placedSymbolArray.length-1);}const $=Object.keys(i.horizontal);for(const r of $){const s=i.horizontal[r];if(!I){O=ba(s.text);const t=l.layout.get("text-rotate").evaluate(w,{},A);I=new Ey(u,n,c,h,p,s,f,d,y,t);}const a=1===s.positionedLines.length;if(z+=$y(e,n,s,o,l,y,w,m,k,i.vertical?t.ax.horizontal:t.ax.horizontalOnly,a?$:[r],L,C,_,A),a)break}i.vertical&&(B+=$y(e,n,i.vertical,o,l,y,w,m,k,t.ax.vertical,["vertical"],L,V,_,A));const R=I?I.boxStartIndex:e.collisionBoxArray.length,N=I?I.boxEndIndex:e.collisionBoxArray.length,U=T?T.boxStartIndex:e.collisionBoxArray.length,j=T?T.boxEndIndex:e.collisionBoxArray.length,q=E?E.boxStartIndex:e.collisionBoxArray.length,X=E?E.boxEndIndex:e.collisionBoxArray.length,Y=F?F.boxStartIndex:e.collisionBoxArray.length,Z=F?F.boxEndIndex:e.collisionBoxArray.length;let H=-1;const W=(t,e)=>(null==t?void 0:t.circleDiameter)?Math.max(t.circleDiameter,e):e;H=W(I,H),H=W(T,H),H=W(E,H),H=W(F,H);const K=H>-1?1:0;K&&(H*=M/Ih),e.glyphOffsetArray.length>=kp.MAX_GLYPHS&&G("Too many glyphs being rendered in a tile. See https://github.com/mapbox/mapbox-gl-js/issues/2907"),void 0!==w.sortKey&&e.addToSortKeyRanges(e.symbolInstances.length,w.sortKey);const J=Vy(l,w,A),[Q,tt]=function(e,n){const r=e.length,i=null==n?void 0:n.values;if((null==i?void 0:i.length)>0)for(let n=0;n=0?L.right:-1,L.center>=0?L.center:-1,L.left>=0?L.left:-1,L.vertical||-1,C,V,O,R,N,U,j,q,X,Y,Z,c,z,B,P,D,K,0,f,H,Q,tt);}(e,p,l,r,i,s,P,e.layers[0],e.collisionBoxArray,n.index,n.sourceLayerIndex,e.index,x,[_,_,_,_],M,u,b,S,k,y,n,o,c,h,a);};if("line"===I)for(const t of uy(n.geometry,0,0,T,T)){const n=Su(t,D),s=wy(n,w,A,r.vertical||m,i,24,v,e.overscaling,T);for(const t of s)m&&Ny(e,m.text,E,t)||z(n,t);}else if("line-center"===I){for(const t of n.geometry)if(t.length>1){const e=Su(t,D),n=by(e,A,r.vertical||m,i,24,v);n&&z(e,n);}}else if("Polygon"===n.type)for(const t of rr(n.geometry,0)){const e=Fy(t,16);z(Su(t[0],D,!0),new yy(e.x,e.y,0));}else if("LineString"===n.type)for(const t of n.geometry){const e=Su(t,D);z(e,new yy(e[0].x,e[0].y,0));}else if("Point"===n.type)for(const t of n.geometry)for(const e of t)z([e],new yy(e.x,e.y,0));}function $y(t,e,r,i,s,o,a,l,u,c,h,p,f,d,y){const m=function(t,e,r,i,s,o,a,l){const u=i.layout.get("text-rotate").evaluate(o,{})*Math.PI/180,c=[];for(const t of e.positionedLines)for(const i of t.positionedGlyphs){if(!i.rect)continue;const o=i.rect||{};let h=4,p=!0,f=1,d=0;const y=(s||l)&&i.vertical,m=i.metrics.advance*i.scale/2;if(l&&e.verticalizable&&(d=t.lineOffset/2-(i.imageName?-(Ih-i.metrics.width*i.scale)/2:(i.scale-1)*Ih)),i.imageName){const t=a[i.imageName];p=t.sdf,f=t.pixelRatio,h=1/f;}const g=s?[i.x+m,i.y]:[0,0];let x=s?[0,0]:[i.x+m+r[0],i.y+r[1]-d],v=[0,0];y&&(v=x,x=[0,0]);const b=i.metrics.isDoubleResolution?2:1,w=(i.metrics.left-h)*i.scale-m+x[0],_=(-i.metrics.top-h)*i.scale+x[1],S=w+o.w/b*i.scale/f,A=_+o.h/b*i.scale/f,M=new n(w,_),k=new n(S,_),I=new n(w,A),E=new n(S,A);if(y){const t=new n(-m,m- -17),e=-Math.PI/2,r=12-m,s=new n(22-r,-(i.imageName?r:0)),o=new n(...v);M._rotateAround(e,t)._add(s)._add(o),k._rotateAround(e,t)._add(s)._add(o),I._rotateAround(e,t)._add(s)._add(o),E._rotateAround(e,t)._add(s)._add(o);}if(u){const t=Math.sin(u),e=Math.cos(u),n=[e,-t,t,e];M._matMult(n),k._matMult(n),I._matMult(n),E._matMult(n);}const T=new n(0,0),F=new n(0,0);c.push({tl:M,tr:k,bl:I,br:E,tex:o,writingMode:e.writingMode,glyphOffset:g,sectionIndex:i.sectionIndex,isSDF:p,pixelOffsetTL:T,pixelOffsetBR:F,minFontScaleX:0,minFontScaleY:0});}return c}(0,r,l,s,o,a,i,t.allowVerticalPlacement),g=t.textSizeData;let x=null;"source"===g.kind?(x=[mp*s.layout.get("text-size").evaluate(a,{})],x[0]>gp&&G(`${t.layerIds[0]}: Value for "text-size" is >= 255. Reduce your "text-size".`)):"composite"===g.kind&&(x=[mp*d.compositeTextSizes[0].evaluate(a,{},y),mp*d.compositeTextSizes[1].evaluate(a,{},y)],(x[0]>gp||x[1]>gp)&&G(`${t.layerIds[0]}: Value for "text-size" is >= 255. Reduce your "text-size".`)),t.addSymbols(t.text,m,x,l,o,a,c,e,u.lineStartIndex,u.lineLength,f,y);for(const e of h)p[e]=t.text.placedSymbolArray.length-1;return 4*m.length}function Ry(t){for(const e in t)return t[e];return null}function Ny(t,e,n,r){const i=t.compareText;if(e in i){const t=i[e];for(let e=t.length-1;e>=0;e--)if(r.dist(t[e])this.process())),this.subscription=tt(this.target,"message",(t=>this.receive(t)),!1),this.globalScope=Y(self)?t:window;}registerMessageHandler(t,e){this.messageHandlers[t]=e;}unregisterMessageHandler(t){delete this.messageHandlers[t];}sendAsync(t,e){return new Promise(((n,r)=>{const i=Math.round(1e18*Math.random()).toString(36).substring(0,10),s=e?tt(e.signal,"abort",(()=>{null==s||s.unsubscribe(),delete this.resolveRejects[i];const e={id:i,type:"",origin:location.origin,targetMapId:t.targetMapId,sourceMapId:this.mapId};this.target.postMessage(e);}),Lp):null;this.resolveRejects[i]={resolve:t=>{null==s||s.unsubscribe(),n(t);},reject:t=>{null==s||s.unsubscribe(),r(t);}};const o=[],a=Object.assign(Object.assign({},t),{id:i,sourceMapId:this.mapId,origin:location.origin,data:xs(t.data,o)});this.target.postMessage(a,{transfer:o});}))}receive(t){const e=t.data,n=e.id,r=["file://","resource://android","null"],i=[e.origin,location.origin],s=e.origin===location.origin,o=i.some((t=>r.includes(t)));if((s||o)&&(!e.targetMapId||this.mapId===e.targetMapId)){if(""===e.type){delete this.tasks[n];const t=this.abortControllers[n];return delete this.abortControllers[n],void(t&&t.abort())}if(Y(self)||e.mustQueue)return this.tasks[n]=e,this.taskQueue.push(n),void this.invoker.trigger();this.processTask(n,e);}}process(){if(0===this.taskQueue.length)return;const t=this.taskQueue.shift(),e=this.tasks[t];delete this.tasks[t],this.taskQueue.length>0&&this.invoker.trigger(),e&&this.processTask(t,e);}processTask(t,n){return e(this,void 0,void 0,(function*(){if(""===n.type){const e=this.resolveRejects[t];if(delete this.resolveRejects[t],!e)return;return void(n.error?e.reject(P(vs(n.error))):e.resolve(vs(n.data)))}if(!this.messageHandlers[n.type])return void this.completeTask(t,new Error(`Could not find a registered handler for ${n.type}, map ID: ${this.mapId}, available handlers: ${Object.keys(this.messageHandlers).join(", ")}`));const e=vs(n.data),r=new AbortController;this.abortControllers[t]=r;try{const i=yield this.messageHandlers[n.type](n.sourceMapId,e,r);this.completeTask(t,null,i);}catch(e){this.completeTask(t,P(e));}}))}completeTask(t,e,n){const r=[];delete this.abortControllers[t];const i={id:t,type:"",sourceMapId:this.mapId,origin:location.origin,error:e?xs(e):null,data:xs(n,r)};this.target.postMessage(i,{transfer:r});}remove(){this.invoker.remove(),this.subscription.unsubscribe();}},t.O=function(){var t=new f(16);return f!=Float32Array&&(t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[11]=0,t[12]=0,t[13]=0,t[14]=0),t[0]=1,t[5]=1,t[10]=1,t[15]=1,t},t.P=n,t.Q=function(t,e,n){var r,i,s,o,a,l,u,c,h,p,f,d,y=n[0],m=n[1],g=n[2];return e===t?(t[12]=e[0]*y+e[4]*m+e[8]*g+e[12],t[13]=e[1]*y+e[5]*m+e[9]*g+e[13],t[14]=e[2]*y+e[6]*m+e[10]*g+e[14],t[15]=e[3]*y+e[7]*m+e[11]*g+e[15]):(i=e[1],s=e[2],o=e[3],a=e[4],l=e[5],u=e[6],c=e[7],h=e[8],p=e[9],f=e[10],d=e[11],t[0]=r=e[0],t[1]=i,t[2]=s,t[3]=o,t[4]=a,t[5]=l,t[6]=u,t[7]=c,t[8]=h,t[9]=p,t[10]=f,t[11]=d,t[12]=r*y+a*m+h*g+e[12],t[13]=i*y+l*m+p*g+e[13],t[14]=s*y+u*m+f*g+e[14],t[15]=o*y+c*m+d*g+e[15]),t},t.R=Il,t.S=function(t,e,n){var r=n[0],i=n[1],s=n[2];return t[0]=e[0]*r,t[1]=e[1]*r,t[2]=e[2]*r,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*i,t[6]=e[6]*i,t[7]=e[7]*i,t[8]=e[8]*s,t[9]=e[9]*s,t[10]=e[10]*s,t[11]=e[11]*s,t[12]=e[12],t[13]=e[13],t[14]=e[14],t[15]=e[15],t},t.T=Ol,t.U=function(t,e,n){var r=e[0],i=e[1],s=e[2],o=e[3],a=e[4],l=e[5],u=e[6],c=e[7],h=e[8],p=e[9],f=e[10],d=e[11],y=e[12],m=e[13],g=e[14],x=e[15],v=n[0],b=n[1],w=n[2],_=n[3];return t[0]=v*r+b*a+w*h+_*y,t[1]=v*i+b*l+w*p+_*m,t[2]=v*s+b*u+w*f+_*g,t[3]=v*o+b*c+w*d+_*x,t[4]=(v=n[4])*r+(b=n[5])*a+(w=n[6])*h+(_=n[7])*y,t[5]=v*i+b*l+w*p+_*m,t[6]=v*s+b*u+w*f+_*g,t[7]=v*o+b*c+w*d+_*x,t[8]=(v=n[8])*r+(b=n[9])*a+(w=n[10])*h+(_=n[11])*y,t[9]=v*i+b*l+w*p+_*m,t[10]=v*s+b*u+w*f+_*g,t[11]=v*o+b*c+w*d+_*x,t[12]=(v=n[12])*r+(b=n[13])*a+(w=n[14])*h+(_=n[15])*y,t[13]=v*i+b*l+w*p+_*m,t[14]=v*s+b*u+w*f+_*g,t[15]=v*o+b*c+w*d+_*x,t},t.V=function(t,e){const n={};for(const r of e)r in t&&(n[r]=t[r]);return n},t.W=$p,t.X=O,t.Y=jp,t.Z=Up,t._=e,t.a=ot,t.a$=function(t){var e=new f(3);return e[0]=t[0],e[1]=t[1],e[2]=t[2],e},t.a0=c,t.a1=h,t.a2=K,t.a3=Jp,t.a4=Gp,t.a5=Xp,t.a6=T,t.a7=Zp,t.a8=tf,t.a9=25,t.aA=function(t){var e=t[0],n=t[1];return Math.sqrt(e*e+n*n)},t.aB=function(t){return t[0]=0,t[1]=0,t},t.aC=function(t,e,n){return t[0]=e[0]*n,t[1]=e[1]*n,t},t.aD=_p,t.aE=A,t.aF=function(t,e,r,i){const s=e.y-t.y,o=e.x-t.x,a=i.y-r.y,l=i.x-r.x,u=a*o-l*s;if(0===u)return null;const c=(l*(t.y-r.y)-a*(t.x-r.x))/u;return new n(t.x+c*o,t.y+c*s)},t.aG=uy,t.aH=Ha,t.aI=function(t){let e=1/0,n=1/0,r=-1/0,i=-1/0;for(const s of t)e=Math.min(e,s.x),n=Math.min(n,s.y),r=Math.max(r,s.x),i=Math.max(i,s.y);return [e,n,r,i]},t.aJ=Ih,t.aK=F,t.aL=function(t,e,n,r,i=!1){if(!n[0]&&!n[1])return [0,0];const s=i?"map"===r?-t.bearingInRadians:0:"viewport"===r?t.bearingInRadians:0;if(s){const t=Math.sin(s),e=Math.cos(s);n=[n[0]*e-n[1]*t,n[0]*t+n[1]*e];}return [i?n[0]:F(e,n[0],t.zoom),i?n[1]:F(e,n[1],t.zoom)]},t.aN=vp,t.aO=Ly,t.aP=op,t.aQ=t=>"symbol"===t.type,t.aR=ec,t.aS=ao,t.aT=xu,t.aU=Xo,t.aV=ua,t.aW=sa,t.aX=nt,t.aY=Yp,t.aZ=b,t.a_=v,t.aa=Wp,t.ab=t=>{const e=window.document.createElement("video");return e.muted=!0,new Promise((n=>{e.onloadstart=()=>{n(e);};for(const n of t){const t=window.document.createElement("source");yt(n)||(e.crossOrigin="Anonymous"),t.src=n,e.appendChild(t);}}))},t.ac=zt,t.ad=function(){return R++},t.ae=Co,t.af=kp,t.ag=Jd,t.ah=yi,t.ai=Ga,t.aj=nf,t.ak=function(t){const e={};if(t.replace(/(?:^|(?:\s*\,\s*))([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)(?:\=(?:([^\x00-\x20\(\)<>@\,;\:\\"\/\[\]\?\=\{\}\x7F]+)|(?:\"((?:[^"\\]|\\.)*)\")))?/g,((t,n,r,i)=>{const s=r||i;return e[n]=!s||s.toLowerCase(),""})),e["max-age"]){const t=parseInt(e["max-age"],10);isNaN(t)?delete e["max-age"]:e["max-age"]=t;}return e},t.al=L,t.am=85.051129,t.an=et,t.ao=function(t){return Math.pow(2,t)},t.ap=y,t.aq=qp,t.ar=function(t){return Math.log(t)/Math.LN2},t.as=function(t){var e=t[0],n=t[1];return e*e+n*n},t.at=function(t){if(!t.length)return new Set;const e=Math.max(...t.map((t=>t.canonical.z)));let n=1/0,r=-1/0,i=1/0,s=-1/0;const o=[];for(const a of t){const{x:t,y:l,z:u}=a.canonical,c=Math.pow(2,e-u),h=t*c,p=l*c;o.push({id:a,x:h,y:p}),hr&&(r=h),ps&&(s=p);}const a=new Set;for(const t of o)t.x!==n&&t.x!==r&&t.y!==i&&t.y!==s||a.add(t.id);return a},t.au=function(t,e){const n=Math.abs(2*t.wrap)-+(t.wrap<0),r=Math.abs(2*e.wrap)-+(e.wrap<0);return t.overscaledZ-e.overscaledZ||r-n||e.canonical.y-t.canonical.y||e.canonical.x-t.canonical.x},t.av=class{constructor(t,e){this.max=t,this.onRemove=e,this.reset();}reset(){for(const t in this.data)for(const e of this.data[t])e.timeout&&clearTimeout(e.timeout),this.onRemove(e.value);return this.data={},this.order=[],this}add(t,e,n){const r=t.wrapped().key;void 0===this.data[r]&&(this.data[r]=[]);const i={value:e,timeout:void 0};if(void 0!==n&&(i.timeout=setTimeout((()=>{this.remove(t,i);}),n)),this.data[r].push(i),this.order.push(r),this.order.length>this.max){const t=this._getAndRemoveByKey(this.order[0]);t&&this.onRemove(t);}return this}has(t){return t.wrapped().key in this.data}getAndRemove(t){return this.has(t)?this._getAndRemoveByKey(t.wrapped().key):null}_getAndRemoveByKey(t){const e=this.data[t].shift();return e.timeout&&clearTimeout(e.timeout),0===this.data[t].length&&delete this.data[t],this.order.splice(this.order.indexOf(t),1),e.value}getByKey(t){const e=this.data[t];return e?e[0].value:null}get(t){return this.has(t)?this.data[t.wrapped().key][0].value:null}remove(t,e){if(!this.has(t))return this;const n=t.wrapped().key,r=void 0===e?0:this.data[n].indexOf(e),i=this.data[n][r];return this.data[n].splice(r,1),i.timeout&&clearTimeout(i.timeout),0===this.data[n].length&&delete this.data[n],this.onRemove(i.value),this.order.splice(this.order.indexOf(n),1),this}setMaxSize(t){for(this.max=t;this.order.length>this.max;){const t=this._getAndRemoveByKey(this.order[0]);t&&this.onRemove(t);}return this}filter(t){const e=[];for(const n in this.data)for(const r of this.data[n])t(r.value)||e.push(r);for(const t of e)this.remove(t.value.tileID,t);}},t.aw=function(t,e){let n=0,r=0;if("constant"===t.kind)r=t.layoutSize;else if("source"!==t.kind){const{interpolationType:i,minZoom:s,maxZoom:o}=t,a=i?L(mn.interpolationFactor(i,e,s,o),0,1):0;"camera"===t.kind?r=xn.number(t.minSize,t.maxSize,a):n=a;}return {uSizeT:n,uSize:r}},t.ay=function(t,{uSize:e,uSizeT:n},{lowerSize:r,upperSize:i}){return "source"===t.kind?r/mp:"composite"===t.kind?xn.number(r/mp,i/mp,n):e},t.az=I,t.b=H,t.b$=Ea,t.b0=function(t,e,n){return t[0]=e[0]-n[0],t[1]=e[1]-n[1],t[2]=e[2]-n[2],t},t.b1=function(t,e){var n=e[0],r=e[1],i=e[2],s=n*n+r*r+i*i;return s>0&&(s=1/Math.sqrt(s)),t[0]=e[0]*s,t[1]=e[1]*s,t[2]=e[2]*s,t},t.b2=w,t.b3=function(t,e){return t[0]*e[0]+t[1]*e[1]+t[2]*e[2]},t.b4=function(t,e,n){return t[0]=e[0]*n[0],t[1]=e[1]*n[1],t[2]=e[2]*n[2],t[3]=e[3]*n[3],t},t.b5=g,t.b6=function(t,e,n){const r=e[0]*n[0]+e[1]*n[1]+e[2]*n[2];return 0===r?null:(-(t[0]*n[0]+t[1]*n[1]+t[2]*n[2])-n[3])/r},t.b7=S,t.b8=function(t,e,n){return t[0]=e[0]*n,t[1]=e[1]*n,t[2]=e[2]*n,t[3]=e[3]*n,t},t.b9=function(t,e){return t[0]*e[0]+t[1]*e[1]+t[2]*e[2]+t[3]},t.bA=k,t.bB=function(t,e,n){var r=n[0],i=n[1],s=n[2],o=n[3],a=e[0],l=e[1],u=e[2],c=i*u-s*l,h=s*a-r*u,p=r*l-i*a;return t[0]=a+o*(c+=c)+i*(p+=p)-s*(h+=h),t[1]=l+o*h+s*c-r*p,t[2]=u+o*p+r*h-i*c,t},t.bC=function(t,e,n){const r=(i=[t[0],t[1],t[2],e[0],e[1],e[2],n[0],n[1],n[2]])[0]*((c=i[8])*(o=i[4])-(a=i[5])*(u=i[7]))+i[1]*(-c*(s=i[3])+a*(l=i[6]))+i[2]*(u*s-o*l);var i,s,o,a,l,u,c;if(0===r)return null;const h=w([],[e[0],e[1],e[2]],[n[0],n[1],n[2]]),p=w([],[n[0],n[1],n[2]],[t[0],t[1],t[2]]),f=w([],[t[0],t[1],t[2]],[e[0],e[1],e[2]]),d=b([],h,-t[3]);return v(d,d,b([],p,-e[3])),v(d,d,b([],f,-n[3])),b(d,d,1/r),d},t.bD=Op,t.bE=function(){return new Float64Array(4)},t.bF=function(t,e,n,r){var i=[],s=[];return i[0]=e[0]-n[0],i[1]=e[1]-n[1],i[2]=e[2]-n[2],s[0]=i[0]*Math.cos(r)-i[1]*Math.sin(r),s[1]=i[0]*Math.sin(r)+i[1]*Math.cos(r),s[2]=i[2],t[0]=s[0]+n[0],t[1]=s[1]+n[1],t[2]=s[2]+n[2],t},t.bG=function(t,e,n,r){var i=[],s=[];return i[0]=e[0]-n[0],i[1]=e[1]-n[1],i[2]=e[2]-n[2],s[0]=i[0],s[1]=i[1]*Math.cos(r)-i[2]*Math.sin(r),s[2]=i[1]*Math.sin(r)+i[2]*Math.cos(r),t[0]=s[0]+n[0],t[1]=s[1]+n[1],t[2]=s[2]+n[2],t},t.bH=function(t,e,n,r){var i=[],s=[];return i[0]=e[0]-n[0],i[1]=e[1]-n[1],i[2]=e[2]-n[2],s[0]=i[2]*Math.sin(r)+i[0]*Math.cos(r),s[1]=i[1],s[2]=i[2]*Math.cos(r)-i[0]*Math.sin(r),t[0]=s[0]+n[0],t[1]=s[1]+n[1],t[2]=s[2]+n[2],t},t.bI=function(t,e,n){var r=Math.sin(n),i=Math.cos(n),s=e[0],o=e[1],a=e[2],l=e[3],u=e[8],c=e[9],h=e[10],p=e[11];return e!==t&&(t[4]=e[4],t[5]=e[5],t[6]=e[6],t[7]=e[7],t[12]=e[12],t[13]=e[13],t[14]=e[14],t[15]=e[15]),t[0]=s*i-u*r,t[1]=o*i-c*r,t[2]=a*i-h*r,t[3]=l*i-p*r,t[8]=s*r+u*i,t[9]=o*r+c*i,t[10]=a*r+h*i,t[11]=l*r+p*i,t},t.bJ=function(t,e){const n=D(t,360),r=D(e,360),i=r-n,s=r>n?i-360:i+360;return Math.abs(i)0?o:-o},t.bM=function(t,e){const n=D(t,2*Math.PI),r=D(e,2*Math.PI);return Math.min(Math.abs(n-r),Math.abs(n-r+2*Math.PI),Math.abs(n-r-2*Math.PI))},t.bN=function(){const t={},e=wt.$version;for(const n in wt.$root){const r=wt.$root[n];if(r.required){let i=null;i="version"===n?e:"array"===r.type?[]:{},null!=i&&(t[n]=i);}}return t},t.bO=ft,t.bP=bs,t.bQ=function t(e,n){if(Array.isArray(e)){if(!Array.isArray(n)||e.length!==n.length)return !1;for(let r=0;r"raster"===t.type,t.bV=j,t.bW=function(t,e){if(!t)return [{command:"setStyle",args:[e]}];let n=[];try{if(!At(t.version,e.version))return [{command:"setStyle",args:[e]}];At(t.center,e.center)||n.push({command:"setCenter",args:[e.center]}),At(t.state,e.state)||n.push({command:"setGlobalState",args:[e.state]}),At(t.centerAltitude,e.centerAltitude)||n.push({command:"setCenterAltitude",args:[e.centerAltitude]}),At(t.zoom,e.zoom)||n.push({command:"setZoom",args:[e.zoom]}),At(t.bearing,e.bearing)||n.push({command:"setBearing",args:[e.bearing]}),At(t.pitch,e.pitch)||n.push({command:"setPitch",args:[e.pitch]}),At(t.roll,e.roll)||n.push({command:"setRoll",args:[e.roll]}),At(t.sprite,e.sprite)||n.push({command:"setSprite",args:[e.sprite]}),At(t.glyphs,e.glyphs)||n.push({command:"setGlyphs",args:[e.glyphs]}),At(t.transition,e.transition)||n.push({command:"setTransition",args:[e.transition]}),At(t.light,e.light)||n.push({command:"setLight",args:[e.light]}),At(t.terrain,e.terrain)||n.push({command:"setTerrain",args:[e.terrain]}),At(t.sky,e.sky)||n.push({command:"setSky",args:[e.sky]}),At(t.projection,e.projection)||n.push({command:"setProjection",args:[e.projection]});const r={},i=[];!function(t,e,n,r){let i;for(i in e=e||{},t=t||{})Object.prototype.hasOwnProperty.call(t,i)&&(Object.prototype.hasOwnProperty.call(e,i)||It(i,n,r));for(i in e)Object.prototype.hasOwnProperty.call(e,i)&&(Object.prototype.hasOwnProperty.call(t,i)?At(t[i],e[i])||("geojson"===t[i].type&&"geojson"===e[i].type&&Tt(t,e,i)?Mt(n,{command:"setGeoJSONSourceData",args:[i,e[i].data]}):Et(i,e,n,r)):kt(i,e,n));}(t.sources,e.sources,i,r);const s=[];t.layers&&t.layers.forEach((t=>{"source"in t&&r[t.source]?n.push({command:"removeLayer",args:[t.id]}):s.push(t);})),n=n.concat(i),function(t,e,n){e=e||[];const r=(t=t||[]).map(Pt),i=e.map(Pt),s=t.reduce(Dt,{}),o=e.reduce(Dt,{}),a=r.slice(),l=Object.create(null);let u,c,h,p,f;for(let t=0,e=0;tp?(i=Math.acos(s),o=Math.sin(i),a=Math.sin((1-r)*i)/o,l=Math.sin(r*i)/o):(a=1-r,l=r),t[0]=a*u+l*d,t[1]=a*c+l*y,t[2]=a*h+l*m,t[3]=a*f+l*g,t},t.bm=function(t){const e=new Float64Array(9);var n,r,i,s,o,a,l,u,c,h,p,f,d,y,m,g,x,v;h=(i=(r=t)[0])*(l=i+i),p=(s=r[1])*l,d=(o=r[2])*l,y=o*(u=s+s),g=(a=r[3])*l,x=a*u,v=a*(c=o+o),(n=e)[0]=1-(f=s*u)-(m=o*c),n[3]=p-v,n[6]=d+x,n[1]=p+v,n[4]=1-h-m,n[7]=y-g,n[2]=d-x,n[5]=y+g,n[8]=1-h-f;const b=nt(-Math.asin(L(e[2],-1,1)));let w,_;return Math.hypot(e[5],e[8])<.001?(w=0,_=-nt(Math.atan2(e[3],e[4]))):(w=nt(0===e[5]&&0===e[8]?0:Math.atan2(e[5],e[8])),_=nt(0===e[1]&&0===e[0]?0:Math.atan2(e[1],e[0]))),{roll:w,pitch:b+90,bearing:_}},t.bn=function(t,e){return t.roll==e.roll&&t.pitch==e.pitch&&t.bearing==e.bearing},t.bo=Te,t.bp=ka,t.bq=vu,t.br=bu,t.bs=gu,t.bt=z,t.bu=B,t.bv=Ne,t.bw=function(t,e,n,r,i){return z(r,i,L((t-e)/(n-e),0,1))},t.bx=function(t,e,n,r){return t[0]=e[0]+n[0]*r,t[1]=e[1]+n[1]*r,t[2]=e[2]+n[2]*r,t},t.by=D,t.bz=function(){return new Float64Array(3)},t.c=ut,t.c$=si,t.c0=class extends Ma{constructor(t,e){super(t,e),this.current=Ta;}set(t){if(t[12]!==this.current[12]||t[0]!==this.current[0])return this.current=t,void this.gl.uniformMatrix4fv(this.location,!1,t);for(let e=1;e<16;e++)if(t[e]!==this.current[e]){this.current=t,this.gl.uniformMatrix4fv(this.location,!1,t);break}}},t.c1=Ia,t.c2=class extends Ma{constructor(t,e){super(t,e),this.current=[0,0,0];}set(t){t[0]===this.current[0]&&t[1]===this.current[1]&&t[2]===this.current[2]||(this.current=t,this.gl.uniform3f(this.location,t[0],t[1],t[2]));}},t.c3=class extends Ma{constructor(t,e){super(t,e),this.current=[0,0];}set(t){t[0]===this.current[0]&&t[1]===this.current[1]||(this.current=t,this.gl.uniform2f(this.location,t[0],t[1]));}},t.c4=d,t.c5=function(t,e){var n=Math.sin(e),r=Math.cos(e);return t[0]=r,t[1]=n,t[2]=0,t[3]=-n,t[4]=r,t[5]=0,t[6]=0,t[7]=0,t[8]=1,t},t.c6=function(t,e,n){var r=e[0],i=e[1],s=e[2];return t[0]=r*n[0]+i*n[3]+s*n[6],t[1]=r*n[1]+i*n[4]+s*n[7],t[2]=r*n[2]+i*n[5]+s*n[8],t},t.c7=function(t,e,n,r,i,s,o){var a=1/(e-n),l=1/(r-i),u=1/(s-o);return t[0]=-2*a,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=-2*l,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=2*u,t[11]=0,t[12]=(e+n)*a,t[13]=(i+r)*l,t[14]=(o+s)*u,t[15]=1,t},t.c8=class extends Ma{constructor(t,e){super(t,e),this.current=new Array;}set(t){if(t!=this.current){this.current=t;const e=new Float32Array(4*t.length);for(let n=0;n25||r<0||r>=1||n<0||n>=1)},t.cE=function(t,e){return t[0]=e[0],t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=e[1],t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=e[2],t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},t.cF=class extends co{},t.cG=pt,t.cH=function(t,e){ut.REGISTERED_PROTOCOLS[t]=e;},t.cI=function(t){delete ut.REGISTERED_PROTOCOLS[t];},t.cJ=function(t,e){const n={};for(let r=0;rt*Ih));}let x=u?"center":s.get("text-justify").evaluate(o,{},e.canonical);const b="point"===s.get("symbol-placement")?s.get("text-max-width").evaluate(o,{},e.canonical)*Ih:1/0,w=()=>{e.bucket.allowVerticalPlacement&&As(n)&&(m.vertical=sp(g,e.glyphMap,e.glyphPositions,e.imagePositions,p,b,l,h,"left",a,v,t.ax.vertical,!0,d,f));};if(!u&&y){const n=new Set;if("auto"===x)for(let t=0;tQd(t,e,n)),t.layers[r]);}(t,n,e),n.finish()},t.cT=function(t,e,n,r,i,s){let o=cy(t,e,n,i,0);return o=cy(o,e,r,s,1),o},t.cU=class{constructor(t){this.maxEntries=t,this.map=new Map;}get(t){const e=this.map.get(t);return void 0!==e&&(this.map.delete(t),this.map.set(t,e)),e}set(t,e){if(this.map.has(t))this.map.delete(t);else if(this.map.size>=this.maxEntries){const t=this.map.keys().next().value;this.map.delete(t);}this.map.set(t,e);}clear(){this.map.clear();}},t.cV=Nu,t.cW=Rh,t.cX=Wd,t.cY=function(t,n,r,i,s){return e(this,void 0,void 0,(function*(){if(h())try{return yield K(t,n,r,i,s)}catch(t){}return function(t,e,n,r,i){const s=t.width,o=t.height;J&&Q||(J=new OffscreenCanvas(s,o),Q=J.getContext("2d",{willReadFrequently:!0})),J.width=s,J.height=o,Q.drawImage(t,0,0,s,o);const a=Q.getImageData(e,n,r,i);return Q.clearRect(0,0,s,o),a.data}(t,n,r,i,s)}))},t.cZ=$l,t.c_=class{constructor(t,e){this.layers={[Jd]:this},this.name=Jd,this.version=e?e.version:1,this.extent=e?e.extent:4096,this.length=t.length,this.features=t;}feature(t){return new Kd(this.features[t],this.extent)}},t.ca=class extends So{},t.cb=Ah,t.cc=class extends Mo{},t.cd=Fl,t.ce=function(t){return t<=1?1:Math.pow(2,Math.ceil(Math.log(t)/Math.LN2))},t.cf=Tl,t.cg=function(t,e,n){var r=e[0],i=e[1],s=e[2],o=n[3]*r+n[7]*i+n[11]*s+n[15];return t[0]=(n[0]*r+n[4]*i+n[8]*s+n[12])/(o=o||1),t[1]=(n[1]*r+n[5]*i+n[9]*s+n[13])/o,t[2]=(n[2]*r+n[6]*i+n[10]*s+n[14])/o,t},t.ch=class extends ho{},t.ci=class extends Do{},t.cj=function(t,e){return t[0]===e[0]&&t[1]===e[1]&&t[2]===e[2]&&t[3]===e[3]&&t[4]===e[4]&&t[5]===e[5]&&t[6]===e[6]&&t[7]===e[7]&&t[8]===e[8]&&t[9]===e[9]&&t[10]===e[10]&&t[11]===e[11]&&t[12]===e[12]&&t[13]===e[13]&&t[14]===e[14]&&t[15]===e[15]},t.ck=function(t,e){var n=t[0],r=t[1],i=t[2],s=t[3],o=t[4],a=t[5],l=t[6],u=t[7],c=t[8],h=t[9],f=t[10],d=t[11],y=t[12],m=t[13],g=t[14],x=t[15],v=e[0],b=e[1],w=e[2],_=e[3],S=e[4],A=e[5],M=e[6],k=e[7],I=e[8],E=e[9],T=e[10],F=e[11],P=e[12],D=e[13],z=e[14],B=e[15];return Math.abs(n-v)<=p*Math.max(1,Math.abs(n),Math.abs(v))&&Math.abs(r-b)<=p*Math.max(1,Math.abs(r),Math.abs(b))&&Math.abs(i-w)<=p*Math.max(1,Math.abs(i),Math.abs(w))&&Math.abs(s-_)<=p*Math.max(1,Math.abs(s),Math.abs(_))&&Math.abs(o-S)<=p*Math.max(1,Math.abs(o),Math.abs(S))&&Math.abs(a-A)<=p*Math.max(1,Math.abs(a),Math.abs(A))&&Math.abs(l-M)<=p*Math.max(1,Math.abs(l),Math.abs(M))&&Math.abs(u-k)<=p*Math.max(1,Math.abs(u),Math.abs(k))&&Math.abs(c-I)<=p*Math.max(1,Math.abs(c),Math.abs(I))&&Math.abs(h-E)<=p*Math.max(1,Math.abs(h),Math.abs(E))&&Math.abs(f-T)<=p*Math.max(1,Math.abs(f),Math.abs(T))&&Math.abs(d-F)<=p*Math.max(1,Math.abs(d),Math.abs(F))&&Math.abs(y-P)<=p*Math.max(1,Math.abs(y),Math.abs(P))&&Math.abs(m-D)<=p*Math.max(1,Math.abs(m),Math.abs(D))&&Math.abs(g-z)<=p*Math.max(1,Math.abs(g),Math.abs(z))&&Math.abs(x-B)<=p*Math.max(1,Math.abs(x),Math.abs(B))},t.cl=function(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t[6]=e[6],t[7]=e[7],t[8]=e[8],t[9]=e[9],t[10]=e[10],t[11]=e[11],t[12]=e[12],t[13]=e[13],t[14]=e[14],t[15]=e[15],t},t.cm=t=>"circle"===t.type,t.cn=t=>"heatmap"===t.type,t.co=t=>"line"===t.type,t.cp=t=>"fill"===t.type,t.cq=t=>"fill-extrusion"===t.type,t.cr=t=>"hillshade"===t.type,t.cs=t=>"color-relief"===t.type,t.ct=t=>"background"===t.type,t.cu=t=>"custom"===t.type,t.cv=C,t.cw=function(t,e,n){if(e<=0)return t;const r=1/e;return void 0===n||Math.abs(n)<1e-10?Math.round(t*r)/r:(n>0?Math.ceil(t*r-1e-9):Math.floor(t*r+1e-10))/r},t.cx=function(t,e,n){const r=E(e.x-n.x,e.y-n.y),i=E(t.x-n.x,t.y-n.y);var s,o;return nt(Math.atan2(r[0]*i[1]-r[1]*i[0],(s=r)[0]*(o=i)[0]+s[1]*o[1]))},t.cy=V,t.cz=function(t,e){var n;if(!it[e])return !1;const r=null==t?void 0:t.target,i=(null===(n=null==r?void 0:r.ownerDocument)||void 0===n?void 0:n.defaultView)||window;return t instanceof i.MouseEvent||t instanceof i.WheelEvent},t.d=P,t.d0=class{constructor(t,e){const n=(e=this.options=Object.assign({},sh,e)).debug;if(n&&console.time("preprocess data"),e.maxZoom<0||e.maxZoom>24)throw new Error("maxZoom should be in the 0-24 range");if(e.promoteId&&e.generateId)throw new Error("promoteId and generateId cannot be used together.");let r=pc(t,e);n&&(console.timeEnd("preprocess data"),console.log("index: maxZoom: %d, maxPoints: %d",e.indexMaxZoom,e.indexMaxPoints),console.time("generate tiles")),r=Lc(r,e),e.updateable&&(this.source=r),this.initializeIndex(r,e);}initializeIndex(t,e){this.tileIndex=e.cluster?new qc(e.clusterOptions):new rh(e),t.length&&this.tileIndex.initialize(t);}getTile(t,e,n){return e=+e,n=+n,(t=+t)<0||t>24?null:this.tileIndex.getTile(t,e,n)}updateData(t,e){const n=this.options;if(!n.updateable)throw new Error("to update tile geojson `updateable` option must be set to true");let{affected:r,source:i}=function(t,e,n){const r=function(t,e){return t?{removeAll:t.removeAll,remove:new Set(t.remove||[]),add:new Map(t.add?.map((t=>[e.promoteId?t.properties[e.promoteId]:t.id,t]))),update:new Map(t.update?.map((t=>[t.id,t])))}:{remove:new Set,add:new Map,update:new Map}}(e,n);let i=[];if(r.removeAll&&(i=t,t=[]),r.remove.size||r.add.size){const e=[];for(const n of t)(r.remove.has(n.id)||r.add.has(n.id))&&e.push(n);if(e.length){i.push(...e);const n=new Set(e.map((t=>t.id)));t=t.filter((t=>!n.has(t.id)));}if(r.add.size){let e=pc({type:"FeatureCollection",features:Array.from(r.add.values())},n);e=Lc(e,n),i.push(...e),t.push(...e);}}if(r.update.size){const e=new Map,s=[];for(const n of t)r.update.has(n.id)?e.set(n.id,[...e.get(n.id)||[],n]):s.push(n);for(const[t,o]of r.update){const r=e.get(t);if(!r||0===r.length)continue;const a=Nc(r,o,n);i.push(...r,...a),s.push(...a);}t=s;}return {affected:i,source:t}}(this.source,t,n);e&&({affected:r,source:i}=this.filterUpdate(i,r,e)),r.length&&(this.source=i,this.tileIndex.updateIndex(i,r,n));}filterUpdate(t,e,n){const r=new Set;for(const i of t)null!=i.id&&(n(xc(i))||(e.push(i),r.add(i.id)));return {affected:e,source:t=t.filter((t=>!r.has(t.id)))}}getData(){if(!this.options.updateable)throw new Error("to retrieve data the `updateable` option must be set to true");return {type:"FeatureCollection",features:this.source.map((t=>xc(t)))}}updateClusterOptions(t,e){const n=this.options.cluster;this.options.cluster=t,this.options.clusterOptions=e,n!=t?this.initializeIndex(this.source,this.options):this.tileIndex.updateIndex(this.source,[],this.options);}getClusterExpansionZoom(t){return this.tileIndex.getClusterExpansionZoom(t)}getClusterChildren(t){return this.tileIndex.getChildren(t)}getClusterLeaves(t,e,n){return this.tileIndex.getLeaves(t,e,n)}},t.d1=Bs,t.e=$,t.f=yt,t.g=ct,t.h=t=>e(void 0,void 0,void 0,(function*(){if(0===t.byteLength)return createImageBitmap(new ImageData(1,1));const e=new Blob([new Uint8Array(t)],{type:"image/png"});try{return createImageBitmap(e)}catch(t){throw new Error(`Could not load image because of ${P(t).message}. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.`)}})),t.i=Y,t.j=t=>new Promise(((e,n)=>{const r=new Image;r.onload=()=>{e(r),URL.revokeObjectURL(r.src),r.onload=null,window.requestAnimationFrame((()=>r.src=W));},r.onerror=()=>n(new Error("Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported."));const i=new Blob([new Uint8Array(t)],{type:"image/png"});r.src=t.byteLength?URL.createObjectURL(i):W;})),t.k=(t,e)=>dt($(t,{type:"json"}),e),t.l=vt,t.m=dt,t.n=xt,t.o=(t,e)=>dt($(t,{type:"arrayBuffer"}),e),t.p=ep,t.q=function(t){return new Rh(t).readFields(Jh,[])},t.r=function(t){return /[\u02EA\u02EB\u1100-\u11FF\u2E80-\u2FDF\u3000-\u30FF\u3105-\u312F\u3131-\u318E\u31A0-\u4DBF\u4E00-\uA48C\uA490-\uA4C6\uA960-\uA97C\uAC00-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFE10-\uFE1F\uFE30-\uFE4F\uFF00-\uFFEF]|\uD81B[\uDFE0-\uDFFF]|[\uD81C-\uD822\uD840-\uD868\uD86A-\uD86D\uD86F-\uD872\uD874-\uD879\uD880-\uD883\uD885-\uD88C][\uDC00-\uDFFF]|\uD823[\uDC00-\uDCD5\uDCFF-\uDD1E\uDD80-\uDDF2]|\uD82B[\uDFF0-\uDFFF]|\uD82C[\uDC00-\uDEFB]|\uD83C[\uDE00-\uDEFF]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEAD\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0\uDFF0-\uDFFF]|\uD87B[\uDC00-\uDE5D]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A\uDF50-\uDFFF]|\uD88D[\uDC00-\uDC79]/gim.test(String.fromCodePoint(t))},t.s=tt,t.t=kl,t.u=Ks,t.v=ss,t.w=G,t.x=wt,t.y=Rs,t.z=as;})); + +define("worker",["./shared"],(function(e){"use strict";class t{constructor(e,t){this.keyCache={},e&&this.replace(e,t);}replace(e,t){this._layerConfigs={},this._layers={},this.update(e,[],t);}update(t,i,s){for(const i of t){this._layerConfigs[i.id]=i;const t=this._layers[i.id]=e.bT(i,s);t._featureFilter=e.ah(t.filter,s),this.keyCache[i.id]&&delete this.keyCache[i.id];}for(const e of i)delete this.keyCache[e],delete this._layerConfigs[e],delete this._layers[e];this.familiesBySource={};const r=e.cJ(Object.values(this._layerConfigs),this.keyCache);for(const t of r){const i=t.map((e=>this._layers[e.id])),s=i[0];if(s.isHidden())continue;const r=s.source||"";let o=this.familiesBySource[r];o||(o=this.familiesBySource[r]={});const n=s.sourceLayer||e.ag;let a=o[n];a||(a=o[n]=[]),a.push(i);}}}class i{constructor(t){const i={},s=[];for(const e in t){const r=t[e],o=i[e]={};for(const e in r){const t=r[+e];if(!t||0===t.bitmap.width||0===t.bitmap.height)continue;const i={x:0,y:0,w:t.bitmap.width+2,h:t.bitmap.height+2};s.push(i),o[e]={rect:i,metrics:t.metrics};}}const{w:r,h:o}=e.p(s),n=new e.t({width:r||1,height:o||1});for(const s in t){const r=t[s];for(const t in r){const o=r[+t];if(!o||0===o.bitmap.width||0===o.bitmap.height)continue;const a=i[s][t].rect;e.t.copy(o.bitmap,n,{x:0,y:0},{x:a.x+1,y:a.y+1},o.bitmap);}}this.image=n,this.positions=i;}}e.cK("GlyphAtlas",i);class s{constructor(t){this.tileID=new e.a3(t.tileID.overscaledZ,t.tileID.wrap,t.tileID.canonical.z,t.tileID.canonical.x,t.tileID.canonical.y),this.uid=t.uid,this.zoom=t.zoom,this.pixelRatio=t.pixelRatio,this.tileSize=t.tileSize,this.source=t.source,this.overscaling=this.tileID.overscaleFactor(),this.showCollisionBoxes=t.showCollisionBoxes,this.collectResourceTiming=!!t.collectResourceTiming,this.returnDependencies=!!t.returnDependencies,this.promoteId=t.promoteId,this.inFlightDependencies=[];}parse(t,s,o,n,a){return e._(this,void 0,void 0,(function*(){this.status="parsing",this.data=t,this.collisionBoxArray=new e.ae;const l=new e.cL(Object.keys(t.layers).sort()),c=new e.cM(this.tileID,this.promoteId);c.bucketLayerIDs=[];const h={},d={featureIndex:c,iconDependencies:{},patternDependencies:{},glyphDependencies:{},dashDependencies:{},availableImages:o,subdivisionGranularity:a},u=s.familiesBySource[this.source];for(const i in u){const s=t.layers[i];if(!s)continue;1===s.version&&e.w(`Vector tile source "${this.source}" layer "${i}" does not use vector tile spec v2 and therefore may have some rendering errors.`);const n=l.encode(i),a=[];for(let e=0;ee.id))));}}const g=e.bY(d.glyphDependencies,(e=>Object.keys(e).map(Number)));for(const e of this.inFlightDependencies)null==e||e.abort();this.inFlightDependencies=[];let p=Promise.resolve({});if(Object.keys(g).length){const e=new AbortController;this.inFlightDependencies.push(e),p=n.sendAsync({type:"GG",data:{stacks:g,source:this.source,tileID:this.tileID,type:"glyphs"}},e);}const f=Object.keys(d.iconDependencies);let y=Promise.resolve({});if(f.length){const e=new AbortController;this.inFlightDependencies.push(e),y=n.sendAsync({type:"GI",data:{icons:f,source:this.source,tileID:this.tileID,type:"icons"}},e);}const v=Object.keys(d.patternDependencies);let m=Promise.resolve({});if(v.length){const e=new AbortController;this.inFlightDependencies.push(e),m=n.sendAsync({type:"GI",data:{icons:v,source:this.source,tileID:this.tileID,type:"patterns"}},e);}const _=d.dashDependencies;let S=Promise.resolve({});if(Object.keys(_).length){const e=new AbortController;this.inFlightDependencies.push(e),S=n.sendAsync({type:"GDA",data:{dashes:_}},e);}const[w,b,I,T]=yield Promise.all([p,y,m,S]),k=new i(w),x=new e.cN(b,I);for(const t in h){const i=h[t];i instanceof e.af?(r(i.layers,this.zoom,o),e.cO({bucket:i,glyphMap:w,glyphPositions:k.positions,imageMap:b,imagePositions:x.iconPositions,showCollisionBoxes:this.showCollisionBoxes,canonical:this.tileID.canonical,subdivisionGranularity:d.subdivisionGranularity})):i.hasDependencies&&(i instanceof e.cP||i instanceof e.cQ||i instanceof e.cR)&&(r(i.layers,this.zoom,o),i.addFeatures(d,this.tileID.canonical,x.patternPositions,T));}return this.status="done",{buckets:Object.values(h).filter((e=>!e.isEmpty())),featureIndex:c,collisionBoxArray:this.collisionBoxArray,glyphAtlasImage:k.image,imageAtlas:x,dashPositions:T,glyphMap:this.returnDependencies?w:null,iconMap:this.returnDependencies?b:null,glyphPositions:this.returnDependencies?k.positions:null}}))}}function r(t,i,s){const r=new e.J(i);for(const e of t)e.recalculate(r,s);}class o{constructor(){this.loading={},this.loaded={},this.parsing={};}startLoading(e,t){this.loading[e]=t;}finishLoading(e){delete this.loading[e];}abort(e){const t=this.loading[e];(null==t?void 0:t.abort)&&(t.abort.abort(),delete this.loading[e]);}setParsing(e,t){this.parsing[e]=t;}consumeParsing(e){const t=this.parsing[e];if(t)return delete this.parsing[e],t}clearParsing(e){delete this.parsing[e];}markLoaded(e,t){this.loaded[e]=t;}getLoaded(e){const t=this.loaded[e];if(t)return t}removeLoaded(e){delete this.loaded[e];}clearLoaded(){this.loaded={};}}class n{constructor(e){this.start=`${e}#start`,this.end=`${e}#end`,this.measure=e,performance.mark(this.start);}finish(){performance.mark(this.end);let e=performance.getEntriesByName(this.measure);return 0===e.length&&(performance.measure(this.measure,this.start,this.end),e=performance.getEntriesByName(this.measure),performance.clearMarks(this.start),performance.clearMarks(this.end),performance.clearMeasures(this.measure)),e}}class a{constructor(e,t,i,s,r){this.type=e,this.properties=i||{},this.extent=r,this.pointsArray=t,this.id=s;}loadGeometry(){return this.pointsArray.map((t=>t.map((t=>new e.P(t.x,t.y)))))}}class l{constructor(e,t,i){this.version=2,this._myFeatures=e,this.name=t,this.length=e.length,this.extent=i;}feature(e){return this._myFeatures[e]}}class c{constructor(){this.layers={};}addLayer(e){this.layers[e.name]=e;}}function h(t){let i=e.cS(t);return 0===i.byteOffset&&i.byteLength===i.buffer.byteLength||(i=new Uint8Array(i)),{vectorTile:t,rawData:i.buffer}}function d(t,i,s){const{extent:r}=t,o=Math.pow(2,s.z-i.z),n=(s.x-i.x*o)*r,c=(s.y-i.y*o)*r,h=[];for(let i=0;i0&&u.addLayer(r);}const p=h(u);return this.overzoomedTileResultCache.set(a,p),p}reloadTile(t){return e._(this,void 0,void 0,(function*(){const e=t.uid,i=this.tileState.getLoaded(e);if(!i)throw new Error("Should not be trying to reload a tile that was never loaded or has been removed");if(i.showCollisionBoxes=t.showCollisionBoxes,"parsing"===i.status){const s=this.tileState.consumeParsing(e);return yield this._parseWorkerTile(i,t,s)}if("done"===i.status&&i.vectorTile)return yield this._parseWorkerTile(i,t)}))}abortTile(t){return e._(this,void 0,void 0,(function*(){this.tileState.abort(t.uid);}))}removeTile(t){return e._(this,void 0,void 0,(function*(){this.tileState.removeLoaded(t.uid);}))}}class g{constructor(){this.loaded={};}loadTile(t){return e._(this,void 0,void 0,(function*(){const{uid:i,encoding:s,rawImageData:r,redFactor:o,greenFactor:n,blueFactor:a,baseShift:l}=t,c=r.width+2,h=r.height+2,d=e.b(r)?new e.R({width:c,height:h},yield e.cY(r,-1,-1,c,h)):r,u=new e.cZ(i,d,s,o,n,a,l);return this.loaded||(this.loaded={}),this.loaded[i]=u,u}))}removeTile(e){const t=this.loaded,i=e.uid;(null==t?void 0:t[i])&&delete t[i];}}class p{constructor(e,t,i,s=f){this.actor=e,this.layerIndex=t,this.availableImages=i,this.tileState=new o,this._createGeoJSONIndex=s;}loadVectorTile(t){if(!this._geoJSONIndex)throw new Error("Unable to parse the data into a cluster or geojson");const{z:i,x:s,y:r}=t.tileID.canonical,o=this._geoJSONIndex.getTile(i,s,r);return o?h(new e.c_(o.features,{version:2,extent:e.a6})):null}loadTile(t){return e._(this,void 0,void 0,(function*(){const{uid:e}=t,i=new s(t);i.abort=new AbortController;try{const s=this.loadVectorTile(t);if(!s)return null;const{vectorTile:r,rawData:o}=s;i.vectorTile=r,this.tileState.markLoaded(e,i);const n={rawData:o};this.tileState.setParsing(e,n);try{return yield this._parseWorkerTile(i,t,n)}finally{this.tileState.clearParsing(e);}}catch(t){throw i.status="done",this.tileState.markLoaded(e,i),t}}))}_reloadLoadedTile(t){return e._(this,void 0,void 0,(function*(){const e=t.uid,i=this.tileState.getLoaded(e);if(!i)throw new Error("Should not be trying to reload a tile that was never loaded or has been removed");if(i.showCollisionBoxes=t.showCollisionBoxes,"parsing"===i.status){const s=this.tileState.consumeParsing(e);return yield this._parseWorkerTile(i,t,s)}if("done"===i.status&&i.vectorTile)return yield this._parseWorkerTile(i,t)}))}_parseWorkerTile(t,i,s){return e._(this,void 0,void 0,(function*(){let r=yield t.parse(t.vectorTile,this.layerIndex,this.availableImages,this.actor,i.subdivisionGranularity);if(s){const{rawData:t}=s;r=e.e({rawTileData:t.slice(0),encoding:"mvt"},r);}return r}))}abortTile(t){return e._(this,void 0,void 0,(function*(){this.tileState.abort(t.uid);}))}removeTile(t){return e._(this,void 0,void 0,(function*(){this.tileState.removeLoaded(t.uid);}))}loadData(t){return e._(this,void 0,void 0,(function*(){var i;null===(i=this._pendingRequest)||void 0===i||i.abort();const s=this._startRequestTiming(t);this._pendingRequest=new AbortController;try{yield this.loadAndProcessGeoJSON(t,this._pendingRequest),delete this._pendingRequest,this.tileState.clearLoaded();const e={};return t.request&&(e.data=t.data),this._finishRequestTiming(s,t,e),e}catch(t){if(delete this._pendingRequest,!e.$(t))throw t;return {abandoned:!0}}}))}_startRequestTiming(e){var t;if(null===(t=e.request)||void 0===t?void 0:t.collectResourceTiming)return new n(e.request.url)}_finishRequestTiming(e,t,i){const s=null==e?void 0:e.finish();s&&(i.resourceTiming={[t.source]:JSON.parse(JSON.stringify(s))});}reloadTile(e){return this.tileState.getLoaded(e.uid)?this._reloadLoadedTile(e):this.loadTile(e)}loadAndProcessGeoJSON(t,i){return e._(this,void 0,void 0,(function*(){var s;if(t.request&&(t.data=(yield e.k(t.request,i)).data),t.data)return t.data=this._filterGeoJSON(t.data,t.filter),void(this._geoJSONIndex=this._createGeoJSONIndex(t.data,t));if(t.dataDiff)return null!==(s=this._geoJSONIndex)&&void 0!==s||(this._geoJSONIndex=this._createGeoJSONIndex({type:"FeatureCollection",features:[]},t)),void this._geoJSONIndex.updateData(t.dataDiff,this._getFilterPredicate(t.filter));if(t.updateCluster&&this._geoJSONIndex.updateClusterOptions(t.geojsonVtOptions.cluster,y(t)),null==this._geoJSONIndex)throw new Error(`Input data given to '${t.source}' is not a valid GeoJSON object.`)}))}_filterGeoJSON(e,t){if("FeatureCollection"!==e.type)return e;const i=this._getFilterPredicate(t);return i?{type:"FeatureCollection",features:e.features.filter((e=>i(e)))}:e}_getFilterPredicate(t){if("boolean"!=typeof t&&!(null==t?void 0:t.length))return;const i=e.c$(t,{type:"boolean","property-type":"data-driven",overridable:!1,transition:!1});if("error"===i.result)throw new Error(i.value.map((e=>`${e.key}: ${e.message}`)).join(", "));return e=>i.value.evaluate({zoom:0},e)}removeSource(t){return e._(this,void 0,void 0,(function*(){var e;null===(e=this._pendingRequest)||void 0===e||e.abort();}))}getClusterExpansionZoom(e){return this._geoJSONIndex.getClusterExpansionZoom(e.clusterId)}getClusterChildren(e){return this._geoJSONIndex.getClusterChildren(e.clusterId)}getClusterLeaves(e){return this._geoJSONIndex.getClusterLeaves(e.clusterId,e.limit,e.offset)}}function f(t,i){const s=e.e(i.geojsonVtOptions||{},{updateable:!0,clusterOptions:y(i)});return new e.d0(t,s)}function y({geojsonVtOptions:t,clusterProperties:i}){if(!i||!t.clusterOptions)return t.clusterOptions;const s={},r={},o={accumulated:null,zoom:0},n={properties:null},a=Object.keys(i);for(const t of a){const[o,n]=i[t],a=e.c$(n),l=e.c$("string"==typeof o?[o,["accumulated"],["get",t]]:o);s[t]=a.value,r[t]=l.value;}return t.clusterOptions.map=e=>{n.properties=e;const t={};for(const e of a)t[e]=s[e].evaluate(o,n);return t},t.clusterOptions.reduce=(e,t)=>{n.properties=t;for(const t of a)o.accumulated=e[t],e[t]=r[t].evaluate(o,n);},t.clusterOptions}class v{constructor(t){this.self=t,this.actor=new e.N(t),this.layerIndexes={},this.availableImages={},this.workerSources={},this.demWorkerSources={},this.externalWorkerSourceTypes={},this.globalStates=new Map,this.self.registerWorkerSource=(e,t)=>{if(this.externalWorkerSourceTypes[e])throw new Error(`Worker source with name "${e}" already registered.`);this.externalWorkerSourceTypes[e]=t;},this.self.addProtocol=e.cH,this.self.removeProtocol=e.cI,this.self.registerRTLTextPlugin=t=>{e.d1.setMethods(t);},this.self.makeRequest=e.m,this.actor.registerMessageHandler("LDT",((e,t)=>this._getDEMWorkerSource(e,t.source).loadTile(t))),this.actor.registerMessageHandler("RDT",((t,i)=>e._(this,void 0,void 0,(function*(){this._getDEMWorkerSource(t,i.source).removeTile(i);})))),this.actor.registerMessageHandler("GCEZ",((t,i)=>e._(this,void 0,void 0,(function*(){return this._getWorkerSource(t,i.type,i.source).getClusterExpansionZoom(i)})))),this.actor.registerMessageHandler("GCC",((t,i)=>e._(this,void 0,void 0,(function*(){return this._getWorkerSource(t,i.type,i.source).getClusterChildren(i)})))),this.actor.registerMessageHandler("GCL",((t,i)=>e._(this,void 0,void 0,(function*(){return this._getWorkerSource(t,i.type,i.source).getClusterLeaves(i)})))),this.actor.registerMessageHandler("LD",((e,t)=>this._getWorkerSource(e,t.type,t.source).loadData(t))),this.actor.registerMessageHandler("LT",((e,t)=>this._getWorkerSource(e,t.type,t.source).loadTile(t))),this.actor.registerMessageHandler("RT",((e,t)=>this._getWorkerSource(e,t.type,t.source).reloadTile(t))),this.actor.registerMessageHandler("AT",((e,t)=>this._getWorkerSource(e,t.type,t.source).abortTile(t))),this.actor.registerMessageHandler("RMT",((e,t)=>this._getWorkerSource(e,t.type,t.source).removeTile(t))),this.actor.registerMessageHandler("RS",((t,i)=>e._(this,void 0,void 0,(function*(){var e,s;if(!(null===(s=null===(e=this.workerSources[t])||void 0===e?void 0:e[i.type])||void 0===s?void 0:s[i.source]))return;const r=this.workerSources[t][i.type][i.source];delete this.workerSources[t][i.type][i.source],void 0!==r.removeSource&&r.removeSource(i);})))),this.actor.registerMessageHandler("RM",(t=>e._(this,void 0,void 0,(function*(){delete this.layerIndexes[t],delete this.availableImages[t],delete this.workerSources[t],delete this.demWorkerSources[t],this.globalStates.delete(t);})))),this.actor.registerMessageHandler("SR",((t,i)=>e._(this,void 0,void 0,(function*(){this.referrer=i;})))),this.actor.registerMessageHandler("SRPS",((e,t)=>this._syncRTLPluginState(e,t))),this.actor.registerMessageHandler("IS",((t,i)=>e._(this,void 0,void 0,(function*(){this.self.importScripts(i);})))),this.actor.registerMessageHandler("SI",((e,t)=>this._setImages(e,t))),this.actor.registerMessageHandler("UL",((t,i)=>e._(this,void 0,void 0,(function*(){this._getLayerIndex(t).update(i.layers,i.removedIds,this._getGlobalState(t));})))),this.actor.registerMessageHandler("UGS",((t,i)=>e._(this,void 0,void 0,(function*(){const e=this._getGlobalState(t);for(const t in i)e[t]=i[t];})))),this.actor.registerMessageHandler("SL",((t,i)=>e._(this,void 0,void 0,(function*(){this._getLayerIndex(t).replace(i,this._getGlobalState(t));}))));}_getGlobalState(e){let t=this.globalStates.get(e);return t||(t={},this.globalStates.set(e,t)),t}_setImages(t,i){return e._(this,void 0,void 0,(function*(){this.availableImages[t]=i;for(const e in this.workerSources[t]){const s=this.workerSources[t][e];for(const e in s)s[e].availableImages=i;}}))}_syncRTLPluginState(t,i){return e._(this,void 0,void 0,(function*(){return yield e.d1.syncState(i,this.self.importScripts)}))}_getAvailableImages(e){let t=this.availableImages[e];return t||(t=[]),t}_getLayerIndex(e){let i=this.layerIndexes[e];return i||(i=this.layerIndexes[e]=new t),i}_getWorkerSource(e,t,i){var s,r;if((s=this.workerSources)[e]||(s[e]={}),(r=this.workerSources[e])[t]||(r[t]={}),!this.workerSources[e][t][i]){const s={sendAsync:(t,i)=>(t.targetMapId=e,this.actor.sendAsync(t,i))};switch(t){case "vector":this.workerSources[e][t][i]=new u(s,this._getLayerIndex(e),this._getAvailableImages(e));break;case "geojson":this.workerSources[e][t][i]=new p(s,this._getLayerIndex(e),this._getAvailableImages(e));break;default:this.workerSources[e][t][i]=new this.externalWorkerSourceTypes[t](s,this._getLayerIndex(e),this._getAvailableImages(e));}}return this.workerSources[e][t][i]}_getDEMWorkerSource(e,t){var i,s;return (i=this.demWorkerSources)[e]||(i[e]={}),(s=this.demWorkerSources[e])[t]||(s[t]=new g),this.demWorkerSources[e][t]}}return e.i(self)&&(self.worker=new v(self)),v})); + +define("index",["exports","./shared"],(function(e,t){"use strict";var i="5.24.0";function o(){var e=new t.A(4);return t.A!=Float32Array&&(e[1]=0,e[2]=0),e[0]=1,e[3]=1,e}let a,r,s;const n={frame(e,i,o,a){const r=a||window,s=r.requestAnimationFrame((e=>{n(),i(e);})),{unsubscribe:n}=t.s(e.signal,"abort",(()=>{n(),r.cancelAnimationFrame(s),o(new t.a(e.signal.reason));}),!1);},frameAsync(e,t){return new Promise(((i,o)=>{this.frame(e,i,o,t);}))},getImageData(e,t=0){return this.getImageCanvasContext(e).getImageData(-t,-t,e.width+2*t,e.height+2*t)},getImageCanvasContext(e){const t=window.document.createElement("canvas"),i=t.getContext("2d",{willReadFrequently:!0});if(!i)throw new Error("failed to create canvas 2d context");return t.width=e.width,t.height=e.height,i.drawImage(e,0,0,e.width,e.height),i},resolveURL:e=>(a||(a=document.createElement("a")),a.href=e,a.href),hardwareConcurrency:"undefined"!=typeof navigator&&navigator.hardwareConcurrency||4,get prefersReducedMotion(){return void 0!==s?s:!!matchMedia&&(null!=r||(r=matchMedia("(prefers-reduced-motion: reduce)")),r.matches)},set prefersReducedMotion(e){s=e;}},l=new class{constructor(){this._frozenAt=null;}getCurrentTime(){return null!==this._frozenAt?this._frozenAt:performance.now()}setNow(e){this._frozenAt=e;}restoreNow(){this._frozenAt=null;}isFrozen(){return null!==this._frozenAt}};function c(){return l.getCurrentTime()}var h,u;class d{static create(e,t,i){const o=window.document.createElement(e);return void 0!==t&&(o.className=t),i&&i.appendChild(o),o}static createNS(e,t){return window.document.createElementNS(e,t)}static disableDrag(){d.docStyle&&d.selectProp&&(d.userSelect=d.docStyle[d.selectProp],d.docStyle[d.selectProp]="none");}static enableDrag(){d.docStyle&&d.selectProp&&(d.docStyle[d.selectProp]=d.userSelect);}static suppressClickInternal(e){e.preventDefault(),e.stopPropagation(),window.removeEventListener("click",d.suppressClickInternal,!0);}static suppressClick(){window.addEventListener("click",d.suppressClickInternal,!0),window.setTimeout((()=>{window.removeEventListener("click",d.suppressClickInternal,!0);}),0);}static getScale(e){const t=e.getBoundingClientRect();return {x:t.width/e.offsetWidth||1,y:t.height/e.offsetHeight||1,boundingClientRect:t}}static getPoint(e,i,o){const a=i.boundingClientRect;return new t.P((o.clientX-a.left)/i.x-e.clientLeft,(o.clientY-a.top)/i.y-e.clientTop)}static mousePos(e,t){const i=d.getScale(e);return d.getPoint(e,i,t)}static touchPos(e,t){const i=[],o=d.getScale(e);for(const a of t)i.push(d.getPoint(e,o,a));return i}static sanitize(e){const t=(new DOMParser).parseFromString(e,"text/html").body||document.createElement("body"),i=t.querySelectorAll("script");for(const e of i)e.remove();return d.clean(t),t.innerHTML}static isPossiblyDangerous(e,t){const i=t.replace(/\s+/g,"").toLowerCase();return !(!["src","href","xlink:href"].includes(e)||!i.includes("javascript:")&&!i.includes("data:"))||!!e.startsWith("on")||void 0}static clean(e){const t=e.children;for(const e of t)d.removeAttributes(e),d.clean(e);}static removeAttributes(e){for(const{name:t,value:i}of e.attributes)d.isPossiblyDangerous(t,i)&&e.removeAttribute(t);}}d.docStyle="undefined"!=typeof window&&(null===(h=window.document)||void 0===h?void 0:h.documentElement.style),d.selectProp=!d.docStyle||"userSelect"in d.docStyle?"userSelect":"webkitUserSelect",function(e){let i,o,a,r;e.resetRequestQueue=()=>{i=[],o=0,a=0,r={};},e.addThrottleControl=e=>{const t=a++;return r[t]=e,t},e.removeThrottleControl=e=>{delete r[e],n();},e.getImage=(e,o,a=!0)=>new Promise(((r,s)=>{e.headers||(e.headers={}),e.headers.accept="image/webp,*/*",t.e(e,{type:"image"}),i.push({abortController:o,requestParameters:e,supportImageRefresh:a,state:"queued",onError:e=>{s(e);},onSuccess:e=>{r(e);}}),n();}));const s=e=>t._(this,void 0,void 0,(function*(){e.state="running";const{requestParameters:i,supportImageRefresh:a,onError:r,onSuccess:s,abortController:c}=e,h=!1===a&&!t.i(self)&&!t.g(i.url)&&(!i.headers||Object.keys(i.headers).reduce(((e,t)=>e&&"accept"===t),!0));o++;const u=h?l(i,c):t.m(i,c);try{const i=yield u;delete e.abortController,e.state="completed",i.data instanceof HTMLImageElement||t.b(i.data)?s(i):i.data&&s({data:yield(d=i.data,"function"==typeof createImageBitmap?t.h(d):t.j(d)),cacheControl:i.cacheControl,expires:i.expires});}catch(i){delete e.abortController,r(t.d(i));}finally{o--,n();}var d;})),n=()=>{const e=(()=>{for(const e of Object.keys(r))if(r[e]())return !0;return !1})()?t.c.MAX_PARALLEL_IMAGE_REQUESTS_PER_FRAME:t.c.MAX_PARALLEL_IMAGE_REQUESTS;for(let t=o;t0;t++){const e=i.shift();e.abortController.signal.aborted?t--:s(e);}},l=(e,i)=>new Promise(((o,a)=>{const r=new Image,s=e.url,n=e.credentials;n&&"include"===n?r.crossOrigin="use-credentials":(n&&"same-origin"===n||!t.f(s))&&(r.crossOrigin="anonymous"),i.signal.addEventListener("abort",(()=>{r.src="",a(new t.a(i.signal.reason));})),r.fetchPriority="high",r.onload=()=>{r.onerror=r.onload=null,o({data:r});},r.onerror=()=>{r.onerror=r.onload=null,i.signal.aborted||a(new Error("Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported."));},r.src=s;}));}(u||(u={})),u.resetRequestQueue();class _{constructor(e){this._transformRequestFn=null!=e?e:null;}transformRequest(e,t){return this._transformRequestFn&&this._transformRequestFn(e,t)||{url:e}}setTransformRequest(e){this._transformRequestFn=e;}}function p(e){const t=[];if("string"==typeof e)t.push({id:"default",url:e});else if(e&&e.length>0){const i=[];for(const{id:o,url:a}of e){const e=`${o}${a}`;i.includes(e)||(i.push(e),t.push({id:o,url:a}));}}return t}function m(e,t,i){try{const o=new URL(e);return o.pathname+=`${t}${i}`,o.toString()}catch(t){throw new Error(`Invalid sprite URL "${e}", must be absolute. Modify style specification directly or use TransformStyleFunction to correct the issue dynamically`)}}function f(e){const{userImage:t}=e;return !(!(null==t?void 0:t.render)||!t.render()||(e.data.replace(new Uint8Array(t.data.buffer)),0))}class g extends t.E{constructor(){super(),this.images={},this.updatedImages={},this.callbackDispatchedThisFrame={},this.loaded=!1,this.requestors=[],this.patterns={},this.atlasImage=new t.R({width:1,height:1}),this.dirty=!0;}destroy(){this.atlasTexture&&(this.atlasTexture.destroy(),this.atlasTexture=null);for(const e of Object.keys(this.images))this.removeImage(e);this.patterns={},this.atlasImage=new t.R({width:1,height:1}),this.dirty=!0;}isLoaded(){return this.loaded}setLoaded(e){if(this.loaded!==e&&(this.loaded=e,e)){for(const{ids:e,promiseResolve:t}of this.requestors)t(this._getImagesForIds(e));this.requestors=[];}}getImage(e){const i=this.images[e];if(i&&!i.data&&i.spriteData){const e=i.spriteData;i.data=new t.R({width:e.width,height:e.height},e.context.getImageData(e.x,e.y,e.width,e.height).data),i.spriteData=null;}return i}addImage(e,t){if(this.images[e])throw new Error(`Image id ${e} already exist, use updateImage instead`);this._validate(e,t)&&(this.images[e]=t);}_validate(e,i){let o=!0;const a=i.data||i.spriteData;return this._validateStretch(i.stretchX,null==a?void 0:a.width)||(this.fire(new t.l(new Error(`Image "${e}" has invalid "stretchX" value`))),o=!1),this._validateStretch(i.stretchY,null==a?void 0:a.height)||(this.fire(new t.l(new Error(`Image "${e}" has invalid "stretchY" value`))),o=!1),this._validateContent(i.content,i)||(this.fire(new t.l(new Error(`Image "${e}" has invalid "content" value`))),o=!1),o}_validateStretch(e,t){if(!e)return !0;let i=0;for(const o of e){if(o[0]=e[1]))}updateImage(e,t,i=!0){const o=this.getImage(e);if(i&&(o.data.width!==t.data.width||o.data.height!==t.data.height))throw new Error(`size mismatch between old image (${o.data.width}x${o.data.height}) and new image (${t.data.width}x${t.data.height}).`);t.version=o.version+1,this.images[e]=t,this.updatedImages[e]=!0;}removeImage(e){var t;const i=this.images[e];delete this.images[e],delete this.patterns[e],(null===(t=i.userImage)||void 0===t?void 0:t.onRemove)&&i.userImage.onRemove();}listImages(){return Object.keys(this.images)}getImages(e){return new Promise(((t,i)=>{let o=!0;if(!this.isLoaded())for(const t of e)this.images[t]||(o=!1);this.isLoaded()||o?t(this._getImagesForIds(e)):this.requestors.push({ids:e,promiseResolve:t});}))}_getImagesForIds(e){var i;const o={};for(const a of e){let e=this.getImage(a);e||(this.fire(new t.n("styleimagemissing",{id:a})),e=this.getImage(a)),e?o[a]={data:e.data.clone(),pixelRatio:e.pixelRatio,sdf:e.sdf,version:e.version,stretchX:e.stretchX,stretchY:e.stretchY,content:e.content,textFitWidth:e.textFitWidth,textFitHeight:e.textFitHeight,hasRenderCallback:Boolean(null===(i=e.userImage)||void 0===i?void 0:i.render)}:t.w(`Image "${a}" could not be loaded. Please make sure you have added the image with map.addImage() or a "sprite" property in your style. You can provide missing images by listening for the "styleimagemissing" map event.`);}return o}getPixelSize(){const{width:e,height:t}=this.atlasImage;return {width:e,height:t}}getPattern(e){const i=this.patterns[e],o=this.getImage(e);if(!o)return null;if(i&&i.position.version===o.version)return i.position;if(i)i.position.version=o.version;else {const i={w:o.data.width+2,h:o.data.height+2,x:0,y:0},a=new t.I(i,o);this.patterns[e]={bin:i,position:a};}return this._updatePatternAtlas(),this.patterns[e].position}bind(e){const i=e.gl;this.atlasTexture?this.dirty&&(this.atlasTexture.update(this.atlasImage),this.dirty=!1):this.atlasTexture=new t.T(e,this.atlasImage,i.RGBA),this.atlasTexture.bind(i.LINEAR,i.CLAMP_TO_EDGE);}_updatePatternAtlas(){const e=[];for(const t in this.patterns)e.push(this.patterns[t].bin);const{w:i,h:o}=t.p(e),a=this.atlasImage;a.resize({width:i||1,height:o||1});for(const e in this.patterns){const{bin:i}=this.patterns[e],o=i.x+1,r=i.y+1,s=this.getImage(e).data,n=s.width,l=s.height;t.R.copy(s,a,{x:0,y:0},{x:o,y:r},{width:n,height:l}),t.R.copy(s,a,{x:0,y:l-1},{x:o,y:r-1},{width:n,height:1}),t.R.copy(s,a,{x:0,y:0},{x:o,y:r+l},{width:n,height:1}),t.R.copy(s,a,{x:n-1,y:0},{x:o-1,y:r},{width:1,height:l}),t.R.copy(s,a,{x:0,y:0},{x:o+n,y:r},{width:1,height:l});}this.dirty=!0;}beginFrame(){this.callbackDispatchedThisFrame={};}dispatchRenderCallbacks(e){for(const i of e){if(this.callbackDispatchedThisFrame[i])continue;this.callbackDispatchedThisFrame[i]=!0;const e=this.getImage(i);e||t.w(`Image with ID: "${i}" was not found`),f(e)&&this.updateImage(i,e);}}cloneImages(){const e={};for(const t in this.images){const i=this.images[t];e[t]=Object.assign(Object.assign({},i),{data:i.data?i.data.clone():null});}return e}}const v=1e20,x=new Float64Array(256);for(let e=0;e<256;e++){const t=.5-Math.pow(e/255,1/2.2);x[e]=t*Math.abs(t);}function b(e,t,i,o,a,r,s,n,l){for(let c=t;c-1);l++,r[l]=n,s[l]=c,s[l+1]=v;}for(let n=0,l=0;n/[-\w]+/.test(e)?e:`'${CSS.escape(e)}'`)).join(",");return new T.TinySDF({fontSize:48,buffer:6,radius:16,cutoff:.25,fontFamily:i,fontWeight:this._fontWeight(t[0]),fontStyle:this._fontStyle(t[0]),lang:this.lang})}_fontStyle(e){return /italic/i.test(e)?"italic":/oblique/i.test(e)?"oblique":"normal"}_fontWeight(e){const t={thin:100,hairline:100,"extra light":200,"ultra light":200,light:300,normal:400,regular:400,medium:500,semibold:600,demibold:600,bold:700,"extra bold":800,"ultra bold":800,black:900,heavy:900,"extra black":950,"ultra black":950};let i;for(const[o,a]of Object.entries(t))new RegExp(`\\b${o}\\b`,"i").test(e)&&(i=`${a}`);return i}destroy(){for(const e in this.entries){const t=this.entries[e];t.tinySDF=null,t.ideographTinySDF=null,t.glyphs={},t.requests={},t.ranges={};}this.entries={};}}T.loadGlyphRange=function(e,i,o,a){return t._(this,void 0,void 0,(function*(){const r=256*i,s=r+255,n=yield a.transformRequest(o.replace("{fontstack}",e).replace("{range}",`${r}-${s}`),"Glyphs"),l=yield t.o(n,new AbortController);if(!(null==l?void 0:l.data))throw new Error(`Could not load glyph range. range: ${i}, ${r}-${s}`);const c={};for(const e of t.q(l.data))c[e.id]=e;return c}))},T.TinySDF=class{constructor({fontSize:e=24,buffer:t=3,radius:i=8,cutoff:o=.25,fontFamily:a="sans-serif",fontWeight:r="normal",fontStyle:s="normal",lang:n=null}={}){this.buffer=t,this.radius=i,this.cutoff=o,this.lang=n;const l=this.size=e+4*t,c=this._createCanvas(l),h=this.ctx=c.getContext("2d",{willReadFrequently:!0});h.font=`${s} ${r} ${e}px ${a}`,h.textBaseline="alphabetic",h.textAlign="left",h.fillStyle="black",this.gridOuter=new Float64Array(l*l),this.gridInner=new Float64Array(l*l),this.f=new Float64Array(l),this.z=new Float64Array(l+1),this.v=new Uint16Array(l);}_createCanvas(e){if("undefined"!=typeof OffscreenCanvas)return new OffscreenCanvas(e,e);const t=document.createElement("canvas");return t.width=t.height=e,t}draw(e){const{width:t,actualBoundingBoxAscent:i,actualBoundingBoxDescent:o,actualBoundingBoxLeft:a,actualBoundingBoxRight:r}=this.ctx.measureText(e),s=Math.ceil(i),n=Math.floor(a),l=Math.max(0,Math.min(this.size-this.buffer,Math.ceil(r)-n)),c=Math.max(0,Math.min(this.size-this.buffer,s+Math.ceil(o))),h=l+2*this.buffer,u=c+2*this.buffer,d=Math.max(h*u,0),_=new Uint8ClampedArray(d),p={data:_,width:h,height:u,glyphWidth:l,glyphHeight:c,glyphTop:s,glyphLeft:n,glyphAdvance:t};if(0===l||0===c)return p;const{ctx:m,buffer:f,gridInner:g,gridOuter:y}=this;this.lang&&(m.lang=this.lang),m.clearRect(f,f,l,c),m.fillText(e,f-n,f+s);const w=m.getImageData(f,f,l,c);y.fill(v,0,d),g.fill(0,0,d);let T=3;for(let e=0;e1&&(s=e[++r]);const l=Math.abs(n-s.left),c=Math.abs(n-s.right),h=Math.min(l,c);let u;const d=t/i*(o+1);if(s.isDash){const e=o-Math.abs(d);u=Math.sqrt(h*h+e*e);}else u=o-Math.sqrt(h*h+d*d);this.data[a+n]=Math.max(0,Math.min(255,u+128));}}}addRegularDash(e){for(let t=e.length-1;t>=0;--t){const i=e[t],o=e[t+1];i.zeroLength?e.splice(t,1):o&&o.isDash===i.isDash&&(o.left=i.left,e.splice(t,1));}const t=e[0],i=e[e.length-1];t.isDash===i.isDash&&(t.left=i.left-this.width,i.right=t.right+this.width);const o=this.width*this.nextRow;let a=0,r=e[a];for(let t=0;t1&&(r=e[++a]);const i=Math.abs(t-r.left),s=Math.abs(t-r.right),n=Math.min(i,s);this.data[o+t]=Math.max(0,Math.min(255,(r.isDash?n:-n)+128));}}addDash(e,i){const o=i?7:0,a=2*o+1;if(this.nextRow+a>this.height)return t.w("LineAtlas out of space"),null;let r=0;for(const t of e)r+=t;if(0!==r){const t=this.width/r,a=this.getDashRanges(e,this.width,t);i?this.addRoundDash(a,t,o):this.addRegularDash(a);}const s={y:this.nextRow+o,height:2*o,width:r};return this.nextRow+=a,this.dirty=!0,s}bind(e){const t=e.gl;this.texture?(t.bindTexture(t.TEXTURE_2D,this.texture),this.dirty&&(this.dirty=!1,t.texSubImage2D(t.TEXTURE_2D,0,0,0,this.width,this.height,t.ALPHA,t.UNSIGNED_BYTE,this.data))):(this.texture=t.createTexture(),t.bindTexture(t.TEXTURE_2D,this.texture),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_S,t.REPEAT),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_WRAP_T,t.REPEAT),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MIN_FILTER,t.LINEAR),t.texParameteri(t.TEXTURE_2D,t.TEXTURE_MAG_FILTER,t.LINEAR),t.texImage2D(t.TEXTURE_2D,0,t.ALPHA,this.width,this.height,0,t.ALPHA,t.UNSIGNED_BYTE,this.data));}}const z="maplibre_preloaded_worker_pool";class R{constructor(){this.active={};}acquire(e){if(!this.workers)for(this.workers=[];this.workers.lengtht.m(i,o)))),L}function O(e,i){const o=t.O();return t.Q(o,o,[1,1,0]),t.S(o,o,[.5*e.width,.5*e.height,1]),e.calculatePosMatrix?t.U(o,o,e.calculatePosMatrix(i.toUnwrapped())):o}function j(e,t,i,o,a,r,s){var n;const l=function(e,t,i){if(e)for(const o of e){const e=t[o];if((null==e?void 0:e.source)===i&&"fill-extrusion"===e.type)return !0}else for(const e in t){const o=t[e];if(o.source===i&&"fill-extrusion"===o.type)return !0}return !1}(null!==(n=null==a?void 0:a.layers)&&void 0!==n?n:null,t,e.id),c=r.maxPitchScaleFactor(),h=e.tilesIn(o,c,l);h.sort(N);const u=[];for(const o of h)u.push({wrappedTileID:o.tileID.wrapped().key,queryResults:o.tile.queryRenderedFeatures(t,i,e.getState(),o.queryGeometry,o.cameraQueryGeometry,o.scale,a,r,c,O(r,o.tileID),s?(e,t)=>s(o.tileID,e,t):void 0)});return function(e,t){for(const i in e)for(const o of e[i])Z(o,t);return e}(function(e){const t={},i={};for(const{queryResults:o,wrappedTileID:a}of e){i[a]||(i[a]={});const e=i[a];for(const i in o){const a=o[i];e[i]||(e[i]={});const r=e[i];t[i]||(t[i]=[]);for(const e of a)r[e.featureIndex]||(r[e.featureIndex]=!0,t[i].push(e));}}return t}(u),e)}function N(e,t){const i=e.tileID,o=t.tileID;return i.overscaledZ-o.overscaledZ||i.canonical.y-o.canonical.y||i.wrap-o.wrap||i.canonical.x-o.canonical.x}function Z(e,t){const i=e.feature,o=t.getFeatureState(i.layer["source-layer"],i.id);i.source=i.layer.source,i.layer["source-layer"]&&(i.sourceLayer=i.layer["source-layer"]),i.state=o;}function U(e,i,o,a){return t._(this,void 0,void 0,(function*(){let r=e;if(e.url?r=(yield t.k(yield i.transformRequest(e.url,"Source"),o)).data:yield n.frameAsync(o,a),!r)return null;const s=t.V(t.e(r,e),["tiles","minzoom","maxzoom","attribution","bounds","scheme","tileSize","encoding"]);return "vector_layers"in r&&r.vector_layers&&(s.vectorLayerIds=r.vector_layers.map((e=>e.id))),s}))}class G{constructor(e,t){e&&(t?this.setSouthWest(e).setNorthEast(t):Array.isArray(e)&&(4===e.length?this.setSouthWest([e[0],e[1]]).setNorthEast([e[2],e[3]]):this.setSouthWest(e[0]).setNorthEast(e[1])));}setNorthEast(e){return this._ne=e instanceof t.W?new t.W(e.lng,e.lat):t.W.convert(e),this}setSouthWest(e){return this._sw=e instanceof t.W?new t.W(e.lng,e.lat):t.W.convert(e),this}extend(e){const i=this._sw,o=this._ne;let a,r;if(e instanceof t.W)a=e,r=e;else {if(!(e instanceof G))return Array.isArray(e)?4===e.length||e.every(Array.isArray)?this.extend(G.convert(e)):this.extend(t.W.convert(e)):e&&("lng"in e||"lon"in e)&&"lat"in e?this.extend(t.W.convert(e)):this;if(a=e._sw,r=e._ne,!a||!r)return this}return i||o?(i.lng=Math.min(a.lng,i.lng),i.lat=Math.min(a.lat,i.lat),o.lng=Math.max(r.lng,o.lng),o.lat=Math.max(r.lat,o.lat)):(this._sw=new t.W(a.lng,a.lat),this._ne=new t.W(r.lng,r.lat)),this}getCenter(){return new t.W((this._sw.lng+this._ne.lng)/2,(this._sw.lat+this._ne.lat)/2)}getSouthWest(){return this._sw}getNorthEast(){return this._ne}getNorthWest(){return new t.W(this.getWest(),this.getNorth())}getSouthEast(){return new t.W(this.getEast(),this.getSouth())}getWest(){return this._sw.lng}getSouth(){return this._sw.lat}getEast(){return this._ne.lng}getNorth(){return this._ne.lat}toArray(){return [this._sw.toArray(),this._ne.toArray()]}toString(){return `LngLatBounds(${this._sw.toString()}, ${this._ne.toString()})`}isEmpty(){return !(this._sw&&this._ne)}contains(e){const{lng:i,lat:o}=t.W.convert(e);let a=this._sw.lng<=i&&i<=this._ne.lng;return this._sw.lng>this._ne.lng&&(a=this._sw.lng>=i&&i>=this._ne.lng),this._sw.lat<=o&&o<=this._ne.lat&&a}intersects(e){if(!((e=G.convert(e)).getNorth()>=this.getSouth()&&e.getSouth()<=this.getNorth()))return !1;const i=Math.abs(this.getEast()-this.getWest()),o=Math.abs(e.getEast()-e.getWest());if(i>=360||o>=360)return !0;const a=t.X(this.getWest(),-180,180),r=t.X(this.getEast(),-180,180),s=t.X(e.getWest(),-180,180),n=t.X(e.getEast(),-180,180),l=a>r,c=s>n;return !(!l||!c)||(l?n>=a||s<=r:c?r>=s||a<=n:s<=r&&n>=a)}static convert(e){return e instanceof G?e:e?new G(e):e}static fromLngLat(e,i=0){const o=360*i/40075017,a=o/Math.cos(Math.PI/180*e.lat);return new G(new t.W(e.lng-a,e.lat-o),new t.W(e.lng+a,e.lat+o))}adjustAntiMeridian(){const e=new t.W(this._sw.lng,this._sw.lat),i=new t.W(this._ne.lng,this._ne.lat);return new G(e,e.lng>i.lng?new t.W(i.lng+360,i.lat):i)}}class V{constructor(e,t,i){this.bounds=G.convert(this.validateBounds(e)),this.minzoom=t||0,this.maxzoom=i||24;}validateBounds(e){return Array.isArray(e)&&4===e.length?[Math.max(-180,e[0]),Math.max(-90,e[1]),Math.min(180,e[2]),Math.min(90,e[3])]:[-180,-90,180,90]}contains(e){const i=Math.pow(2,e.z),o=Math.floor(t.Z(this.bounds.getWest())*i),a=Math.floor(t.Y(this.bounds.getNorth())*i),r=Math.ceil(t.Z(this.bounds.getEast())*i),s=Math.ceil(t.Y(this.bounds.getSouth())*i);return e.x>=o&&e.x=a&&e.y{this._options.tiles=e;})),this}setUrl(e){return this.setSourceProperty((()=>{this.url=e,this._options.url=e;})),this}onRemove(){this._tileJSONRequest&&(this._tileJSONRequest.abort(),this._tileJSONRequest=null);}serialize(){return t.e({},this._options)}loadTile(e){return t._(this,void 0,void 0,(function*(){const t=e.tileID.canonical.url(this.tiles,this.map.getPixelRatio(),this.scheme),i={request:yield this.map._requestManager.transformRequest(t,"Tile"),uid:e.uid,tileID:e.tileID,zoom:e.tileID.overscaledZ,tileSize:this.tileSize*e.tileID.overscaleFactor(),type:this.type,source:this.id,pixelRatio:this.map.getPixelRatio(),showCollisionBoxes:this.map.showCollisionBoxes,promoteId:this.promoteId,subdivisionGranularity:this.map.style.projection.subdivisionGranularity,encoding:this.encoding,overzoomParameters:yield this._getOverzoomParameters(e),etag:e.etag};i.request.collectResourceTiming=this._collectResourceTiming;let o="RT";if(e.actor&&"expired"!==e.state){if("loading"===e.state)return new Promise(((t,i)=>{e.reloadPromise={resolve:t,reject:i};}))}else e.actor=this.dispatcher.getActor(),o="LT";e.abortController=new AbortController;try{const t=yield e.actor.sendAsync({type:o,data:i},e.abortController);if(delete e.abortController,e.aborted)return;this._afterTileLoadWorkerResponse(e,t);const a={};return (null==t?void 0:t.etagUnmodified)&&(a.unmodified=!0),a}catch(t){if(delete e.abortController,e.aborted)return;if(t&&404!==t.status)throw t;this._afterTileLoadWorkerResponse(e,null);}}))}_getOverzoomParameters(e){return t._(this,void 0,void 0,(function*(){if(e.tileID.canonical.z<=this.maxzoom)return;if(void 0===this.map._zoomLevelsToOverscale)return;const t=e.tileID.scaledTo(this.maxzoom).canonical,i=t.url(this.tiles,this.map.getPixelRatio(),this.scheme);return {maxZoomTileID:t,overzoomRequest:yield this.map._requestManager.transformRequest(i,"Tile")}}))}_afterTileLoadWorkerResponse(e,t){if((null==t?void 0:t.resourceTiming)&&(e.resourceTiming=t.resourceTiming),t&&this.map._refreshExpiredTiles&&e.setExpiryData(t),e.etag=null==t?void 0:t.etag,e.loadVectorData(t,this.map.painter),e.reloadPromise){const t=e.reloadPromise;e.reloadPromise=null,this.loadTile(e).then(t.resolve).catch(t.reject);}}abortTile(e){return t._(this,void 0,void 0,(function*(){e.abortController&&(e.abortController.abort(),delete e.abortController),e.actor&&(yield e.actor.sendAsync({type:"AT",data:{uid:e.uid,type:this.type,source:this.id}}));}))}unloadTile(e){return t._(this,void 0,void 0,(function*(){e.unloadVectorData(),e.actor&&(yield e.actor.sendAsync({type:"RMT",data:{uid:e.uid,type:this.type,source:this.id}}));}))}hasTransition(){return !1}}class q extends t.E{constructor(e,i,o,a){super(),this.id=e,this.dispatcher=o,this.setEventedParent(a),this.type="raster",this.minzoom=0,this.maxzoom=22,this.roundZoom=!0,this.scheme="xyz",this.tileSize=512,this._loaded=!1,this._options=t.e({type:"raster"},i),t.e(this,t.V(i,["url","scheme","tileSize"]));}load(){return t._(this,arguments,void 0,(function*(e=!1){this._loaded=!1,this.fire(new t.n("dataloading",{dataType:"source"})),this._tileJSONRequest=new AbortController;try{const i=yield U(this._options,this.map._requestManager,this._tileJSONRequest,this.map._ownerWindow);this._tileJSONRequest=null,this._loaded=!0,i&&(t.e(this,i),i.bounds&&(this.tileBounds=new V(i.bounds,this.minzoom,this.maxzoom)),this.fire(new t.n("data",{dataType:"source",sourceDataType:"metadata"})),this.fire(new t.n("data",{dataType:"source",sourceDataType:"content",sourceDataChanged:e})));}catch(e){this._tileJSONRequest=null,this._loaded=!0,t.$(e)||this.fire(new t.l(t.d(e)));}}))}loaded(){return this._loaded}onAdd(e){this.map=e,this.load();}onRemove(){this._tileJSONRequest&&(this._tileJSONRequest.abort(),this._tileJSONRequest=null);}setSourceProperty(e){this._tileJSONRequest&&(this._tileJSONRequest.abort(),this._tileJSONRequest=null),e(),this.load(!0);}setTiles(e){return this.setSourceProperty((()=>{this._options.tiles=e;})),this}setUrl(e){return this.setSourceProperty((()=>{this.url=e,this._options.url=e;})),this}serialize(){return t.e({},this._options)}hasTile(e){return !this.tileBounds||this.tileBounds.contains(e.canonical)}loadTile(e){return t._(this,void 0,void 0,(function*(){const i=e.tileID.canonical.url(this.tiles,this.map.getPixelRatio(),this.scheme);e.abortController=new AbortController;try{const o=yield u.getImage(yield this.map._requestManager.transformRequest(i,"Tile"),e.abortController,this.map._refreshExpiredTiles);if(delete e.abortController,e.aborted)return void(e.state="unloaded");if(null==o?void 0:o.data){this.map._refreshExpiredTiles&&(o.cacheControl||o.expires)&&e.setExpiryData({cacheControl:o.cacheControl,expires:o.expires});const i=this.map.painter.context,a=i.gl,r=o.data;e.texture=this.map.painter.getTileTexture(r.width),e.texture?e.texture.update(r,{useMipmap:!0}):(e.texture=new t.T(i,r,a.RGBA,{useMipmap:!0}),e.texture.bind(a.LINEAR,a.CLAMP_TO_EDGE,a.LINEAR_MIPMAP_NEAREST)),e.state="loaded";}}catch(t){if(delete e.abortController,e.aborted)e.state="unloaded";else if(t)throw e.state="errored",t}}))}abortTile(e){return t._(this,void 0,void 0,(function*(){e.abortController&&(e.abortController.abort(),delete e.abortController);}))}unloadTile(e){return t._(this,void 0,void 0,(function*(){e.texture&&this.map.painter.saveTileTexture(e.texture);}))}hasTransition(){return !1}}class $ extends q{constructor(e,i,o,a){super(e,i,o,a),this.type="raster-dem",this.maxzoom=22,this._options=t.e({type:"raster-dem"},i),this.encoding=i.encoding||"mapbox",this.redFactor=i.redFactor,this.greenFactor=i.greenFactor,this.blueFactor=i.blueFactor,this.baseShift=i.baseShift;}loadTile(e){return t._(this,void 0,void 0,(function*(){const i=e.tileID.canonical.url(this.tiles,this.map.getPixelRatio(),this.scheme),o=yield this.map._requestManager.transformRequest(i,"Tile");e.neighboringTiles=this._getNeighboringTiles(e.tileID),e.abortController=new AbortController;try{const i=yield u.getImage(o,e.abortController,this.map._refreshExpiredTiles);if(delete e.abortController,e.aborted)return void(e.state="unloaded");if(null==i?void 0:i.data){const o=i.data;this.map._refreshExpiredTiles&&(i.cacheControl||i.expires)&&e.setExpiryData({cacheControl:i.cacheControl,expires:i.expires});const a=t.b(o)&&t.a0()?o:yield this.readImageNow(o),r={type:this.type,uid:e.uid,source:this.id,rawImageData:a,encoding:this.encoding,redFactor:this.redFactor,greenFactor:this.greenFactor,blueFactor:this.blueFactor,baseShift:this.baseShift};if(e.actor&&"expired"!==e.state&&"reloading"!==e.state)return;e.actor&&"expired"!==e.state||(e.actor=this.dispatcher.getActor()),e.dem=yield e.actor.sendAsync({type:"LDT",data:r}),e.needsHillshadePrepare=!0,e.needsTerrainPrepare=!0,e.state="loaded";}}catch(t){if(delete e.abortController,e.aborted)e.state="unloaded";else if(t)throw e.state="errored",t}}))}readImageNow(e){return t._(this,void 0,void 0,(function*(){if("undefined"!=typeof VideoFrame&&t.a1()){const i=e.width+2,o=e.height+2;try{return new t.R({width:i,height:o},yield t.a2(e,-1,-1,i,o))}catch(e){}}return n.getImageData(e,1)}))}_getNeighboringTiles(e){const i=e.canonical,o=Math.pow(2,i.z),a=(i.x-1+o)%o,r=0===i.x?e.wrap-1:e.wrap,s=(i.x+1+o)%o,n=i.x+1===o?e.wrap+1:e.wrap,l={};return l[new t.a3(e.overscaledZ,r,i.z,a,i.y).key]={backfilled:!1},l[new t.a3(e.overscaledZ,n,i.z,s,i.y).key]={backfilled:!1},i.y>0&&(l[new t.a3(e.overscaledZ,r,i.z,a,i.y-1).key]={backfilled:!1},l[new t.a3(e.overscaledZ,e.wrap,i.z,i.x,i.y-1).key]={backfilled:!1},l[new t.a3(e.overscaledZ,n,i.z,s,i.y-1).key]={backfilled:!1}),i.y+1e.key===i));t>-1&&e.addOrUpdateProperties.splice(t,1);}return (e.removeAllProperties||t.removeAllProperties)&&(i.removeAllProperties=!0),(e.removeProperties||t.removeProperties)&&(i.removeProperties=[...e.removeProperties||[],...t.removeProperties||[]]),(e.addOrUpdateProperties||t.addOrUpdateProperties)&&(i.addOrUpdateProperties=[...e.addOrUpdateProperties||[],...t.addOrUpdateProperties||[]]),(e.newGeometry||t.newGeometry)&&(i.newGeometry=t.newGeometry||e.newGeometry),i}function K(e){var t,i;if(!e)return {};const o={};return o.removeAll=e.removeAll,o.remove=new Set(e.remove||[]),o.add=new Map(null===(t=e.add)||void 0===t?void 0:t.map((e=>[e.id,e]))),o.update=new Map(null===(i=e.update)||void 0===i?void 0:i.map((e=>[e.id,e]))),o}function Y(e){return e&&0!==e.length?"number"==typeof e[0]?[e]:e.flatMap((e=>Y(e))):[]}function Q(e){return "GeometryCollection"===e.type?e.geometries.flatMap((e=>Q(e))):Y(e.coordinates)}function J(e){const t=new G;let i;switch(e.type){case "FeatureCollection":i=e.features.flatMap((e=>Q(e.geometry)));break;case "Feature":i=Q(e.geometry);break;default:i=Q(e);}if(0===i.length)return t;for(const e of i){const[i,o]=e;t.extend([i,o]);}return t}class ee extends t.E{constructor(e,i,o,a){super(),this.id=e,this.type="geojson",this.minzoom=0,this.maxzoom=18,this.tileSize=512,this.isTileClipped=!0,this.reparseOverscaled=!0,this._removed=!1,this._isUpdatingWorker=!1,this._pendingWorkerUpdate={data:i.data},this.actor=o.getActor(),this.setEventedParent(a),this._data="string"==typeof i.data?{url:i.data}:{geojson:i.data},this._options=t.e({},i),this._collectResourceTiming=i.collectResourceTiming,void 0!==i.maxzoom&&(this.maxzoom=i.maxzoom),i.type&&(this.type=i.type),i.attribution&&(this.attribution=i.attribution),this.promoteId=i.promoteId,void 0!==i.clusterMaxZoom&&this.maxzoom<=i.clusterMaxZoom&&t.w(`The maxzoom value "${this.maxzoom}" is expected to be greater than the clusterMaxZoom value "${i.clusterMaxZoom}".`),this.workerOptions=t.e({source:this.id,geojsonVtOptions:{buffer:this._pixelsToTileUnits(void 0!==i.buffer?i.buffer:128),tolerance:this._pixelsToTileUnits(void 0!==i.tolerance?i.tolerance:.375),extent:t.a6,maxZoom:this.maxzoom,lineMetrics:i.lineMetrics||!1,generateId:i.generateId||!1,promoteId:"string"==typeof i.promoteId?i.promoteId:void 0,cluster:i.cluster||!1,clusterOptions:{maxZoom:this._getClusterMaxZoom(i.clusterMaxZoom),minPoints:Math.max(2,i.clusterMinPoints||2),extent:t.a6,radius:this._pixelsToTileUnits(i.clusterRadius||50),log:!1,generateId:i.generateId||!1}},clusterProperties:i.clusterProperties,filter:i.filter},i.workerOptions);}_hasPendingWorkerUpdate(){return void 0!==this._pendingWorkerUpdate.data||void 0!==this._pendingWorkerUpdate.diff||this._pendingWorkerUpdate.updateCluster}_pixelsToTileUnits(e){return e*(t.a6/this.tileSize)}_getClusterMaxZoom(e){const i=e?Math.round(e):this.maxzoom-1;return Number.isInteger(e)||void 0===e||t.w(`Integer expected for option 'clusterMaxZoom': provided value "${e}" rounded to "${i}"`),i}load(){return t._(this,void 0,void 0,(function*(){yield this._updateWorkerData();}))}onAdd(e){this.map=e,this.load();}setData(e,t){this._data="string"==typeof e?{url:e}:{geojson:e},this._pendingWorkerUpdate={data:e};const i=this._updateWorkerData();return t?i:this}updateData(e,t){this._pendingWorkerUpdate.diff=function(e,t){if(!e)return t||{};if(!t)return e||{};const i=K(e),o=K(t);!function(e,t){t.removeAll&&(e.add.clear(),e.update.clear(),e.remove.clear(),t.remove.clear());for(const i of t.remove)e.add.delete(i),e.update.delete(i);for(const[i,o]of t.update){const a=e.update.get(i);a&&(t.update.set(i,X(a,o)),e.update.delete(i));}}(i,o);const a={};if((i.removeAll||o.removeAll)&&(a.removeAll=!0),a.remove=new Set([...i.remove,...o.remove]),a.add=new Map([...i.add,...o.add]),a.update=new Map([...i.update,...o.update]),a.remove.size&&a.add.size)for(const e of a.add.keys())a.remove.delete(e);return function(e){const t={};return e.removeAll&&(t.removeAll=e.removeAll),e.remove&&(t.remove=Array.from(e.remove)),e.add&&(t.add=Array.from(e.add.values())),e.update&&(t.update=Array.from(e.update.values())),t}(a)}(this._pendingWorkerUpdate.diff,e);const i=this._updateWorkerData();return t?i:this}getData(){return t._(this,void 0,void 0,(function*(){return this._data.url&&(yield this.once("data")),this._data.geojson?this._data.geojson:{type:"FeatureCollection",features:Array.from(this._data.updateable.values())}}))}getBounds(){return t._(this,void 0,void 0,(function*(){return J(yield this.getData())}))}setClusterOptions(e){return this.workerOptions.geojsonVtOptions.cluster=e.cluster,void 0!==e.clusterRadius&&(this.workerOptions.geojsonVtOptions.clusterOptions.radius=this._pixelsToTileUnits(e.clusterRadius)),void 0!==e.clusterMaxZoom&&(this.workerOptions.geojsonVtOptions.clusterOptions.maxZoom=this._getClusterMaxZoom(e.clusterMaxZoom)),this._pendingWorkerUpdate.updateCluster=!0,this._updateWorkerData(),this}getClusterExpansionZoom(e){return this.actor.sendAsync({type:"GCEZ",data:{type:this.type,clusterId:e,source:this.id}})}getClusterChildren(e){return this.actor.sendAsync({type:"GCC",data:{type:this.type,clusterId:e,source:this.id}})}getClusterLeaves(e,t,i){return this.actor.sendAsync({type:"GCL",data:{type:this.type,source:this.id,clusterId:e,limit:t,offset:i}})}_updateWorkerData(){return t._(this,void 0,void 0,(function*(){if(this._isUpdatingWorker)return;if(!this._hasPendingWorkerUpdate())return void t.w(`No pending worker updates for GeoJSONSource ${this.id}.`);const{data:e,diff:i,updateCluster:o}=this._pendingWorkerUpdate,a=this._getLoadGeoJSONParameters(e,i,o);void 0!==e?this._pendingWorkerUpdate.data=void 0:i?this._pendingWorkerUpdate.diff=void 0:o&&(this._pendingWorkerUpdate.updateCluster=void 0),yield this._dispatchWorkerUpdate(a);}))}_getLoadGeoJSONParameters(e,i,o){return t._(this,void 0,void 0,(function*(){const a=t.e({type:this.type},this.workerOptions);return "string"==typeof e?(a.request=yield this.map._requestManager.transformRequest(n.resolveURL(e),"Source"),a.request.collectResourceTiming=this._collectResourceTiming,a):void 0!==e?(a.data=e,a):i?(a.dataDiff=i,a):o?(a.updateCluster=!0,a):void 0}))}_dispatchWorkerUpdate(e){return t._(this,void 0,void 0,(function*(){this._isUpdatingWorker=!0,this.fire(new t.n("dataloading",{dataType:"source"}));try{const i=yield e,o=yield this.actor.sendAsync({type:"LD",data:i});if(this._isUpdatingWorker=!1,this._removed||o.abandoned)return void this.fire(new t.n("dataabort",{dataType:"source"}));o.data&&(this._data={geojson:o.data});const a=this._applyDiffToSource(i.dataDiff),r=this._getShouldReloadTileOptions(a),s={dataType:"source"};this._applyResourceTiming(s,o),this.fire(new t.n("data",Object.assign(Object.assign({},s),{sourceDataType:"metadata"}))),this.fire(new t.n("data",Object.assign(Object.assign({},s),{sourceDataType:"content",shouldReloadTileOptions:r})));}catch(e){if(this._isUpdatingWorker=!1,this._removed)return void this.fire(new t.n("dataabort",{dataType:"source"}));this.fire(new t.l(t.d(e)));}finally{this._hasPendingWorkerUpdate()&&this._updateWorkerData();}}))}_applyResourceTiming(e,i){var o;if(!this._collectResourceTiming)return;const a=null===(o=i.resourceTiming)||void 0===o?void 0:o[this.id];if(!a)return;const r=a.slice(0);(null==r?void 0:r.length)&&t.e(e,{resourceTiming:r});}_applyDiffToSource(e){if(!e)return;const t="string"==typeof this.promoteId?this.promoteId:void 0;if(!this._data.url&&!this._data.updateable){const e=function(e,t){const i=new Map;if(null==e)return i;if(null==e.type)return i;if("Feature"===e.type){const o=H(e,t);if(null==o)return;return i.set(o,e),i}if("FeatureCollection"===e.type){const o=new Set;for(const a of e.features){const e=H(a,t);if(null==e)return;if(o.has(e))return;o.add(e),i.set(e,a);}return i}}(this._data.geojson,t);if(!e)throw new Error(`GeoJSONSource "${this.id}": GeoJSON data is not compatible with updateData`);this._data={updateable:e};}if(!this._data.updateable)return;const i=function(e,t,i){var o,a;const r=[];if(t.removeAll)e.clear();else if(t.remove)for(const i of t.remove){const t=e.get(i);t&&(r.push(t.geometry),e.delete(i));}if(t.add)for(const o of t.add){const t=H(o,i);if(null==t)continue;const a=e.get(t);a&&r.push(a.geometry),r.push(o.geometry),e.set(t,o);}if(t.update)for(const i of t.update){const t=e.get(i.id);if(!t)continue;const s=!!i.newGeometry,n=i.removeAllProperties||(null===(o=i.removeProperties)||void 0===o?void 0:o.length)>0||(null===(a=i.addOrUpdateProperties)||void 0===a?void 0:a.length)>0;if(!s&&!n)continue;r.push(t.geometry);const l=Object.assign({},t);if(e.set(i.id,l),s&&(r.push(i.newGeometry),l.geometry=i.newGeometry),n){if(l.properties=i.removeAllProperties?{}:Object.assign({},l.properties||{}),i.removeProperties)for(const e of i.removeProperties)delete l.properties[e];if(i.addOrUpdateProperties)for(const{key:e,value:t}of i.addOrUpdateProperties)l.properties[e]=t;}}return r}(this._data.updateable,e,t);return e.removeAll||this._options.cluster?void 0:i}_getShouldReloadTileOptions(e){if(e)return {affectedBounds:e.filter(Boolean).map((e=>J(e)))}}shouldReloadTile(e,{affectedBounds:i}){if("loading"===e.state)return !0;if("unloaded"===e.state)return !1;const{buffer:o,extent:a}=this.workerOptions.geojsonVtOptions,r=function({x:e,y:i,z:o},a=0){const r=t.a4((e-a)/Math.pow(2,o)),s=t.a5((i+1+a)/Math.pow(2,o)),n=t.a4((e+1+a)/Math.pow(2,o)),l=t.a5((i-a)/Math.pow(2,o));return new G([r,s],[n,l])}(e.tileID.canonical,o/a);for(const e of i)if(r.intersects(e))return !0;return !1}loaded(){return !this._isUpdatingWorker&&!this._hasPendingWorkerUpdate()}loadTile(e){return t._(this,void 0,void 0,(function*(){const t=e.actor?"RT":"LT";e.actor=this.actor;const i={type:this.type,uid:e.uid,tileID:e.tileID,zoom:e.tileID.overscaledZ,maxZoom:this.maxzoom,tileSize:this.tileSize,source:this.id,pixelRatio:this.map.getPixelRatio(),showCollisionBoxes:this.map.showCollisionBoxes,promoteId:this.promoteId,subdivisionGranularity:this.map.style.projection.subdivisionGranularity};e.abortController=new AbortController;const o=yield this.actor.sendAsync({type:t,data:i},e.abortController);delete e.abortController,e.unloadVectorData(),e.aborted||e.loadVectorData(o,this.map.painter,"RT"===t);}))}abortTile(e){return t._(this,void 0,void 0,(function*(){e.abortController&&(e.abortController.abort(),delete e.abortController),e.aborted=!0;}))}unloadTile(e){return t._(this,void 0,void 0,(function*(){e.unloadVectorData(),yield this.actor.sendAsync({type:"RMT",data:{uid:e.uid,type:this.type,source:this.id}});}))}onRemove(){this._removed=!0,this.actor.sendAsync({type:"RS",data:{type:this.type,source:this.id}});}serialize(){return t.e({},this._options,{type:this.type,data:this._data.updateable?{type:"FeatureCollection",features:Array.from(this._data.updateable.values())}:this._data.url||this._data.geojson})}hasTransition(){return !1}}class te extends t.E{constructor(e,t,i,o){super(),this.flippedWindingOrder=!1,this.id=e,this.dispatcher=i,this.coordinates=t.coordinates,this.type="image",this.minzoom=0,this.maxzoom=22,this.tileSize=512,this.tiles={},this._loaded=!1,this.setEventedParent(o),this.options=t;}load(e){return t._(this,void 0,void 0,(function*(){this._loaded=!1,this.fire(new t.n("dataloading",{dataType:"source"})),this.url=this.options.url,this._request=new AbortController;try{const t=yield u.getImage(yield this.map._requestManager.transformRequest(this.url,"Image"),this._request);this._request=null,this._loaded=!0,(null==t?void 0:t.data)&&(this.image=t.data,e&&(this.coordinates=e),this._finishLoading());}catch(e){this._request=null,this._loaded=!0,t.$(e)||this.fire(new t.l(t.d(e)));}}))}loaded(){return this._loaded}updateImage(e){return e.url?(this._request&&(this._request.abort(),this._request=null),this.options.url=e.url,this.load(e.coordinates).finally((()=>this.texture=null)),this):this}_finishLoading(){this.map&&(this.setCoordinates(this.coordinates),this.fire(new t.n("data",{dataType:"source",sourceDataType:"metadata"})));}onAdd(e){this.map=e,this.load();}onRemove(){this._request&&(this._request.abort(),this._request=null);}setCoordinates(e){this.coordinates=e;const i=e.map(t.a7.fromLngLat);var o;return this.tileID=function(e){const i=t.a8.fromPoints(e),o=i.width(),a=i.height(),r=Math.max(o,a),s=Math.max(0,Math.floor(-Math.log(r)/Math.LN2)),n=Math.pow(2,s);return new t.aa(s,Math.floor((i.minX+i.maxX)/2*n),Math.floor((i.minY+i.maxY)/2*n))}(i),this.terrainTileRanges=this._getOverlappingTileRanges(i),this.minzoom=this.maxzoom=this.tileID.z,this.tileCoords=i.map((e=>this.tileID.getTilePoint(e)._round())),this.flippedWindingOrder=((o=this.tileCoords)[1].x-o[0].x)*(o[2].y-o[0].y)-(o[1].y-o[0].y)*(o[2].x-o[0].x)<0,this.fire(new t.n("data",{dataType:"source",sourceDataType:"content"})),this}prepare(){if(0===Object.keys(this.tiles).length||!this.image)return;const e=this.map.painter.context,i=e.gl;this.texture||(this.texture=new t.T(e,this.image,i.RGBA),this.texture.bind(i.LINEAR,i.CLAMP_TO_EDGE));let o=!1;for(const e in this.tiles){const t=this.tiles[e];"loaded"!==t.state&&(t.state="loaded",t.texture=this.texture,o=!0);}o&&this.fire(new t.n("data",{dataType:"source",sourceDataType:"idle",sourceId:this.id}));}loadTile(e){return t._(this,void 0,void 0,(function*(){var t;(null===(t=this.tileID)||void 0===t?void 0:t.equals(e.tileID.canonical))?(this.tiles[String(e.tileID.wrap)]=e,e.buckets={}):e.state="errored";}))}serialize(){return {type:"image",url:this.options.url,coordinates:this.coordinates}}hasTransition(){return !1}_getOverlappingTileRanges(e){const{minX:i,minY:o,maxX:a,maxY:r}=t.a8.fromPoints(e),s={};for(let e=0;e<=t.a9;e++){const t=Math.pow(2,e),n=Math.floor(i*t),l=Math.floor(o*t),c=Math.floor(a*t),h=Math.floor(r*t),u=(n%t+t)%t,d=c%t,_=Math.floor(n/t),p=Math.floor(c/t);s[e]={minWrap:_,maxWrap:p,minTileXWrapped:u,maxTileXWrapped:d,minTileY:l,maxTileY:h};}return s}}class ie extends te{constructor(e,t,i,o){super(e,t,i,o),this._onPlayingHandler=()=>{var e;null===(e=this.map)||void 0===e||e.triggerRepaint();},this.roundZoom=!0,this.type="video",this.options=t;}load(){return t._(this,void 0,void 0,(function*(){this._loaded=!1;const e=this.options;this.urls=[];for(const t of e.urls)this.urls.push((yield this.map._requestManager.transformRequest(t,"Source")).url);try{const e=yield t.ab(this.urls);if(this._loaded=!0,!e)return;this.video=e,this.video.loop=!0,this.video.addEventListener("playing",this._onPlayingHandler),this.map&&this.video.play(),this._finishLoading();}catch(e){this.fire(new t.l(t.d(e)));}}))}pause(){this.video&&this.video.pause();}play(){this.video&&this.video.play();}seek(e){if(this.video){const i=this.video.seekable;ei.end(0)?this.fire(new t.l(new t.ac(`sources.${this.id}`,null,`Playback for this video can be set only between the ${i.start(0)} and ${i.end(0)}-second mark.`))):this.video.currentTime=e;}}getVideo(){return this.video}onAdd(e){this.map||(this.map=e,this.load(),this.video&&(this.video.play(),this.setCoordinates(this.coordinates)));}onRemove(){super.onRemove(),this.video&&(this.video.removeEventListener("playing",this._onPlayingHandler),this.video.pause());}prepare(){if(0===Object.keys(this.tiles).length||this.video.readyState<2)return;const e=this.map.painter.context,i=e.gl;this.texture?this.video.paused||(this.texture.bind(i.LINEAR,i.CLAMP_TO_EDGE),i.texSubImage2D(i.TEXTURE_2D,0,0,0,i.RGBA,i.UNSIGNED_BYTE,this.video)):(this.texture=new t.T(e,this.video,i.RGBA),this.texture.bind(i.LINEAR,i.CLAMP_TO_EDGE));let o=!1;for(const e in this.tiles){const t=this.tiles[e];"loaded"!==t.state&&(t.state="loaded",t.texture=this.texture,o=!0);}o&&this.fire(new t.n("data",{dataType:"source",sourceDataType:"idle",sourceId:this.id}));}serialize(){return {type:"video",urls:this.urls,coordinates:this.coordinates}}hasTransition(){return this.video&&!this.video.paused}}class oe extends te{constructor(e,i,o,a){super(e,i,o,a),i.coordinates?Array.isArray(i.coordinates)&&4===i.coordinates.length&&!i.coordinates.some((e=>!Array.isArray(e)||2!==e.length||e.some((e=>"number"!=typeof e))))||this.fire(new t.l(new t.ac(`sources.${e}`,null,'"coordinates" property must be an array of 4 longitude/latitude array pairs'))):this.fire(new t.l(new t.ac(`sources.${e}`,null,'missing required property "coordinates"'))),i.animate&&"boolean"!=typeof i.animate&&this.fire(new t.l(new t.ac(`sources.${e}`,null,'optional "animate" property must be a boolean value'))),i.canvas?"string"==typeof i.canvas||i.canvas instanceof HTMLCanvasElement||this.fire(new t.l(new t.ac(`sources.${e}`,null,'"canvas" must be either a string representing the ID of the canvas element from which to read, or an HTMLCanvasElement instance'))):this.fire(new t.l(new t.ac(`sources.${e}`,null,'missing required property "canvas"'))),this.options=i,this.animate=void 0===i.animate||i.animate;}load(){return t._(this,void 0,void 0,(function*(){this._loaded=!0,this.canvas||(this.canvas=this.options.canvas instanceof HTMLCanvasElement?this.options.canvas:document.getElementById(this.options.canvas)),this.width=this.canvas.width,this.height=this.canvas.height,this._hasInvalidDimensions()?this.fire(new t.l(new Error("Canvas dimensions cannot be less than or equal to zero."))):(this.play=function(){this._playing=!0,this.map.triggerRepaint();},this.pause=function(){this._playing&&(this.prepare(),this._playing=!1);},this._finishLoading());}))}getCanvas(){return this.canvas}onAdd(e){this.map=e,this.load(),this.canvas&&this.animate&&this.play();}onRemove(){this.pause();}prepare(){let e=!1;if(this.canvas.width!==this.width&&(this.width=this.canvas.width,e=!0),this.canvas.height!==this.height&&(this.height=this.canvas.height,e=!0),this._hasInvalidDimensions())return;if(0===Object.keys(this.tiles).length)return;const i=this.map.painter.context,o=i.gl;this.texture?(e||this._playing)&&this.texture.update(this.canvas,{premultiply:!0}):(this.texture=new t.T(i,this.canvas,o.RGBA,{premultiply:!0}),this.texture.bind(o.LINEAR,o.CLAMP_TO_EDGE));let a=!1;for(const e in this.tiles){const t=this.tiles[e];"loaded"!==t.state&&(t.state="loaded",t.texture=this.texture,a=!0);}a&&this.fire(new t.n("data",{dataType:"source",sourceDataType:"idle",sourceId:this.id}));}serialize(){return {type:"canvas",animate:this.animate,canvas:this.options.canvas,coordinates:this.coordinates}}hasTransition(){return this._playing}_hasInvalidDimensions(){for(const e of [this.canvas.width,this.canvas.height])if(isNaN(e)||e<=0)return !0;return !1}}const ae={},re=e=>{switch(e){case "geojson":return ee;case "image":return te;case "raster":return q;case "raster-dem":return $;case "vector":return W;case "video":return ie;case "canvas":return oe}return ae[e]},se="RTLPluginLoaded";class ne extends t.E{constructor(){super(...arguments),this.status="unavailable",this.url=null,this.dispatcher=B();}_syncState(e){return this.status=e,this.dispatcher.broadcast("SRPS",{pluginStatus:e,pluginURL:this.url}).catch((e=>{throw this.status="error",e}))}getRTLTextPluginStatus(){return this.status}clearRTLTextPlugin(){this.status="unavailable",this.url=null;}setRTLTextPlugin(e){return t._(this,arguments,void 0,(function*(e,t=!1){if(this.url)throw new Error("setRTLTextPlugin cannot be called multiple times.");if(this.url=n.resolveURL(e),!this.url)throw new Error(`requested url ${e} is invalid`);if("unavailable"===this.status){if(!t)return this._requestImport();this.status="deferred",this._syncState(this.status);}else if("requested"===this.status)return this._requestImport()}))}_requestImport(){return t._(this,void 0,void 0,(function*(){yield this._syncState("loading"),this.status="loaded",this.fire(new t.n(se));}))}lazyLoad(){"unavailable"===this.status?this.status="requested":"deferred"===this.status&&this._requestImport();}}let le=null;function ce(){return le||(le=new ne),le}var he,ue;!function(e){e[e.Base=0]="Base",e[e.Parent=1]="Parent";}(he||(he={})),function(e){e[e.Departing=0]="Departing",e[e.Incoming=1]="Incoming";}(ue||(ue={}));class de{constructor(e,i){this.timeAdded=0,this.fadeEndTime=0,this.fadeOpacity=1,this.tileID=e,this.uid=t.ad(),this.uses=0,this.tileSize=i,this.buckets={},this.expirationTime=null,this.queryPadding=0,this.hasSymbolBuckets=!1,this.hasRTLText=!1,this.dependencies={},this.rtt=[],this.rttFingerprint={},this.expiredRequestCount=0,this.state="loading";}isRenderable(e){return this.hasData()&&(!this.fadeEndTime||this.fadeOpacity>0)&&(e||!this.holdingForSymbolFade())}setCrossFadeLogic({fadingRole:e,fadingDirection:t,fadingParentID:i,fadeEndTime:o}){this.resetFadeLogic(),this.fadingRole=e,this.fadingDirection=t,this.fadingParentID=i,this.fadeEndTime=o;}setSelfFadeLogic(e){this.resetFadeLogic(),this.selfFading=!0,this.fadeEndTime=e;}resetFadeLogic(){this.fadingRole=null,this.fadingDirection=null,this.fadingParentID=null,this.selfFading=!1,this.timeAdded=c(),this.fadeEndTime=0,this.fadeOpacity=1;}wasRequested(){return "errored"===this.state||"loaded"===this.state||"reloading"===this.state}clearTextures(e){this.demTexture&&e.saveTileTexture(this.demTexture),this.demTexture=null;}loadVectorData(e,i,o){if(!0!==(null==e?void 0:e.etagUnmodified))if(this.hasData()&&this.unloadVectorData(),this.state="loaded",e){e.featureIndex&&(this.latestFeatureIndex=e.featureIndex,e.rawTileData?(this.latestRawTileData=e.rawTileData,this.latestEncoding=e.encoding,this.latestFeatureIndex.rawTileData=e.rawTileData,this.latestFeatureIndex.encoding=e.encoding):this.latestRawTileData&&(this.latestFeatureIndex.rawTileData=this.latestRawTileData,this.latestFeatureIndex.encoding=this.latestEncoding)),this.collisionBoxArray=e.collisionBoxArray,this.buckets=function(e,t){const i={};if(!t)return i;for(const o of e){const e=o.layerIds.map((e=>t.getLayer(e))).filter(Boolean);if(0!==e.length){o.layers=e,o.stateDependentLayerIds&&(o.stateDependentLayers=o.stateDependentLayerIds.map((t=>e.filter((e=>e.id===t))[0])));for(const t of e)i[t.id]=o;}}return i}(e.buckets,null==i?void 0:i.style),this.hasSymbolBuckets=!1;for(const e in this.buckets){const i=this.buckets[e];if(i instanceof t.af){if(this.hasSymbolBuckets=!0,!o)break;i.justReloaded=!0;}}if(this.hasRTLText=!1,this.hasSymbolBuckets)for(const e in this.buckets){const i=this.buckets[e];if(i instanceof t.af&&i.hasRTLText){this.hasRTLText=!0,ce().lazyLoad();break}}this.queryPadding=0;for(const e in this.buckets){const t=this.buckets[e];this.queryPadding=Math.max(this.queryPadding,i.style.getLayer(e).queryRadius(t));}e.imageAtlas&&(this.imageAtlas=e.imageAtlas),e.glyphAtlasImage&&(this.glyphAtlasImage=e.glyphAtlasImage),this.dashPositions=e.dashPositions;}else this.collisionBoxArray=new t.ae;else this.state="loaded";}unloadVectorData(){for(const e in this.buckets)this.buckets[e].destroy();this.buckets={},this.imageAtlasTexture&&this.imageAtlasTexture.destroy(),this.glyphAtlasTexture&&this.glyphAtlasTexture.destroy(),this.imageAtlas=null,this.dashPositions=null,this.latestFeatureIndex=null,this.state="unloaded";}getBucket(e){return this.buckets[e.id]}upload(e){for(const t in this.buckets){const i=this.buckets[t];i.uploadPending()&&i.upload(e);}const i=e.gl;this.imageAtlas&&!this.imageAtlas.uploaded&&(this.imageAtlasTexture=new t.T(e,this.imageAtlas.image,i.RGBA),this.imageAtlas.uploaded=!0),this.glyphAtlasImage&&(this.glyphAtlasTexture=new t.T(e,this.glyphAtlasImage,i.ALPHA),this.glyphAtlasImage=null);}prepare(e){this.imageAtlas&&this.imageAtlas.patchUpdatedImages(e,this.imageAtlasTexture);}queryRenderedFeatures(e,t,i,o,a,r,s,n,l,c,h){var u;return (null===(u=this.latestFeatureIndex)||void 0===u?void 0:u.rawTileData)?this.latestFeatureIndex.query({queryGeometry:o,cameraQueryGeometry:a,scale:r,tileSize:this.tileSize,pixelPosMatrix:c,transform:n,params:s,queryPadding:this.queryPadding*l,getElevation:h},e,t,i):{}}querySourceFeatures(e,i){const o=this.latestFeatureIndex;if(!(null==o?void 0:o.rawTileData))return;const a=o.loadVTLayers(),r=(null==i?void 0:i.sourceLayer)?i.sourceLayer:"",s=a[t.ag]||a[r];if(!s)return;const n=t.ah(null==i?void 0:i.filter,null==i?void 0:i.globalState),{z:l,x:c,y:h}=this.tileID.canonical,u={z:l,x:c,y:h};for(let i=0;ie)t=!1;else if(i)if(this.expirationTime({zoom:0,x:0,y:0,wrap:e,fullyVisible:!1}),b=[],y=[];if(e.renderWorldCopies&&n.allowWorldCopies())for(let e=1;e<=3;e++)b.push(x(-e)),b.push(x(e));for(b.push(x(0));b.length>0;){const _=b.pop(),f=_.x,x=_.y;let w=_.fullyVisible;const T={x:f,y:x,z:_.zoom},P=n.getTileBoundingVolume(T,_.wrap,e.elevation,i);if(!w){const e=ye(o,P,a);if(0===e)continue;w=2===e;}const C=n.distanceToTile2d(r.x,r.y,T,P);let M=c;l&&(M=(i.calculateTileZoom||Pe)(e.zoom+t.ar(e.tileSize/i.tileSize),C,g,v,e.fov)),M=(i.roundZoom?Math.round:Math.floor)(M),M=Math.max(0,M);const I=Math.min(M,u);if(_.wrap=n.getWrap(s,T,_.wrap),_.zoom>=I){if(_.zoom>1),wrap:_.wrap,fullyVisible:w});}return y.sort(((e,t)=>e.distanceSq-t.distanceSq)).map((e=>e.tileID))}const Ie=t.a8.fromPoints([new t.P(0,0),new t.P(t.a6,t.a6)]);function Ee(e){return "raster"===e||"image"===e||"video"===e}function Se(e,t,i,o,a,r,s){if(!t.hasData())return !1;const{tileID:n,fadingRole:l,fadingDirection:c,fadingParentID:h}=t;if(l===he.Base&&c===ue.Incoming&&h)return i[h.key]=h,!0;const u=Math.max(n.overscaledZ-a,r);for(let a=n.overscaledZ-1;a>=u;a--){const r=n.scaledTo(a),l=e.getLoadedTile(r);if(l)return t.setCrossFadeLogic({fadingRole:he.Base,fadingDirection:ue.Incoming,fadingParentID:l.tileID,fadeEndTime:o+s}),l.setCrossFadeLogic({fadingRole:he.Parent,fadingDirection:ue.Departing,fadeEndTime:o+s}),i[r.key]=r,!0}return !1}function ze(e,t,i,o,a,r){if(!t.hasData())return !1;const s=t.tileID.children(a);let n=Re(e,t,s,i,o,a,r);if(n)return !0;for(const l of s)Re(e,t,l.children(a),i,o,a,r)&&(n=!0);return n}function Re(e,t,i,o,a,r,s){if(i[0].overscaledZ>=r)return !1;let n=!1;for(const r of i){const i=e.getLoadedTile(r);if(!i)continue;const{fadingRole:l,fadingDirection:c,fadingParentID:h}=i;l===he.Base&&c===ue.Departing&&h||(i.setCrossFadeLogic({fadingRole:he.Base,fadingDirection:ue.Departing,fadingParentID:t.tileID,fadeEndTime:a+s}),t.setCrossFadeLogic({fadingRole:he.Parent,fadingDirection:ue.Incoming,fadeEndTime:a+s})),o[r.key]=r,n=!0;}return n}function De(e,t,i,o){const a=e.tileID;return !!e.selfFading||!e.hasData()&&!!t.has(a)&&(e.setSelfFadeLogic(i+o),!0)}function Ae(e,t){var i;e.needsHillshadePrepare=!0,e.needsTerrainPrepare=!0;let o=t.tileID.canonical.x-e.tileID.canonical.x;const a=t.tileID.canonical.y-e.tileID.canonical.y,r=Math.pow(2,e.tileID.canonical.z),s=t.tileID.key;0===o&&0===a||Math.abs(a)>1||(Math.abs(o)>1&&(1===Math.abs(o+r)?o+=r:1===Math.abs(o-r)&&(o-=r)),t.dem&&e.dem&&(e.dem.backfillBorder(t.dem,o,a),(null===(i=e.neighboringTiles)||void 0===i?void 0:i[s])&&(e.neighboringTiles[s].backfilled=!0)));}class Le{constructor(){this._tiles={};}handleWrapJump(e){const t={};for(const i in this._tiles){const o=this._tiles[i];o.tileID=o.tileID.unwrapTo(o.tileID.wrap+e),t[o.tileID.key]=o;}this._tiles=t;}setFeatureState(e,t){for(const i in this._tiles)this._tiles[i].setFeatureState(e,t);}getAllTiles(){return Object.values(this._tiles)}getAllIds(e=!1){return e?Object.values(this._tiles).map((e=>e.tileID)).sort(t.au).map((e=>e.key)):Object.keys(this._tiles)}getTileById(e){return this._tiles[e]}setTile(e,t){this._tiles[e]=t;}deleteTileById(e){delete this._tiles[e];}getLoadedTile(e){const t=this.getTileById(e.key);return (null==t?void 0:t.hasData())?t:null}isIdRenderable(e,t=!1){var i;return null===(i=this.getTileById(e))||void 0===i?void 0:i.isRenderable(t)}getRenderableIds(e=0,i){const o=[];for(const e of this.getAllIds())this.isIdRenderable(e,i)&&o.push(this.getTileById(e));return i?o.sort(((i,o)=>{const a=i.tileID,r=o.tileID,s=new t.P(a.canonical.x,a.canonical.y)._rotate(-e),n=new t.P(r.canonical.x,r.canonical.y)._rotate(-e);return a.overscaledZ-r.overscaledZ||n.y-s.y||n.x-s.x})).map((e=>e.tileID.key)):o.map((e=>e.tileID)).sort(t.au).map((e=>e.key))}}class Fe extends t.E{constructor(e,i,o){super(),this.id=e,this.dispatcher=o,this.on("data",(e=>{this._dataHandler(e);})),this.on("dataloading",(()=>{this._sourceErrored=!1;})),this.on("error",(()=>{this._sourceErrored=this._source.loaded();})),this._source=((e,t,i,o)=>{const a=new(re(t.type))(e,t,i,o);if(a.id!==e)throw new Error(`Expected Source id to be ${e} instead of ${a.id}`);return a})(e,i,o,this),this._inViewTiles=new Le,this._outOfViewCache=new t.av(0,(e=>this._unloadTile(e))),this._timers={},this._maxTileCacheSize=null,this._maxTileCacheZoomLevels=null,this._rasterFadeDuration=0,this._maxFadingAncestorLevels=5,this._state=new _e,this._didEmitContent=!1,this._updated=!1;}onAdd(e){var t;this.map=e,this._maxTileCacheSize=e?e._maxTileCacheSize:null,this._maxTileCacheZoomLevels=e?e._maxTileCacheZoomLevels:null,(null===(t=this._source)||void 0===t?void 0:t.onAdd)&&this._source.onAdd(e);}onRemove(e){var t;for(const e of this._inViewTiles.getAllTiles())e.unloadVectorData();this.clearTiles(),(null===(t=this._source)||void 0===t?void 0:t.onRemove)&&this._source.onRemove(e),this._inViewTiles=new Le;}loaded(){if(this._sourceErrored)return !0;if(!this._sourceLoaded)return !1;if(!this._source.loaded())return !1;if(!(void 0===this.used&&void 0===this.usedForTerrain||this.used||this.usedForTerrain))return !0;if(!this._updated)return !1;for(const e of this._inViewTiles.getAllTiles())if("loaded"!==e.state&&"errored"!==e.state)return !1;return !0}getSource(){return this._source}getState(){return this._state}pause(){this._paused=!0;}resume(){if(!this._paused)return;const e=this._shouldReloadOnResume;this._paused=!1,this._shouldReloadOnResume=!1,e&&this.reload(),this.transform&&this.update(this.transform,this.terrain);}_loadTile(e,i,o){return t._(this,void 0,void 0,(function*(){try{const t=yield this._source.loadTile(e);this._tileLoaded(e,i,o,t);}catch(i){e.state="errored",404!==i.status?this._source.fire(new t.l(t.d(i),{tile:e})):this.update(this.transform,this.terrain);}}))}_unloadTile(e){this._source.unloadTile&&this._source.unloadTile(e);}_abortTile(e){this._source.abortTile&&this._source.abortTile(e),this._source.fire(new t.n("dataabort",{tile:e,coord:e.tileID,dataType:"source"}));}serialize(){return this._source.serialize()}prepare(e){this._source.prepare&&this._source.prepare(),this._state.coalesceChanges(this._inViewTiles,this.map?this.map.painter:null);for(const t of this._inViewTiles.getAllTiles())t.upload(e),t.prepare(this.map.style.imageManager);}getIds(){return this._inViewTiles.getAllIds(!0)}getRenderableIds(e){var t;return this._inViewTiles.getRenderableIds(null===(t=this.transform)||void 0===t?void 0:t.bearingInRadians,e)}hasRenderableParent(e){const t=e.overscaledZ-1;if(t>=this._source.minzoom){const i=this.getLoadedTile(e.scaledTo(t));if(i)return this._inViewTiles.isIdRenderable(i.tileID.key)}return !1}reload(e,t=void 0){if(this._paused)this._shouldReloadOnResume=!0;else {this._outOfViewCache.reset();for(const i of this._inViewTiles.getAllIds()){const o=this._inViewTiles.getTileById(i);t&&!this._source.shouldReloadTile(o,t)||(e?this._reloadTile(i,"expired"):"errored"!==o.state&&this._reloadTile(i,"reloading"));}}}_reloadTile(e,i){return t._(this,void 0,void 0,(function*(){const t=this._inViewTiles.getTileById(e);t&&("loading"!==t.state&&(t.state=i),yield this._loadTile(t,e,i));}))}_tileLoaded(e,i,o,a){e.timeAdded=c(),e.selfFading&&(e.fadeEndTime=e.timeAdded+this._rasterFadeDuration),"expired"===o&&(e.refreshedUponExpiration=!0),this._setTileReloadTimer(i,e),(null==a?void 0:a.unmodified)||("raster-dem"===this.getSource().type&&e.dem&&function(e,t){var i,o,a;const r=t.getRenderableIds();for(const s of r){if(!(null===(i=e.neighboringTiles)||void 0===i?void 0:i[s]))continue;const r=t.getTileById(s);e.neighboringTiles[s].backfilled||Ae(e,r),(null===(a=null===(o=r.neighboringTiles)||void 0===o?void 0:o[e.tileID.key])||void 0===a?void 0:a.backfilled)||Ae(r,e);}}(e,this._inViewTiles),this._state.initializeTileState(e,this.map?this.map.painter:null),e.aborted||this._source.fire(new t.n("data",{dataType:"source",tile:e,coord:e.tileID})));}getTile(e){return this.getTileByID(e.key)}getTileByID(e){return this._inViewTiles.getTileById(e)}_retainLoadedChildren(e,t){const i=this._getLoadedDescendents(t),o=new Set;for(const a of t){const t=i[a.key];if(!(null==t?void 0:t.length)){o.add(a);continue}const r=a.overscaledZ+Fe.maxOverzooming,s=t.filter((e=>e.tileID.overscaledZ<=r));if(!s.length){o.add(a);continue}const n=Math.min(...s.map((e=>e.tileID.overscaledZ))),l=s.filter((e=>e.tileID.overscaledZ===n)).map((e=>e.tileID));for(const t of l)e[t.key]=t;this._areDescendentsComplete(l,n,a.overscaledZ)||o.add(a);}return o}_getLoadedDescendents(e){var t;const i={};for(const o of this._inViewTiles.getAllTiles().filter((e=>e.hasData())))for(const a of e)o.tileID.isChildOf(a)&&(i[t=a.key]||(i[t]=[]),i[a.key].push(o));return i}_areDescendentsComplete(e,t,i){return 1===e.length&&e[0].isOverscaled()?e[0].overscaledZ===t:Math.pow(4,t-i)===e.length}getLoadedTile(e){return this._inViewTiles.getLoadedTile(e)}updateCacheSize(e){const i=Math.ceil(e.width/this._source.tileSize)+1,o=Math.ceil(e.height/this._source.tileSize)+1,a=Math.floor(i*o*(null===this._maxTileCacheZoomLevels?t.c.MAX_TILE_CACHE_ZOOM_LEVELS:this._maxTileCacheZoomLevels)),r="number"==typeof this._maxTileCacheSize?Math.min(this._maxTileCacheSize,a):a;this._outOfViewCache.setMaxSize(r);}handleWrapJump(e){const t=Math.round((e-(void 0===this._prevLng?e:this._prevLng))/360);this._prevLng=e,t&&(this._inViewTiles.handleWrapJump(t),this._resetTileReloadTimers());}update(e,i){if(!this._sourceLoaded||this._paused)return;let o;this.transform=e,this.terrain=i,this.updateCacheSize(e),this.handleWrapJump(this.transform.center.lng),this.used||this.usedForTerrain?this._source.tileID?o=e.getVisibleUnwrappedCoordinates(this._source.tileID).map((e=>new t.a3(e.canonical.z,e.wrap,e.canonical.z,e.canonical.x,e.canonical.y))):(o=Me(e,{tileSize:this.usedForTerrain?this.tileSize:this._source.tileSize,minzoom:this._source.minzoom,maxzoom:"vector"===this._source.type&&void 0!==this.map._zoomLevelsToOverscale?e.maxZoom-this.map._zoomLevelsToOverscale:this._source.maxzoom,roundZoom:!this.usedForTerrain&&this._source.roundZoom,reparseOverscaled:this._source.reparseOverscaled,terrain:i,calculateTileZoom:this._source.calculateTileZoom}),this._source.hasTile&&(o=o.filter((e=>this._source.hasTile(e))))):o=[],this.usedForTerrain&&(o=this._addTerrainIdealTiles(o));const a=0===o.length&&!this._updated&&this._didEmitContent;this._updated=!0,a&&this.fire(new t.n("data",{sourceDataType:"idle",dataType:"source",sourceId:this.id}));const r=Ce(e,this._source),s=this._updateRetainedTiles(o,r),n=Ee(this._source.type);n&&this._rasterFadeDuration>0&&!i&&function(e,i,o,a,r,s,n){const l=c(),h=t.at(i);for(const t of i){const i=e.getTileById(t.key);i.fadingDirection!==ue.Departing&&0!==i.fadeOpacity||i.resetFadeLogic(),Se(e,i,o,l,a,r,n)||ze(e,i,o,l,s,n)||De(i,h,l,n)||i.resetFadeLogic();}}(this._inViewTiles,o,s,this._maxFadingAncestorLevels,this._source.minzoom,this._source.maxzoom,this._rasterFadeDuration),n?this._cleanUpRasterTiles(s):this._cleanUpVectorTiles(s);}_cleanUpRasterTiles(e){for(const t of this._inViewTiles.getAllIds())e[t]||this._removeTile(t);}_cleanUpVectorTiles(e){for(const t of this._inViewTiles.getAllIds()){const i=this._inViewTiles.getTileById(t);e[t]?i.clearSymbolFadeHold():i.hasSymbolBuckets?i.holdingForSymbolFade()?i.symbolFadeFinished()&&this._removeTile(t):i.setSymbolHoldDuration(this.map._fadeDuration):this._removeTile(t);}}_addTerrainIdealTiles(e){const t=[];for(const i of e)if(i.canonical.z>this._source.minzoom){const e=i.scaledTo(i.canonical.z-1);t.push(e);const o=i.scaledTo(Math.max(this._source.minzoom,Math.min(i.canonical.z,5)));t.push(o);}return e.concat(t)}releaseSymbolFadeTiles(){for(const e of this._inViewTiles.getAllIds())this._inViewTiles.getTileById(e).holdingForSymbolFade()&&this._removeTile(e);}_updateRetainedTiles(e,t){var i;const o=new Set;for(const t of e)this._addTile(t).hasData()||o.add(t);const a=e.reduce(((e,t)=>(e[t.key]=t,e)),{}),r=this._retainLoadedChildren(a,o),s={},n=Math.max(t-Fe.maxUnderzooming,this._source.minzoom);for(const e of r){let t=this._inViewTiles.getTileById(e.key),o=null==t?void 0:t.wasRequested();for(let r=e.overscaledZ-1;r>=n;--r){const n=e.scaledTo(r);if(s[n.key])break;if(s[n.key]=!0,t=this.getTile(n),!t&&o&&(t=this._addTile(n)),t){const e=t.hasData();if((e||!(null===(i=this.map)||void 0===i?void 0:i.cancelPendingTileRequestsWhileZooming)||o)&&(a[n.key]=n),o=t.wasRequested(),e)break}}}return a}_addTile(e){let i=this._inViewTiles.getTileById(e.key);if(i)return i;i=this._outOfViewCache.getAndRemove(e),i&&(i.resetFadeLogic(),this._setTileReloadTimer(e.key,i),i.tileID=e,this._state.initializeTileState(i,this.map?this.map.painter:null));const o=i;return i||(i=new de(e,this._source.tileSize*e.overscaleFactor()),this._loadTile(i,e.key,i.state)),i.uses++,this._inViewTiles.setTile(e.key,i),o||this._source.fire(new t.n("dataloading",{tile:i,coord:i.tileID,dataType:"source"})),i}_setTileReloadTimer(e,t){this._clearTileReloadTimer(e);const i=t.getExpiryTimeout();i&&(this._timers[e]=setTimeout((()=>{this._reloadTile(e,"expired"),delete this._timers[e];}),i));}_clearTileReloadTimer(e){const t=this._timers[e];t&&(clearTimeout(t),delete this._timers[e]);}_resetTileReloadTimers(){for(const e in this._timers)clearTimeout(this._timers[e]),delete this._timers[e];for(const e of this._inViewTiles.getAllIds()){const t=this._inViewTiles.getTileById(e);this._setTileReloadTimer(e,t);}}refreshTiles(e){for(const t of this._inViewTiles.getAllIds()){const i=this._inViewTiles.getTileById(t);(this._inViewTiles.isIdRenderable(t)||"errored"==i.state)&&e.some((e=>e.equals(i.tileID.canonical)))&&this._reloadTile(t,"expired");}}_removeTile(e){const t=this._inViewTiles.getTileById(e);t&&(t.uses--,this._inViewTiles.deleteTileById(e),this._clearTileReloadTimer(e),t.uses>0||(t.hasData()&&"reloading"!==t.state?this._outOfViewCache.add(t.tileID,t,t.getExpiryTimeout()):(t.aborted=!0,this._abortTile(t),this._unloadTile(t))));}_dataHandler(e){"source"===e.dataType&&("metadata"!==e.sourceDataType?"content"===e.sourceDataType&&this._sourceLoaded&&!this._paused&&(this.reload(e.sourceDataChanged,e.shouldReloadTileOptions),this.transform&&this.update(this.transform,this.terrain),this._didEmitContent=!0):this._sourceLoaded=!0);}clearTiles(){this._shouldReloadOnResume=!1,this._paused=!1;for(const e of this._inViewTiles.getAllIds())this._removeTile(e);this._outOfViewCache.reset();}tilesIn(e,i,o){const a=[],r=this.transform;if(!r)return a;const s=r.getCoveringTilesDetailsProvider().allowWorldCopies(),n=o?r.getCameraQueryGeometry(e):e,l=e=>r.screenPointToMercatorCoordinate(e,this.terrain),c=this.transformBbox(e,l,!s),h=this.transformBbox(n,l,!s),u=this.getIds(),d=t.a8.fromPoints(h);for(const e of u){const o=this._inViewTiles.getTileById(e);if(o.holdingForSymbolFade())continue;const n=s?[o.tileID]:[o.tileID.unwrapTo(-1),o.tileID.unwrapTo(0)],l=Math.pow(2,r.zoom-o.tileID.overscaledZ),u=i*o.queryPadding*t.a6/o.tileSize/l;for(const e of n){const i=d.map((i=>e.getTilePoint(new t.a7(i.x,i.y))));if(i.expandBy(u),i.intersects(Ie)){const t=c.map((t=>e.getTilePoint(t))),i=h.map((t=>e.getTilePoint(t)));a.push({tile:o,tileID:s?e:e.unwrapTo(0),queryGeometry:t,cameraQueryGeometry:i,scale:l});}}}return a}transformBbox(e,i,o){let a=e.map(i);if(o){const o=t.a8.fromPoints(e);o.shrinkBy(.001*Math.min(o.width(),o.height()));const r=o.map(i);t.a8.fromPoints(a).covers(r)||(a=a.map((e=>e.x>.5?new t.a7(e.x-1,e.y,e.z):e)));}return a}getVisibleCoordinates(e){const t=this.getRenderableIds(e).map((e=>this._inViewTiles.getTileById(e).tileID));return this.transform&&this.transform.populateCache(t),t}hasTransition(){return !!this._source.hasTransition()||Ee(this._source.type)&&function(e,t){if(t<=0)return !1;const i=c();for(const t of e.getAllTiles())if(t.fadeEndTime>=i)return !0;return !1}(this._inViewTiles,this._rasterFadeDuration)}setRasterFadeDuration(e){this._rasterFadeDuration=e;}setFeatureState(e,i,o){e||(e=t.ag),this._state.updateState(e,i,o);}removeFeatureState(e,i,o){e||(e=t.ag),this._state.removeFeatureState(e,i,o);}getFeatureState(e,i){return e||(e=t.ag),this._state.getState(e,i)}setDependencies(e,t,i){const o=this._inViewTiles.getTileById(e);o&&o.setDependencies(t,i);}reloadTilesForDependencies(e,t){for(const i of this._inViewTiles.getAllIds())this._inViewTiles.getTileById(i).hasDependency(e,t)&&this._reloadTile(i,"reloading");this._outOfViewCache.filter((i=>!i.hasDependency(e,t)));}areTilesLoaded(){for(const e of this._inViewTiles.getAllTiles())if("loaded"!==e.state&&"errored"!==e.state)return !1;return !0}}Fe.maxUnderzooming=10,Fe.maxOverzooming=3;class ke{constructor(e,t){this.reset(e,t);}reset(e,t){this.points=e||[],this._distances=[0];for(let e=1;e0?(a-s)/n:0;return this.points[r].mult(1-l).add(this.points[i].mult(l))}}function Be(e,t){let i=!0;return "always"===e||"never"!==e&&"never"!==t||(i=!1),i}class Oe{constructor(e,t,i){const o=this.boxCells=[],a=this.circleCells=[];this.xCellCount=Math.ceil(e/i),this.yCellCount=Math.ceil(t/i);for(let e=0;ethis.width||o<0||t>this.height)return [];const n=[];if(e<=0&&t<=0&&this.width<=i&&this.height<=o){if(a)return [{key:null,x1:e,y1:t,x2:i,y2:o}];for(let e=0;e0}hitTestCircle(e,t,i,o,a){const r=e-i,s=e+i,n=t-i,l=t+i;if(s<0||r>this.width||l<0||n>this.height)return !1;const c=[];return this._forEachCell(r,n,s,l,this._queryCellCircle,c,{hitTest:!0,overlapMode:o,circle:{x:e,y:t,radius:i},seenUids:{box:{},circle:{}}},a),c.length>0}_queryCell(e,t,i,o,a,r,s,n){const{seenUids:l,hitTest:c,overlapMode:h}=s,u=this.boxCells[a],d=1e-6;if(null!==u){const a=this.bboxes;for(const s of u)if(!l.box[s]){l.box[s]=!0;const u=4*s,_=this.boxKeys[s];if(e<=a[u+2]+d&&t<=a[u+3]+d&&i>=a[u+0]-d&&o>=a[u+1]-d&&(!n||n(_))&&(!c||!Be(h,_.overlapMode))&&(r.push({key:_,x1:a[u],y1:a[u+1],x2:a[u+2],y2:a[u+3]}),c))return !0}}const _=this.circleCells[a];if(null!==_){const a=this.circles;for(const s of _)if(!l.circle[s]){l.circle[s]=!0;const u=3*s,d=this.circleKeys[s];if(this._circleAndRectCollide(a[u],a[u+1],a[u+2],e,t,i,o)&&(!n||n(d))&&(!c||!Be(h,d.overlapMode))){const e=a[u],t=a[u+1],i=a[u+2];if(r.push({key:d,x1:e-i,y1:t-i,x2:e+i,y2:t+i}),c)return !0}}}return !1}_queryCellCircle(e,t,i,o,a,r,s,n){const{circle:l,seenUids:c,overlapMode:h}=s,u=this.boxCells[a];if(null!==u){const e=this.bboxes;for(const t of u)if(!c.box[t]){c.box[t]=!0;const i=4*t,o=this.boxKeys[t];if(this._circleAndRectCollide(l.x,l.y,l.radius,e[i+0],e[i+1],e[i+2],e[i+3])&&(!n||n(o))&&!Be(h,o.overlapMode))return r.push(!0),!0}}const d=this.circleCells[a];if(null!==d){const e=this.circles;for(const t of d)if(!c.circle[t]){c.circle[t]=!0;const i=3*t,o=this.circleKeys[t];if(this._circlesCollide(e[i],e[i+1],e[i+2],l.x,l.y,l.radius)&&(!n||n(o))&&!Be(h,o.overlapMode))return r.push(!0),!0}}}_forEachCell(e,t,i,o,a,r,s,n){const l=this._convertToXCellCoord(e),c=this._convertToYCellCoord(t),h=this._convertToXCellCoord(i),u=this._convertToYCellCoord(o);for(let d=l;d<=h;d++)for(let l=c;l<=u;l++)if(a.call(this,e,t,i,o,this.xCellCount*l+d,r,s,n))return}_convertToXCellCoord(e){return Math.max(0,Math.min(this.xCellCount-1,Math.floor(e*this.xScale)))}_convertToYCellCoord(e){return Math.max(0,Math.min(this.yCellCount-1,Math.floor(e*this.yScale)))}_circlesCollide(e,t,i,o,a,r){const s=o-e,n=a-t,l=i+r;return l*l>s*s+n*n}_circleAndRectCollide(e,t,i,o,a,r,s){const n=(r-o)/2,l=Math.abs(e-(o+n));if(l>n+i)return !1;const c=(s-a)/2,h=Math.abs(t-(a+c));if(h>c+i)return !1;if(l<=n||h<=c)return !0;const u=l-n,d=h-c;return u*u+d*d<=i*i}}function je(e,t){const i=1/(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]),o=1/(t[8]*t[8]+t[9]*t[9]+t[10]*t[10]),a=t[0]*i,r=t[4]*i,s=t[8]*o,n=t[1]*i,l=t[5]*i,c=t[9]*o,h=t[2]*i,u=t[6]*i,d=t[10]*o;e[0]=a,e[1]=r,e[2]=s,e[4]=n,e[5]=l,e[6]=c,e[8]=h,e[9]=u,e[10]=d;const _=t[12],p=t[13],m=t[14];return e[12]=-a*_-n*p-h*m,e[13]=-r*_-l*p-u*m,e[14]=-s*_-c*p-d*m,e[3]=0,e[7]=0,e[11]=0,e[15]=1,e}const Ne=t.O();function Ze(e,i,a){const r=t.O();if(!e){const{vecSouth:e,vecEast:t}=Ge(i),a=o();a[0]=t[0],a[1]=t[1],a[2]=e[0],a[3]=e[1],s=a,(d=(l=(n=a)[0])*(u=n[3])-(h=n[2])*(c=n[1]))&&(s[0]=u*(d=1/d),s[1]=-c*d,s[2]=-h*d,s[3]=l*d),r[0]=a[0],r[1]=a[1],r[4]=a[2],r[5]=a[3];}var s,n,l,c,h,u,d;return t.S(r,r,[1/a,1/a,1]),r}function Ue(e,i,o,a){if(e){const e=t.O();if(!i){const{vecSouth:t,vecEast:i}=Ge(o);e[0]=i[0],e[1]=i[1],e[4]=t[0],e[5]=t[1];}return t.S(e,e,[a,a,1]),e}return o.pixelsToClipSpaceMatrix}function Ge(e){const i=Math.cos(e.rollInRadians),o=Math.sin(e.rollInRadians),a=Math.cos(e.pitchInRadians),r=Math.cos(e.bearingInRadians),s=Math.sin(e.bearingInRadians),n=t.az();n[0]=-r*a*o-s*i,n[1]=-s*a*o+r*i;const l=t.aA(n);l<1e-9?t.aB(n):t.aC(n,n,1/l);const c=t.az();c[0]=r*a*i-s*o,c[1]=s*a*i+r*o;const h=t.aA(c);return h<1e-9?t.aB(c):t.aC(c,c,1/h),{vecEast:c,vecSouth:n}}function Ve(e,i,o,a){let r;a?(r=[e,i,a(e,i),1],t.aE(r,r,o)):(r=[e,i,0,1],nt(r,r,o));const s=r[3];return {point:new t.P(r[0]/s,r[1]/s),signedDistanceFromCamera:s,isOccluded:!1}}function We(e,t){return .5+e/t*.5}function qe(e,t){return e.x>=-t[0]&&e.x<=t[0]&&e.y>=-t[1]&&e.y<=t[1]}function $e(e,i,o,a,r,s,n,l,c,h,u,d,_){const p=o?e.textSizeData:e.iconSizeData,m=t.aw(p,i.transform.zoom),f=[256/i.width*2+1,256/i.height*2+1],g=o?e.text.dynamicLayoutVertexArray:e.icon.dynamicLayoutVertexArray;g.clear();const v=e.lineVertexArray,x=o?e.text.placedSymbolArray:e.icon.placedSymbolArray,b=i.transform.width/i.transform.height;let y=!1;for(let o=0;oMath.abs(o.x-i.x)*a?{useVertical:!0}:(e===t.ax.vertical?i.yo.x)?{needsFlipping:!0}:null}function Ke(e){const{projectionContext:i,pitchedLabelPlaneMatrixInverse:o,symbol:a,fontSize:r,flip:s,keepUpright:n,glyphOffsetArray:l,dynamicLayoutVertexArray:c,aspectRatio:h,rotateToLine:u}=e,d=r/24,_=a.lineOffsetX*d,p=a.lineOffsetY*d;let m;if(a.numGlyphs>1){const e=a.glyphStartIndex+a.numGlyphs,t=a.lineStartIndex,r=a.lineStartIndex+a.lineLength,c=He(d,l,_,p,s,a,u,i);if(!c)return {notEnoughRoom:!0};const f=et(c.first.point.x,c.first.point.y,i,o),g=et(c.last.point.x,c.last.point.y,i,o);if(n&&!s){const e=Xe(a.writingMode,f,g,h);if(e)return e}m=[c.first];for(let o=a.glyphStartIndex+1;o0?n.point:Ye(i.tileAnchorPoint,s,e,1,i),c=et(e.x,e.y,i,o),u=et(l.x,l.y,i,o),d=Xe(a.writingMode,c,u,h);if(d)return d}const e=at(d*l.getoffsetX(a.glyphStartIndex),_,p,s,a.segment,a.lineStartIndex,a.lineStartIndex+a.lineLength,i,u);if(!e||i.projectionCache.anyProjectionOccluded)return {notEnoughRoom:!0};m=[e];}for(const e of m)t.aD(c,e.point,e.angle);return {}}function Ye(e,t,i,o,a){const r=e.add(e.sub(t)._unit()),s=Je(r.x,r.y,a).point,n=i.sub(s);return i.add(n._mult(o/n.mag()))}function Qe(e,i,o){const a=i.projectionCache;if(a.projections[e])return a.projections[e];const r=new t.P(i.lineVertexArray.getx(e),i.lineVertexArray.gety(e)),s=Je(r.x,r.y,i);if(s.signedDistanceFromCamera>0)return a.projections[e]=s.point,a.anyProjectionOccluded||(a.anyProjectionOccluded=s.isOccluded),s.point;const n=e-o.direction;return Ye(0===o.distanceFromAnchor?i.tileAnchorPoint:new t.P(i.lineVertexArray.getx(n),i.lineVertexArray.gety(n)),r,o.previousVertex,o.absOffsetX-o.distanceFromAnchor+1,i)}function Je(e,t,i){const o=e+i.translation[0],a=t+i.translation[1];let r;return i.pitchWithMap?(r=Ve(o,a,i.pitchedLabelPlaneMatrix,i.getElevation),r.isOccluded=!1):(r=i.transform.projectTileCoordinates(o,a,i.unwrappedTileID,i.getElevation),r.point.x=(.5*r.point.x+.5)*i.width,r.point.y=(.5*-r.point.y+.5)*i.height),r}function et(e,i,o,a){if(o.pitchWithMap){const r=[e,i,0,1];return t.aE(r,r,a),o.transform.projectTileCoordinates(r[0]/r[3],r[1]/r[3],o.unwrappedTileID,o.getElevation).point}return {x:e/o.width*2-1,y:1-i/o.height*2}}function tt(e,t,i){return i.transform.projectTileCoordinates(e,t,i.unwrappedTileID,i.getElevation)}function it(e,t,i){return e._unit()._perp()._mult(t*i)}function ot(e,i,o,a,r,s,n,l,c){if(l.projectionCache.offsets[e])return l.projectionCache.offsets[e];const h=o.add(i);if(e+c.direction=r)return l.projectionCache.offsets[e]=h,h;const u=Qe(e+c.direction,l,c),d=it(u.sub(o),n,c.direction),_=o.add(d),p=u.add(d);return l.projectionCache.offsets[e]=t.aF(s,h,_,p)||h,l.projectionCache.offsets[e]}function at(e,t,i,o,a,r,s,n,l){const c=o?e-t:e+t;let h=c>0?1:-1,u=0;o&&(h*=-1,u=Math.PI),h<0&&(u+=Math.PI);let d,_=h>0?r+a:r+a+1;n.projectionCache.cachedAnchorPoint?d=n.projectionCache.cachedAnchorPoint:(d=Je(n.tileAnchorPoint.x,n.tileAnchorPoint.y,n).point,n.projectionCache.cachedAnchorPoint=d);let p,m,f=d,g=d,v=0,x=0;const b=Math.abs(c),y=[];let w;for(;v+x<=b;){if(_+=h,_=s)return null;v+=x,g=f,m=p;const e={absOffsetX:b,direction:h,distanceFromAnchor:v,previousVertex:g};if(f=Qe(_,n,e),0===i)y.push(g),w=f.sub(g);else {let t;const o=f.sub(g);t=0===o.mag()?it(Qe(_+h,n,e).sub(f),i,h):it(o,i,h),m||(m=g.add(t)),p=ot(_,t,f,r,s,m,i,n,e),y.push(m),w=p.sub(m);}x=w.mag();}const T=w._mult((b-v)/x)._add(m||g),P=u+Math.atan2(f.y-g.y,f.x-g.x);return y.push(T),{point:T,angle:l?P:0,path:y}}const rt=new Float32Array([-1/0,-1/0,0,-1/0,-1/0,0,-1/0,-1/0,0,-1/0,-1/0,0]);function st(e,t){for(let i=0;i=1;e--)_.push(s.path[e]);for(let e=1;ee.signedDistanceFromCamera<=0))?[]:e.map((e=>e.point));}let f=[];if(_.length>0){const e=_[0].clone(),i=_[0].clone();for(let t=1;t<_.length;t++)e.x=Math.min(e.x,_[t].x),e.y=Math.min(e.y,_[t].y),i.x=Math.max(i.x,_[t].x),i.y=Math.max(i.y,_[t].y);f=e.x>=o.x&&i.x<=a.x&&e.y>=o.y&&i.y<=a.y?[_]:i.xa.x||i.ya.y?[]:t.aG([_],o.x,o.y,a.x,a.y);}for(const t of f){r.reset(t,.25*i);let o=0;o=r.length<=.5*i?1:Math.ceil(r.paddedLength/p)+1;for(let t=0;t{const o=Ve(e.x,e.y,i,t.getElevation),a=t.transform.projectTileCoordinates(o.point.x,o.point.y,t.unwrappedTileID,t.getElevation);return a.point.x=(.5*a.point.x+.5)*t.width,a.point.y=(.5*-a.point.y+.5)*t.height,a}))}(e,t);return function(e){let t=0,i=0,o=0,a=0;for(let r=0;ri&&(i=a,t=o));return e.slice(t,t+i)}(i)}queryRenderedSymbols(e){if(0===e.length||0===this.grid.keysLength()&&0===this.ignoredGrid.keysLength())return {};const i=[],o=new t.a8;for(const a of e){const e=new t.P(a.x+lt,a.y+lt);o.extend(e),i.push(e);}const{minX:a,minY:r,maxX:s,maxY:n}=o,l=this.grid.query(a,r,s,n).concat(this.ignoredGrid.query(a,r,s,n)),c={},h={};for(const e of l){const o=e.key;if(void 0===c[o.bucketInstanceId]&&(c[o.bucketInstanceId]={}),c[o.bucketInstanceId][o.featureIndex])continue;const a=[new t.P(e.x1,e.y1),new t.P(e.x2,e.y1),new t.P(e.x2,e.y2),new t.P(e.x1,e.y2)];t.aH(i,a)&&(c[o.bucketInstanceId][o.featureIndex]=!0,void 0===h[o.bucketInstanceId]&&(h[o.bucketInstanceId]=[]),h[o.bucketInstanceId].push(o.featureIndex));}return h}insertCollisionBox(e,t,i,o,a,r){(i?this.ignoredGrid:this.grid).insert({bucketInstanceId:o,featureIndex:a,collisionGroupID:r,overlapMode:t},e[0],e[1],e[2],e[3]);}insertCollisionCircles(e,t,i,o,a,r){const s=i?this.ignoredGrid:this.grid,n={bucketInstanceId:o,featureIndex:a,collisionGroupID:r,overlapMode:t};for(let t=0;t=this.screenRightBoundary||othis.screenBottomBoundary}isInsideGrid(e,t,i,o){return i>=0&&e=0&&tthis.projectAndGetPerspectiveRatio(e.x,e.y,a,c,u)));S=e.some((e=>!e.isOccluded)),E=e.map((e=>new t.P(e.x,e.y)));}else S=!0;return {box:t.aI(E),allPointsOccluded:!S}}}class ht{constructor(e,t,i,o){this.opacity=e?Math.max(0,Math.min(1,e.opacity+(e.placed?t:-t))):o&&i?1:0,this.placed=i;}isHidden(){return 0===this.opacity&&!this.placed}}class ut{constructor(e,t,i,o,a){this.text=new ht(e?e.text:null,t,i,a),this.icon=new ht(e?e.icon:null,t,o,a);}isHidden(){return this.text.isHidden()&&this.icon.isHidden()}}class dt{constructor(e,t,i){this.text=e,this.icon=t,this.skipFade=i;}}class _t{constructor(e,t,i,o,a){this.bucketInstanceId=e,this.featureIndex=t,this.sourceLayerIndex=i,this.bucketIndex=o,this.tileID=a;}}class pt{constructor(e){this.crossSourceCollisions=e,this.maxGroupID=0,this.collisionGroups={};}get(e){if(this.crossSourceCollisions)return {ID:0,predicate:null};if(!this.collisionGroups[e]){const t=++this.maxGroupID;this.collisionGroups[e]={ID:t,predicate:e=>e.collisionGroupID===t};}return this.collisionGroups[e]}}function mt(e,i,o,a,r){const{horizontalAlign:s,verticalAlign:n}=t.aP(e);return new t.P(-(s-.5)*i+a[0]*r,-(n-.5)*o+a[1]*r)}class ft{constructor(e,t,i,o,a){this.transform=e.clone(),this.terrain=t,this.collisionIndex=new ct(this.transform),this.placements={},this.opacities={},this.variableOffsets={},this.stale=!1,this.commitTime=0,this.fadeDuration=i,this.retainedQueryData={},this.collisionGroups=new pt(o),this.collisionCircleArrays={},this.collisionBoxArrays=new Map,this.prevPlacement=a,a&&(a.prevPlacement=void 0),this.placedOrientations={};}_getTerrainElevationFunc(e){const t=this.terrain;return t?(i,o)=>t.getElevation(e,i,o):null}getBucketParts(e,i,o,a){const r=o.getBucket(i),s=o.latestFeatureIndex;if(!r||!s||i.id!==r.layerIds[0])return;const n=o.collisionBoxArray,l=r.layers[0].layout,c=r.layers[0].paint,h=Math.pow(2,this.transform.zoom-o.tileID.overscaledZ),u=o.tileSize/t.a6,d=o.tileID.toUnwrapped(),_="map"===l.get("text-rotation-alignment"),p=t.aK(o,1,this.transform.zoom),m=t.aL(this.collisionIndex.transform,o,c.get("text-translate"),c.get("text-translate-anchor")),f=t.aL(this.collisionIndex.transform,o,c.get("icon-translate"),c.get("icon-translate-anchor")),g=Ze(_,this.transform,p);this.retainedQueryData[r.bucketInstanceId]=new _t(r.bucketInstanceId,s,r.sourceLayerIndex,r.index,o.tileID);const v={bucket:r,layout:l,translationText:m,translationIcon:f,unwrappedTileID:d,pitchedLabelPlaneMatrix:g,scale:h,textPixelRatio:u,holdingForFade:o.holdingForSymbolFade(),collisionBoxArray:n,partiallyEvaluatedTextSize:t.aw(r.textSizeData,this.transform.zoom),collisionGroup:this.collisionGroups.get(r.sourceID)};if(a)for(const t of r.sortKeyRanges){const{sortKey:i,symbolInstanceStart:o,symbolInstanceEnd:a}=t;e.push({sortKey:i,symbolInstanceStart:o,symbolInstanceEnd:a,parameters:v});}else e.push({symbolInstanceStart:0,symbolInstanceEnd:r.symbolInstances.length,parameters:v});}attemptAnchorPlacement(e,i,o,a,r,s,n,l,c,h,u,d,_,p,m,f,g,v,x,b){var y,w,T;const P=t.aM[e.textAnchor],C=[e.textOffset0,e.textOffset1],M=mt(P,o,a,C,r),I=this.collisionIndex.placeCollisionBox(i,d,l,c,h,n,s,f,u.predicate,x,M,b);if((!v||this.collisionIndex.placeCollisionBox(v,d,l,c,h,n,s,g,u.predicate,x,M,b).placeable)&&I.placeable){let e;if((null===(y=this.prevPlacement)||void 0===y?void 0:y.variableOffsets[_.crossTileID])&&(null===(T=null===(w=this.prevPlacement)||void 0===w?void 0:w.placements[_.crossTileID])||void 0===T?void 0:T.text)&&(e=this.prevPlacement.variableOffsets[_.crossTileID].anchor),0===_.crossTileID)throw new Error("symbolInstance.crossTileID can't be 0");return this.variableOffsets[_.crossTileID]={textOffset:C,width:o,height:a,anchor:P,textBoxScale:r,prevAnchor:e},this.markUsedJustification(p,P,_,m),p.allowVerticalPlacement&&(this.markUsedOrientation(p,m,_),this.placedOrientations[_.crossTileID]=m),{shift:M,placedGlyphBoxes:I}}}placeLayerBucketPart(e,i,o){const{bucket:a,layout:r,translationText:s,translationIcon:n,unwrappedTileID:l,pitchedLabelPlaneMatrix:c,textPixelRatio:h,holdingForFade:u,collisionBoxArray:d,partiallyEvaluatedTextSize:_,collisionGroup:p}=e.parameters,m=r.get("text-optional"),f=r.get("icon-optional"),g=t.aN(r,"text-overlap","text-allow-overlap"),v="always"===g,x=t.aN(r,"icon-overlap","icon-allow-overlap"),b="always"===x,y="map"===r.get("text-rotation-alignment"),w="map"===r.get("text-pitch-alignment"),T="none"!==r.get("icon-text-fit"),P="viewport-y"===r.get("symbol-z-order"),C=v&&(b||!a.hasIconData()||f),M=b&&(v||!a.hasTextData()||m);!a.collisionArrays&&d&&a.deserializeCollisionBoxes(d);const I=this.retainedQueryData[a.bucketInstanceId].tileID,E=this._getTerrainElevationFunc(I),S=this.transform.getFastPathSimpleProjectionMatrix(I),z=(e,d,b)=>{var P,z;if(i[e.crossTileID])return;if(u)return void(this.placements[e.crossTileID]=new dt(!1,!1,!1));let R=!1,D=!1,A=!0,L=null,F={box:null,placeable:!1,offscreen:null,occluded:!1},k={placeable:!1},B=null,O=null,j=null,N=0,Z=0,U=0;d.textFeatureIndex?N=d.textFeatureIndex:e.useRuntimeCollisionCircles&&(N=e.featureIndex),d.verticalTextFeatureIndex&&(Z=d.verticalTextFeatureIndex);const G=d.textBox;if(G){const i=i=>{let o=t.ax.horizontal;if(a.allowVerticalPlacement&&!i&&this.prevPlacement){const t=this.prevPlacement.placedOrientations[e.crossTileID];t&&(this.placedOrientations[e.crossTileID]=t,o=t,this.markUsedOrientation(a,o,e));}return o},r=(i,o)=>{if(a.allowVerticalPlacement&&e.numVerticalGlyphVertices>0&&d.verticalTextBox){for(const e of a.writingModes)if(e===t.ax.vertical?(F=o(),k=F):F=i(),null==F?void 0:F.placeable)break}else F=i();},c=e.textAnchorOffsetStartIndex,u=e.textAnchorOffsetEndIndex;if(u===c){const o=(t,i)=>{const o=this.collisionIndex.placeCollisionBox(t,g,h,I,l,w,y,s,p.predicate,E,void 0,S);return (null==o?void 0:o.placeable)&&(this.markUsedOrientation(a,i,e),this.placedOrientations[e.crossTileID]=i),o};r((()=>o(G,t.ax.horizontal)),(()=>{const i=d.verticalTextBox;return a.allowVerticalPlacement&&e.numVerticalGlyphVertices>0&&i?o(i,t.ax.vertical):{box:null,offscreen:null}})),i(null==F?void 0:F.placeable);}else {let _=t.aM[null===(z=null===(P=this.prevPlacement)||void 0===P?void 0:P.variableOffsets[e.crossTileID])||void 0===z?void 0:z.anchor];const m=(t,i,r)=>{const d=t.x2-t.x1,m=t.y2-t.y1,f=e.textBoxScale,v=T&&"never"===x?i:null;let b=null,P="never"===g?1:2,C="never";_&&P++;for(let i=0;im(G,d.iconBox,t.ax.horizontal)),(()=>{const i=d.verticalTextBox;return a.allowVerticalPlacement&&!(null==F?void 0:F.placeable)&&e.numVerticalGlyphVertices>0&&i?m(i,d.verticalIconBox,t.ax.vertical):{box:null,occluded:!0,offscreen:null}})),F&&(R=F.placeable,A=F.offscreen);const f=i(null==F?void 0:F.placeable);if(!R&&this.prevPlacement){const t=this.prevPlacement.variableOffsets[e.crossTileID];t&&(this.variableOffsets[e.crossTileID]=t,this.markUsedJustification(a,t.anchor,e,f));}}}if(B=F,R=null==B?void 0:B.placeable,A=null==B?void 0:B.offscreen,e.useRuntimeCollisionCircles&&e.centerJustifiedTextSymbolIndex>=0){const i=a.text.placedSymbolArray.get(e.centerJustifiedTextSymbolIndex),n=t.ay(a.textSizeData,_,i),h=r.get("text-padding");O=this.collisionIndex.placeCollisionCircles(g,i,a.lineVertexArray,a.glyphOffsetArray,n,l,c,o,w,p.predicate,e.collisionCircleDiameter,h,s,E),O.circles.length&&O.collisionDetected&&!o&&t.w("Collisions detected, but collision boxes are not shown"),R=v||O.circles.length>0&&!O.collisionDetected,A&&(A=O.offscreen);}if(d.iconFeatureIndex&&(U=d.iconFeatureIndex),d.iconBox){const e=e=>this.collisionIndex.placeCollisionBox(e,x,h,I,l,w,y,n,p.predicate,E,T&&L?L:void 0,S);k&&k.placeable&&d.verticalIconBox?(j=e(d.verticalIconBox),D=j.placeable):(j=e(d.iconBox),D=j.placeable),A&&(A=j.offscreen);}const V=m||0===e.numHorizontalGlyphVertices&&0===e.numVerticalGlyphVertices,W=f||0===e.numIconVertices;V||W?W?V||D&&(D=R):R=D&&R:D=R=D&&R;const q=D&&j.placeable;if(R&&B.placeable&&this.collisionIndex.insertCollisionBox(B.box,g,r.get("text-ignore-placement"),a.bucketInstanceId,k&&k.placeable&&Z?Z:N,p.ID),q&&this.collisionIndex.insertCollisionBox(j.box,x,r.get("icon-ignore-placement"),a.bucketInstanceId,U,p.ID),O&&R&&this.collisionIndex.insertCollisionCircles(O.circles,g,r.get("text-ignore-placement"),a.bucketInstanceId,N,p.ID),o&&this.storeCollisionData(a.bucketInstanceId,b,d,B,j,O),0===e.crossTileID)throw new Error("symbolInstance.crossTileID can't be 0");if(0===a.bucketInstanceId)throw new Error("bucket.bucketInstanceId can't be 0");this.placements[e.crossTileID]=new dt((R||C)&&!(null==B?void 0:B.occluded),(D||M)&&!(null==j?void 0:j.occluded),A||a.justReloaded),i[e.crossTileID]=!0;};if(P){if(0!==e.symbolInstanceStart)throw new Error("bucket.bucketInstanceId should be 0");const t=a.getSortedSymbolIndexes(-this.transform.bearingInRadians);for(let e=t.length-1;e>=0;--e){const i=t[e];z(a.symbolInstances.get(i),a.collisionArrays[i],i);}}else for(let t=e.symbolInstanceStart;t=0&&(e.text.placedSymbolArray.get(t).crossTileID=r>=0&&t!==r?0:o.crossTileID);}markUsedOrientation(e,i,o){const a=i===t.ax.horizontal||i===t.ax.horizontalOnly?i:0,r=i===t.ax.vertical?i:0,s=[o.leftJustifiedTextSymbolIndex,o.centerJustifiedTextSymbolIndex,o.rightJustifiedTextSymbolIndex];for(const t of s)e.text.placedSymbolArray.get(t).placedOrientation=a;o.verticalPlacedTextSymbolIndex&&(e.text.placedSymbolArray.get(o.verticalPlacedTextSymbolIndex).placedOrientation=r);}commit(e){this.commitTime=e,this.zoomAtLastRecencyCheck=this.transform.zoom;const t=this.prevPlacement;let i=!1;this.prevZoomAdjustment=t?t.zoomAdjustment(this.transform.zoom):0;const o=t?t.symbolFadeChange(e):1,a=t?t.opacities:{},r=t?t.variableOffsets:{},s=t?t.placedOrientations:{};for(const e in this.placements){const t=this.placements[e],r=a[e];r?(this.opacities[e]=new ut(r,o,t.text,t.icon),i||(i=t.text!==r.text.placed),i||(i=t.icon!==r.icon.placed)):(this.opacities[e]=new ut(null,o,t.text,t.icon,t.skipFade),i||(i=t.text||t.icon));}for(const e in a){const t=a[e];if(!this.opacities[e]){const a=new ut(t,o,!1,!1);a.isHidden()||(this.opacities[e]=a,i||(i=t.text.placed),i||(i=t.icon.placed));}}for(const e in r)this.variableOffsets[e]||!this.opacities[e]||this.opacities[e].isHidden()||(this.variableOffsets[e]=r[e]);for(const e in s)this.placedOrientations[e]||!this.opacities[e]||this.opacities[e].isHidden()||(this.placedOrientations[e]=s[e]);if(t&&void 0===t.lastPlacementChangeTime)throw new Error("Last placement time for previous placement is not defined");i?this.lastPlacementChangeTime=e:"number"!=typeof this.lastPlacementChangeTime&&(this.lastPlacementChangeTime=t?t.lastPlacementChangeTime:e);}updateLayerOpacities(e,t){const i={};for(const o of t){const t=o.getBucket(e);t&&o.latestFeatureIndex&&e.id===t.layerIds[0]&&this.updateBucketOpacities(t,o.tileID,i,o.collisionBoxArray);}}updateBucketOpacities(e,i,o,a){e.hasTextData()&&(e.text.opacityVertexArray.clear(),e.text.hasVisibleVertices=!1),e.hasIconData()&&(e.icon.opacityVertexArray.clear(),e.icon.hasVisibleVertices=!1),e.hasIconCollisionBoxData()&&e.iconCollisionBox.collisionVertexArray.clear(),e.hasTextCollisionBoxData()&&e.textCollisionBox.collisionVertexArray.clear();const r=e.layers[0],s=r.layout,n=new ut(null,0,!1,!1,!0),l=s.get("text-allow-overlap"),c=s.get("icon-allow-overlap"),h=r._unevaluatedLayout.hasValue("text-variable-anchor")||r._unevaluatedLayout.hasValue("text-variable-anchor-offset"),u="map"===s.get("text-rotation-alignment"),d="map"===s.get("text-pitch-alignment"),_="none"!==s.get("icon-text-fit"),p=new ut(null,0,l&&(c||!e.hasIconData()||s.get("icon-optional")),c&&(l||!e.hasTextData()||s.get("text-optional")),!0);!e.collisionArrays&&a&&(e.hasIconCollisionBoxData()||e.hasTextCollisionBoxData())&&e.deserializeCollisionBoxes(a);const m=(e,t,i)=>{for(let o=0;o0,v=this.placedOrientations[a.crossTileID],x=v===t.ax.vertical,b=v===t.ax.horizontal||v===t.ax.horizontalOnly;if(r>0||s>0){const t=Ct(c.text);m(e.text,r,x?Mt:t),m(e.text,s,b?Mt:t);const i=c.text.isHidden(),o=[a.rightJustifiedTextSymbolIndex,a.centerJustifiedTextSymbolIndex,a.leftJustifiedTextSymbolIndex];for(const t of o)t>=0&&(e.text.placedSymbolArray.get(t).hidden=i||x?1:0);a.verticalPlacedTextSymbolIndex>=0&&(e.text.placedSymbolArray.get(a.verticalPlacedTextSymbolIndex).hidden=i||b?1:0);const n=this.variableOffsets[a.crossTileID];n&&this.markUsedJustification(e,n.anchor,a,v);const l=this.placedOrientations[a.crossTileID];l&&(this.markUsedJustification(e,"left",a,l),this.markUsedOrientation(e,l,a));}if(g){const t=Ct(c.icon),i=!(_&&a.verticalPlacedIconSymbolIndex&&x);a.placedIconSymbolIndex>=0&&(m(e.icon,a.numIconVertices,i?t:Mt),e.icon.placedSymbolArray.get(a.placedIconSymbolIndex).hidden=c.icon.isHidden()),a.verticalPlacedIconSymbolIndex>=0&&(m(e.icon,a.numVerticalIconVertices,i?Mt:t),e.icon.placedSymbolArray.get(a.verticalPlacedIconSymbolIndex).hidden=c.icon.isHidden());}const y=(null==f?void 0:f.has(i))?f.get(i):{text:null,icon:null};if(e.hasIconCollisionBoxData()||e.hasTextCollisionBoxData()){const o=e.collisionArrays[i];if(o){let i=new t.P(0,0);if(o.textBox||o.verticalTextBox){let t=!0;if(h){const e=this.variableOffsets[l];e?(i=mt(e.anchor,e.width,e.height,e.textOffset,e.textBoxScale),u&&i._rotate(d?-this.transform.bearingInRadians:this.transform.bearingInRadians)):t=!1;}if(o.textBox||o.verticalTextBox){let a;o.textBox&&(a=x),o.verticalTextBox&&(a=b),gt(e.textCollisionBox.collisionVertexArray,c.text.placed,!t||a,y.text,i.x,i.y);}}if(o.iconBox||o.verticalIconBox){const t=Boolean(!b&&o.verticalIconBox);let a;o.iconBox&&(a=t),o.verticalIconBox&&(a=!t),gt(e.iconCollisionBox.collisionVertexArray,c.icon.placed,a,y.icon,_?i.x:0,_?i.y:0);}}}}if(e.sortFeatures(-this.transform.bearingInRadians),this.retainedQueryData[e.bucketInstanceId]&&(this.retainedQueryData[e.bucketInstanceId].featureSortOrder=e.featureSortOrder),e.hasTextData()&&e.text.opacityVertexBuffer&&e.text.opacityVertexBuffer.updateData(e.text.opacityVertexArray),e.hasIconData()&&e.icon.opacityVertexBuffer&&e.icon.opacityVertexBuffer.updateData(e.icon.opacityVertexArray),e.hasIconCollisionBoxData()&&e.iconCollisionBox.collisionVertexBuffer&&e.iconCollisionBox.collisionVertexBuffer.updateData(e.iconCollisionBox.collisionVertexArray),e.hasTextCollisionBoxData()&&e.textCollisionBox.collisionVertexBuffer&&e.textCollisionBox.collisionVertexBuffer.updateData(e.textCollisionBox.collisionVertexArray),e.text.opacityVertexArray.length!==e.text.layoutVertexArray.length/4)throw new Error(`bucket.text.opacityVertexArray.length (= ${e.text.opacityVertexArray.length}) !== bucket.text.layoutVertexArray.length (= ${e.text.layoutVertexArray.length}) / 4`);if(e.icon.opacityVertexArray.length!==e.icon.layoutVertexArray.length/4)throw new Error(`bucket.icon.opacityVertexArray.length (= ${e.icon.opacityVertexArray.length}) !== bucket.icon.layoutVertexArray.length (= ${e.icon.layoutVertexArray.length}) / 4`);e.bucketInstanceId in this.collisionCircleArrays&&(e.collisionCircleArray=this.collisionCircleArrays[e.bucketInstanceId],delete this.collisionCircleArrays[e.bucketInstanceId]);}symbolFadeChange(e){return 0===this.fadeDuration?1:(e-this.commitTime)/this.fadeDuration+this.prevZoomAdjustment}zoomAdjustment(e){return Math.max(0,(this.transform.zoom-e)/1.5)}hasTransitions(e){return this.stale||e-this.lastPlacementChangeTimee}setStale(){this.stale=!0;}}function gt(e,t,i,o,a,r){o&&0!==o.length||(o=[0,0,0,0]);const s=o[0]-lt,n=o[1]-lt,l=o[2]-lt,c=o[3]-lt;e.emplaceBack(t?1:0,i?1:0,a||0,r||0,s,n),e.emplaceBack(t?1:0,i?1:0,a||0,r||0,l,n),e.emplaceBack(t?1:0,i?1:0,a||0,r||0,l,c),e.emplaceBack(t?1:0,i?1:0,a||0,r||0,s,c);}const vt=Math.pow(2,25),xt=Math.pow(2,24),bt=Math.pow(2,17),yt=Math.pow(2,16),wt=Math.pow(2,9),Tt=Math.pow(2,8),Pt=Math.pow(2,1);function Ct(e){if(0===e.opacity&&!e.placed)return 0;if(1===e.opacity&&e.placed)return 4294967295;const t=e.placed?1:0,i=Math.floor(127*e.opacity);return i*vt+t*xt+i*bt+t*yt+i*wt+t*Tt+i*Pt+t}const Mt=0;class It{constructor(e){this._sortAcrossTiles="viewport-y"!==e.layout.get("symbol-z-order")&&!e.layout.get("symbol-sort-key").isConstant(),this._currentTileIndex=0,this._currentPartIndex=0,this._seenCrossTileIDs={},this._bucketParts=[];}continuePlacement(e,t,i,o,a){const r=this._bucketParts;for(;this._currentTileIndexe.sortKey-t.sortKey)));this._currentPartIndex!this._forceFullPlacement&&c()-a>2;for(;this._currentPlacementIndex>=0;){const a=i[e[this._currentPlacementIndex]],s=this.placement.collisionIndex.transform.zoom;if(t.aQ(a)&&a.layout&&(!a.minzoom||a.minzoom<=s)&&(!a.maxzoom||a.maxzoom>s)){if(this._inProgressLayer||(this._inProgressLayer=new It(a)),this._inProgressLayer.continuePlacement(o[a.source],this.placement,this._showCollisionBoxes,a,r))return;delete this._inProgressLayer;}this._currentPlacementIndex--;}this._done=!0;}commit(e){return this.placement.commit(e),this.placement}}const St=512/t.a6/2;class zt{constructor(e,i,o){this.tileID=e,this.bucketInstanceId=o,this._symbolsByKey={};const a=new Map;for(let e=0;e({x:Math.floor(e.anchorX*St),y:Math.floor(e.anchorY*St)}))),crossTileIDs:i.map((e=>e.crossTileID))};if(o.positions.length>128){const e=new t.aR(o.positions.length,16,Uint16Array);for(const{x:t,y:i}of o.positions)e.add(t,i);e.finish(),delete o.positions,o.index=e;}this._symbolsByKey[e]=o;}}getScaledCoordinates(e,i){const{x:o,y:a,z:r}=this.tileID.canonical,{x:s,y:n,z:l}=i.canonical,c=St/Math.pow(2,l-r),h=(n*t.a6+e.anchorY)*c,u=a*t.a6*St;return {x:Math.floor((s*t.a6+e.anchorX)*c-o*t.a6*St),y:Math.floor(h-u)}}findMatches(e,t,i){const o=this.tileID.canonical.ze))}}class Rt{constructor(){this.maxCrossTileID=0;}generate(){return ++this.maxCrossTileID}}class Dt{constructor(){this.indexes={},this.usedCrossTileIDs={},this.lng=0;}handleWrapJump(e){const t=Math.round((e-this.lng)/360);if(0!==t)for(const e in this.indexes){const i=this.indexes[e],o={};for(const e in i){const a=i[e];a.tileID=a.tileID.unwrapTo(a.tileID.wrap+t),o[a.tileID.key]=a;}this.indexes[e]=o;}this.lng=e;}addBucket(e,t,i){var o,a,r;if(null===(o=this.indexes[e.overscaledZ])||void 0===o?void 0:o[e.key]){if(this.indexes[e.overscaledZ][e.key].bucketInstanceId===t.bucketInstanceId)return !1;this.removeBucketCrossTileIDs(e.overscaledZ,this.indexes[e.overscaledZ][e.key]);}for(let e=0;ee.overscaledZ)for(const i in o){const a=o[i];a.tileID.isChildOf(e)&&a.findMatches(t.symbolInstances,e,s);}else {const a=o[e.scaledTo(Number(i)).key];a&&a.findMatches(t.symbolInstances,e,s);}}for(let e=0;e 0.95) return 1.0;return (d+depthOpacity(frag+vec3(0.0,0.01,0.0)))/2.0;\n#else\nreturn 1.0;\n#endif\n}float ele(vec2 pos) {\n#ifdef TERRAIN3D\nvec4 rgb=(texture(u_terrain,pos)*255.0)*u_terrain_unpack;return rgb.r+rgb.g+rgb.b-u_terrain_unpack.a;\n#else\nreturn 0.0;\n#endif\n}float get_elevation(vec2 pos) {\n#ifdef TERRAIN3D\n#ifdef GLOBE\nif ((pos.y <-32767.5) || (pos.y > 32766.5)) {return 0.0;}\n#endif\nvec2 coord=(u_terrain_matrix*vec4(pos,0.0,1.0)).xy*u_terrain_dim+1.0;vec2 f=fract(coord);vec2 c=(floor(coord)+0.5)/(u_terrain_dim+2.0);float d=1.0/(u_terrain_dim+2.0);float tl=ele(c);float tr=ele(c+vec2(d,0.0));float bl=ele(c+vec2(0.0,d));float br=ele(c+vec2(d,d));float elevation=mix(mix(tl,tr,f.x),mix(bl,br,f.x),f.y);return elevation*u_terrain_exaggeration;\n#else\nreturn 0.0;\n#endif\n}const float PI=3.141592653589793;uniform mat4 u_projection_matrix;"),projectionMercator:kt("","float projectLineThickness(float tileY) {return 1.0;}float projectCircleRadius(float tileY) {return 1.0;}vec4 projectTile(vec2 p) {vec4 result=u_projection_matrix*vec4(p,0.0,1.0);return result;}vec4 projectTile(vec2 p,vec2 rawPos) {vec4 result=u_projection_matrix*vec4(p,0.0,1.0);if (rawPos.y <-32767.5 || rawPos.y > 32766.5) {result.z=-10000000.0;}return result;}vec4 projectTileWithElevation(vec2 posInTile,float elevation) {return u_projection_matrix*vec4(posInTile,elevation,1.0);}vec4 projectTileFor3D(vec2 posInTile,float elevation) {return projectTileWithElevation(posInTile,elevation);}"),projectionGlobe:kt("","#define GLOBE_RADIUS 6371008.8\nuniform highp vec4 u_projection_tile_mercator_coords;uniform highp vec4 u_projection_clipping_plane;uniform highp float u_projection_transition;uniform mat4 u_projection_fallback_matrix;vec3 globeRotateVector(vec3 vec,vec2 angles) {vec3 axisRight=vec3(vec.z,0.0,-vec.x);vec3 axisUp=cross(axisRight,vec);axisRight=normalize(axisRight);axisUp=normalize(axisUp);vec2 t=tan(angles);return normalize(vec+axisRight*t.x+axisUp*t.y);}mat3 globeGetRotationMatrix(vec3 spherePos) {vec3 axisRight=vec3(spherePos.z,0.0,-spherePos.x);vec3 axisDown=cross(axisRight,spherePos);axisRight=normalize(axisRight);axisDown=normalize(axisDown);return mat3(axisRight,axisDown,spherePos\n);}float circumferenceRatioAtTileY(float tileY) {float mercator_pos_y=u_projection_tile_mercator_coords.y+u_projection_tile_mercator_coords.w*tileY;float spherical_y=2.0*atan(exp(PI-(mercator_pos_y*PI*2.0)))-PI*0.5;return cos(spherical_y);}float projectLineThickness(float tileY) {float thickness=1.0/circumferenceRatioAtTileY(tileY); \nif (u_projection_transition < 0.999) {return mix(1.0,thickness,u_projection_transition);} else {return thickness;}}vec3 projectToSphere(vec2 translatedPos,vec2 rawPos) {vec2 mercator_pos=u_projection_tile_mercator_coords.xy+u_projection_tile_mercator_coords.zw*translatedPos;vec2 spherical;spherical.x=mercator_pos.x*PI*2.0+PI;spherical.y=2.0*atan(exp(PI-(mercator_pos.y*PI*2.0)))-PI*0.5;float len=cos(spherical.y);vec3 pos=vec3(sin(spherical.x)*len,sin(spherical.y),cos(spherical.x)*len\n);if (rawPos.y <-32767.5) {pos=vec3(0.0,1.0,0.0);}if (rawPos.y > 32766.5) {pos=vec3(0.0,-1.0,0.0);}return pos;}vec3 projectToSphere(vec2 posInTile) {return projectToSphere(posInTile,vec2(0.0,0.0));}float globeComputeClippingZ(vec3 spherePos) {return (1.0-(dot(spherePos,u_projection_clipping_plane.xyz)+u_projection_clipping_plane.w));}vec4 interpolateProjection(vec2 posInTile,vec3 spherePos,float elevation) {vec3 elevatedPos=spherePos*(1.0+elevation/GLOBE_RADIUS);vec4 globePosition=u_projection_matrix*vec4(elevatedPos,1.0);globePosition.z=globeComputeClippingZ(elevatedPos)*globePosition.w;if (u_projection_transition > 0.999) {return globePosition;}vec4 flatPosition=u_projection_fallback_matrix*vec4(posInTile,elevation,1.0);const float z_globeness_threshold=0.2;vec4 result=globePosition;result.z=mix(0.0,globePosition.z,clamp((u_projection_transition-z_globeness_threshold)/(1.0-z_globeness_threshold),0.0,1.0));result.xyw=mix(flatPosition.xyw,globePosition.xyw,u_projection_transition);if ((posInTile.y <-32767.5) || (posInTile.y > 32766.5)) {result=globePosition;const float poles_hidden_anim_percentage=0.02;result.z=mix(globePosition.z,100.0,pow(max((1.0-u_projection_transition)/poles_hidden_anim_percentage,0.0),8.0));}return result;}vec4 interpolateProjectionFor3D(vec2 posInTile,vec3 spherePos,float elevation) {vec3 elevatedPos=spherePos*(1.0+elevation/GLOBE_RADIUS);vec4 globePosition=u_projection_matrix*vec4(elevatedPos,1.0);if (u_projection_transition > 0.999) {return globePosition;}vec4 fallbackPosition=u_projection_fallback_matrix*vec4(posInTile,elevation,1.0);return mix(fallbackPosition,globePosition,u_projection_transition);}vec4 projectTile(vec2 posInTile) {return interpolateProjection(posInTile,projectToSphere(posInTile),0.0);}vec4 projectTile(vec2 posInTile,vec2 rawPos) {return interpolateProjection(posInTile,projectToSphere(posInTile,rawPos),0.0);}vec4 projectTileWithElevation(vec2 posInTile,float elevation) {return interpolateProjection(posInTile,projectToSphere(posInTile),elevation);}vec4 projectTileFor3D(vec2 posInTile,float elevation) {vec3 spherePos=projectToSphere(posInTile,posInTile);return interpolateProjectionFor3D(posInTile,spherePos,elevation);}"),background:kt("uniform vec4 u_color;uniform float u_opacity;void main() {fragColor=u_color*u_opacity;\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","in vec2 a_pos;void main() {gl_Position=projectTile(a_pos);}"),backgroundPattern:kt("uniform vec2 u_pattern_tl_a;uniform vec2 u_pattern_br_a;uniform vec2 u_pattern_tl_b;uniform vec2 u_pattern_br_b;uniform vec2 u_texsize;uniform float u_mix;uniform float u_opacity;uniform sampler2D u_image;in vec2 v_pos_a;in vec2 v_pos_b;void main() {vec2 imagecoord=mod(v_pos_a,1.0);vec2 pos=mix(u_pattern_tl_a/u_texsize,u_pattern_br_a/u_texsize,imagecoord);vec4 color1=texture(u_image,pos);vec2 imagecoord_b=mod(v_pos_b,1.0);vec2 pos2=mix(u_pattern_tl_b/u_texsize,u_pattern_br_b/u_texsize,imagecoord_b);vec4 color2=texture(u_image,pos2);fragColor=mix(color1,color2,u_mix)*u_opacity;\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform vec2 u_pattern_size_a;uniform vec2 u_pattern_size_b;uniform vec2 u_pixel_coord_upper;uniform vec2 u_pixel_coord_lower;uniform float u_scale_a;uniform float u_scale_b;uniform float u_tile_units_to_pixels;in vec2 a_pos;out vec2 v_pos_a;out vec2 v_pos_b;void main() {gl_Position=projectTile(a_pos);v_pos_a=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,u_scale_a*u_pattern_size_a,u_tile_units_to_pixels,a_pos);v_pos_b=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,u_scale_b*u_pattern_size_b,u_tile_units_to_pixels,a_pos);}"),circle:kt("in vec3 v_data;in float v_visibility;\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define mediump float radius\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define highp vec4 stroke_color\n#pragma mapbox: define mediump float stroke_width\n#pragma mapbox: define lowp float stroke_opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize mediump float radius\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize highp vec4 stroke_color\n#pragma mapbox: initialize mediump float stroke_width\n#pragma mapbox: initialize lowp float stroke_opacity\nvec2 extrude=v_data.xy;float extrude_length=length(extrude);float antialiased_blur=v_data.z;float opacity_t=smoothstep(0.0,antialiased_blur,extrude_length-1.0);float color_t=stroke_width < 0.01 ? 0.0 : smoothstep(antialiased_blur,0.0,extrude_length-radius/(radius+stroke_width));fragColor=v_visibility*opacity_t*mix(color*opacity,stroke_color*stroke_opacity,color_t);const float epsilon=0.5/255.0;if (fragColor.r < epsilon && fragColor.g < epsilon && fragColor.b < epsilon && fragColor.a < epsilon) {discard;}\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform bool u_scale_with_map;uniform bool u_pitch_with_map;uniform vec2 u_extrude_scale;uniform highp float u_globe_extrude_scale;uniform lowp float u_device_pixel_ratio;uniform highp float u_camera_to_center_distance;uniform vec2 u_translate;in vec2 a_pos;out vec3 v_data;out float v_visibility;\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define mediump float radius\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define highp vec4 stroke_color\n#pragma mapbox: define mediump float stroke_width\n#pragma mapbox: define lowp float stroke_opacity\nvoid main(void) {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize mediump float radius\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize highp vec4 stroke_color\n#pragma mapbox: initialize mediump float stroke_width\n#pragma mapbox: initialize lowp float stroke_opacity\nvec2 pos_raw=a_pos+32768.0;vec2 extrude=vec2(mod(pos_raw,8.0)/7.0*2.0-1.0);vec2 circle_center=floor(pos_raw/8.0)+u_translate;float ele=get_elevation(circle_center);v_visibility=calculate_visibility(projectTileWithElevation(circle_center,ele));if (u_pitch_with_map) {\n#ifdef GLOBE\nvec3 center_vector=projectToSphere(circle_center);\n#endif\nfloat angle_scale=u_globe_extrude_scale;vec2 corner_position=circle_center;if (u_scale_with_map) {angle_scale*=(radius+stroke_width);corner_position+=extrude*u_extrude_scale*(radius+stroke_width);} else {\n#ifdef GLOBE\nvec4 projected_center=interpolateProjection(circle_center,center_vector,ele);\n#else\nvec4 projected_center=projectTileWithElevation(circle_center,ele);\n#endif\ncorner_position+=extrude*u_extrude_scale*(radius+stroke_width)*(projected_center.w/u_camera_to_center_distance);angle_scale*=(radius+stroke_width)*(projected_center.w/u_camera_to_center_distance);}\n#ifdef GLOBE\nvec2 angles=extrude*angle_scale;vec3 corner_vector=globeRotateVector(center_vector,angles);gl_Position=interpolateProjection(corner_position,corner_vector,ele);\n#else\ngl_Position=projectTileWithElevation(corner_position,ele);\n#endif\n} else {gl_Position=projectTileWithElevation(circle_center,ele);if (gl_Position.z/gl_Position.w > 1.0) {gl_Position.xy=vec2(10000.0);}if (u_scale_with_map) {gl_Position.xy+=extrude*(radius+stroke_width)*u_extrude_scale*u_camera_to_center_distance;} else {gl_Position.xy+=extrude*(radius+stroke_width)*u_extrude_scale*gl_Position.w;}}float antialiasblur=-max(1.0/u_device_pixel_ratio/(radius+stroke_width),blur);v_data=vec3(extrude.x,extrude.y,antialiasblur);}"),clippingMask:kt(Lt,"in vec2 a_pos;void main() {gl_Position=projectTile(a_pos);}"),heatmap:kt("uniform highp float u_intensity;in vec2 v_extrude;\n#pragma mapbox: define highp float weight\n#define GAUSS_COEF 0.3989422804014327\nvoid main() {\n#pragma mapbox: initialize highp float weight\nfloat d=-0.5*3.0*3.0*dot(v_extrude,v_extrude);float val=weight*u_intensity*GAUSS_COEF*exp(d);fragColor=vec4(val,1.0,1.0,1.0);\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform float u_extrude_scale;uniform float u_opacity;uniform float u_intensity;uniform highp float u_globe_extrude_scale;in vec2 a_pos;out vec2 v_extrude;\n#pragma mapbox: define highp float weight\n#pragma mapbox: define mediump float radius\nconst highp float ZERO=1.0/255.0/16.0;\n#define GAUSS_COEF 0.3989422804014327\nvoid main(void) {\n#pragma mapbox: initialize highp float weight\n#pragma mapbox: initialize mediump float radius\nvec2 pos_raw=a_pos+32768.0;vec2 unscaled_extrude=vec2(mod(pos_raw,8.0)/7.0*2.0-1.0);float S=sqrt(-2.0*log(ZERO/weight/u_intensity/GAUSS_COEF))/3.0;v_extrude=S*unscaled_extrude;vec2 extrude=v_extrude*radius*u_extrude_scale;vec2 circle_center=floor(pos_raw/8.0);\n#ifdef GLOBE\nvec2 angles=v_extrude*radius*u_globe_extrude_scale;vec3 center_vector=projectToSphere(circle_center);vec3 corner_vector=globeRotateVector(center_vector,angles);gl_Position=interpolateProjection(circle_center+extrude,corner_vector,0.0);\n#else\ngl_Position=projectTileFor3D(circle_center+extrude,get_elevation(circle_center));\n#endif\n}"),heatmapTexture:kt("uniform sampler2D u_image;uniform sampler2D u_color_ramp;uniform float u_opacity;in vec2 v_pos;void main() {float t=texture(u_image,v_pos).r;vec4 color=texture(u_color_ramp,vec2(t,0.5));fragColor=color*u_opacity;\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(0.0);\n#endif\n}","uniform mat4 u_matrix;uniform vec2 u_world;in vec2 a_pos;out vec2 v_pos;void main() {gl_Position=u_matrix*vec4(a_pos*u_world,0,1);v_pos.x=a_pos.x;v_pos.y=1.0-a_pos.y;}"),collisionBox:kt("in float v_placed;in float v_notUsed;void main() {float alpha=0.5;fragColor=vec4(1.0,0.0,0.0,1.0)*alpha;if (v_placed > 0.5) {fragColor=vec4(0.0,0.0,1.0,0.5)*alpha;}if (v_notUsed > 0.5) {fragColor*=.1;}}","in vec2 a_anchor_pos;in vec2 a_placed;in vec2 a_box_real;uniform vec2 u_pixel_extrude_scale;out float v_placed;out float v_notUsed;void main() {gl_Position=projectTileWithElevation(a_anchor_pos,get_elevation(a_anchor_pos));gl_Position.xy=((a_box_real+0.5)*u_pixel_extrude_scale*2.0-1.0)*vec2(1.0,-1.0)*gl_Position.w;if (gl_Position.z/gl_Position.w < 1.1) {gl_Position.z=0.5;}v_placed=a_placed.x;v_notUsed=a_placed.y;}"),collisionCircle:kt("in float v_radius;in vec2 v_extrude;in float v_collision;void main() {float alpha=0.5;float stroke_radius=0.9;float distance_to_center=length(v_extrude);float distance_to_edge=abs(distance_to_center-v_radius);float opacity_t=smoothstep(-stroke_radius,0.0,-distance_to_edge);vec4 color=mix(vec4(0.0,0.0,1.0,0.5),vec4(1.0,0.0,0.0,1.0),v_collision);fragColor=color*alpha*opacity_t;}","in vec2 a_pos;in float a_radius;in vec2 a_flags;uniform vec2 u_viewport_size;out float v_radius;out vec2 v_extrude;out float v_collision;void main() {float radius=a_radius;float collision=a_flags.x;float vertexIdx=a_flags.y;vec2 quadVertexOffset=vec2(mix(-1.0,1.0,float(vertexIdx >=2.0)),mix(-1.0,1.0,float(vertexIdx >=1.0 && vertexIdx <=2.0)));vec2 quadVertexExtent=quadVertexOffset*radius;float padding_factor=1.2;v_radius=radius;v_extrude=quadVertexExtent*padding_factor;v_collision=collision;gl_Position=vec4((a_pos/u_viewport_size*2.0-1.0)*vec2(1.0,-1.0),0.0,1.0)+vec4(quadVertexExtent*padding_factor/u_viewport_size*2.0,0.0,0.0);}"),colorRelief:kt("#ifdef GL_ES\nprecision highp float;\n#endif\nuniform sampler2D u_image;uniform vec4 u_unpack;uniform sampler2D u_elevation_stops;uniform sampler2D u_color_stops;uniform int u_color_ramp_size;uniform float u_opacity;in vec2 v_pos;float getElevation(vec2 coord) {vec4 data=texture(u_image,coord)*255.0;data.a=-1.0;return dot(data,u_unpack);}float getElevationStop(int stop) {float x=(float(stop)+0.5)/float(u_color_ramp_size);vec4 data=texture(u_elevation_stops,vec2(x,0))*255.0;data.a=-1.0;return dot(data,u_unpack);}void main() {float el=getElevation(v_pos);int r=(u_color_ramp_size-1);int l=0;float el_l=getElevationStop(l);float el_r=getElevationStop(r);while(r-l > 1){int m=(r+l)/2;float el_m=getElevationStop(m);if(el < el_m){r=m;el_r=el_m;}else\n{l=m;el_l=el_m;}}float x=(float(l)+(el-el_l)/(el_r-el_l)+0.5)/float(u_color_ramp_size);fragColor=u_opacity*texture(u_color_stops,vec2(x,0));\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform vec2 u_dimension;in vec2 a_pos;out vec2 v_pos;void main() {gl_Position=projectTile(a_pos,a_pos);highp vec2 epsilon=1.0/u_dimension;float scale=(u_dimension.x-2.0)/u_dimension.x;v_pos=(a_pos/8192.0)*scale+epsilon;if (a_pos.y <-32767.5) {v_pos.y=0.0;}if (a_pos.y > 32766.5) {v_pos.y=1.0;}}"),debug:kt("uniform highp vec4 u_color;uniform sampler2D u_overlay;in vec2 v_uv;void main() {vec4 overlay_color=texture(u_overlay,v_uv);fragColor=mix(u_color,overlay_color,overlay_color.a);}","in vec2 a_pos;out vec2 v_uv;uniform float u_overlay_scale;void main() {v_uv=a_pos/8192.0;gl_Position=projectTileWithElevation(a_pos*u_overlay_scale,get_elevation(a_pos));}"),depth:kt(Lt,"in vec2 a_pos;void main() {\n#ifdef GLOBE\ngl_Position=projectTileFor3D(a_pos,0.0);\n#else\ngl_Position=u_projection_matrix*vec4(a_pos,0.0,1.0);\n#endif\n}"),fill:kt("#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float opacity\nfragColor=color*opacity;\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform vec2 u_fill_translate;in vec2 a_pos;\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float opacity\ngl_Position=projectTile(a_pos+u_fill_translate,a_pos);}"),fillOutline:kt("in vec2 v_pos;\n#ifdef GLOBE\nin float v_depth;\n#endif\n#pragma mapbox: define highp vec4 outline_color\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 outline_color\n#pragma mapbox: initialize lowp float opacity\nfloat dist=length(v_pos-gl_FragCoord.xy);float alpha=1.0-smoothstep(0.0,1.0,dist);fragColor=outline_color*(alpha*opacity);\n#ifdef GLOBE\nif (v_depth > 1.0) {discard;}\n#endif\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform vec2 u_world;uniform vec2 u_fill_translate;in vec2 a_pos;out vec2 v_pos;\n#ifdef GLOBE\nout float v_depth;\n#endif\n#pragma mapbox: define highp vec4 outline_color\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 outline_color\n#pragma mapbox: initialize lowp float opacity\ngl_Position=projectTile(a_pos+u_fill_translate,a_pos);v_pos=(gl_Position.xy/gl_Position.w+1.0)/2.0*u_world;\n#ifdef GLOBE\nv_depth=gl_Position.z/gl_Position.w;\n#endif\n}"),fillOutlinePattern:kt("uniform vec2 u_texsize;uniform sampler2D u_image;uniform float u_fade;in vec2 v_pos_a;in vec2 v_pos_b;in vec2 v_pos;\n#ifdef GLOBE\nin float v_depth;\n#endif\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;vec2 imagecoord=mod(v_pos_a,1.0);vec2 pos=mix(pattern_tl_a/u_texsize,pattern_br_a/u_texsize,imagecoord);vec4 color1=texture(u_image,pos);vec2 imagecoord_b=mod(v_pos_b,1.0);vec2 pos2=mix(pattern_tl_b/u_texsize,pattern_br_b/u_texsize,imagecoord_b);vec4 color2=texture(u_image,pos2);float dist=length(v_pos-gl_FragCoord.xy);float alpha=1.0-smoothstep(0.0,1.0,dist);fragColor=mix(color1,color2,u_fade)*alpha*opacity;\n#ifdef GLOBE\nif (v_depth > 1.0) {discard;}\n#endif\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform vec2 u_world;uniform vec2 u_pixel_coord_upper;uniform vec2 u_pixel_coord_lower;uniform vec3 u_scale;uniform vec2 u_fill_translate;in vec2 a_pos;out vec2 v_pos_a;out vec2 v_pos_b;out vec2 v_pos;\n#ifdef GLOBE\nout float v_depth;\n#endif\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;float tileRatio=u_scale.x;float fromScale=u_scale.y;float toScale=u_scale.z;gl_Position=projectTile(a_pos+u_fill_translate,a_pos);vec2 display_size_a=(pattern_br_a-pattern_tl_a)/pixel_ratio_from;vec2 display_size_b=(pattern_br_b-pattern_tl_b)/pixel_ratio_to;v_pos_a=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,fromScale*display_size_a,tileRatio,a_pos);v_pos_b=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,toScale*display_size_b,tileRatio,a_pos);v_pos=(gl_Position.xy/gl_Position.w+1.0)/2.0*u_world;\n#ifdef GLOBE\nv_depth=gl_Position.z/gl_Position.w;\n#endif\n}"),fillPattern:kt("#ifdef GL_ES\nprecision highp float;\n#endif\nuniform vec2 u_texsize;uniform float u_fade;uniform sampler2D u_image;in vec2 v_pos_a;in vec2 v_pos_b;\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;vec2 imagecoord=mod(v_pos_a,1.0);vec2 pos=mix(pattern_tl_a/u_texsize,pattern_br_a/u_texsize,imagecoord);vec4 color1=texture(u_image,pos);vec2 imagecoord_b=mod(v_pos_b,1.0);vec2 pos2=mix(pattern_tl_b/u_texsize,pattern_br_b/u_texsize,imagecoord_b);vec4 color2=texture(u_image,pos2);fragColor=mix(color1,color2,u_fade)*opacity;\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform vec2 u_pixel_coord_upper;uniform vec2 u_pixel_coord_lower;uniform vec3 u_scale;uniform vec2 u_fill_translate;in vec2 a_pos;out vec2 v_pos_a;out vec2 v_pos_b;\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;float tileZoomRatio=u_scale.x;float fromScale=u_scale.y;float toScale=u_scale.z;vec2 display_size_a=(pattern_br_a-pattern_tl_a)/pixel_ratio_from;vec2 display_size_b=(pattern_br_b-pattern_tl_b)/pixel_ratio_to;gl_Position=projectTile(a_pos+u_fill_translate,a_pos);v_pos_a=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,fromScale*display_size_a,tileZoomRatio,a_pos);v_pos_b=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,toScale*display_size_b,tileZoomRatio,a_pos);}"),fillExtrusion:kt("in vec4 v_color;void main() {fragColor=v_color;\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform vec3 u_lightcolor;uniform lowp vec3 u_lightpos;uniform lowp vec3 u_lightpos_globe;uniform lowp float u_lightintensity;uniform float u_vertical_gradient;uniform lowp float u_opacity;uniform vec2 u_fill_translate;in vec2 a_pos;in vec4 a_normal_ed;\n#ifdef TERRAIN3D\nin vec2 a_centroid;\n#endif\nout vec4 v_color;\n#pragma mapbox: define highp float base\n#pragma mapbox: define highp float height\n#pragma mapbox: define highp vec4 color\nvoid main() {\n#pragma mapbox: initialize highp float base\n#pragma mapbox: initialize highp float height\n#pragma mapbox: initialize highp vec4 color\nvec3 normal=a_normal_ed.xyz;\n#ifdef TERRAIN3D\nfloat height_terrain3d_offset=get_elevation(a_centroid);float base_terrain3d_offset=height_terrain3d_offset-(base > 0.0 ? 0.0 : 10.0);\n#else\nfloat height_terrain3d_offset=0.0;float base_terrain3d_offset=0.0;\n#endif\nbase=max(0.0,base)+base_terrain3d_offset;height=max(0.0,height)+height_terrain3d_offset;float t=mod(normal.x,2.0);float elevation=t > 0.0 ? height : base;vec2 posInTile=a_pos+u_fill_translate;\n#ifdef GLOBE\nvec3 spherePos=projectToSphere(posInTile,a_pos);gl_Position=interpolateProjectionFor3D(posInTile,spherePos,elevation);\n#else\ngl_Position=u_projection_matrix*vec4(posInTile,elevation,1.0);\n#endif\nfloat colorvalue=color.r*0.2126+color.g*0.7152+color.b*0.0722;v_color=vec4(0.0,0.0,0.0,1.0);vec4 ambientlight=vec4(0.03,0.03,0.03,1.0);color+=ambientlight;vec3 normalForLighting=normal/16384.0;float directional=clamp(dot(normalForLighting,u_lightpos),0.0,1.0);\n#ifdef GLOBE\nmat3 rotMatrix=globeGetRotationMatrix(spherePos);normalForLighting=rotMatrix*normalForLighting;directional=mix(directional,clamp(dot(normalForLighting,u_lightpos_globe),0.0,1.0),u_projection_transition);\n#endif\ndirectional=mix((1.0-u_lightintensity),max((1.0-colorvalue+u_lightintensity),1.0),directional);if (normal.y !=0.0) {directional*=((1.0-u_vertical_gradient)+(u_vertical_gradient*clamp((t+base)*pow(height/150.0,0.5),mix(0.7,0.98,1.0-u_lightintensity),1.0)));}v_color.r+=clamp(color.r*directional*u_lightcolor.r,mix(0.0,0.3,1.0-u_lightcolor.r),1.0);v_color.g+=clamp(color.g*directional*u_lightcolor.g,mix(0.0,0.3,1.0-u_lightcolor.g),1.0);v_color.b+=clamp(color.b*directional*u_lightcolor.b,mix(0.0,0.3,1.0-u_lightcolor.b),1.0);v_color*=u_opacity;}"),fillExtrusionPattern:kt("uniform vec2 u_texsize;uniform float u_fade;uniform sampler2D u_image;in vec2 v_pos_a;in vec2 v_pos_b;in vec4 v_lighting;\n#pragma mapbox: define lowp float base\n#pragma mapbox: define lowp float height\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\nvoid main() {\n#pragma mapbox: initialize lowp float base\n#pragma mapbox: initialize lowp float height\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;vec2 imagecoord=mod(v_pos_a,1.0);vec2 pos=mix(pattern_tl_a/u_texsize,pattern_br_a/u_texsize,imagecoord);vec4 color1=texture(u_image,pos);vec2 imagecoord_b=mod(v_pos_b,1.0);vec2 pos2=mix(pattern_tl_b/u_texsize,pattern_br_b/u_texsize,imagecoord_b);vec4 color2=texture(u_image,pos2);vec4 mixedColor=mix(color1,color2,u_fade);fragColor=mixedColor*v_lighting;\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform vec2 u_pixel_coord_upper;uniform vec2 u_pixel_coord_lower;uniform float u_height_factor;uniform vec3 u_scale;uniform float u_vertical_gradient;uniform lowp float u_opacity;uniform vec2 u_fill_translate;uniform vec3 u_lightcolor;uniform lowp vec3 u_lightpos;uniform lowp vec3 u_lightpos_globe;uniform lowp float u_lightintensity;in vec2 a_pos;in vec4 a_normal_ed;\n#ifdef TERRAIN3D\nin vec2 a_centroid;\n#endif\n#ifdef GLOBE\nout vec3 v_sphere_pos;\n#endif\nout vec2 v_pos_a;out vec2 v_pos_b;out vec4 v_lighting;\n#pragma mapbox: define lowp float base\n#pragma mapbox: define lowp float height\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\nvoid main() {\n#pragma mapbox: initialize lowp float base\n#pragma mapbox: initialize lowp float height\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;float tileRatio=u_scale.x;float fromScale=u_scale.y;float toScale=u_scale.z;vec3 normal=a_normal_ed.xyz;float edgedistance=a_normal_ed.w;vec2 display_size_a=(pattern_br_a-pattern_tl_a)/pixel_ratio_from;vec2 display_size_b=(pattern_br_b-pattern_tl_b)/pixel_ratio_to;\n#ifdef TERRAIN3D\nfloat height_terrain3d_offset=get_elevation(a_centroid);float base_terrain3d_offset=height_terrain3d_offset-(base > 0.0 ? 0.0 : 10.0);\n#else\nfloat height_terrain3d_offset=0.0;float base_terrain3d_offset=0.0;\n#endif\nbase=max(0.0,base)+base_terrain3d_offset;height=max(0.0,height)+height_terrain3d_offset;float t=mod(normal.x,2.0);float elevation=t > 0.0 ? height : base;vec2 posInTile=a_pos+u_fill_translate;\n#ifdef GLOBE\nvec3 spherePos=projectToSphere(posInTile,a_pos);vec3 elevatedPos=spherePos*(1.0+elevation/GLOBE_RADIUS);v_sphere_pos=elevatedPos;gl_Position=interpolateProjectionFor3D(posInTile,spherePos,elevation);\n#else\ngl_Position=u_projection_matrix*vec4(posInTile,elevation,1.0);\n#endif\nvec2 pos=normal.x==1.0 && normal.y==0.0 && normal.z==16384.0\n? a_pos\n: vec2(edgedistance,elevation*u_height_factor);v_pos_a=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,fromScale*display_size_a,tileRatio,pos);v_pos_b=get_pattern_pos(u_pixel_coord_upper,u_pixel_coord_lower,toScale*display_size_b,tileRatio,pos);v_lighting=vec4(0.0,0.0,0.0,1.0);float directional=clamp(dot(normal/16383.0,u_lightpos),0.0,1.0);directional=mix((1.0-u_lightintensity),max((0.5+u_lightintensity),1.0),directional);if (normal.y !=0.0) {directional*=((1.0-u_vertical_gradient)+(u_vertical_gradient*clamp((t+base)*pow(height/150.0,0.5),mix(0.7,0.98,1.0-u_lightintensity),1.0)));}v_lighting.rgb+=clamp(directional*u_lightcolor,mix(vec3(0.0),vec3(0.3),1.0-u_lightcolor),vec3(1.0));v_lighting*=u_opacity;}"),hillshadePrepare:kt("#ifdef GL_ES\nprecision highp float;\n#endif\nuniform sampler2D u_image;in vec2 v_pos;uniform vec2 u_dimension;uniform float u_zoom;uniform vec4 u_unpack;float getElevation(vec2 coord,float bias) {vec4 data=texture(u_image,coord)*255.0;data.a=-1.0;return dot(data,u_unpack);}void main() {vec2 epsilon=1.0/u_dimension;float tileSize=u_dimension.x-2.0;float a=getElevation(v_pos+vec2(-epsilon.x,-epsilon.y),0.0);float b=getElevation(v_pos+vec2(0,-epsilon.y),0.0);float c=getElevation(v_pos+vec2(epsilon.x,-epsilon.y),0.0);float d=getElevation(v_pos+vec2(-epsilon.x,0),0.0);float e=getElevation(v_pos,0.0);float f=getElevation(v_pos+vec2(epsilon.x,0),0.0);float g=getElevation(v_pos+vec2(-epsilon.x,epsilon.y),0.0);float h=getElevation(v_pos+vec2(0,epsilon.y),0.0);float i=getElevation(v_pos+vec2(epsilon.x,epsilon.y),0.0);float exaggerationFactor=u_zoom < 2.0 ? 0.4 : u_zoom < 4.5 ? 0.35 : 0.3;float exaggeration=u_zoom < 15.0 ? (u_zoom-15.0)*exaggerationFactor : 0.0;vec2 deriv=vec2((c+f+f+i)-(a+d+d+g),(g+h+h+i)-(a+b+b+c))*tileSize/pow(2.0,exaggeration+(28.2562-u_zoom));fragColor=clamp(vec4(deriv.x/8.0+0.5,deriv.y/8.0+0.5,1.0,1.0),0.0,1.0);\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;uniform vec2 u_dimension;in vec2 a_pos;in vec2 a_texture_pos;out vec2 v_pos;void main() {gl_Position=u_matrix*vec4(a_pos,0,1);highp vec2 epsilon=1.0/u_dimension;float scale=(u_dimension.x-2.0)/u_dimension.x;v_pos=(a_texture_pos/8192.0)*scale+epsilon;}"),hillshade:kt("uniform sampler2D u_image;in vec2 v_pos;uniform vec2 u_latrange;uniform float u_exaggeration;uniform vec4 u_accent;uniform int u_method;uniform float u_altitudes[NUM_ILLUMINATION_SOURCES];uniform float u_azimuths[NUM_ILLUMINATION_SOURCES];uniform vec4 u_shadows[NUM_ILLUMINATION_SOURCES];uniform vec4 u_highlights[NUM_ILLUMINATION_SOURCES];\n#define PI 3.141592653589793\n#define STANDARD 0\n#define COMBINED 1\n#define IGOR 2\n#define MULTIDIRECTIONAL 3\n#define BASIC 4\nfloat get_aspect(vec2 deriv){return deriv.x !=0.0 ? atan(deriv.y,-deriv.x) : PI/2.0*(deriv.y > 0.0 ? 1.0 :-1.0);}void igor_hillshade(vec2 deriv){deriv=deriv*u_exaggeration*2.0;float aspect=get_aspect(deriv);float azimuth=u_azimuths[0]+PI;float slope_stength=atan(length(deriv))*2.0/PI;float aspect_strength=1.0-abs(mod((aspect+azimuth)/PI+0.5,2.0)-1.0);float shadow_strength=slope_stength*aspect_strength;float highlight_strength=slope_stength*(1.0-aspect_strength);fragColor=u_shadows[0]*shadow_strength+u_highlights[0]*highlight_strength;}void standard_hillshade(vec2 deriv){float azimuth=u_azimuths[0]+PI;float slope=atan(0.625*length(deriv));float aspect=get_aspect(deriv);float intensity=u_exaggeration;float base=1.875-intensity*1.75;float maxValue=0.5*PI;float scaledSlope=intensity !=0.5 ? ((pow(base,slope)-1.0)/(pow(base,maxValue)-1.0))*maxValue : slope;float accent=cos(scaledSlope);vec4 accent_color=(1.0-accent)*u_accent*clamp(intensity*2.0,0.0,1.0);float shade=abs(mod((aspect+azimuth)/PI+0.5,2.0)-1.0);vec4 shade_color=mix(u_shadows[0],u_highlights[0],shade)*sin(scaledSlope)*clamp(intensity*2.0,0.0,1.0);fragColor=accent_color*(1.0-shade_color.a)+shade_color;}void basic_hillshade(vec2 deriv){deriv=deriv*u_exaggeration*2.0;float azimuth=u_azimuths[0]+PI;float cos_az=cos(azimuth);float sin_az=sin(azimuth);float cos_alt=cos(u_altitudes[0]);float sin_alt=sin(u_altitudes[0]);float cang=(sin_alt-(deriv.y*cos_az*cos_alt-deriv.x*sin_az*cos_alt))/sqrt(1.0+dot(deriv,deriv));float shade=clamp(cang,0.0,1.0);if(shade > 0.5){fragColor=u_highlights[0]*(2.0*shade-1.0);}else\n{fragColor=u_shadows[0]*(1.0-2.0*shade);}}void multidirectional_hillshade(vec2 deriv){deriv=deriv*u_exaggeration*2.0;fragColor=vec4(0,0,0,0);for(int i=0; i < NUM_ILLUMINATION_SOURCES; i++){float cos_alt=cos(u_altitudes[i]);float sin_alt=sin(u_altitudes[i]);float cos_az=-cos(u_azimuths[i]);float sin_az=-sin(u_azimuths[i]);float cang=(sin_alt-(deriv.y*cos_az*cos_alt-deriv.x*sin_az*cos_alt))/sqrt(1.0+dot(deriv,deriv));float shade=clamp(cang,0.0,1.0);if(shade > 0.5){fragColor+=u_highlights[i]*(2.0*shade-1.0)/float(NUM_ILLUMINATION_SOURCES);}else\n{fragColor+=u_shadows[i]*(1.0-2.0*shade)/float(NUM_ILLUMINATION_SOURCES);}}}void combined_hillshade(vec2 deriv){deriv=deriv*u_exaggeration*2.0;float azimuth=u_azimuths[0]+PI;float cos_az=cos(azimuth);float sin_az=sin(azimuth);float cos_alt=cos(u_altitudes[0]);float sin_alt=sin(u_altitudes[0]);float cang=acos((sin_alt-(deriv.y*cos_az*cos_alt-deriv.x*sin_az*cos_alt))/sqrt(1.0+dot(deriv,deriv)));cang=clamp(cang,0.0,PI/2.0);float shade=cang*atan(length(deriv))*4.0/PI/PI;float highlight=(PI/2.0-cang)*atan(length(deriv))*4.0/PI/PI;fragColor=u_shadows[0]*shade+u_highlights[0]*highlight;}void main() {vec4 pixel=texture(u_image,v_pos);float scaleFactor=cos(radians((u_latrange[0]-u_latrange[1])*(1.0-v_pos.y)+u_latrange[1]));vec2 deriv=((pixel.rg*8.0)-4.0)/scaleFactor;if (u_method==BASIC) {basic_hillshade(deriv);} else if (u_method==COMBINED) {combined_hillshade(deriv);} else if (u_method==IGOR) {igor_hillshade(deriv);} else if (u_method==MULTIDIRECTIONAL) {multidirectional_hillshade(deriv);} else if (u_method==STANDARD) {standard_hillshade(deriv);} else {standard_hillshade(deriv);}\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform mat4 u_matrix;in vec2 a_pos;out vec2 v_pos;void main() {gl_Position=projectTile(a_pos,a_pos);v_pos=a_pos/8192.0;if (a_pos.y <-32767.5) {v_pos.y=0.0;}if (a_pos.y > 32766.5) {v_pos.y=1.0;}}"),line:kt("uniform lowp float u_device_pixel_ratio;in vec2 v_width2;in vec2 v_normal;in float v_gamma_scale;\n#ifdef GLOBE\nin float v_depth;\n#endif\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\nfloat dist=length(v_normal)*v_width2.s;float blur2=(blur+1.0/u_device_pixel_ratio)*v_gamma_scale;float alpha=clamp(min(dist-(v_width2.t-blur2),v_width2.s-dist)/blur2,0.0,1.0);fragColor=color*(alpha*opacity);\n#ifdef GLOBE\nif (v_depth > 1.0) {discard;}\n#endif\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","\n#define scale 0.015873016\nin vec2 a_pos_normal;in vec4 a_data;uniform vec2 u_translation;uniform mediump float u_ratio;uniform vec2 u_units_to_pixels;uniform lowp float u_device_pixel_ratio;out vec2 v_normal;out vec2 v_width2;out float v_gamma_scale;out highp float v_linesofar;\n#ifdef GLOBE\nout float v_depth;\n#endif\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define mediump float gapwidth\n#pragma mapbox: define lowp float offset\n#pragma mapbox: define mediump float width\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump float gapwidth\n#pragma mapbox: initialize lowp float offset\n#pragma mapbox: initialize mediump float width\nfloat ANTIALIASING=1.0/u_device_pixel_ratio/2.0;vec2 a_extrude=a_data.xy-128.0;float a_direction=mod(a_data.z,4.0)-1.0;v_linesofar=(floor(a_data.z/4.0)+a_data.w*64.0)*2.0;vec2 pos=floor(a_pos_normal*0.5);mediump vec2 normal=a_pos_normal-2.0*pos;normal.y=normal.y*2.0-1.0;v_normal=normal;gapwidth=gapwidth/2.0;float halfwidth=width/2.0;offset=-1.0*offset;float inset=gapwidth+(gapwidth > 0.0 ? ANTIALIASING : 0.0);float outset=gapwidth+halfwidth*(gapwidth > 0.0 ? 2.0 : 1.0)+(halfwidth==0.0 ? 0.0 : ANTIALIASING);mediump vec2 dist=outset*a_extrude*scale;mediump float u=0.5*a_direction;mediump float t=1.0-abs(u);mediump vec2 offset2=offset*a_extrude*scale*normal.y*mat2(t,-u,u,t);float adjustedThickness=projectLineThickness(pos.y);vec4 projected_no_extrude=projectTile(pos+offset2/u_ratio*adjustedThickness+u_translation);vec4 projected_with_extrude=projectTile(pos+offset2/u_ratio*adjustedThickness+u_translation+dist/u_ratio*adjustedThickness);gl_Position=projected_with_extrude;\n#ifdef GLOBE\nv_depth=gl_Position.z/gl_Position.w;\n#endif\n#ifdef TERRAIN3D\nv_gamma_scale=1.0;\n#else\nfloat extrude_length_without_perspective=length(dist);float extrude_length_with_perspective=length((projected_with_extrude.xy-projected_no_extrude.xy)/projected_with_extrude.w*u_units_to_pixels);v_gamma_scale=extrude_length_without_perspective/extrude_length_with_perspective;\n#endif\nv_width2=vec2(outset,inset);}"),lineGradient:kt("uniform lowp float u_device_pixel_ratio;uniform sampler2D u_image;in vec2 v_width2;in vec2 v_normal;in float v_gamma_scale;in highp vec2 v_uv;\n#ifdef GLOBE\nin float v_depth;\n#endif\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\nfloat dist=length(v_normal)*v_width2.s;float blur2=(blur+1.0/u_device_pixel_ratio)*v_gamma_scale;float alpha=clamp(min(dist-(v_width2.t-blur2),v_width2.s-dist)/blur2,0.0,1.0);vec4 color=texture(u_image,v_uv);fragColor=color*(alpha*opacity);\n#ifdef GLOBE\nif (v_depth > 1.0) {discard;}\n#endif\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","\n#define scale 0.015873016\nin vec2 a_pos_normal;in vec4 a_data;in float a_uv_x;in float a_split_index;uniform vec2 u_translation;uniform mediump float u_ratio;uniform lowp float u_device_pixel_ratio;uniform vec2 u_units_to_pixels;uniform float u_image_height;out vec2 v_normal;out vec2 v_width2;out float v_gamma_scale;out highp vec2 v_uv;\n#ifdef GLOBE\nout float v_depth;\n#endif\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define mediump float gapwidth\n#pragma mapbox: define lowp float offset\n#pragma mapbox: define mediump float width\nvoid main() {\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump float gapwidth\n#pragma mapbox: initialize lowp float offset\n#pragma mapbox: initialize mediump float width\nfloat ANTIALIASING=1.0/u_device_pixel_ratio/2.0;vec2 a_extrude=a_data.xy-128.0;float a_direction=mod(a_data.z,4.0)-1.0;highp float texel_height=1.0/u_image_height;highp float half_texel_height=0.5*texel_height;v_uv=vec2(a_uv_x,a_split_index*texel_height-half_texel_height);vec2 pos=floor(a_pos_normal*0.5);mediump vec2 normal=a_pos_normal-2.0*pos;normal.y=normal.y*2.0-1.0;v_normal=normal;gapwidth=gapwidth/2.0;float halfwidth=width/2.0;offset=-1.0*offset;float inset=gapwidth+(gapwidth > 0.0 ? ANTIALIASING : 0.0);float outset=gapwidth+halfwidth*(gapwidth > 0.0 ? 2.0 : 1.0)+(halfwidth==0.0 ? 0.0 : ANTIALIASING);mediump vec2 dist=outset*a_extrude*scale;mediump float u=0.5*a_direction;mediump float t=1.0-abs(u);mediump vec2 offset2=offset*a_extrude*scale*normal.y*mat2(t,-u,u,t);float adjustedThickness=projectLineThickness(pos.y);vec4 projected_no_extrude=projectTile(pos+offset2/u_ratio*adjustedThickness+u_translation);vec4 projected_with_extrude=projectTile(pos+offset2/u_ratio*adjustedThickness+u_translation+dist/u_ratio*adjustedThickness);gl_Position=projected_with_extrude;\n#ifdef GLOBE\nv_depth=gl_Position.z/gl_Position.w;\n#endif\n#ifdef TERRAIN3D\nv_gamma_scale=1.0;\n#else\nfloat extrude_length_without_perspective=length(dist);float extrude_length_with_perspective=length((projected_with_extrude.xy-projected_no_extrude.xy)/projected_with_extrude.w*u_units_to_pixels);v_gamma_scale=extrude_length_without_perspective/extrude_length_with_perspective;\n#endif\nv_width2=vec2(outset,inset);}"),linePattern:kt("#ifdef GL_ES\nprecision highp float;\n#endif\nuniform lowp float u_device_pixel_ratio;uniform vec2 u_texsize;uniform float u_fade;uniform mediump vec3 u_scale;uniform sampler2D u_image;in vec2 v_normal;in vec2 v_width2;in float v_linesofar;in float v_gamma_scale;in float v_width;\n#ifdef GLOBE\nin float v_depth;\n#endif\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\nvec2 pattern_tl_a=pattern_from.xy;vec2 pattern_br_a=pattern_from.zw;vec2 pattern_tl_b=pattern_to.xy;vec2 pattern_br_b=pattern_to.zw;float tileZoomRatio=u_scale.x;float fromScale=u_scale.y;float toScale=u_scale.z;vec2 display_size_a=(pattern_br_a-pattern_tl_a)/pixel_ratio_from;vec2 display_size_b=(pattern_br_b-pattern_tl_b)/pixel_ratio_to;vec2 pattern_size_a=vec2(display_size_a.x*fromScale/tileZoomRatio,display_size_a.y);vec2 pattern_size_b=vec2(display_size_b.x*toScale/tileZoomRatio,display_size_b.y);float aspect_a=display_size_a.y/v_width;float aspect_b=display_size_b.y/v_width;float dist=length(v_normal)*v_width2.s;float blur2=(blur+1.0/u_device_pixel_ratio)*v_gamma_scale;float alpha=clamp(min(dist-(v_width2.t-blur2),v_width2.s-dist)/blur2,0.0,1.0);float x_a=mod(v_linesofar/pattern_size_a.x*aspect_a,1.0);float x_b=mod(v_linesofar/pattern_size_b.x*aspect_b,1.0);float y=0.5*v_normal.y+0.5;vec2 texel_size=1.0/u_texsize;vec2 pos_a=mix(pattern_tl_a*texel_size-texel_size,pattern_br_a*texel_size+texel_size,vec2(x_a,y));vec2 pos_b=mix(pattern_tl_b*texel_size-texel_size,pattern_br_b*texel_size+texel_size,vec2(x_b,y));vec4 color=mix(texture(u_image,pos_a),texture(u_image,pos_b),u_fade);fragColor=color*alpha*opacity;\n#ifdef GLOBE\nif (v_depth > 1.0) {discard;}\n#endif\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","\n#define scale 0.015873016\n#define LINE_DISTANCE_SCALE 2.0\nin vec2 a_pos_normal;in vec4 a_data;uniform vec2 u_translation;uniform vec2 u_units_to_pixels;uniform mediump float u_ratio;uniform lowp float u_device_pixel_ratio;out vec2 v_normal;out vec2 v_width2;out float v_linesofar;out float v_gamma_scale;out float v_width;\n#ifdef GLOBE\nout float v_depth;\n#endif\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp float offset\n#pragma mapbox: define mediump float gapwidth\n#pragma mapbox: define mediump float width\n#pragma mapbox: define lowp float floorwidth\n#pragma mapbox: define lowp vec4 pattern_from\n#pragma mapbox: define lowp vec4 pattern_to\n#pragma mapbox: define lowp float pixel_ratio_from\n#pragma mapbox: define lowp float pixel_ratio_to\nvoid main() {\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize lowp float offset\n#pragma mapbox: initialize mediump float gapwidth\n#pragma mapbox: initialize mediump float width\n#pragma mapbox: initialize lowp float floorwidth\n#pragma mapbox: initialize mediump vec4 pattern_from\n#pragma mapbox: initialize mediump vec4 pattern_to\n#pragma mapbox: initialize lowp float pixel_ratio_from\n#pragma mapbox: initialize lowp float pixel_ratio_to\nfloat ANTIALIASING=1.0/u_device_pixel_ratio/2.0;vec2 a_extrude=a_data.xy-128.0;float a_direction=mod(a_data.z,4.0)-1.0;float a_linesofar=(floor(a_data.z/4.0)+a_data.w*64.0)*LINE_DISTANCE_SCALE;vec2 pos=floor(a_pos_normal*0.5);mediump vec2 normal=a_pos_normal-2.0*pos;normal.y=normal.y*2.0-1.0;v_normal=normal;gapwidth=gapwidth/2.0;float halfwidth=width/2.0;offset=-1.0*offset;float inset=gapwidth+(gapwidth > 0.0 ? ANTIALIASING : 0.0);float outset=gapwidth+halfwidth*(gapwidth > 0.0 ? 2.0 : 1.0)+(halfwidth==0.0 ? 0.0 : ANTIALIASING);mediump vec2 dist=outset*a_extrude*scale;mediump float u=0.5*a_direction;mediump float t=1.0-abs(u);mediump vec2 offset2=offset*a_extrude*scale*normal.y*mat2(t,-u,u,t);float adjustedThickness=projectLineThickness(pos.y);vec4 projected_no_extrude=projectTile(pos+offset2/u_ratio*adjustedThickness+u_translation);vec4 projected_with_extrude=projectTile(pos+offset2/u_ratio*adjustedThickness+u_translation+dist/u_ratio*adjustedThickness);gl_Position=projected_with_extrude;\n#ifdef GLOBE\nv_depth=gl_Position.z/gl_Position.w;\n#endif\n#ifdef TERRAIN3D\nv_gamma_scale=1.0;\n#else\nfloat extrude_length_without_perspective=length(dist);float extrude_length_with_perspective=length((projected_with_extrude.xy-projected_no_extrude.xy)/projected_with_extrude.w*u_units_to_pixels);v_gamma_scale=extrude_length_without_perspective/extrude_length_with_perspective;\n#endif\nv_linesofar=a_linesofar;v_width2=vec2(outset,inset);v_width=floorwidth;}"),lineSDF:kt("uniform lowp float u_device_pixel_ratio;uniform lowp float u_lineatlas_width;uniform sampler2D u_image;uniform float u_mix;in vec2 v_normal;in vec2 v_width2;in vec2 v_tex_a;in vec2 v_tex_b;in float v_gamma_scale;\n#ifdef GLOBE\nin float v_depth;\n#endif\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define mediump float width\n#pragma mapbox: define lowp float floorwidth\n#pragma mapbox: define mediump vec4 dasharray_from\n#pragma mapbox: define mediump vec4 dasharray_to\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump float width\n#pragma mapbox: initialize lowp float floorwidth\n#pragma mapbox: initialize mediump vec4 dasharray_from\n#pragma mapbox: initialize mediump vec4 dasharray_to\nfloat dist=length(v_normal)*v_width2.s;float blur2=(blur+1.0/u_device_pixel_ratio)*v_gamma_scale;float alpha=clamp(min(dist-(v_width2.t-blur2),v_width2.s-dist)/blur2,0.0,1.0);float sdfdist_a=texture(u_image,v_tex_a).a;float sdfdist_b=texture(u_image,v_tex_b).a;float sdfdist=mix(sdfdist_a,sdfdist_b,u_mix);float sdfgamma=(u_lineatlas_width/256.0/u_device_pixel_ratio)/min(dasharray_from.w,dasharray_to.w);alpha*=smoothstep(0.5-sdfgamma/floorwidth,0.5+sdfgamma/floorwidth,sdfdist);fragColor=color*(alpha*opacity);\n#ifdef GLOBE\nif (v_depth > 1.0) {discard;}\n#endif\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","\n#define scale 0.015873016\n#define LINE_DISTANCE_SCALE 2.0\nin vec2 a_pos_normal;in vec4 a_data;uniform vec2 u_translation;uniform mediump float u_ratio;uniform lowp float u_device_pixel_ratio;uniform vec2 u_units_to_pixels;uniform float u_tileratio;uniform float u_crossfade_from;uniform float u_crossfade_to;uniform float u_lineatlas_height;out vec2 v_normal;out vec2 v_width2;out vec2 v_tex_a;out vec2 v_tex_b;out float v_gamma_scale;\n#ifdef GLOBE\nout float v_depth;\n#endif\n#pragma mapbox: define highp vec4 color\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define mediump float gapwidth\n#pragma mapbox: define lowp float offset\n#pragma mapbox: define mediump float width\n#pragma mapbox: define lowp float floorwidth\n#pragma mapbox: define mediump vec4 dasharray_from\n#pragma mapbox: define mediump vec4 dasharray_to\nvoid main() {\n#pragma mapbox: initialize highp vec4 color\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump float gapwidth\n#pragma mapbox: initialize lowp float offset\n#pragma mapbox: initialize mediump float width\n#pragma mapbox: initialize lowp float floorwidth\n#pragma mapbox: initialize mediump vec4 dasharray_from\n#pragma mapbox: initialize mediump vec4 dasharray_to\nfloat ANTIALIASING=1.0/u_device_pixel_ratio/2.0;vec2 a_extrude=a_data.xy-128.0;float a_direction=mod(a_data.z,4.0)-1.0;float a_linesofar=(floor(a_data.z/4.0)+a_data.w*64.0)*LINE_DISTANCE_SCALE;vec2 pos=floor(a_pos_normal*0.5);mediump vec2 normal=a_pos_normal-2.0*pos;normal.y=normal.y*2.0-1.0;v_normal=normal;gapwidth=gapwidth/2.0;float halfwidth=width/2.0;offset=-1.0*offset;float inset=gapwidth+(gapwidth > 0.0 ? ANTIALIASING : 0.0);float outset=gapwidth+halfwidth*(gapwidth > 0.0 ? 2.0 : 1.0)+(halfwidth==0.0 ? 0.0 : ANTIALIASING);mediump vec2 dist=outset*a_extrude*scale;mediump float u=0.5*a_direction;mediump float t=1.0-abs(u);mediump vec2 offset2=offset*a_extrude*scale*normal.y*mat2(t,-u,u,t);float adjustedThickness=projectLineThickness(pos.y);vec4 projected_no_extrude=projectTile(pos+offset2/u_ratio*adjustedThickness+u_translation);vec4 projected_with_extrude=projectTile(pos+offset2/u_ratio*adjustedThickness+u_translation+dist/u_ratio*adjustedThickness);gl_Position=projected_with_extrude;\n#ifdef GLOBE\nv_depth=gl_Position.z/gl_Position.w;\n#endif\n#ifdef TERRAIN3D\nv_gamma_scale=1.0;\n#else\nfloat extrude_length_without_perspective=length(dist);float extrude_length_with_perspective=length((projected_with_extrude.xy-projected_no_extrude.xy)/projected_with_extrude.w*u_units_to_pixels);v_gamma_scale=extrude_length_without_perspective/extrude_length_with_perspective;\n#endif\nfloat u_patternscale_a_x=u_tileratio/dasharray_from.w/u_crossfade_from;float u_patternscale_a_y=-dasharray_from.z/2.0/u_lineatlas_height;float u_patternscale_b_x=u_tileratio/dasharray_to.w/u_crossfade_to;float u_patternscale_b_y=-dasharray_to.z/2.0/u_lineatlas_height;v_tex_a=vec2(a_linesofar*u_patternscale_a_x/floorwidth,normal.y*u_patternscale_a_y+(float(dasharray_from.y)+0.5)/u_lineatlas_height);v_tex_b=vec2(a_linesofar*u_patternscale_b_x/floorwidth,normal.y*u_patternscale_b_y+(float(dasharray_to.y)+0.5)/u_lineatlas_height);v_width2=vec2(outset,inset);}"),lineGradientSDF:kt("uniform lowp float u_device_pixel_ratio;uniform sampler2D u_image;uniform sampler2D u_image_dash;uniform float u_mix;uniform lowp float u_lineatlas_width;in vec2 v_normal;in vec2 v_width2;in vec2 v_tex_a;in vec2 v_tex_b;in float v_gamma_scale;in highp vec2 v_uv;\n#ifdef GLOBE\nin float v_depth;\n#endif\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define mediump float width\n#pragma mapbox: define lowp float floorwidth\n#pragma mapbox: define mediump vec4 dasharray_from\n#pragma mapbox: define mediump vec4 dasharray_to\nvoid main() {\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump float width\n#pragma mapbox: initialize lowp float floorwidth\n#pragma mapbox: initialize mediump vec4 dasharray_from\n#pragma mapbox: initialize mediump vec4 dasharray_to\nfloat dist=length(v_normal)*v_width2.s;float blur2=(blur+1.0/u_device_pixel_ratio)*v_gamma_scale;float alpha=clamp(min(dist-(v_width2.t-blur2),v_width2.s-dist)/blur2,0.0,1.0);vec4 color=texture(u_image,v_uv);float sdfdist_a=texture(u_image_dash,v_tex_a).a;float sdfdist_b=texture(u_image_dash,v_tex_b).a;float sdfdist=mix(sdfdist_a,sdfdist_b,u_mix);float sdfgamma=(u_lineatlas_width/256.0)/min(dasharray_from.w,dasharray_to.w);float dash_alpha=smoothstep(0.5-sdfgamma/floorwidth,0.5+sdfgamma/floorwidth,sdfdist);fragColor=color*(alpha*dash_alpha*opacity);\n#ifdef GLOBE\nif (v_depth > 1.0) {discard;}\n#endif\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","\n#define scale 0.015873016\n#define LINE_DISTANCE_SCALE 2.0\nin vec2 a_pos_normal;in vec4 a_data;in float a_uv_x;in float a_split_index;uniform vec2 u_translation;uniform mediump float u_ratio;uniform lowp float u_device_pixel_ratio;uniform vec2 u_units_to_pixels;uniform float u_image_height;uniform float u_tileratio;uniform float u_crossfade_from;uniform float u_crossfade_to;uniform float u_lineatlas_height;out vec2 v_normal;out vec2 v_width2;out float v_gamma_scale;out highp vec2 v_uv;out vec2 v_tex_a;out vec2 v_tex_b;\n#ifdef GLOBE\nout float v_depth;\n#endif\n#pragma mapbox: define lowp float blur\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define mediump float gapwidth\n#pragma mapbox: define lowp float offset\n#pragma mapbox: define mediump float width\n#pragma mapbox: define lowp float floorwidth\n#pragma mapbox: define mediump vec4 dasharray_from\n#pragma mapbox: define mediump vec4 dasharray_to\nvoid main() {\n#pragma mapbox: initialize lowp float blur\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize mediump float gapwidth\n#pragma mapbox: initialize lowp float offset\n#pragma mapbox: initialize mediump float width\n#pragma mapbox: initialize lowp float floorwidth\n#pragma mapbox: initialize mediump vec4 dasharray_from\n#pragma mapbox: initialize mediump vec4 dasharray_to\nfloat ANTIALIASING=1.0/u_device_pixel_ratio/2.0;vec2 a_extrude=a_data.xy-128.0;float a_direction=mod(a_data.z,4.0)-1.0;float a_linesofar=(floor(a_data.z/4.0)+a_data.w*64.0)*LINE_DISTANCE_SCALE;float texel_height=1.0/u_image_height;float half_texel_height=0.5*texel_height;v_uv=vec2(a_uv_x,a_split_index*texel_height-half_texel_height);vec2 pos=floor(a_pos_normal*0.5);mediump vec2 normal=a_pos_normal-2.0*pos;normal.y=normal.y*2.0-1.0;v_normal=normal;gapwidth=gapwidth/2.0;float halfwidth=width/2.0;offset=-1.0*offset;float inset=gapwidth+(gapwidth > 0.0 ? ANTIALIASING : 0.0);float outset=gapwidth+halfwidth*(gapwidth > 0.0 ? 2.0 : 1.0)+(halfwidth==0.0 ? 0.0 : ANTIALIASING);mediump vec2 dist=outset*a_extrude*scale;mediump float u=0.5*a_direction;mediump float t=1.0-abs(u);mediump vec2 offset2=offset*a_extrude*scale*normal.y*mat2(t,-u,u,t);float adjustedThickness=projectLineThickness(pos.y);vec4 projected_no_extrude=projectTile(pos+offset2/u_ratio*adjustedThickness+u_translation);vec4 projected_with_extrude=projectTile(pos+offset2/u_ratio*adjustedThickness+u_translation+dist/u_ratio*adjustedThickness);gl_Position=projected_with_extrude;\n#ifdef GLOBE\nv_depth=gl_Position.z/gl_Position.w;\n#endif\n#ifdef TERRAIN3D\nv_gamma_scale=1.0;\n#else\nfloat extrude_length_without_perspective=length(dist);float extrude_length_with_perspective=length((projected_with_extrude.xy-projected_no_extrude.xy)/projected_with_extrude.w*u_units_to_pixels);v_gamma_scale=extrude_length_without_perspective/extrude_length_with_perspective;\n#endif\nfloat u_patternscale_a_x=u_tileratio/dasharray_from.w/u_crossfade_from;float u_patternscale_a_y=-dasharray_from.z/2.0/u_lineatlas_height;float u_patternscale_b_x=u_tileratio/dasharray_to.w/u_crossfade_to;float u_patternscale_b_y=-dasharray_to.z/2.0/u_lineatlas_height;v_tex_a=vec2(a_linesofar*u_patternscale_a_x/floorwidth,normal.y*u_patternscale_a_y+(float(dasharray_from.y)+0.5)/u_lineatlas_height);v_tex_b=vec2(a_linesofar*u_patternscale_b_x/floorwidth,normal.y*u_patternscale_b_y+(float(dasharray_to.y)+0.5)/u_lineatlas_height);v_width2=vec2(outset,inset);}"),raster:kt("uniform float u_fade_t;uniform float u_opacity;uniform sampler2D u_image0;uniform sampler2D u_image1;in vec2 v_pos0;in vec2 v_pos1;uniform float u_brightness_low;uniform float u_brightness_high;uniform float u_saturation_factor;uniform float u_contrast_factor;uniform vec3 u_spin_weights;void main() {vec4 color0=texture(u_image0,v_pos0);vec4 color1=texture(u_image1,v_pos1);if (color0.a > 0.0) {color0.rgb=color0.rgb/color0.a;}if (color1.a > 0.0) {color1.rgb=color1.rgb/color1.a;}vec4 color=mix(color0,color1,u_fade_t);color.a*=u_opacity;vec3 rgb=color.rgb;rgb=vec3(dot(rgb,u_spin_weights.xyz),dot(rgb,u_spin_weights.zxy),dot(rgb,u_spin_weights.yzx));float average=(color.r+color.g+color.b)/3.0;rgb+=(average-rgb)*u_saturation_factor;rgb=(rgb-0.5)*u_contrast_factor+0.5;vec3 u_high_vec=vec3(u_brightness_low,u_brightness_low,u_brightness_low);vec3 u_low_vec=vec3(u_brightness_high,u_brightness_high,u_brightness_high);fragColor=vec4(mix(u_high_vec,u_low_vec,rgb)*color.a,color.a);\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","uniform vec2 u_tl_parent;uniform float u_scale_parent;uniform float u_buffer_scale;uniform vec4 u_coords_top;uniform vec4 u_coords_bottom;in vec2 a_pos;out vec2 v_pos0;out vec2 v_pos1;void main() {vec2 fractionalPos=a_pos/8192.0;vec2 position=mix(mix(u_coords_top.xy,u_coords_top.zw,fractionalPos.x),mix(u_coords_bottom.xy,u_coords_bottom.zw,fractionalPos.x),fractionalPos.y);gl_Position=projectTile(position,position);v_pos0=((fractionalPos-0.5)/u_buffer_scale)+0.5;\n#ifdef GLOBE\nif (a_pos.y <-32767.5) {v_pos0.y=0.0;}if (a_pos.y > 32766.5) {v_pos0.y=1.0;}\n#endif\nv_pos1=(v_pos0*u_scale_parent)+u_tl_parent;}"),symbolIcon:kt("uniform sampler2D u_texture;in vec2 v_tex;in float v_total_opacity;void main() {fragColor=texture(u_texture,v_tex)*v_total_opacity;\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","in vec4 a_pos_offset;in vec4 a_data;in vec4 a_pixeloffset;in vec3 a_projected_pos;in float a_fade_opacity;uniform bool u_is_size_zoom_constant;uniform bool u_is_size_feature_constant;uniform highp float u_size_t;uniform highp float u_size;uniform highp float u_camera_to_center_distance;uniform highp float u_pitch;uniform bool u_rotate_symbol;uniform highp float u_aspect_ratio;uniform float u_fade_change;uniform mat4 u_label_plane_matrix;uniform mat4 u_coord_matrix;uniform bool u_is_text;uniform bool u_pitch_with_map;uniform vec2 u_texsize;uniform bool u_is_along_line;uniform bool u_is_variable_anchor;uniform vec2 u_translation;uniform float u_pitched_scale;out vec2 v_tex;out float v_total_opacity;\n#pragma mapbox: define lowp float opacity\nvoid main() {\n#pragma mapbox: initialize lowp float opacity\nvec2 a_pos=a_pos_offset.xy;vec2 a_offset=a_pos_offset.zw;vec2 a_tex=a_data.xy;vec2 a_size=a_data.zw;float a_size_min=floor(a_size[0]*0.5);vec2 a_pxoffset=a_pixeloffset.xy;vec2 a_minFontScale=a_pixeloffset.zw/256.0;float ele=get_elevation(a_pos);highp float segment_angle=-a_projected_pos[2];float size;if (!u_is_size_zoom_constant && !u_is_size_feature_constant) {size=mix(a_size_min,a_size[1],u_size_t)/128.0;} else if (u_is_size_zoom_constant && !u_is_size_feature_constant) {size=a_size_min/128.0;} else {size=u_size;}vec2 translated_a_pos=a_pos+u_translation;vec4 projectedPoint=projectTileWithElevation(translated_a_pos,ele);vec2 fade_opacity=unpack_opacity(a_fade_opacity);float fade_change=fade_opacity[1] > 0.5 ? u_fade_change :-u_fade_change;float visibility=calculate_visibility(projectedPoint);v_total_opacity=opacity*max(0.0,min(visibility,fade_opacity[0]+fade_change));if (v_total_opacity < 0.1){gl_Position=vec4(-2.,-2.,-2.,1.);return;}highp float camera_to_anchor_distance=projectedPoint.w;highp float distance_ratio=u_pitch_with_map ?\ncamera_to_anchor_distance/u_camera_to_center_distance :\nu_camera_to_center_distance/camera_to_anchor_distance;highp float perspective_ratio=clamp(0.5+0.5*distance_ratio,0.0,4.0);size*=perspective_ratio;float fontScale=u_is_text ? size/24.0 : size;highp float symbol_rotation=0.0;if (u_rotate_symbol) {vec4 offsetProjectedPoint=projectTileWithElevation(translated_a_pos+vec2(1,0),ele);vec2 a=projectedPoint.xy/projectedPoint.w;vec2 b=offsetProjectedPoint.xy/offsetProjectedPoint.w;symbol_rotation=atan((b.y-a.y)/u_aspect_ratio,b.x-a.x);}highp float angle_sin=sin(segment_angle+symbol_rotation);highp float angle_cos=cos(segment_angle+symbol_rotation);mat2 rotation_matrix=mat2(angle_cos,-1.0*angle_sin,angle_sin,angle_cos);vec4 projected_pos;if (u_is_along_line || u_is_variable_anchor) {projected_pos=vec4(a_projected_pos.xy,ele,1.0);} else if (u_pitch_with_map) {projected_pos=u_label_plane_matrix*vec4(a_projected_pos.xy+u_translation,ele,1.0);} else {projected_pos=u_label_plane_matrix*projectTileWithElevation(a_projected_pos.xy+u_translation,ele);}float z=float(u_pitch_with_map)*projected_pos.z/projected_pos.w;float projectionScaling=1.0;\n#ifdef GLOBE\nif(u_pitch_with_map) {float anchor_pos_tile_y=(u_coord_matrix*vec4(projected_pos.xy/projected_pos.w,z,1.0)).y;projectionScaling=mix(projectionScaling,1.0/circumferenceRatioAtTileY(anchor_pos_tile_y)*u_pitched_scale,u_projection_transition);}\n#endif\nvec4 finalPos=u_coord_matrix*vec4(projected_pos.xy/projected_pos.w+rotation_matrix*(a_offset/32.0*max(a_minFontScale,fontScale)+a_pxoffset/16.0)*projectionScaling,z,1.0);if(u_pitch_with_map) {finalPos=projectTileWithElevation(finalPos.xy,finalPos.z);}gl_Position=finalPos;v_tex=a_tex/u_texsize;}"),symbolSDF:kt("#define SDF_PX 8.0\nuniform bool u_is_halo;uniform bool u_is_plain;uniform sampler2D u_texture;uniform highp float u_gamma_scale;uniform lowp float u_device_pixel_ratio;uniform bool u_is_text;in vec2 v_data0;in vec3 v_data1;\n#pragma mapbox: define highp vec4 fill_color\n#pragma mapbox: define highp vec4 halo_color\n#pragma mapbox: define lowp float halo_width\n#pragma mapbox: define lowp float halo_blur\nvoid main() {\n#pragma mapbox: initialize highp vec4 fill_color\n#pragma mapbox: initialize highp vec4 halo_color\n#pragma mapbox: initialize lowp float halo_width\n#pragma mapbox: initialize lowp float halo_blur\nfloat EDGE_GAMMA=0.105/u_device_pixel_ratio;vec2 tex=v_data0.xy;float gamma_scale=v_data1.x;float size=v_data1.y;float total_opacity=v_data1[2];float fontScale=u_is_text ? size/24.0 : size;highp float gamma=EDGE_GAMMA/(fontScale*u_gamma_scale);lowp float inner_edge=(256.0-64.0)/256.0;lowp float dist=texture(u_texture,tex).a;lowp vec4 color_alpha_out_text,color_alpha_out_halo;if (u_is_plain){highp float gamma_scaled=gamma*gamma_scale;highp float alpha=smoothstep(inner_edge-gamma_scaled,inner_edge+gamma_scaled,dist);color_alpha_out_text=total_opacity*alpha*fill_color;}if (u_is_halo) {float gamma_halo=(halo_blur*1.19/SDF_PX+EDGE_GAMMA)/(fontScale*u_gamma_scale);float inner_edge_halo=inner_edge+gamma_halo*gamma_scale;highp float gamma_scaled_halo=gamma_halo*gamma_scale;highp float alpha_halo=smoothstep(inner_edge_halo-gamma_scaled_halo,inner_edge_halo+gamma_scaled_halo,dist);highp float halo_edge=(6.0-halo_width/fontScale)/SDF_PX;alpha_halo= min(smoothstep(halo_edge-gamma_scaled_halo,halo_edge+gamma_scaled_halo,dist),1.0-alpha_halo);color_alpha_out_halo=total_opacity*alpha_halo*halo_color;}if (u_is_plain && u_is_halo) {fragColor=color_alpha_out_text+(1.-color_alpha_out_text.a)*color_alpha_out_halo;} else if (u_is_halo){fragColor=color_alpha_out_halo;} else {fragColor=color_alpha_out_text;}\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","in vec4 a_pos_offset;in vec4 a_data;in vec4 a_pixeloffset;in vec3 a_projected_pos;in float a_fade_opacity;uniform bool u_is_size_zoom_constant;uniform bool u_is_size_feature_constant;uniform highp float u_size_t;uniform highp float u_size;uniform mat4 u_label_plane_matrix;uniform mat4 u_coord_matrix;uniform bool u_is_text;uniform bool u_pitch_with_map;uniform bool u_is_along_line;uniform bool u_is_variable_anchor;uniform highp float u_pitch;uniform bool u_rotate_symbol;uniform highp float u_aspect_ratio;uniform highp float u_camera_to_center_distance;uniform float u_fade_change;uniform vec2 u_texsize;uniform vec2 u_translation;uniform float u_pitched_scale;out vec2 v_data0;out vec3 v_data1;\n#pragma mapbox: define highp vec4 fill_color\n#pragma mapbox: define highp vec4 halo_color\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp float halo_width\n#pragma mapbox: define lowp float halo_blur\nvoid main() {\n#pragma mapbox: initialize highp vec4 fill_color\n#pragma mapbox: initialize highp vec4 halo_color\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize lowp float halo_width\n#pragma mapbox: initialize lowp float halo_blur\nvec2 a_pos=a_pos_offset.xy;vec2 a_offset=a_pos_offset.zw;vec2 a_tex=a_data.xy;vec2 a_size=a_data.zw;float a_size_min=floor(a_size[0]*0.5);vec2 a_pxoffset=a_pixeloffset.xy/16.0;vec2 a_minFontScale=a_pixeloffset.zw/256.0;float ele=get_elevation(a_pos);highp float segment_angle=-a_projected_pos[2];float size;if (!u_is_size_zoom_constant && !u_is_size_feature_constant) {size=mix(a_size_min,a_size[1],u_size_t)/128.0;} else if (u_is_size_zoom_constant && !u_is_size_feature_constant) {size=a_size_min/128.0;} else {size=u_size;}vec2 translated_a_pos=a_pos+u_translation;vec4 projectedPoint=projectTileWithElevation(translated_a_pos,ele);vec2 fade_opacity=unpack_opacity(a_fade_opacity);float visibility=calculate_visibility(projectedPoint);float fade_change=fade_opacity[1] > 0.5 ? u_fade_change :-u_fade_change;float interpolated_fade_opacity=max(0.0,min(visibility,fade_opacity[0]+fade_change));float total_opacity=opacity*interpolated_fade_opacity;if (total_opacity < 0.1){gl_Position=vec4(-2.,-2.,-2.,1.);return;}highp float camera_to_anchor_distance=projectedPoint.w;highp float distance_ratio=u_pitch_with_map ?\ncamera_to_anchor_distance/u_camera_to_center_distance :\nu_camera_to_center_distance/camera_to_anchor_distance;highp float perspective_ratio=clamp(0.5+0.5*distance_ratio,0.0,4.0);size*=perspective_ratio;float fontScale=u_is_text ? size/24.0 : size;highp float symbol_rotation=0.0;if (u_rotate_symbol) {vec4 offsetProjectedPoint=projectTileWithElevation(translated_a_pos+vec2(1,0),ele);vec2 a=projectedPoint.xy/projectedPoint.w;vec2 b=offsetProjectedPoint.xy/offsetProjectedPoint.w;symbol_rotation=atan((b.y-a.y)/u_aspect_ratio,b.x-a.x);}highp float angle_sin=sin(segment_angle+symbol_rotation);highp float angle_cos=cos(segment_angle+symbol_rotation);mat2 rotation_matrix=mat2(angle_cos,-1.0*angle_sin,angle_sin,angle_cos);vec4 projected_pos;if (u_is_along_line || u_is_variable_anchor) {projected_pos=vec4(a_projected_pos.xy,ele,1.0);} else if (u_pitch_with_map) {projected_pos=u_label_plane_matrix*vec4(a_projected_pos.xy+u_translation,ele,1.0);} else {projected_pos=u_label_plane_matrix*projectTileWithElevation(a_projected_pos.xy+u_translation,ele);}float z=float(u_pitch_with_map)*projected_pos.z/projected_pos.w;float projectionScaling=1.0;\n#ifdef GLOBE\nif(u_pitch_with_map) {float anchor_pos_tile_y=(u_coord_matrix*vec4(projected_pos.xy/projected_pos.w,z,1.0)).y;projectionScaling=mix(projectionScaling,1.0/circumferenceRatioAtTileY(anchor_pos_tile_y)*u_pitched_scale,u_projection_transition);}\n#endif\nvec4 finalPos=u_coord_matrix*vec4(projected_pos.xy/projected_pos.w+rotation_matrix*(a_offset/32.0*max(a_minFontScale,fontScale)+a_pxoffset)*projectionScaling,z,1.0);if(u_pitch_with_map) {finalPos=projectTileWithElevation(finalPos.xy,finalPos.z);}float gamma_scale=finalPos.w;gl_Position=finalPos;v_data0=a_tex/u_texsize;v_data1=vec3(gamma_scale,size,total_opacity);}"),symbolTextAndIcon:kt("#define SDF_PX 8.0\n#define SDF 1.0\n#define ICON 0.0\nuniform bool u_is_halo;uniform bool u_is_text;uniform sampler2D u_texture;uniform sampler2D u_texture_icon;uniform highp float u_gamma_scale;uniform lowp float u_device_pixel_ratio;in vec4 v_data0;in vec4 v_data1;\n#pragma mapbox: define highp vec4 fill_color\n#pragma mapbox: define highp vec4 halo_color\n#pragma mapbox: define lowp float halo_width\n#pragma mapbox: define lowp float halo_blur\nvoid main() {\n#pragma mapbox: initialize highp vec4 fill_color\n#pragma mapbox: initialize highp vec4 halo_color\n#pragma mapbox: initialize lowp float halo_width\n#pragma mapbox: initialize lowp float halo_blur\nfloat total_opacity=v_data1[2];if (v_data1.w==ICON) {vec2 tex_icon=v_data0.zw;fragColor=texture(u_texture_icon,tex_icon)*total_opacity;\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\nreturn;}vec2 tex=v_data0.xy;float EDGE_GAMMA=0.105/u_device_pixel_ratio;float gamma_scale=v_data1.x;float size=v_data1.y;float fontScale=size/24.0;highp float gamma=EDGE_GAMMA/(fontScale*u_gamma_scale);lowp float buff=(256.0-64.0)/256.0;lowp float dist=texture(u_texture,tex).a;lowp vec4 color_alpha_out,color_alpha_out_halo;if (u_is_text) {highp float gamma_scaled=gamma*gamma_scale;highp float alpha=smoothstep(buff-gamma_scaled,buff+gamma_scaled,dist);color_alpha_out=fill_color*(alpha*total_opacity);}if (u_is_halo) {highp float gamma_halo=(halo_blur*1.19/SDF_PX+EDGE_GAMMA)/(fontScale*u_gamma_scale);lowp float buff_halo=(6.0-halo_width/fontScale)/SDF_PX;highp float gamma_scaled_halo=gamma_halo*gamma_scale;highp float alpha_halo=smoothstep(buff_halo-gamma_scaled_halo,buff_halo+gamma_scaled_halo,dist);color_alpha_out_halo=halo_color*(alpha_halo*total_opacity);}if (u_is_text && u_is_halo) {fragColor=color_alpha_out+(1.-color_alpha_out.a)*color_alpha_out_halo;} else if (u_is_halo) {fragColor=color_alpha_out_halo;} else {fragColor=color_alpha_out;}\n#ifdef OVERDRAW_INSPECTOR\nfragColor=vec4(1.0);\n#endif\n}","in vec4 a_pos_offset;in vec4 a_data;in vec3 a_projected_pos;in float a_fade_opacity;uniform bool u_is_size_zoom_constant;uniform bool u_is_size_feature_constant;uniform highp float u_size_t;uniform highp float u_size;uniform mat4 u_label_plane_matrix;uniform mat4 u_coord_matrix;uniform bool u_is_text;uniform bool u_pitch_with_map;uniform highp float u_pitch;uniform bool u_rotate_symbol;uniform highp float u_aspect_ratio;uniform highp float u_camera_to_center_distance;uniform float u_fade_change;uniform vec2 u_texsize;uniform vec2 u_texsize_icon;uniform bool u_is_along_line;uniform bool u_is_variable_anchor;uniform vec2 u_translation;uniform float u_pitched_scale;out vec4 v_data0;out vec4 v_data1;\n#pragma mapbox: define highp vec4 fill_color\n#pragma mapbox: define highp vec4 halo_color\n#pragma mapbox: define lowp float opacity\n#pragma mapbox: define lowp float halo_width\n#pragma mapbox: define lowp float halo_blur\nvoid main() {\n#pragma mapbox: initialize highp vec4 fill_color\n#pragma mapbox: initialize highp vec4 halo_color\n#pragma mapbox: initialize lowp float opacity\n#pragma mapbox: initialize lowp float halo_width\n#pragma mapbox: initialize lowp float halo_blur\nvec2 a_pos=a_pos_offset.xy;vec2 a_offset=a_pos_offset.zw;vec2 a_tex=a_data.xy;vec2 a_size=a_data.zw;float a_size_min=floor(a_size[0]*0.5);float is_sdf=a_size[0]-2.0*a_size_min;float ele=get_elevation(a_pos);highp float segment_angle=-a_projected_pos[2];float size;if (!u_is_size_zoom_constant && !u_is_size_feature_constant) {size=mix(a_size_min,a_size[1],u_size_t)/128.0;} else if (u_is_size_zoom_constant && !u_is_size_feature_constant) {size=a_size_min/128.0;} else {size=u_size;}vec2 translated_a_pos=a_pos+u_translation;vec4 projectedPoint=projectTileWithElevation(translated_a_pos,ele);vec2 fade_opacity=unpack_opacity(a_fade_opacity);float visibility=calculate_visibility(projectedPoint);float fade_change=fade_opacity[1] > 0.5 ? u_fade_change :-u_fade_change;float interpolated_fade_opacity=max(0.0,min(visibility,fade_opacity[0]+fade_change));float total_opacity=opacity*interpolated_fade_opacity;if (total_opacity < 0.1){gl_Position=vec4(-2.,-2.,-2.,1.);return;}highp float camera_to_anchor_distance=projectedPoint.w;highp float distance_ratio=u_pitch_with_map ?\ncamera_to_anchor_distance/u_camera_to_center_distance :\nu_camera_to_center_distance/camera_to_anchor_distance;highp float perspective_ratio=clamp(0.5+0.5*distance_ratio,0.0,4.0);size*=perspective_ratio;float fontScale=size/24.0;highp float symbol_rotation=0.0;if (u_rotate_symbol) {vec4 offsetProjectedPoint=projectTileWithElevation(translated_a_pos+vec2(1,0),ele);vec2 a=projectedPoint.xy/projectedPoint.w;vec2 b=offsetProjectedPoint.xy/offsetProjectedPoint.w;symbol_rotation=atan((b.y-a.y)/u_aspect_ratio,b.x-a.x);}highp float angle_sin=sin(segment_angle+symbol_rotation);highp float angle_cos=cos(segment_angle+symbol_rotation);mat2 rotation_matrix=mat2(angle_cos,-1.0*angle_sin,angle_sin,angle_cos);vec4 projected_pos;if (u_is_along_line || u_is_variable_anchor) {projected_pos=vec4(a_projected_pos.xy,ele,1.0);} else if (u_pitch_with_map) {projected_pos=u_label_plane_matrix*vec4(a_projected_pos.xy+u_translation,ele,1.0);} else {projected_pos=u_label_plane_matrix*projectTileWithElevation(a_projected_pos.xy+u_translation,ele);}float z=float(u_pitch_with_map)*projected_pos.z/projected_pos.w;float projectionScaling=1.0;\n#ifdef GLOBE\nif(u_pitch_with_map && !u_is_along_line) {float anchor_pos_tile_y=(u_coord_matrix*vec4(projected_pos.xy/projected_pos.w,z,1.0)).y;projectionScaling=mix(projectionScaling,1.0/circumferenceRatioAtTileY(anchor_pos_tile_y)*u_pitched_scale,u_projection_transition);}\n#endif\nvec4 finalPos=u_coord_matrix*vec4(projected_pos.xy/projected_pos.w+rotation_matrix*(a_offset/32.0*fontScale)*projectionScaling,z,1.0);if(u_pitch_with_map) {finalPos=projectTileWithElevation(finalPos.xy,finalPos.z);}float gamma_scale=finalPos.w;gl_Position=finalPos;v_data0.xy=a_tex/u_texsize;v_data0.zw=a_tex/u_texsize_icon;v_data1=vec4(gamma_scale,size,total_opacity,is_sdf);}"),terrain:kt("uniform sampler2D u_texture;uniform vec4 u_fog_color;uniform vec4 u_horizon_color;uniform float u_fog_ground_blend;uniform float u_fog_ground_blend_opacity;uniform float u_horizon_fog_blend;uniform bool u_is_globe_mode;in vec2 v_texture_pos;in float v_fog_depth;const float gamma=2.2;vec4 gammaToLinear(vec4 color) {return pow(color,vec4(gamma));}vec4 linearToGamma(vec4 color) {return pow(color,vec4(1.0/gamma));}void main() {vec4 surface_color=texture(u_texture,vec2(v_texture_pos.x,1.0-v_texture_pos.y));if (!u_is_globe_mode && u_fog_ground_blend_opacity > 0.0 && v_fog_depth > u_fog_ground_blend) {vec4 surface_color_linear=gammaToLinear(surface_color);float blend_color=smoothstep(0.0,1.0,max((v_fog_depth-u_horizon_fog_blend)/(1.0-u_horizon_fog_blend),0.0));vec4 fog_horizon_color_linear=mix(gammaToLinear(u_fog_color),gammaToLinear(u_horizon_color),blend_color);float factor_fog=max(v_fog_depth-u_fog_ground_blend,0.0)/(1.0-u_fog_ground_blend);fragColor=linearToGamma(mix(surface_color_linear,fog_horizon_color_linear,pow(factor_fog,2.0)*u_fog_ground_blend_opacity));} else {fragColor=surface_color;}}","in vec3 a_pos3d;uniform mat4 u_fog_matrix;uniform float u_ele_delta;out vec2 v_texture_pos;out float v_fog_depth;void main() {float ele=get_elevation(a_pos3d.xy);float ele_delta=a_pos3d.z==1.0 ? u_ele_delta : 0.0;v_texture_pos=a_pos3d.xy/8192.0;gl_Position=projectTileFor3D(a_pos3d.xy,ele-ele_delta);vec4 pos=u_fog_matrix*vec4(a_pos3d.xy,ele,1.0);v_fog_depth=pos.z/pos.w*0.5+0.5;}"),terrainDepth:kt("in float v_depth;const highp vec4 bitSh=vec4(256.*256.*256.,256.*256.,256.,1.);const highp vec4 bitMsk=vec4(0.,vec3(1./256.0));highp vec4 pack(highp float value) {highp vec4 comp=fract(value*bitSh);comp-=comp.xxyz*bitMsk;return comp;}void main() {fragColor=pack(v_depth);}","in vec3 a_pos3d;uniform float u_ele_delta;out float v_depth;void main() {float ele=get_elevation(a_pos3d.xy);float ele_delta=a_pos3d.z==1.0 ? u_ele_delta : 0.0;gl_Position=projectTileFor3D(a_pos3d.xy,ele-ele_delta);v_depth=gl_Position.z/gl_Position.w;}"),terrainCoords:kt("precision mediump float;uniform sampler2D u_texture;uniform float u_terrain_coords_id;in vec2 v_texture_pos;void main() {vec4 rgba=texture(u_texture,v_texture_pos);fragColor=vec4(rgba.r,rgba.g,rgba.b,u_terrain_coords_id);}","in vec3 a_pos3d;uniform float u_ele_delta;out vec2 v_texture_pos;void main() {float ele=get_elevation(a_pos3d.xy);float ele_delta=a_pos3d.z==1.0 ? u_ele_delta : 0.0;v_texture_pos=a_pos3d.xy/8192.0;gl_Position=projectTileFor3D(a_pos3d.xy,ele-ele_delta);}"),projectionErrorMeasurement:kt("in vec4 v_output_error_encoded;void main() {fragColor=v_output_error_encoded;}","in vec2 a_pos;uniform highp float u_input;uniform highp float u_output_expected;out vec4 v_output_error_encoded;void main() {float real_output=2.0*atan(exp(PI-(u_input*PI*2.0)))-PI*0.5;float error=real_output-u_output_expected;float abs_error=abs(error)*128.0;v_output_error_encoded.x=min(floor(abs_error*256.0),255.0)/255.0;abs_error-=v_output_error_encoded.x;v_output_error_encoded.y=min(floor(abs_error*65536.0),255.0)/255.0;abs_error-=v_output_error_encoded.x/255.0;v_output_error_encoded.z=min(floor(abs_error*16777216.0),255.0)/255.0;v_output_error_encoded.w=error >=0.0 ? 1.0 : 0.0;gl_Position=vec4(a_pos,0.0,1.0);}"),atmosphere:kt("#ifdef GL_ES\nprecision highp float;\n#endif\nin vec3 view_direction;uniform vec3 u_sun_pos;uniform vec3 u_globe_position;uniform float u_globe_radius;uniform float u_atmosphere_blend;/**Shader use from https:*Made some change to adapt to MapLibre Globe geometry*/const float PI=3.141592653589793;const int iSteps=5;const int jSteps=3;/*radius of the planet*/const float EARTH_RADIUS=6371e3;/*radius of the atmosphere*/const float ATMOS_RADIUS=6471e3;vec2 rsi(vec3 r0,vec3 rd,float sr) {float a=dot(rd,rd);float b=2.0*dot(rd,r0);float c=dot(r0,r0)-(sr*sr);float d=(b*b)-4.0*a*c;if (d < 0.0) return vec2(1e5,-1e5);return vec2((-b-sqrt(d))/(2.0*a),(-b+sqrt(d))/(2.0*a));}vec4 atmosphere(vec3 r,vec3 r0,vec3 pSun,float iSun,float rPlanet,float rAtmos,vec3 kRlh,float kMie,float shRlh,float shMie,float g) {pSun=normalize(pSun);r=normalize(r);vec2 p=rsi(r0,r,rAtmos);if (p.x > p.y) {return vec4(0.0,0.0,0.0,1.0);}if (p.x < 0.0) {p.x=0.0;}vec3 pos=r0+r*p.x;vec2 p2=rsi(r0,r,rPlanet);if (p2.x <=p2.y && p2.x > 0.0) {p.y=min(p.y,p2.x);}float iStepSize=(p.y-p.x)/float(iSteps);float iTime=p.x+iStepSize*0.5;vec3 totalRlh=vec3(0,0,0);vec3 totalMie=vec3(0,0,0);float iOdRlh=0.0;float iOdMie=0.0;float mu=dot(r,pSun);float mumu=mu*mu;float gg=g*g;float pRlh=3.0/(16.0*PI)*(1.0+mumu);float pMie=3.0/(8.0*PI)*((1.0-gg)*(mumu+1.0))/(pow(1.0+gg-2.0*mu*g,1.5)*(2.0+gg));for (int i=0; i < iSteps; i++) {vec3 iPos=r0+r*iTime;float iHeight=length(iPos)-rPlanet;float odStepRlh=exp(-iHeight/shRlh)*iStepSize;float odStepMie=exp(-iHeight/shMie)*iStepSize;iOdRlh+=odStepRlh;iOdMie+=odStepMie;float jStepSize=rsi(iPos,pSun,rAtmos).y/float(jSteps);float jTime=jStepSize*0.5;float jOdRlh=0.0;float jOdMie=0.0;for (int j=0; j < jSteps; j++) {vec3 jPos=iPos+pSun*jTime;float jHeight=length(jPos)-rPlanet;jOdRlh+=exp(-jHeight/shRlh)*jStepSize;jOdMie+=exp(-jHeight/shMie)*jStepSize;jTime+=jStepSize;}vec3 attn=exp(-(kMie*(iOdMie+jOdMie)+kRlh*(iOdRlh+jOdRlh)));totalRlh+=odStepRlh*attn;totalMie+=odStepMie*attn;iTime+=iStepSize;}float opacity=exp(-(length(kRlh)*length(totalRlh)+kMie*length(totalMie)));vec3 color=iSun*(pRlh*kRlh*totalRlh+pMie*kMie*totalMie);return vec4(color,opacity);}void main() {vec3 scale_camera_pos=-u_globe_position*EARTH_RADIUS/u_globe_radius;vec4 color=atmosphere(normalize(view_direction),scale_camera_pos,u_sun_pos,22.0,EARTH_RADIUS,ATMOS_RADIUS,vec3(5.5e-6,13.0e-6,22.4e-6),21e-6,8e3,1.2e3,0.758\n);color.rgb=1.0-exp(-1.0*color.rgb);color=pow(color,vec4(1.0/2.2));fragColor=vec4(color.rgb,1.0-color.a)*u_atmosphere_blend;}","in vec2 a_pos;uniform mat4 u_inv_proj_matrix;out vec3 view_direction;void main() {view_direction=(u_inv_proj_matrix*vec4(a_pos,0.0,1.0)).xyz;gl_Position=vec4(a_pos,0.0,1.0);}"),sky:kt("uniform vec4 u_sky_color;uniform vec4 u_horizon_color;uniform vec2 u_horizon;uniform vec2 u_horizon_normal;uniform float u_sky_horizon_blend;uniform float u_sky_blend;void main() {float x=gl_FragCoord.x;float y=gl_FragCoord.y;float blend=(y-u_horizon.y)*u_horizon_normal.y+(x-u_horizon.x)*u_horizon_normal.x;if (blend > 0.0) {if (blend < u_sky_horizon_blend) {fragColor=mix(u_sky_color,u_horizon_color,pow(1.0-blend/u_sky_horizon_blend,2.0));} else {fragColor=u_sky_color;}}fragColor=mix(fragColor,vec4(vec3(0.0),0.0),u_sky_blend);}","in vec2 a_pos;void main() {gl_Position=vec4(a_pos,1.0,1.0);}")};function kt(e,t){const i=/#pragma mapbox: ([\w]+) ([\w]+) ([\w]+) ([\w]+)/g,o=t.match(/in ([\w]+) ([\w]+)/g),a=e.match(/uniform ([\w]+) ([\w]+)([\s]*)([\w]*)/g),r=t.match(/uniform ([\w]+) ([\w]+)([\s]*)([\w]*)/g),s=r?r.concat(a):a,n={};return {fragmentSource:e=e.replace(i,((e,t,i,o,a)=>(n[a]=!0,"define"===t?`\n#ifndef HAS_UNIFORM_u_${a}\nin ${i} ${o} ${a};\n#else\nuniform ${i} ${o} u_${a};\n#endif\n`:`\n#ifdef HAS_UNIFORM_u_${a}\n ${i} ${o} ${a} = u_${a};\n#endif\n`))),vertexSource:t=t.replace(i,((e,t,i,o,a)=>{const r="float"===o?"vec2":"vec4",s=a.match(/color/)?"color":r;return n[a]?"define"===t?`\n#ifndef HAS_UNIFORM_u_${a}\nuniform lowp float u_${a}_t;\nin ${i} ${r} a_${a};\nout ${i} ${o} ${a};\n#else\nuniform ${i} ${o} u_${a};\n#endif\n`:"vec4"===s?`\n#ifndef HAS_UNIFORM_u_${a}\n ${a} = a_${a};\n#else\n ${i} ${o} ${a} = u_${a};\n#endif\n`:`\n#ifndef HAS_UNIFORM_u_${a}\n ${a} = unpack_mix_${s}(a_${a}, u_${a}_t);\n#else\n ${i} ${o} ${a} = u_${a};\n#endif\n`:"define"===t?`\n#ifndef HAS_UNIFORM_u_${a}\nuniform lowp float u_${a}_t;\nin ${i} ${r} a_${a};\n#else\nuniform ${i} ${o} u_${a};\n#endif\n`:"vec4"===s?`\n#ifndef HAS_UNIFORM_u_${a}\n ${i} ${o} ${a} = a_${a};\n#else\n ${i} ${o} ${a} = u_${a};\n#endif\n`:`\n#ifndef HAS_UNIFORM_u_${a}\n ${i} ${o} ${a} = unpack_mix_${s}(a_${a}, u_${a}_t);\n#else\n ${i} ${o} ${a} = u_${a};\n#endif\n`})),staticAttributes:o,staticUniforms:s}}class Bt{constructor(e,t,i){this.vertexBuffer=e,this.indexBuffer=t,this.segments=i;}destroy(){this.vertexBuffer.destroy(),this.indexBuffer.destroy(),this.segments.destroy(),this.vertexBuffer=null,this.indexBuffer=null,this.segments=null;}}var Ot=t.aS([{name:"a_pos",type:"Int16",components:2}]);const jt="#define PROJECTION_MERCATOR",Nt="mercator";class Zt{constructor(){this._cachedMesh=null;}get name(){return "mercator"}get useSubdivision(){return !1}get shaderVariantName(){return Nt}get shaderDefine(){return jt}get shaderPreludeCode(){return Ft.projectionMercator}get vertexShaderPreludeCode(){return Ft.projectionMercator.vertexSource}get subdivisionGranularity(){return t.aT.noSubdivision}get useGlobeControls(){return !1}get transitionState(){return 0}get latitudeErrorCorrectionRadians(){return 0}destroy(){}updateGPUdependent(e){}getMeshFromTileID(e,i,o,a,r){if(this._cachedMesh)return this._cachedMesh;const s=new t.aU;s.emplaceBack(0,0),s.emplaceBack(t.a6,0),s.emplaceBack(0,t.a6),s.emplaceBack(t.a6,t.a6);const n=e.createVertexBuffer(s,Ot.members),l=t.aV.simpleSegment(0,0,4,2),c=new t.aW;c.emplaceBack(1,0,2),c.emplaceBack(1,2,3);const h=e.createIndexBuffer(c);return this._cachedMesh=new Bt(n,h,l),this._cachedMesh}recalculate(){}hasTransition(){return !1}setErrorQueryLatitudeDegrees(e){}}class Ut{constructor(e=0,t=0,i=0,o=0){if(isNaN(e)||e<0||isNaN(t)||t<0||isNaN(i)||i<0||isNaN(o)||o<0)throw new Error("Invalid value for edge-insets, top, bottom, left and right must all be numbers");this.top=e,this.bottom=t,this.left=i,this.right=o;}interpolate(e,i,o){return null!=i.top&&null!=e.top&&(this.top=t.H.number(e.top,i.top,o)),null!=i.bottom&&null!=e.bottom&&(this.bottom=t.H.number(e.bottom,i.bottom,o)),null!=i.left&&null!=e.left&&(this.left=t.H.number(e.left,i.left,o)),null!=i.right&&null!=e.right&&(this.right=t.H.number(e.right,i.right,o)),this}getCenter(e,i){const o=t.al((this.left+e-this.right)/2,0,e),a=t.al((this.top+i-this.bottom)/2,0,i);return new t.P(o,a)}equals(e){return this.top===e.top&&this.bottom===e.bottom&&this.left===e.left&&this.right===e.right}clone(){return new Ut(this.top,this.bottom,this.left,this.right)}toJSON(){return {top:this.top,bottom:this.bottom,left:this.left,right:this.right}}}function Gt(e,t){if(!e.renderWorldCopies||e.lngRange)return;const i=t.lng-e.center.lng;t.lng+=i>180?-360:i<-180?360:0;}function Vt(e){return Math.max(0,Math.floor(e))}class Wt{constructor(e,i){var o;this.applyConstrain=(e,t)=>null!==this._constrainOverride?this._constrainOverride(e,t):this._callbacks.defaultConstrain(e,t),this._callbacks=e,this._tileSize=512,this._renderWorldCopies=void 0===(null==i?void 0:i.renderWorldCopies)||!!(null==i?void 0:i.renderWorldCopies),this._minZoom=(null==i?void 0:i.minZoom)||0,this._maxZoom=(null==i?void 0:i.maxZoom)||22,this._minPitch=null==(null==i?void 0:i.minPitch)?0:null==i?void 0:i.minPitch,this._maxPitch=null==(null==i?void 0:i.maxPitch)?60:null==i?void 0:i.maxPitch,this._constrainOverride=null!==(o=null==i?void 0:i.constrainOverride)&&void 0!==o?o:null,this.setMaxBounds(),this._width=0,this._height=0,this._center=new t.W(0,0),this._elevation=0,this._zoom=0,this._tileZoom=Vt(this._zoom),this._scale=t.ao(this._zoom),this._bearingInRadians=0,this._fovInRadians=.6435011087932844,this._pitchInRadians=0,this._rollInRadians=0,this._unmodified=!0,this._edgeInsets=new Ut,this._minElevationForCurrentTile=0,this._autoCalculateNearFarZ=!0;}apply(e,i,o){this._constrainOverride=e.constrainOverride,this._latRange=e.latRange,this._lngRange=e.lngRange,this._width=e.width,this._height=e.height,this._center=e.center,this._elevation=e.elevation,this._minElevationForCurrentTile=e.minElevationForCurrentTile,this._zoom=e.zoom,this._tileZoom=Vt(this._zoom),this._scale=t.ao(this._zoom),this._bearingInRadians=e.bearingInRadians,this._fovInRadians=e.fovInRadians,this._pitchInRadians=e.pitchInRadians,this._rollInRadians=e.rollInRadians,this._unmodified=e.unmodified,this._edgeInsets=new Ut(e.padding.top,e.padding.bottom,e.padding.left,e.padding.right),this._minZoom=e.minZoom,this._maxZoom=e.maxZoom,this._minPitch=e.minPitch,this._maxPitch=e.maxPitch,this._renderWorldCopies=e.renderWorldCopies,this._cameraToCenterDistance=e.cameraToCenterDistance,this._nearZ=e.nearZ,this._farZ=e.farZ,this._autoCalculateNearFarZ=!o&&e.autoCalculateNearFarZ,i&&this.constrainInternal(),this._calcMatrices();}get pixelsToClipSpaceMatrix(){return this._pixelsToClipSpaceMatrix}get clipSpaceToPixelsMatrix(){return this._clipSpaceToPixelsMatrix}get minElevationForCurrentTile(){return this._minElevationForCurrentTile}setMinElevationForCurrentTile(e){this._minElevationForCurrentTile=e;}get tileSize(){return this._tileSize}get tileZoom(){return this._tileZoom}get scale(){return this._scale}get width(){return this._width}get height(){return this._height}get bearingInRadians(){return this._bearingInRadians}get lngRange(){return this._lngRange}get latRange(){return this._latRange}get pixelsToGLUnits(){return this._pixelsToGLUnits}get minZoom(){return this._minZoom}setMinZoom(e){this._minZoom!==e&&(this._minZoom=e,this.setZoom(this.applyConstrain(this._center,this.zoom).zoom));}get maxZoom(){return this._maxZoom}setMaxZoom(e){this._maxZoom!==e&&(this._maxZoom=e,this.setZoom(this.applyConstrain(this._center,this.zoom).zoom));}get minPitch(){return this._minPitch}setMinPitch(e){this._minPitch!==e&&(this._minPitch=e,this.setPitch(Math.max(this.pitch,e)));}get maxPitch(){return this._maxPitch}setMaxPitch(e){this._maxPitch!==e&&(this._maxPitch=e,this.setPitch(Math.min(this.pitch,e)));}get renderWorldCopies(){return this._renderWorldCopies}setRenderWorldCopies(e){void 0===e?e=!0:null===e&&(e=!1),this._renderWorldCopies=e;}get constrainOverride(){return this._constrainOverride}setConstrainOverride(e){void 0===e&&(e=null),this._constrainOverride!==e&&(this._constrainOverride=e,this.constrainInternal(),this._calcMatrices());}get worldSize(){return this._tileSize*this._scale}get centerOffset(){return this.centerPoint._sub(this.size._div(2))}get size(){return new t.P(this._width,this._height)}get bearing(){return this._bearingInRadians/Math.PI*180}setBearing(e){const i=t.X(e,-180,180)*Math.PI/180;var a,r,s,n,l,c,h,u,d;this._bearingInRadians!==i&&(this._unmodified=!1,this._bearingInRadians=i,this._calcMatrices(),this._rotationMatrix=o(),a=this._rotationMatrix,s=-this._bearingInRadians,n=(r=this._rotationMatrix)[0],l=r[1],c=r[2],h=r[3],u=Math.sin(s),d=Math.cos(s),a[0]=n*d+c*u,a[1]=l*d+h*u,a[2]=n*-u+c*d,a[3]=l*-u+h*d);}get rotationMatrix(){return this._rotationMatrix}get pitchInRadians(){return this._pitchInRadians}get pitch(){return this._pitchInRadians/Math.PI*180}setPitch(e){const i=t.al(e,this.minPitch,this.maxPitch)/180*Math.PI;this._pitchInRadians!==i&&(this._unmodified=!1,this._pitchInRadians=i,this._calcMatrices());}get rollInRadians(){return this._rollInRadians}get roll(){return this._rollInRadians/Math.PI*180}setRoll(e){const t=e/180*Math.PI;this._rollInRadians!==t&&(this._unmodified=!1,this._rollInRadians=t,this._calcMatrices());}get fovInRadians(){return this._fovInRadians}get fov(){return t.aX(this._fovInRadians)}setFov(e){e=t.al(e,.1,150),this.fov!==e&&(this._unmodified=!1,this._fovInRadians=t.an(e),this._calcMatrices());}get zoom(){return this._zoom}setZoom(e){const i=this.applyConstrain(this._center,e).zoom;this._zoom!==i&&(this._unmodified=!1,this._zoom=i,this._tileZoom=Math.max(0,Math.floor(i)),this._scale=t.ao(i),this.constrainInternal(),this._calcMatrices());}get center(){return this._center}setCenter(e){e.lat===this._center.lat&&e.lng===this._center.lng||(this._unmodified=!1,this._center=e,this.constrainInternal(),this._calcMatrices());}get elevation(){return this._elevation}setElevation(e){e!==this._elevation&&(this._elevation=e,this.constrainInternal(),this._calcMatrices());}get padding(){return this._edgeInsets.toJSON()}setPadding(e){this._edgeInsets.equals(e)||(this._unmodified=!1,this._edgeInsets.interpolate(this._edgeInsets,e,1),this._calcMatrices());}get centerPoint(){return this._edgeInsets.getCenter(this._width,this._height)}get pixelsPerMeter(){return this._pixelPerMeter}get unmodified(){return this._unmodified}get cameraToCenterDistance(){return this._cameraToCenterDistance}get nearZ(){return this._nearZ}get farZ(){return this._farZ}get autoCalculateNearFarZ(){return this._autoCalculateNearFarZ}overrideNearFarZ(e,t){this._autoCalculateNearFarZ=!1,this._nearZ=e,this._farZ=t,this._calcMatrices();}clearNearFarZOverride(){this._autoCalculateNearFarZ=!0,this._calcMatrices();}isPaddingEqual(e){return this._edgeInsets.equals(e)}interpolatePadding(e,t,i){this._unmodified=!1,this._edgeInsets.interpolate(e,t,i),this.constrainInternal(),this._calcMatrices();}resize(e,t,i=!0){this._width=e,this._height=t,i&&this.constrainInternal(),this._calcMatrices();}getMaxBounds(){var e,t;return 2!==(null===(e=this._latRange)||void 0===e?void 0:e.length)||2!==(null===(t=this._lngRange)||void 0===t?void 0:t.length)?null:new G([this._lngRange[0],this._latRange[0]],[this._lngRange[1],this._latRange[1]])}setMaxBounds(e){e?(this._lngRange=[e.getWest(),e.getEast()],this._latRange=[e.getSouth(),e.getNorth()],this.constrainInternal()):(this._lngRange=null,this._latRange=[-t.am,t.am]);}getCameraQueryGeometry(e,i){if(1===i.length)return [i[0],e];{const{minX:o,minY:a,maxX:r,maxY:s}=t.a8.fromPoints(i).extend(e);return [new t.P(o,a),new t.P(r,a),new t.P(r,s),new t.P(o,s),new t.P(o,a)]}}constrainInternal(){if(!this.center||!this._width||!this._height||this._constraining)return;this._constraining=!0;const e=this._unmodified,{center:t,zoom:i}=this.applyConstrain(this.center,this.zoom);this.setCenter(t),this.setZoom(i),this._unmodified=e,this._constraining=!1;}_calcMatrices(){if(this._width&&this._height){this._pixelsToGLUnits=[2/this._width,-2/this._height];let e=t.ap(new Float64Array(16));t.S(e,e,[this._width/2,-this._height/2,1]),t.Q(e,e,[1,-1,0]),this._clipSpaceToPixelsMatrix=e,e=t.ap(new Float64Array(16)),t.S(e,e,[1,-1,1]),t.Q(e,e,[-1,-1,0]),t.S(e,e,[2/this._width,2/this._height,1]),this._pixelsToClipSpaceMatrix=e,this._cameraToCenterDistance=.5/Math.tan(this.fovInRadians/2)*this._height;}this._callbacks.calcMatrices();}calculateCenterFromCameraLngLatAlt(e,i,o,a){const r=void 0!==o?o:this.bearing,s=a=void 0!==a?a:this.pitch,{distanceToCenter:n,clampedElevation:l}=this._distanceToCenterFromAltElevationPitch(i,this.elevation,s),{x:c,y:h}=be(s,r),u=t.a7.fromLngLat(e,i);let d,_,p=t.aY(1,u.y),m=0;do{if(m+=1,m>10)break;_=n/p,d=new t.a7(u.x+c*_,u.y+h*_),p=1/d.meterInMercatorCoordinateUnits();}while(Math.abs(n-_*p)>1e-12);return {center:d.toLngLat(),elevation:l,zoom:t.ar(this.height/2/Math.tan(this.fovInRadians/2)/_/this.tileSize)}}recalculateZoomAndCenter(e){if(this.elevation-e==0)return;const i=1/this.worldSize,o=t.aq(1,this.center.lat)*this.worldSize,a=t.a7.fromLngLat(this.center,this.elevation),r=a.x/i,s=a.y/i,n=a.z/i,l=this.pitch,c=this.bearing,{x:h,y:u,z:d}=be(l,c),_=this.cameraToCenterDistance,p=r+_*-h,m=s+_*-u,f=n+_*d,{distanceToCenter:g,clampedElevation:v}=this._distanceToCenterFromAltElevationPitch(f/o,e,l),x=g*o,b=new t.a7((p+h*x)*i,(m+u*x)*i,0).toLngLat(),y=t.aq(1,b.lat),w=t.ar(this.height/2/Math.tan(this.fovInRadians/2)/g/y/this.tileSize);this._elevation=v,this._center=b,this.setZoom(w);}_distanceToCenterFromAltElevationPitch(e,i,o){const a=-Math.cos(t.an(o)),r=e-i;let s,n=i;return a*r>=0||Math.abs(a)<.1?(s=1e4,n=e+s*a):s=-r/a,{distanceToCenter:s,clampedElevation:n}}getCameraPoint(){const e=Math.tan(this.pitchInRadians)*(this.cameraToCenterDistance||1);return this.centerPoint.add(new t.P(e*Math.sin(this.rollInRadians),e*Math.cos(this.rollInRadians)))}getCameraAltitude(){return Math.cos(this.pitchInRadians)*this._cameraToCenterDistance/this._pixelPerMeter+this.elevation}getCameraLngLat(){const e=t.aq(1,this.center.lat)*this.worldSize;return xe(this.center,this.elevation,this.pitch,this.bearing,this.cameraToCenterDistance/e).toLngLat()}getMercatorTileCoordinates(e){if(!e)return [0,0,1,1];const i=e.canonical.z>=0?1<this.max[0]||e.aabb.min[1]>this.max[1]||e.aabb.min[2]>this.max[2]||e.aabb.max[0]0?(t+=e[o]*this.min[o],i+=e[o]*this.max[o]):(i+=e[o]*this.min[o],t+=e[o]*this.max[o]);return t>=0?2:i<0?0:1}}class $t{distanceToTile2d(e,t,i,o){const a=o,r=a.distanceX([e,t]),s=a.distanceY([e,t]);return Math.hypot(r,s)}getWrap(e,t,i){return i}getTileBoundingVolume(e,i,o,a){var r,s;let n=0,l=0;if(null==a?void 0:a.terrain){const c=new t.a3(e.z,i,e.z,e.x,e.y),h=a.terrain.getMinMaxElevation(c);n=null!==(r=h.minElevation)&&void 0!==r?r:Math.min(0,o),l=null!==(s=h.maxElevation)&&void 0!==s?s:Math.max(0,o);}const c=1<a}allowWorldCopies(){return !0}prepareNextFrame(){}}class Ht{constructor(e,t,i){this.points=e,this.planes=t,this.aabb=i;}static fromInvProjectionMatrix(e,i=1,o=0,a,r){const s=r?[[6,5,4],[0,1,2],[0,3,7],[2,1,5],[3,2,6],[0,4,5]]:[[0,1,2],[6,5,4],[0,3,7],[2,1,5],[3,2,6],[0,4,5]],n=Math.pow(2,o),l=[[-1,1,-1,1],[1,1,-1,1],[1,-1,-1,1],[-1,-1,-1,1],[-1,1,1,1],[1,1,1,1],[1,-1,1,1],[-1,-1,1,1]].map((o=>function(e,i,o,a){const r=t.aE([],e,i),s=1/r[3]/o*a;return t.b4(r,r,[s,s,1/r[3],s])}(o,e,i,n)));a&&function(e,i,o,a){const r=a?4:0,s=a?0:4;let n=0;const l=[],c=[];for(let i=0;i<4;i++){const o=t.b0([],e[i+s],e[i+r]),a=t.b5(o);t.aZ(o,o,1/a),l.push(a),c.push(o);}for(let i=0;i<4;i++){const a=t.b6(e[i+r],c[i],o);n=null!==a&&a>=0?Math.max(n,a):Math.max(n,l[i]);}const h=function(e,i){const o=t.b0([],e[i[0]],e[i[1]]),a=t.b0([],e[i[2]],e[i[1]]),r=[0,0,0,0];return t.b1(r,t.b2([],o,a)),r[3]=-t.b3(r,e[i[0]]),r}(e,i),u=function(e,i){const o=t.b7(e),a=t.b8([],e,1/o),r=t.b0([],i,t.aZ([],a,t.b3(i,a))),s=t.b7(r);if(s>0){const e=Math.sqrt(1-a[3]*a[3]),o=t.aZ([],a,-a[3]),n=t.a_([],o,t.aZ([],r,e/s));return t.b9(i,n)}return null}(o,h);if(null!==u){const e=u/t.b3(c[0],h);n=Math.min(n,e);}for(let t=0;t<4;t++){const i=Math.min(n,l[t]);e[t+s]=[e[t+r][0]+c[t][0]*i,e[t+r][1]+c[t][1]*i,e[t+r][2]+c[t][2]*i,1];}}(l,s[0],a,r);const c=s.map((e=>{const i=t.b0([],l[e[0]],l[e[1]]),o=t.b0([],l[e[2]],l[e[1]]),a=t.b1([],t.b2([],i,o)),r=-t.b3(a,l[e[1]]);return a.concat(r)})),h=[Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY],u=[Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY];for(const e of l)for(let t=0;t<3;t++)h[t]=Math.min(h[t],e[t]),u[t]=Math.max(u[t],e[t]);return new Ht(l,c,new qt(h,u))}}class Xt{get pixelsToClipSpaceMatrix(){return this._helper.pixelsToClipSpaceMatrix}get clipSpaceToPixelsMatrix(){return this._helper.clipSpaceToPixelsMatrix}get pixelsToGLUnits(){return this._helper.pixelsToGLUnits}get centerOffset(){return this._helper.centerOffset}get size(){return this._helper.size}get rotationMatrix(){return this._helper.rotationMatrix}get centerPoint(){return this._helper.centerPoint}get pixelsPerMeter(){return this._helper.pixelsPerMeter}setMinZoom(e){this._helper.setMinZoom(e);}setMaxZoom(e){this._helper.setMaxZoom(e);}setMinPitch(e){this._helper.setMinPitch(e);}setMaxPitch(e){this._helper.setMaxPitch(e);}setRenderWorldCopies(e){this._helper.setRenderWorldCopies(e);}setBearing(e){this._helper.setBearing(e);}setPitch(e){this._helper.setPitch(e);}setRoll(e){this._helper.setRoll(e);}setFov(e){this._helper.setFov(e);}setZoom(e){this._helper.setZoom(e);}setCenter(e){this._helper.setCenter(e);}setElevation(e){this._helper.setElevation(e);}setMinElevationForCurrentTile(e){this._helper.setMinElevationForCurrentTile(e);}setPadding(e){this._helper.setPadding(e);}interpolatePadding(e,t,i){this._helper.interpolatePadding(e,t,i);}isPaddingEqual(e){return this._helper.isPaddingEqual(e)}resize(e,t,i=!0){this._helper.resize(e,t,i);}getMaxBounds(){return this._helper.getMaxBounds()}setMaxBounds(e){this._helper.setMaxBounds(e);}setConstrainOverride(e){this._helper.setConstrainOverride(e);}overrideNearFarZ(e,t){this._helper.overrideNearFarZ(e,t);}clearNearFarZOverride(){this._helper.clearNearFarZOverride();}getCameraQueryGeometry(e){return this._helper.getCameraQueryGeometry(this.getCameraPoint(),e)}get tileSize(){return this._helper.tileSize}get tileZoom(){return this._helper.tileZoom}get scale(){return this._helper.scale}get worldSize(){return this._helper.worldSize}get width(){return this._helper.width}get height(){return this._helper.height}get lngRange(){return this._helper.lngRange}get latRange(){return this._helper.latRange}get minZoom(){return this._helper.minZoom}get maxZoom(){return this._helper.maxZoom}get zoom(){return this._helper.zoom}get center(){return this._helper.center}get minPitch(){return this._helper.minPitch}get maxPitch(){return this._helper.maxPitch}get pitch(){return this._helper.pitch}get pitchInRadians(){return this._helper.pitchInRadians}get roll(){return this._helper.roll}get rollInRadians(){return this._helper.rollInRadians}get bearing(){return this._helper.bearing}get bearingInRadians(){return this._helper.bearingInRadians}get fov(){return this._helper.fov}get fovInRadians(){return this._helper.fovInRadians}get elevation(){return this._helper.elevation}get minElevationForCurrentTile(){return this._helper.minElevationForCurrentTile}get padding(){return this._helper.padding}get unmodified(){return this._helper.unmodified}get renderWorldCopies(){return this._helper.renderWorldCopies}get cameraToCenterDistance(){return this._helper.cameraToCenterDistance}get constrainOverride(){return this._helper.constrainOverride}get nearZ(){return this._helper.nearZ}get farZ(){return this._helper.farZ}get autoCalculateNearFarZ(){return this._helper.autoCalculateNearFarZ}setTransitionState(e,t){}constructor(e){this._posMatrixCache=new Map,this._alignedPosMatrixCache=new Map,this._fogMatrixCacheF32=new Map,this.defaultConstrain=(e,i)=>{i=t.al(+i,this.minZoom,this.maxZoom);const o={center:new t.W(e.lng,e.lat),zoom:i};let a=this._helper._lngRange;if(!this._helper._renderWorldCopies&&null===a){const e=180-1e-10;a=[-e,e];}const r=this.tileSize*t.ao(o.zoom);let s=0,n=r,l=0,c=r,h=0,u=0;const{x:d,y:_}=this.size;if(this._helper._latRange){const e=this._helper._latRange;s=t.Y(e[1])*r,n=t.Y(e[0])*r,n-s<_&&(h=_/(n-s));}a&&(l=t.X(t.Z(a[0])*r,0,r),c=t.X(t.Z(a[1])*r,0,r),cn&&(g=n-e);}if(a){const e=(l+c)/2;let i=p;this._helper._renderWorldCopies&&(i=t.X(p,e-r/2,e+r/2));const o=d/2;i-oc&&(f=c-o);}if(void 0!==f||void 0!==g){const e=new t.P(null!=f?f:p,null!=g?g:m);o.center=fe(r,e).wrap();}return o},this.applyConstrain=(e,t)=>this._helper.applyConstrain(e,t),this._helper=new Wt({calcMatrices:()=>this._calcMatrices(),defaultConstrain:(e,t)=>this.defaultConstrain(e,t)},e),this._coveringTilesDetailsProvider=new $t;}clone(){const e=new Xt;return e.apply(this,!1),e}apply(e,t,i){this._helper.apply(e,t,i);}get cameraPosition(){return this._cameraPosition}get projectionMatrix(){return this._projectionMatrix}get modelViewProjectionMatrix(){return this._viewProjMatrix}get inverseProjectionMatrix(){return this._invProjMatrix}get mercatorMatrix(){return this._mercatorMatrix}getVisibleUnwrappedCoordinates(e){const i=[new t.ba(0,e)];if(this._helper._renderWorldCopies){const o=this.screenPointToMercatorCoordinate(new t.P(0,0)),a=this.screenPointToMercatorCoordinate(new t.P(this._helper._width,0)),r=this.screenPointToMercatorCoordinate(new t.P(this._helper._width,this._helper._height)),s=this.screenPointToMercatorCoordinate(new t.P(0,this._helper._height)),n=Math.floor(Math.min(o.x,a.x,r.x,s.x)),l=Math.floor(Math.max(o.x,a.x,r.x,s.x)),c=1;for(let o=n-c;o<=l+c;o++)0!==o&&i.push(new t.ba(o,e));}return i}getCameraFrustum(){return Ht.fromInvProjectionMatrix(this._invViewProjMatrix,this.worldSize)}getClippingPlane(){return null}getCoveringTilesDetailsProvider(){return this._coveringTilesDetailsProvider}recalculateZoomAndCenter(e){const t=this.screenPointToLocation(this.centerPoint,e),i=e?e.getElevationForLngLatZoom(t,this._helper._tileZoom):0;this._helper.recalculateZoomAndCenter(i);}setLocationAtPoint(e,i){const o=t.aq(this.elevation,this.center.lat),a=this.screenPointToMercatorCoordinateAtZ(i,o),r=this.screenPointToMercatorCoordinateAtZ(this.centerPoint,o),s=t.a7.fromLngLat(e),n=new t.a7(s.x-(a.x-r.x),s.y-(a.y-r.y));this.setCenter(null==n?void 0:n.toLngLat()),this._helper._renderWorldCopies&&this.setCenter(this.center.wrap());}locationToScreenPoint(e,i){return i?this.coordinatePoint(t.a7.fromLngLat(e),i.getElevationForLngLat(e,this),this._pixelMatrix3D):this.coordinatePoint(t.a7.fromLngLat(e))}screenPointToLocation(e,t){var i;return null===(i=this.screenPointToMercatorCoordinate(e,t))||void 0===i?void 0:i.toLngLat()}screenPointToMercatorCoordinate(e,t){if(t){const i=t.pointCoordinate(e);if(null!=i)return i}return this.screenPointToMercatorCoordinateAtZ(e)}screenPointToMercatorCoordinateAtZ(e,i){const o=i||0,a=[e.x,e.y,0,1],r=[e.x,e.y,1,1];t.aE(a,a,this._pixelMatrixInverse),t.aE(r,r,this._pixelMatrixInverse);const s=a[3],n=r[3],l=a[1]/s,c=r[1]/n,h=a[2]/s,u=r[2]/n,d=h===u?0:(o-h)/(u-h);return new t.a7(t.H.number(a[0]/s,r[0]/n,d)/this.worldSize,t.H.number(l,c,d)/this.worldSize,o)}coordinatePoint(e,i=0,o=this._pixelMatrix){const a=[e.x*this.worldSize,e.y*this.worldSize,i,1];return t.aE(a,a,o),new t.P(a[0]/a[3],a[1]/a[3])}getBounds(){const e=Math.max(0,this._helper._height/2-ge(this));return (new G).extend(this.screenPointToLocation(new t.P(0,e))).extend(this.screenPointToLocation(new t.P(this._helper._width,e))).extend(this.screenPointToLocation(new t.P(this._helper._width,this._helper._height))).extend(this.screenPointToLocation(new t.P(0,this._helper._height)))}isPointOnMapSurface(e,t){return t?null!=t.pointCoordinate(e):e.y>this.height/2-ge(this)}calculatePosMatrix(e,i=!1,o){var a;const r=null!==(a=e.key)&&void 0!==a?a:t.bb(e.wrap,e.canonical.z,e.canonical.z,e.canonical.x,e.canonical.y),s=i?this._alignedPosMatrixCache:this._posMatrixCache;if(s.has(r)){const e=s.get(r);return o?e.f32:e.f64}const n=ve(e,this.worldSize);t.U(n,i?this._alignedProjMatrix:this._viewProjMatrix,n);const l={f64:n,f32:new Float32Array(n)};return s.set(r,l),o?l.f32:l.f64}calculateFogMatrix(e){const i=e.key,o=this._fogMatrixCacheF32;if(o.has(i))return o.get(i);const a=ve(e,this.worldSize);return t.U(a,this._fogMatrix,a),o.set(i,new Float32Array(a)),o.get(i)}calculateCenterFromCameraLngLatAlt(e,t,i,o){return this._helper.calculateCenterFromCameraLngLatAlt(e,t,i,o)}_calculateNearFarZIfNeeded(e,i,o){if(!this._helper.autoCalculateNearFarZ)return;const a=Math.min(this.elevation,this.minElevationForCurrentTile,this.getCameraAltitude()-100),r=e-a*this._helper._pixelPerMeter/Math.cos(i),s=a<0?r:e,n=Math.PI/2+this.pitchInRadians,l=t.an(this.fov)*(Math.abs(Math.cos(t.an(this.roll)))*this.height+Math.abs(Math.sin(t.an(this.roll)))*this.width)/this.height*(.5+o.y/this.height),c=Math.sin(l)*s/Math.sin(t.al(Math.PI-n-l,.01,Math.PI-.01)),h=ge(this),u=Math.atan(h/this._helper.cameraToCenterDistance),d=t.an(.75),_=u>d?2*u*(.5+o.y/(2*h)):d,p=Math.sin(_)*s/Math.sin(t.al(Math.PI-n-_,.01,Math.PI-.01)),m=Math.min(c,p);this._helper._farZ=1.01*(Math.cos(Math.PI/2-i)*m+s),this._helper._nearZ=this._helper._height/50;}_calcMatrices(){if(!this._helper._height)return;const e=this.centerOffset,i=me(this.worldSize,this.center),o=i.x,a=i.y;this._helper._pixelPerMeter=t.aq(1,this.center.lat)*this.worldSize;const r=t.an(Math.min(this.pitch,pe)),s=Math.max(this._helper.cameraToCenterDistance/2,this._helper.cameraToCenterDistance+this._helper._elevation*this._helper._pixelPerMeter/Math.cos(r));let n;var l,c;this._calculateNearFarZIfNeeded(s,r,e),n=new Float64Array(16),t.bc(n,this.fovInRadians,this._helper._width/this._helper._height,this._helper._nearZ,this._helper._farZ),this._invProjMatrix=new Float64Array(16),(l=this._invProjMatrix)[0]=1/(c=n)[0],l[1]=0,l[2]=0,l[3]=0,l[4]=0,l[5]=1/c[5],l[6]=0,l[7]=0,l[8]=0,l[9]=0,l[10]=0,l[11]=1/c[14],l[12]=0,l[13]=0,l[14]=-1,l[15]=c[10]/c[14],n[8]=2*-e.x/this._helper._width,n[9]=2*e.y/this._helper._height,this._projectionMatrix=t.bd(n),t.S(n,n,[1,-1,1]),t.Q(n,n,[0,0,-this._helper.cameraToCenterDistance]),t.be(n,n,-this.rollInRadians),t.bf(n,n,this.pitchInRadians),t.be(n,n,-this.bearingInRadians),t.Q(n,n,[-o,-a,0]),this._mercatorMatrix=t.S([],n,[this.worldSize,this.worldSize,this.worldSize]),t.S(n,n,[1,1,this._helper._pixelPerMeter]),this._pixelMatrix=t.U(new Float64Array(16),this.clipSpaceToPixelsMatrix,n),t.Q(n,n,[0,0,-this.elevation]),this._viewProjMatrix=n,this._invViewProjMatrix=t.bg([],n);const h=[0,0,-1,1];t.aE(h,h,this._invViewProjMatrix),this._cameraPosition=[h[0]/h[3],h[1]/h[3],h[2]/h[3]],this._fogMatrix=new Float64Array(16),t.bc(this._fogMatrix,this.fovInRadians,this.width/this.height,s,this._helper._farZ),this._fogMatrix[8]=2*-e.x/this.width,this._fogMatrix[9]=2*e.y/this.height,t.S(this._fogMatrix,this._fogMatrix,[1,-1,1]),t.Q(this._fogMatrix,this._fogMatrix,[0,0,-this.cameraToCenterDistance]),t.be(this._fogMatrix,this._fogMatrix,-this.rollInRadians),t.bf(this._fogMatrix,this._fogMatrix,this.pitchInRadians),t.be(this._fogMatrix,this._fogMatrix,-this.bearingInRadians),t.Q(this._fogMatrix,this._fogMatrix,[-o,-a,0]),t.S(this._fogMatrix,this._fogMatrix,[1,1,this._helper._pixelPerMeter]),t.Q(this._fogMatrix,this._fogMatrix,[0,0,-this.elevation]),this._pixelMatrix3D=t.U(new Float64Array(16),this.clipSpaceToPixelsMatrix,n);const u=this._helper._width%2/2,d=this._helper._height%2/2,_=Math.cos(this.bearingInRadians),p=Math.sin(-this.bearingInRadians),m=o-Math.round(o)+_*u+p*d,f=a-Math.round(a)+_*d+p*u,g=new Float64Array(n);if(t.Q(g,g,[m>.5?m-1:m,f>.5?f-1:f,0]),this._alignedProjMatrix=g,n=t.bg(new Float64Array(16),this._pixelMatrix),!n)throw new Error("failed to invert matrix");this._pixelMatrixInverse=n,this._clearMatrixCaches();}_clearMatrixCaches(){this._posMatrixCache.clear(),this._alignedPosMatrixCache.clear(),this._fogMatrixCacheF32.clear();}maxPitchScaleFactor(){if(!this._pixelMatrixInverse)return 1;const e=this.screenPointToMercatorCoordinate(new t.P(0,0)),i=[e.x*this.worldSize,e.y*this.worldSize,0,1];return t.aE(i,i,this._pixelMatrix)[3]/this._helper.cameraToCenterDistance}getCameraPoint(){return this._helper.getCameraPoint()}getCameraAltitude(){return this._helper.getCameraAltitude()}getCameraLngLat(){const e=t.aq(1,this.center.lat)*this.worldSize;return xe(this.center,this.elevation,this.pitch,this.bearing,this._helper.cameraToCenterDistance/e).toLngLat()}lngLatToCameraDepth(e,i){const o=t.a7.fromLngLat(e),a=[o.x*this.worldSize,o.y*this.worldSize,i,1];return t.aE(a,a,this._viewProjMatrix),a[2]/a[3]}getProjectionData(e){const{overscaledTileID:i,aligned:o,applyTerrainMatrix:a}=e,r=this._helper.getMercatorTileCoordinates(i),s=i?this.calculatePosMatrix(i,o,!0):null;let n;return n=(null==i?void 0:i.terrainRttPosMatrix32f)&&a?i.terrainRttPosMatrix32f:s||t.bh(),{mainMatrix:n,tileMercatorCoords:r,clippingPlane:[0,0,0,0],projectionTransition:0,fallbackMatrix:n}}isLocationOccluded(e){return !1}getPixelScale(){return 1}getCircleRadiusCorrection(){return 1}getPitchedTextCorrection(e,t,i){return 1}transformLightDirection(e){return t.a$(e)}getRayDirectionFromPixel(e){throw new Error("Not implemented.")}projectTileCoordinates(e,i,o,a){const r=this.calculatePosMatrix(o);let s;a?(s=[e,i,a(e,i),1],t.aE(s,s,r)):(s=[e,i,0,1],nt(s,s,r));const n=s[3];return {point:new t.P(s[0]/n,s[1]/n),signedDistanceFromCamera:n,isOccluded:!1}}populateCache(e){for(const t of e)this.calculatePosMatrix(t);}getMatrixForModel(e,i){const o=t.a7.fromLngLat(e,i),a=o.meterInMercatorCoordinateUnits(),r=t.bi();return t.Q(r,r,[o.x,o.y,o.z]),t.be(r,r,Math.PI),t.bf(r,r,Math.PI/2),t.S(r,r,[-a,a,a]),r}getProjectionDataForCustomLayer(e=!0){const i=new t.a3(0,0,0,0,0),o=this.getProjectionData({overscaledTileID:i,applyGlobeMatrix:e}),a=ve(i,this.worldSize);t.U(a,this._viewProjMatrix,a),o.tileMercatorCoords=[0,0,1,1];const r=[t.a6,t.a6,this.worldSize/this._helper.pixelsPerMeter],s=t.bj();return t.S(s,a,r),o.fallbackMatrix=s,o.mainMatrix=s,o}getFastPathSimpleProjectionMatrix(e){return this.calculatePosMatrix(e)}}function Kt(){t.w("Map cannot fit within canvas with the given bounds, padding, and/or offset.");}function Yt(e){if(e.useSlerp)if(e.k<1){const i=t.bk(e.startEulerAngles.roll,e.startEulerAngles.pitch,e.startEulerAngles.bearing),o=t.bk(e.endEulerAngles.roll,e.endEulerAngles.pitch,e.endEulerAngles.bearing),a=new Float64Array(4);t.bl(a,i,o,e.k);const r=t.bm(a);e.tr.setRoll(r.roll),e.tr.setPitch(r.pitch),e.tr.setBearing(r.bearing);}else e.tr.setRoll(e.endEulerAngles.roll),e.tr.setPitch(e.endEulerAngles.pitch),e.tr.setBearing(e.endEulerAngles.bearing);else e.tr.setRoll(t.H.number(e.startEulerAngles.roll,e.endEulerAngles.roll,e.k)),e.tr.setPitch(t.H.number(e.startEulerAngles.pitch,e.endEulerAngles.pitch,e.k)),e.tr.setBearing(t.H.number(e.startEulerAngles.bearing,e.endEulerAngles.bearing,e.k));}function Qt(e,i,o,a,r){const s=r.padding,n=me(r.worldSize,o.getNorthWest()),l=me(r.worldSize,o.getNorthEast()),c=me(r.worldSize,o.getSouthEast()),h=me(r.worldSize,o.getSouthWest()),u=t.an(-a),d=n.rotate(u),_=l.rotate(u),p=c.rotate(u),m=h.rotate(u),f=new t.P(Math.max(d.x,_.x,m.x,p.x),Math.max(d.y,_.y,m.y,p.y)),g=new t.P(Math.min(d.x,_.x,m.x,p.x),Math.min(d.y,_.y,m.y,p.y)),v=f.sub(g),x=(r.width-(s.left+s.right+i.left+i.right))/v.x,b=(r.height-(s.top+s.bottom+i.top+i.bottom))/v.y;if(b<0||x<0)return void Kt();const y=Math.min(t.ar(r.scale*Math.min(x,b)),e.maxZoom),w=t.P.convert(e.offset),T=new t.P((i.left-i.right)/2,(i.top-i.bottom)/2).rotate(t.an(a)),P=w.add(T).mult(r.scale/t.ao(y));return {center:fe(r.worldSize,n.add(c).div(2).sub(P)),zoom:y,bearing:a}}class Jt{get useGlobeControls(){return !1}handlePanInertia(e,t){const i=e.mag(),o=Math.abs(ge(t));return {easingOffset:e.mult(Math.min(.75*o/i,1)),easingCenter:t.center}}handleMapControlsRollPitchBearingZoom(e,t){e.bearingDelta&&t.setBearing(t.bearing+e.bearingDelta),e.pitchDelta&&t.setPitch(t.pitch+e.pitchDelta),e.rollDelta&&t.setRoll(t.roll+e.rollDelta),e.zoomDelta&&t.setZoom(t.zoom+e.zoomDelta);}handleMapControlsPan(e,t,i){e.around.distSqr(t.centerPoint)<.01||t.setLocationAtPoint(i,e.around);}cameraForBoxAndBearing(e,t,i,o,a){return Qt(e,t,i,o,a)}handleJumpToCenterZoom(e,i){e.zoom!==(void 0!==i.zoom?+i.zoom:e.zoom)&&e.setZoom(+i.zoom),void 0!==i.center&&e.setCenter(t.W.convert(i.center));}handleEaseTo(e,i){const o=e.zoom,a=e.padding,r={roll:e.roll,pitch:e.pitch,bearing:e.bearing},s={roll:void 0===i.roll?e.roll:i.roll,pitch:void 0===i.pitch?e.pitch:i.pitch,bearing:void 0===i.bearing?e.bearing:i.bearing},n=void 0!==i.zoom,l=!e.isPaddingEqual(i.padding);let c=!1;const h=n?+i.zoom:e.zoom;let u=e.centerPoint.add(i.offsetAsPoint);const d=e.screenPointToLocation(u),{center:_,zoom:p}=e.applyConstrain(t.W.convert(i.center||d),null!=h?h:o);Gt(e,_);const m=me(e.worldSize,d),f=me(e.worldSize,_).sub(m),g=t.ao(p-o);return c=p!==o,{easeFunc:n=>{if(c&&e.setZoom(t.H.number(o,p,n)),t.bn(r,s)||Yt({startEulerAngles:r,endEulerAngles:s,tr:e,k:n,useSlerp:r.roll!=s.roll}),l&&(e.interpolatePadding(a,i.padding,n),u=e.centerPoint.add(i.offsetAsPoint)),i.around)e.setLocationAtPoint(i.around,i.aroundPoint);else {const i=t.ao(e.zoom-o),a=p>o?Math.min(2,g):Math.max(.5,g),r=Math.pow(a,1-n),s=fe(e.worldSize,m.add(f.mult(n*r)).mult(i));e.setLocationAtPoint(e.renderWorldCopies?s.wrap():s,u);}},isZooming:c,elevationCenter:_}}handleFlyTo(e,i){const o=void 0!==i.zoom,a=e.zoom,r=e.applyConstrain(t.W.convert(i.center||i.locationAtOffset),o?+i.zoom:a),s=r.center,n=r.zoom;Gt(e,s);const l=me(e.worldSize,i.locationAtOffset),c=me(e.worldSize,s).sub(l),h=c.mag(),u=t.ao(n-a);let d;if(void 0!==i.minZoom){const o=Math.min(+i.minZoom,a,n),r=e.applyConstrain(s,o).zoom;d=t.ao(r-a);}return {easeFunc:(i,o,r,h)=>{e.setZoom(1===i?n:a+t.ar(o));const u=1===i?s:fe(e.worldSize,l.add(c.mult(r)).mult(o));e.setLocationAtPoint(e.renderWorldCopies?u.wrap():u,h);},scaleOfZoom:u,targetCenter:s,scaleOfMinZoom:d,pixelPathLength:h}}}class ei{constructor(e,t,i){this.blendFunction=e,this.blendColor=t,this.mask=i;}}ei.Replace=[1,0],ei.disabled=new ei(ei.Replace,t.bo.transparent,[!1,!1,!1,!1]),ei.unblended=new ei(ei.Replace,t.bo.transparent,[!0,!0,!0,!0]),ei.alphaBlended=new ei([1,771],t.bo.transparent,[!0,!0,!0,!0]);const ti=2305;class ii{constructor(e,t,i){this.enable=e,this.mode=t,this.frontFace=i;}}ii.disabled=new ii(!1,1029,ti),ii.backCCW=new ii(!0,1029,ti),ii.frontCCW=new ii(!0,1028,ti);class oi{constructor(e,t,i){this.func=e,this.mask=t,this.range=i;}}oi.ReadOnly=!1,oi.ReadWrite=!0,oi.disabled=new oi(519,oi.ReadOnly,[0,1]);const ai=7680;class ri{constructor(e,t,i,o,a,r){this.test=e,this.ref=t,this.mask=i,this.fail=o,this.depthFail=a,this.pass=r;}}function si(e){return "undefined"!=typeof WebGL2RenderingContext&&e instanceof WebGL2RenderingContext}ri.disabled=new ri({func:519,mask:0},0,0,ai,ai,ai);class ni{get awaitingQuery(){return !!this._readbackQueue}constructor(e){this._readbackWaitFrames=4,this._measureWaitFrames=6,this._texWidth=1,this._texHeight=1,this._measuredError=0,this._updateCount=0,this._lastReadbackFrame=-1e3,this._readbackQueue=null,this._cachedRenderContext=e;const i=e.context,o=i.gl;this._texFormat=o.RGBA,this._texType=o.UNSIGNED_BYTE;const a=new t.aU;a.emplaceBack(-1,-1),a.emplaceBack(2,-1),a.emplaceBack(-1,2);const r=new t.aW;r.emplaceBack(0,1,2),this._fullscreenTriangle=new Bt(i.createVertexBuffer(a,Ot.members),i.createIndexBuffer(r),t.aV.simpleSegment(0,0,a.length,r.length)),this._resultBuffer=new Uint8Array(4),i.activeTexture.set(o.TEXTURE1);const s=o.createTexture();o.bindTexture(o.TEXTURE_2D,s),o.texParameteri(o.TEXTURE_2D,o.TEXTURE_WRAP_S,o.CLAMP_TO_EDGE),o.texParameteri(o.TEXTURE_2D,o.TEXTURE_WRAP_T,o.CLAMP_TO_EDGE),o.texParameteri(o.TEXTURE_2D,o.TEXTURE_MIN_FILTER,o.NEAREST),o.texParameteri(o.TEXTURE_2D,o.TEXTURE_MAG_FILTER,o.NEAREST),o.texImage2D(o.TEXTURE_2D,0,this._texFormat,this._texWidth,this._texHeight,0,this._texFormat,this._texType,null),this._fbo=i.createFramebuffer(this._texWidth,this._texHeight,!1,!1),this._fbo.colorAttachment.set(s),si(o)&&(this._pbo=o.createBuffer(),o.bindBuffer(o.PIXEL_PACK_BUFFER,this._pbo),o.bufferData(o.PIXEL_PACK_BUFFER,4,o.STREAM_READ),o.bindBuffer(o.PIXEL_PACK_BUFFER,null));}destroy(){const e=this._cachedRenderContext.context.gl;this._fullscreenTriangle.destroy(),this._fbo.destroy(),e.deleteBuffer(this._pbo),this._fullscreenTriangle=null,this._fbo=null,this._pbo=null,this._resultBuffer=null;}updateErrorLoop(e,t){const i=this._updateCount;return this._readbackQueue?i>=this._readbackQueue.frameNumberIssued+this._readbackWaitFrames&&this._tryReadback():i>=this._lastReadbackFrame+this._measureWaitFrames&&this._renderErrorTexture(e,t),this._updateCount++,this._measuredError}_bindFramebuffer(){const e=this._cachedRenderContext.context,t=e.gl;e.activeTexture.set(t.TEXTURE1),t.bindTexture(t.TEXTURE_2D,this._fbo.colorAttachment.get()),e.bindFramebuffer.set(this._fbo.framebuffer);}_renderErrorTexture(e,i){const o=this._cachedRenderContext.context,a=o.gl;if(this._bindFramebuffer(),o.viewport.set([0,0,this._texWidth,this._texHeight]),o.clear({color:t.bo.transparent}),this._cachedRenderContext.useProgram("projectionErrorMeasurement").draw(o,a.TRIANGLES,oi.disabled,ri.disabled,ei.unblended,ii.disabled,((e,t)=>({u_input:e,u_output_expected:t}))(e,i),null,null,"$clipping",this._fullscreenTriangle.vertexBuffer,this._fullscreenTriangle.indexBuffer,this._fullscreenTriangle.segments),this._pbo&&si(a)){a.bindBuffer(a.PIXEL_PACK_BUFFER,this._pbo),a.readBuffer(a.COLOR_ATTACHMENT0),a.readPixels(0,0,this._texWidth,this._texHeight,this._texFormat,this._texType,0),a.bindBuffer(a.PIXEL_PACK_BUFFER,null);const e=a.fenceSync(a.SYNC_GPU_COMMANDS_COMPLETE,0);a.flush(),this._readbackQueue={frameNumberIssued:this._updateCount,sync:e};}else this._readbackQueue={frameNumberIssued:this._updateCount,sync:null};}_tryReadback(){const e=this._cachedRenderContext.context.gl;if(this._pbo&&this._readbackQueue&&si(e)){const i=e.clientWaitSync(this._readbackQueue.sync,0,0);if(i===e.WAIT_FAILED)return t.w("WebGL2 clientWaitSync failed."),this._readbackQueue=null,void(this._lastReadbackFrame=this._updateCount);if(i===e.TIMEOUT_EXPIRED)return;e.bindBuffer(e.PIXEL_PACK_BUFFER,this._pbo),e.getBufferSubData(e.PIXEL_PACK_BUFFER,0,this._resultBuffer,0,4),e.bindBuffer(e.PIXEL_PACK_BUFFER,null);}else this._bindFramebuffer(),e.readPixels(0,0,this._texWidth,this._texHeight,this._texFormat,this._texType,this._resultBuffer);this._readbackQueue=null,this._measuredError=ni._parseRGBA8float(this._resultBuffer),this._lastReadbackFrame=this._updateCount;}static _parseRGBA8float(e){let t=0;return t+=e[0]/256,t+=e[1]/65536,t+=e[2]/16777216,e[3]<127&&(t=-t),t/128}}const li=t.a6/128;function ci(e,i){const o=void 0!==e.granularity?Math.max(e.granularity,1):1,a=o+(e.generateBorders?2:0),r=o+(e.extendToNorthPole||e.generateBorders?1:0)+(e.extendToSouthPole||e.generateBorders?1:0),s=a+1,n=r+1,l=e.generateBorders?-1:0,c=e.generateBorders||e.extendToNorthPole?-1:0,h=o+(e.generateBorders?1:0),u=o+(e.generateBorders||e.extendToSouthPole?1:0),d=s*n,_=a*r*6,p=s*n>65536;if(p&&"16bit"===i)throw new Error("Granularity is too large and meshes would not fit inside 16 bit vertex indices.");const m=p||"32bit"===i,f=new Int16Array(2*d);let g=0;for(let i=c;i<=u;i++)for(let a=l;a<=h;a++){let r=a/o*t.a6;-1===a&&(r=-li),a===o+1&&(r=t.a6+li);let s=i/o*t.a6;-1===i&&(s=e.extendToNorthPole?t.bq:-li),i===o+1&&(s=e.extendToSouthPole?t.br:t.a6+li),f[g++]=r,f[g++]=s;}const v=m?new Uint32Array(_):new Uint16Array(_);let x=0;for(let e=0;e0}get latitudeErrorCorrectionRadians(){return this._verticalPerspectiveProjection.latitudeErrorCorrectionRadians}get currentProjection(){return this.useGlobeRendering?this._verticalPerspectiveProjection:this._mercatorProjection}get name(){return "globe"}get useSubdivision(){return this.currentProjection.useSubdivision}get shaderVariantName(){return this.currentProjection.shaderVariantName}get shaderDefine(){return this.currentProjection.shaderDefine}get shaderPreludeCode(){return this.currentProjection.shaderPreludeCode}get vertexShaderPreludeCode(){return this.currentProjection.vertexShaderPreludeCode}get subdivisionGranularity(){return this.currentProjection.subdivisionGranularity}get useGlobeControls(){return this.transitionState>0}destroy(){this._mercatorProjection.destroy(),this._verticalPerspectiveProjection.destroy();}updateGPUdependent(e){this._mercatorProjection.updateGPUdependent(e),this._verticalPerspectiveProjection.updateGPUdependent(e);}getMeshFromTileID(e,t,i,o,a){return this.currentProjection.getMeshFromTileID(e,t,i,o,a)}setProjection(e){this._transitionable.setValue("type",(null==e?void 0:e.type)||"mercator");}updateTransitions(e){this._transitioning=this._transitionable.transitioned(e,this._transitioning);}hasTransition(){return this._transitioning.hasTransition()||this.currentProjection.hasTransition()}recalculate(e){this.properties=this._transitioning.possiblyEvaluate(e);}setErrorQueryLatitudeDegrees(e){this._verticalPerspectiveProjection.setErrorQueryLatitudeDegrees(e),this._mercatorProjection.setErrorQueryLatitudeDegrees(e);}}function pi(e){const t=gi(e.worldSize,e.center.lat);return 2*Math.PI*t}function mi(e,i,o,a,r){const s=1/(1<1e-6){const a=e[0]/o,r=Math.acos(e[2]/o),s=(a>0?r:-r)/Math.PI*180;return new t.W(t.X(s,-180,180),i)}return new t.W(0,i)}function xi(e){return Math.cos(e*Math.PI/180)}function bi(e,i){const o=xi(e),a=xi(i);return t.ar(a/o)}function yi(e,i){const o=e.rotate(i.bearingInRadians),a=i.zoom+bi(i.center.lat,0),r=t.bt(1/xi(i.center.lat),1/xi(Math.min(Math.abs(i.center.lat),60)),t.bw(a,7,3,0,1)),s=360/pi({worldSize:i.worldSize,center:{lat:i.center.lat}});return new t.W(i.center.lng-o.x*s*r,t.al(i.center.lat+o.y*s,-t.am,t.am))}function wi(e){const t=.5*e,i=Math.sin(t),o=Math.cos(t);return Math.log(i+o)-Math.log(o-i)}function Ti(e,i,o,a){const r=e.lat+o*a;if(Math.abs(o)>1){const s=(Math.sign(e.lat+o)!==Math.sign(e.lat)?-Math.abs(e.lat):Math.abs(e.lat))*Math.PI/180,n=Math.abs(e.lat+o)*Math.PI/180,l=wi(s+a*(n-s)),c=wi(s),h=wi(n);return new t.W(e.lng+i*((l-c)/(h-c)),r)}return new t.W(e.lng+i*a,r)}class Pi{constructor(e){this._cachePrevious=new Map,this._cache=new Map,this._hadAnyChanges=!1,this._boundingVolumeFactory=e;}swapBuffers(){if(!this._hadAnyChanges)return;const e=this._cachePrevious;this._cachePrevious=this._cache,this._cache=e,this._cache.clear(),this._hadAnyChanges=!1;}getTileBoundingVolume(e,t,i,o){const a=`${e.z}_${e.x}_${e.y}_${(null==o?void 0:o.terrain)?"t":""}`,r=this._cache.get(a);if(r)return r;const s=this._cachePrevious.get(a);if(s)return this._cache.set(a,s),s;const n=this._boundingVolumeFactory(e,t,i,o);return this._cache.set(a,n),this._hadAnyChanges=!0,n}}class Ci{constructor(e,t,i,o){this.min=i,this.max=o,this.points=e,this.planes=t;}static fromAabb(e,t){const i=[];for(let o=0;o<8;o++)i.push([1&~o?e[0]:t[0],1==(o>>1&1)?t[1]:e[1],1==(o>>2&1)?t[2]:e[2]]);return new Ci(i,[[-1,0,0,t[0]],[1,0,0,-e[0]],[0,-1,0,t[1]],[0,1,0,-e[1]],[0,0,-1,t[2]],[0,0,1,-e[2]]],e,t)}static fromCenterSizeAngles(e,i,o){const a=t.bA([],o[0],o[1],o[2]),r=t.bB([],[i[0],0,0],a),s=t.bB([],[0,i[1],0],a),n=t.bB([],[0,0,i[2]],a),l=[...e],c=[...e];for(let t=0;t<8;t++)for(let i=0;i<3;i++){const o=e[i]+r[i]*(1&~t?-1:1)+s[i]*(1==(t>>1&1)?1:-1)+n[i]*(1==(t>>2&1)?1:-1);l[i]=Math.min(l[i],o),c[i]=Math.max(c[i],o);}const h=[];for(let i=0;i<8;i++){const o=[...e];t.a_(o,o,t.aZ([],r,1&~i?-1:1)),t.a_(o,o,t.aZ([],s,1==(i>>1&1)?1:-1)),t.a_(o,o,t.aZ([],n,1==(i>>2&1)?1:-1)),h.push(o);}return new Ci(h,[[...r,-t.b3(r,h[0])],[...s,-t.b3(s,h[0])],[...n,-t.b3(n,h[0])],[-r[0],-r[1],-r[2],-t.b3(r,h[7])],[-s[0],-s[1],-s[2],-t.b3(s,h[7])],[-n[0],-n[1],-n[2],-t.b3(n,h[7])]],l,c)}intersectsFrustum(e){let t=!0;const i=this.points.length,o=this.planes.length,a=e.planes.length,r=e.points.length;for(let o=0;o=0&&r++;}if(0===r)return 0;r=0&&o++;}if(0===o)return 0}return 1}intersectsPlane(e){const t=this.points.length;let i=0;for(let o=0;o=0&&i++;}return i===t?2:0===i?0:1}}function Mi(e,t,i){const o=e-t;return o<0?-o:Math.max(0,o-i)}function Ii(e,t,i,o,a){const r=e-i;let s;return s=r<0?Math.min(-r,1+r-a):r>a?Math.min(Math.max(r-a,0),1-r):0,Math.max(s,Mi(t,o,a))}class Ei{constructor(){this._boundingVolumeCache=new Pi(this._computeTileBoundingVolume);}prepareNextFrame(){this._boundingVolumeCache.swapBuffers();}distanceToTile2d(e,t,i,o){const a=1<4}allowWorldCopies(){return !1}getTileBoundingVolume(e,t,i,o){return this._boundingVolumeCache.getTileBoundingVolume(e,t,i,o)}_computeTileBoundingVolume(e,i,o,a){var r,s;let n=0,l=0;if(null==a?void 0:a.terrain){const c=new t.a3(e.z,i,e.z,e.x,e.y),h=a.terrain.getMinMaxElevation(c);n=null!==(r=h.minElevation)&&void 0!==r?r:Math.min(0,o),l=null!==(s=h.maxElevation)&&void 0!==s?s:Math.max(0,o);}if(n/=t.bD,l/=t.bD,n+=1,l+=1,e.z<=0)return Ci.fromAabb([-l,-l,-l],[l,l,l]);if(1===e.z)return Ci.fromAabb([0===e.x?-l:0,0===e.y?0:-l,-l],[0===e.x?0:l,0===e.y?l:0,l]);{const i=[mi(0,0,e.x,e.y,e.z),mi(t.a6,0,e.x,e.y,e.z),mi(t.a6,t.a6,e.x,e.y,e.z),mi(0,t.a6,e.x,e.y,e.z)],o=[];for(const e of i)o.push(t.aZ([],e,l));if(l!==n)for(const e of i)o.push(t.aZ([],e,n));0===e.y&&o.push([0,1,0]),e.y===(1<=(1<{const o=t.al(e.lat,-t.am,t.am),a=t.al(+i,this.minZoom+bi(0,o),this.maxZoom);return {center:new t.W(e.lng,o),zoom:a}},this.applyConstrain=(e,t)=>this._helper.applyConstrain(e,t),this._helper=new Wt({calcMatrices:()=>this._calcMatrices(),defaultConstrain:(e,t)=>this.defaultConstrain(e,t)},e),this._coveringTilesDetailsProvider=new Ei;}clone(){const e=new zi;return e.apply(this,!1),e}apply(e,t,i){this._globeLatitudeErrorCorrectionRadians=i||0,this._helper.apply(e,t);}get projectionMatrix(){return this._projectionMatrix}get modelViewProjectionMatrix(){return this._globeViewProjMatrixNoCorrection}get inverseProjectionMatrix(){return this._globeProjMatrixInverted}get cameraPosition(){const e=t.bz();return e[0]=this._cameraPosition[0],e[1]=this._cameraPosition[1],e[2]=this._cameraPosition[2],e}get cameraToCenterDistance(){return this._helper.cameraToCenterDistance}getProjectionData(e){const{overscaledTileID:t,applyGlobeMatrix:i}=e,o=this._helper.getMercatorTileCoordinates(t);return {mainMatrix:this._globeViewProjMatrix32f,tileMercatorCoords:o,clippingPlane:this._cachedClippingPlane,projectionTransition:i?1:0,fallbackMatrix:this._globeViewProjMatrix32f}}_computeClippingPlane(e){const i=this.pitchInRadians,o=this.cameraToCenterDistance/e,a=Math.sin(i)*o,r=Math.cos(i)*o+1,s=1/Math.sqrt(a*a+r*r)*1;let n=-a,l=r;const c=Math.sqrt(n*n+l*l);n/=c,l/=c;const h=[0,n,l];t.bF(h,h,[0,0,0],-this.bearingInRadians),t.bG(h,h,[0,0,0],-1*this.center.lat*Math.PI/180),t.bH(h,h,[0,0,0],this.center.lng*Math.PI/180);const u=1/t.b5(h);return t.aZ(h,h,u),[...h,-s*u]}isLocationOccluded(e){return !this.isSurfacePointVisible(fi(e))}transformLightDirection(e){const i=this._helper._center.lng*Math.PI/180,o=this._helper._center.lat*Math.PI/180,a=Math.cos(o),r=[Math.sin(i)*a,Math.sin(o),Math.cos(i)*a],s=[r[2],0,-r[0]],n=[0,0,0];t.b2(n,s,r),t.b1(s,s),t.b1(n,n);const l=[0,0,0];return t.b1(l,[s[0]*e[0]+n[0]*e[1]+r[0]*e[2],s[1]*e[0]+n[1]*e[1]+r[1]*e[2],s[2]*e[0]+n[2]*e[1]+r[2]*e[2]]),l}getPixelScale(){return 1/Math.cos(this._helper._center.lat*Math.PI/180)}getCircleRadiusCorrection(){return Math.cos(this._helper._center.lat*Math.PI/180)}getPitchedTextCorrection(e,i,o){const a=function(e,i,o){const a=1/(1<r&&(r=i),on&&(n=o);}const h=[c.lng+s,c.lat+l,c.lng+r,c.lat+n];return this.isSurfacePointOnScreen([0,1,0])&&(h[3]=90,h[0]=-180,h[2]=180),this.isSurfacePointOnScreen([0,-1,0])&&(h[1]=-90,h[0]=-180,h[2]=180),new G(h)}calculateCenterFromCameraLngLatAlt(e,t,i,o){return this._helper.calculateCenterFromCameraLngLatAlt(e,t,i,o)}setLocationAtPoint(e,i){const o=fi(this.unprojectScreenPoint(i)),a=fi(e),r=t.bz();t.bK(r);const s=t.bz();t.bH(s,o,r,-this.center.lng*Math.PI/180),t.bG(s,s,r,this.center.lat*Math.PI/180);const n=a[0]*a[0]+a[2]*a[2],l=s[0]*s[0];if(n=-g&&p<=g,x=f>=-g&&f<=g;let b,y;if(v&&x){const e=this.center.lng*Math.PI/180,i=this.center.lat*Math.PI/180;t.bM(u,e)+t.bM(p,i)=0}isSurfacePointOnScreen(e){if(!this.isSurfacePointVisible(e))return !1;const i=t.bE();return t.aE(i,[...e,1],this._globeViewProjMatrixNoCorrection),i[0]/=i[3],i[1]/=i[3],i[2]/=i[3],i[0]>-1&&i[0]<1&&i[1]>-1&&i[1]<1&&i[2]>-1&&i[2]<1}rayPlanetIntersection(e,i){const o=t.b3(e,i),a=t.bz(),r=t.bz();t.aZ(r,i,o),t.b0(a,e,r);const s=1-t.b3(a,a);if(s<0)return null;const n=t.b3(e,e)-1,l=-o+(o<0?1:-1)*Math.sqrt(s),c=n/l,h=l;return {tMin:Math.min(c,h),tMax:Math.max(c,h)}}unprojectScreenPoint(e){const i=this._cameraPosition,o=this.getRayDirectionFromPixel(e),a=this.rayPlanetIntersection(i,o);if(a){const e=t.bz();t.a_(e,i,[o[0]*a.tMin,o[1]*a.tMin,o[2]*a.tMin]);const r=t.bz();return t.b1(r,e),vi(r)}const r=this._cachedClippingPlane,s=r[0]*o[0]+r[1]*o[1]+r[2]*o[2],n=-t.b9(r,i)/s,l=t.bz();if(n>0)t.a_(l,i,[o[0]*n,o[1]*n,o[2]*n]);else {const e=t.bz();t.a_(e,i,[2*o[0],2*o[1],2*o[2]]);const a=t.b9(this._cachedClippingPlane,e);t.b0(l,e,[this._cachedClippingPlane[0]*a,this._cachedClippingPlane[1]*a,this._cachedClippingPlane[2]*a]);}const c=function(e){const i=t.bz();return i[0]=e[0]*-e[3],i[1]=e[1]*-e[3],i[2]=e[2]*-e[3],{center:i,radius:Math.sqrt(1-e[3]*e[3])}}(r);return vi(function(e,i,o){const a=t.bz();t.b0(a,o,e);const r=t.bz();return t.bx(r,e,a,i/t.b7(a)),r}(c.center,c.radius,l))}getMatrixForModel(e,i){const o=t.W.convert(e),a=1/t.bD,r=t.bi();return t.bI(r,r,o.lng/180*Math.PI),t.bf(r,r,-o.lat/180*Math.PI),t.Q(r,r,[0,0,1+i/t.bD]),t.bf(r,r,.5*Math.PI),t.S(r,r,[a,a,a]),r}getProjectionDataForCustomLayer(e=!0){const i=this.getProjectionData({overscaledTileID:new t.a3(0,0,0,0,0),applyGlobeMatrix:e});return i.tileMercatorCoords=[0,0,1,1],i}getFastPathSimpleProjectionMatrix(e){}}class Ri{get pixelsToClipSpaceMatrix(){return this._helper.pixelsToClipSpaceMatrix}get clipSpaceToPixelsMatrix(){return this._helper.clipSpaceToPixelsMatrix}get pixelsToGLUnits(){return this._helper.pixelsToGLUnits}get centerOffset(){return this._helper.centerOffset}get size(){return this._helper.size}get rotationMatrix(){return this._helper.rotationMatrix}get centerPoint(){return this._helper.centerPoint}get pixelsPerMeter(){return this._helper.pixelsPerMeter}setMinZoom(e){this._helper.setMinZoom(e);}setMaxZoom(e){this._helper.setMaxZoom(e);}setMinPitch(e){this._helper.setMinPitch(e);}setMaxPitch(e){this._helper.setMaxPitch(e);}setRenderWorldCopies(e){this._helper.setRenderWorldCopies(e);}setBearing(e){this._helper.setBearing(e);}setPitch(e){this._helper.setPitch(e);}setRoll(e){this._helper.setRoll(e);}setFov(e){this._helper.setFov(e);}setZoom(e){this._helper.setZoom(e);}setCenter(e){this._helper.setCenter(e);}setElevation(e){this._helper.setElevation(e);}setMinElevationForCurrentTile(e){this._helper.setMinElevationForCurrentTile(e);}setPadding(e){this._helper.setPadding(e);}interpolatePadding(e,t,i){this._helper.interpolatePadding(e,t,i);}isPaddingEqual(e){return this._helper.isPaddingEqual(e)}resize(e,t,i=!0){this._helper.resize(e,t,i);}getMaxBounds(){return this._helper.getMaxBounds()}setMaxBounds(e){this._helper.setMaxBounds(e);}setConstrainOverride(e){this._helper.setConstrainOverride(e);}overrideNearFarZ(e,t){this._helper.overrideNearFarZ(e,t);}clearNearFarZOverride(){this._helper.clearNearFarZOverride();}getCameraQueryGeometry(e){return this._helper.getCameraQueryGeometry(this.getCameraPoint(),e)}get tileSize(){return this._helper.tileSize}get tileZoom(){return this._helper.tileZoom}get scale(){return this._helper.scale}get worldSize(){return this._helper.worldSize}get width(){return this._helper.width}get height(){return this._helper.height}get lngRange(){return this._helper.lngRange}get latRange(){return this._helper.latRange}get minZoom(){return this._helper.minZoom}get maxZoom(){return this._helper.maxZoom}get zoom(){return this._helper.zoom}get center(){return this._helper.center}get minPitch(){return this._helper.minPitch}get maxPitch(){return this._helper.maxPitch}get pitch(){return this._helper.pitch}get pitchInRadians(){return this._helper.pitchInRadians}get roll(){return this._helper.roll}get rollInRadians(){return this._helper.rollInRadians}get bearing(){return this._helper.bearing}get bearingInRadians(){return this._helper.bearingInRadians}get fov(){return this._helper.fov}get fovInRadians(){return this._helper.fovInRadians}get elevation(){return this._helper.elevation}get minElevationForCurrentTile(){return this._helper.minElevationForCurrentTile}get padding(){return this._helper.padding}get unmodified(){return this._helper.unmodified}get renderWorldCopies(){return this._helper.renderWorldCopies}get cameraToCenterDistance(){return this._helper.cameraToCenterDistance}get constrainOverride(){return this._helper.constrainOverride}get nearZ(){return this._helper.nearZ}get farZ(){return this._helper.farZ}get autoCalculateNearFarZ(){return this._helper.autoCalculateNearFarZ}get isGlobeRendering(){return this._globeness>0}setTransitionState(e,t){this._globeness=e,this._globeLatitudeErrorCorrectionRadians=t,this._calcMatrices(),this._verticalPerspectiveTransform.getCoveringTilesDetailsProvider().prepareNextFrame(),this._mercatorTransform.getCoveringTilesDetailsProvider().prepareNextFrame();}get currentTransform(){return this.isGlobeRendering?this._verticalPerspectiveTransform:this._mercatorTransform}constructor(e){this._globeLatitudeErrorCorrectionRadians=0,this._globeness=1,this.defaultConstrain=(e,t)=>this.currentTransform.defaultConstrain(e,t),this.applyConstrain=(e,t)=>this._helper.applyConstrain(e,t),this._helper=new Wt({calcMatrices:()=>this._calcMatrices(),defaultConstrain:(e,t)=>this.defaultConstrain(e,t)},e),this._globeness=1,this._mercatorTransform=new Xt,this._verticalPerspectiveTransform=new zi;}clone(){const e=new Ri;return e._globeness=this._globeness,e._globeLatitudeErrorCorrectionRadians=this._globeLatitudeErrorCorrectionRadians,e.apply(this,!1),e}apply(e,t){this._helper.apply(e,t),this._mercatorTransform.apply(this,!1),this._verticalPerspectiveTransform.apply(this,!1,this._globeLatitudeErrorCorrectionRadians);}get projectionMatrix(){return this.currentTransform.projectionMatrix}get modelViewProjectionMatrix(){return this.currentTransform.modelViewProjectionMatrix}get inverseProjectionMatrix(){return this.currentTransform.inverseProjectionMatrix}get cameraPosition(){return this.currentTransform.cameraPosition}getProjectionData(e){const t=this._mercatorTransform.getProjectionData(e),i=this._verticalPerspectiveTransform.getProjectionData(e);return {mainMatrix:this.isGlobeRendering?i.mainMatrix:t.mainMatrix,clippingPlane:i.clippingPlane,tileMercatorCoords:i.tileMercatorCoords,projectionTransition:e.applyGlobeMatrix?this._globeness:0,fallbackMatrix:t.fallbackMatrix}}isLocationOccluded(e){return this.currentTransform.isLocationOccluded(e)}transformLightDirection(e){return this.currentTransform.transformLightDirection(e)}getPixelScale(){return t.bt(this._mercatorTransform.getPixelScale(),this._verticalPerspectiveTransform.getPixelScale(),this._globeness)}getCircleRadiusCorrection(){return t.bt(this._mercatorTransform.getCircleRadiusCorrection(),this._verticalPerspectiveTransform.getCircleRadiusCorrection(),this._globeness)}getPitchedTextCorrection(e,i,o){const a=this._mercatorTransform.getPitchedTextCorrection(e,i,o),r=this._verticalPerspectiveTransform.getPitchedTextCorrection(e,i,o);return t.bt(a,r,this._globeness)}projectTileCoordinates(e,t,i,o){return this.currentTransform.projectTileCoordinates(e,t,i,o)}_calcMatrices(){this._helper._width&&this._helper._height&&(this._verticalPerspectiveTransform.apply(this,!1,this._globeLatitudeErrorCorrectionRadians),this._helper._nearZ=this._verticalPerspectiveTransform.nearZ,this._helper._farZ=this._verticalPerspectiveTransform.farZ,this._mercatorTransform.apply(this,!0,this.isGlobeRendering),this._helper._nearZ=this._mercatorTransform.nearZ,this._helper._farZ=this._mercatorTransform.farZ);}calculateFogMatrix(e){return this.currentTransform.calculateFogMatrix(e)}getVisibleUnwrappedCoordinates(e){return this.currentTransform.getVisibleUnwrappedCoordinates(e)}getCameraFrustum(){return this.currentTransform.getCameraFrustum()}getClippingPlane(){return this.currentTransform.getClippingPlane()}getCoveringTilesDetailsProvider(){return this.currentTransform.getCoveringTilesDetailsProvider()}recalculateZoomAndCenter(e){this._mercatorTransform.recalculateZoomAndCenter(e),this._verticalPerspectiveTransform.recalculateZoomAndCenter(e);}maxPitchScaleFactor(){return this._mercatorTransform.maxPitchScaleFactor()}getCameraPoint(){return this._helper.getCameraPoint()}getCameraAltitude(){return this._helper.getCameraAltitude()}getCameraLngLat(){return this._helper.getCameraLngLat()}lngLatToCameraDepth(e,t){return this.currentTransform.lngLatToCameraDepth(e,t)}populateCache(e){this._mercatorTransform.populateCache(e),this._verticalPerspectiveTransform.populateCache(e);}getBounds(){return this.currentTransform.getBounds()}calculateCenterFromCameraLngLatAlt(e,t,i,o){return this._helper.calculateCenterFromCameraLngLatAlt(e,t,i,o)}setLocationAtPoint(e,t){if(!this.isGlobeRendering)return this._mercatorTransform.setLocationAtPoint(e,t),void this.apply(this._mercatorTransform,!1);this._verticalPerspectiveTransform.setLocationAtPoint(e,t),this.apply(this._verticalPerspectiveTransform,!1);}locationToScreenPoint(e,t){return this.currentTransform.locationToScreenPoint(e,t)}screenPointToMercatorCoordinate(e,t){return this.currentTransform.screenPointToMercatorCoordinate(e,t)}screenPointToLocation(e,t){return this.currentTransform.screenPointToLocation(e,t)}isPointOnMapSurface(e,t){return this.currentTransform.isPointOnMapSurface(e,t)}getRayDirectionFromPixel(e){return this._verticalPerspectiveTransform.getRayDirectionFromPixel(e)}getMatrixForModel(e,t){return this.currentTransform.getMatrixForModel(e,t)}getProjectionDataForCustomLayer(e=!0){const t=this._mercatorTransform.getProjectionDataForCustomLayer(e);if(!this.isGlobeRendering)return t;const i=this._verticalPerspectiveTransform.getProjectionDataForCustomLayer(e);return i.fallbackMatrix=t.mainMatrix,i}getFastPathSimpleProjectionMatrix(e){return this.currentTransform.getFastPathSimpleProjectionMatrix(e)}}class Di{get useGlobeControls(){return !0}handlePanInertia(e,i){const o=yi(e,i);return Math.abs(o.lng-i.center.lng)>180&&(o.lng=i.center.lng+179.5*Math.sign(o.lng-i.center.lng)),{easingCenter:o,easingOffset:new t.P(0,0)}}handleMapControlsRollPitchBearingZoom(e,i){const o=e.around,a=i.screenPointToLocation(o);e.bearingDelta&&i.setBearing(i.bearing+e.bearingDelta),e.pitchDelta&&i.setPitch(i.pitch+e.pitchDelta),e.rollDelta&&i.setRoll(i.roll+e.rollDelta);const r=i.zoom;e.zoomDelta&&i.setZoom(i.zoom+e.zoomDelta);const s=i.zoom-r;if(0===s)return;const n=t.bJ(i.center.lng,a.lng),l=n/(Math.abs(n/180)+1),c=t.bJ(i.center.lat,a.lat),h=i.getRayDirectionFromPixel(o),u=i.cameraPosition,d=-1*t.b3(u,h),_=t.bz();t.a_(_,u,[h[0]*d,h[1]*d,h[2]*d]);const p=t.b5(_)-1,m=Math.exp(.5*-Math.max(p-.3,0)),f=gi(i.worldSize,i.center.lat)/Math.min(i.width,i.height),g=t.bw(f,.9,.5,1,.25),v=(1-t.ao(-s))*Math.min(m,g),x=i.center.lat,b=i.zoom,y=new t.W(i.center.lng+l*v,t.al(i.center.lat+c*v,-t.am,t.am));i.setLocationAtPoint(a,o);const w=i.center,T=t.bw(Math.abs(n),45,85,0,1),P=t.bw(f,.75,.35,0,1),C=Math.pow(Math.max(T,P),.25),M=t.bJ(w.lng,y.lng),I=t.bJ(w.lat,y.lat);i.setCenter(new t.W(w.lng+M*C,w.lat+I*C).wrap()),i.setZoom(b+bi(x,i.center.lat));}handleMapControlsPan(e,t,i){if(!e.panDelta)return;const o=t.center.lat,a=t.zoom;t.setCenter(yi(e.panDelta,t).wrap()),t.setZoom(a+bi(o,t.center.lat));}cameraForBoxAndBearing(e,i,o,a,r){const s=Qt(e,i,o,a,r),n=i.left/r.width*2-1,l=(r.width-i.right)/r.width*2-1,c=i.top/r.height*-2+1,h=(r.height-i.bottom)/r.height*-2+1,u=t.bJ(o.getWest(),o.getEast())<0,d=u?o.getEast():o.getWest(),_=u?o.getWest():o.getEast(),p=Math.max(o.getNorth(),o.getSouth()),m=Math.min(o.getNorth(),o.getSouth()),f=d+.5*t.bJ(d,_),g=p+.5*t.bJ(p,m),v=r.clone();v.setCenter(s.center),v.setBearing(s.bearing),v.setPitch(0),v.setRoll(0),v.setZoom(s.zoom);const x=v.modelViewProjectionMatrix,b=[fi(o.getNorthWest()),fi(o.getNorthEast()),fi(o.getSouthWest()),fi(o.getSouthEast()),fi(new t.W(_,g)),fi(new t.W(d,g)),fi(new t.W(f,p)),fi(new t.W(f,m))],y=fi(s.center);let w=Number.POSITIVE_INFINITY;for(const e of b)n<0&&(w=Di.getLesserNonNegativeNonNull(w,Di.solveVectorScale(e,y,x,"x",n))),l>0&&(w=Di.getLesserNonNegativeNonNull(w,Di.solveVectorScale(e,y,x,"x",l))),c>0&&(w=Di.getLesserNonNegativeNonNull(w,Di.solveVectorScale(e,y,x,"y",c))),h<0&&(w=Di.getLesserNonNegativeNonNull(w,Di.solveVectorScale(e,y,x,"y",h)));if(Number.isFinite(w)&&0!==w)return s.zoom=Math.min(v.zoom+t.ar(w),e.maxZoom),s;Kt();}handleJumpToCenterZoom(e,i){const o=e.center.lat,a=e.applyConstrain(i.center?t.W.convert(i.center):e.center,e.zoom).center;e.setCenter(a.wrap());const r=void 0!==i.zoom?+i.zoom:e.zoom+bi(o,a.lat);e.zoom!==r&&e.setZoom(r);}handleEaseTo(e,i){const o=e.zoom,a=e.center,r=e.padding,s={roll:e.roll,pitch:e.pitch,bearing:e.bearing},n={roll:void 0===i.roll?e.roll:i.roll,pitch:void 0===i.pitch?e.pitch:i.pitch,bearing:void 0===i.bearing?e.bearing:i.bearing},l=void 0!==i.zoom,c=!e.isPaddingEqual(i.padding);let h=!1;const u=i.center?t.W.convert(i.center):a,d=e.applyConstrain(u,o).center;Gt(e,d);const _=e.clone();_.setCenter(d),_.setZoom(l?+i.zoom:o+bi(a.lat,u.lat)),_.setBearing(i.bearing);const p=new t.P(t.al(e.centerPoint.x+i.offsetAsPoint.x,0,e.width),t.al(e.centerPoint.y+i.offsetAsPoint.y,0,e.height));_.setLocationAtPoint(d,p);const m=(i.offset&&i.offsetAsPoint.mag())>0?_.center:d,f=l?+i.zoom:o+bi(a.lat,m.lat),g=o+bi(a.lat,0),v=f+bi(m.lat,0),x=t.bJ(a.lng,m.lng),b=t.bJ(a.lat,m.lat),y=t.ao(v-g);return h=f!==o,{easeFunc:o=>{if(t.bn(s,n)||Yt({startEulerAngles:s,endEulerAngles:n,tr:e,k:o,useSlerp:s.roll!=n.roll}),c&&e.interpolatePadding(r,i.padding,o),i.around)t.w("Easing around a point is not supported under globe projection."),e.setLocationAtPoint(i.around,i.aroundPoint);else {const t=v>g?Math.min(2,y):Math.max(.5,y),i=Math.pow(t,1-o),r=Ti(a,x,b,o*i);e.setCenter(r.wrap());}if(h){const i=t.H.number(g,v,o)+bi(0,e.center.lat);e.setZoom(i);}},isZooming:h,elevationCenter:m}}handleFlyTo(e,i){const o=void 0!==i.zoom,a=e.center,r=e.zoom,s=e.padding,n=!e.isPaddingEqual(i.padding),l=e.applyConstrain(t.W.convert(i.center||i.locationAtOffset),r).center,c=o?+i.zoom:e.zoom+bi(e.center.lat,l.lat),h=e.clone();h.setCenter(l),h.setZoom(c),h.setBearing(i.bearing);const u=new t.P(t.al(e.centerPoint.x+i.offsetAsPoint.x,0,e.width),t.al(e.centerPoint.y+i.offsetAsPoint.y,0,e.height));h.setLocationAtPoint(l,u);const d=h.center;Gt(e,d);const _=function(e,i,o){const a=fi(i),r=fi(o),s=t.b3(a,r),n=Math.acos(s),l=pi(e);return n/(2*Math.PI)*l}(e,a,d),p=r+bi(a.lat,0),m=c+bi(d.lat,0),f=t.ao(m-p);let g;if("number"==typeof i.minZoom){const o=+i.minZoom+bi(d.lat,0),a=Math.min(o,p,m)+bi(0,d.lat),r=e.applyConstrain(d,a).zoom+bi(d.lat,0);g=t.ao(r-p);}const v=t.bJ(a.lng,d.lng),x=t.bJ(a.lat,d.lat);return {easeFunc:(o,r,l,h)=>{const u=Ti(a,v,x,l);n&&e.interpolatePadding(s,i.padding,o);const _=1===o?d:u;e.setCenter(_.wrap());const m=p+t.ar(r);e.setZoom(1===o?c:m+bi(0,_.lat));},scaleOfZoom:f,targetCenter:d,scaleOfMinZoom:g,pixelPathLength:_}}static solveVectorScale(e,t,i,o,a){const r="x"===o?[i[0],i[4],i[8],i[12]]:[i[1],i[5],i[9],i[13]],s=[i[3],i[7],i[11],i[15]],n=e[0]*r[0]+e[1]*r[1]+e[2]*r[2],l=e[0]*s[0]+e[1]*s[1]+e[2]*s[2],c=t[0]*r[0]+t[1]*r[1]+t[2]*r[2],h=t[0]*s[0]+t[1]*s[1]+t[2]*s[2];return c+a*l===n+a*h||s[3]*(n-c)+r[3]*(h-l)+n*h==c*l?null:(c+r[3]-a*h-a*s[3])/(c-n-a*h+a*l)}static getLesserNonNegativeNonNull(e,t){return null!==t&&t>=0&&tt.C(e,null==i?void 0:i.filter((e=>"source.canvas"!==e.identifier))),Fi=t.bN();class ki extends t.E{constructor(e,i={}){var o,a;super(),this._rtlPluginLoaded=()=>{for(const e in this.tileManagers){const t=this.tileManagers[e].getSource().type;"vector"!==t&&"geojson"!==t||this.tileManagers[e].reload();}},this.map=e,this.dispatcher=new k(F(),e._getMapId()),this.dispatcher.registerMessageHandler("GG",((e,t)=>this.getGlyphs(e,t))),this.dispatcher.registerMessageHandler("GI",((e,t)=>this.getImages(e,t))),this.dispatcher.registerMessageHandler("GDA",((e,t)=>this.getDashes(e,t))),this.imageManager=new g,this.imageManager.setEventedParent(this);const r=(null===(o=e._container)||void 0===o?void 0:o.lang)||"undefined"!=typeof document&&(null===(a=document.documentElement)||void 0===a?void 0:a.lang)||void 0;this.glyphManager=new T(e._requestManager,i.localIdeographFontFamily,r),this.lineAtlas=new S(256,512),this.crossTileSymbolIndex=new At,this._setInitialValues(),this._resetUpdates(),this.dispatcher.broadcast("SR",t.bO()),ce().on(se,this._rtlPluginLoaded),this.on("data",(e=>{if("source"!==e.dataType||"metadata"!==e.sourceDataType)return;const t=this.tileManagers[e.sourceId];if(!t)return;const i=t.getSource();if(null==i?void 0:i.vectorLayerIds)for(const e in this._layers){const t=this._layers[e];t.source===i.id&&this._validateLayer(t);}}));}_setInitialValues(){var e;this._spritesImagesIds={},this._layers={},this._order=[],this.tileManagers={},this.zoomHistory=new t.bP,this._availableImages=[],this._globalState={},this._serializedLayers={},this.stylesheet=null,this.light=null,this.sky=null,this.projection&&(this.projection.destroy(),delete this.projection),this._loaded=!1,this._changed=!1,this._updatedLayers={},this._updatedSources={},this._changedImages={},this._glyphsDidChange=!1,this._updatedPaintProps={},this._layerOrderChanged=!1,this.crossTileSymbolIndex=new((null===(e=this.crossTileSymbolIndex)||void 0===e?void 0:e.constructor)||Object),this.pauseablePlacement=void 0,this.placement=void 0,this.z=0;}setGlobalStateProperty(e,i){var o,a,r;this._checkLoaded();const s=null===i?null!==(r=null===(a=null===(o=this.stylesheet.state)||void 0===o?void 0:o[e])||void 0===a?void 0:a.default)&&void 0!==r?r:null:i;if(t.bQ(s,this._globalState[e]))return this;this._globalState[e]=s,this._applyGlobalStateChanges([e]);}getGlobalState(){return this._globalState}setGlobalState(e){this._checkLoaded();const i=[];for(const o in e)!t.bQ(this._globalState[o],e[o].default)&&(i.push(o),this._globalState[o]=e[o].default);this._applyGlobalStateChanges(i);}_applyGlobalStateChanges(e){if(0===e.length)return;const t=new Set,i={};for(const o of e){i[o]=this._globalState[o];for(const e in this._layers){const i=this._layers[e],a=i.getLayoutAffectingGlobalStateRefs(),r=i.getPaintAffectingGlobalStateRefs(),s=i.getVisibilityAffectingGlobalStateRefs();if(a.has(o)&&t.add(i.source),r.has(o))for(const{name:e,value:t}of r.get(o))this._updatePaintProperty(i,e,t);(null==s?void 0:s.has(o))&&(i.recalculateVisibility(),this._updateLayer(i));}}this.dispatcher.broadcast("UGS",i);for(const e in this.tileManagers)t.has(e)&&(this._reloadSource(e),this._changed=!0);}loadURL(e){return t._(this,arguments,void 0,(function*(e,i={},o){this.fire(new t.n("dataloading",{dataType:"style"})),i.validate="boolean"!=typeof i.validate||i.validate,this._loadStyleRequest=new AbortController;const a=this._loadStyleRequest;try{const r=yield this.map._requestManager.transformRequest(e,"Style");t.bR(a.signal);const s=yield t.k(r,a);this._loadStyleRequest===a&&(this._loadStyleRequest=null),this._load(s.data,i,o);}catch(e){this._loadStyleRequest===a&&(this._loadStyleRequest=null),e&&!a.signal.aborted&&this.fire(new t.l(t.d(e)));}}))}loadJSON(e,i={},o){this.fire(new t.n("dataloading",{dataType:"style"})),this._frameRequest=new AbortController,n.frameAsync(this._frameRequest,this.map._ownerWindow).then((()=>{this._frameRequest=null,i.validate=!1!==i.validate,this._load(e,i,o);})).catch((()=>{}));}loadEmpty(){this.fire(new t.n("dataloading",{dataType:"style"})),this._load(Fi,{validate:!1});}_load(e,i,o){var a,r;let s=i.transformStyle?i.transformStyle(o,e):e;if(!i.validate||!Li(this,t.F(s))){s=Object.assign({},s),this._loaded=!0,this.stylesheet=s;for(const e in s.sources)this.addSource(e,s.sources[e],{validate:!1});s.sprite?this._loadSprite(s.sprite):this.imageManager.setLoaded(!0),this.glyphManager.setURL(s.glyphs),this._createLayers(),this.light=new M(this.stylesheet.light),this._setProjectionInternal((null===(a=this.stylesheet.projection)||void 0===a?void 0:a.type)||"mercator"),this.sky=new E(this.stylesheet.sky),this.map.setTerrain(null!==(r=this.stylesheet.terrain)&&void 0!==r?r:null),this.fire(new t.n("data",{dataType:"style"})),this.fire(new t.n("style.load"));}}_createLayers(){var e,i,o;const a=t.bS(this.stylesheet.layers);this.setGlobalState(null!==(e=this.stylesheet.state)&&void 0!==e?e:null),this.dispatcher.broadcast("SL",a),this._order=a.map((e=>e.id)),this._layers={},this._serializedLayers=null;for(const e of a){const a=t.bT(e,this._globalState);if(a.setEventedParent(this,{layer:{id:e.id}}),this._layers[e.id]=a,t.bU(a)&&this.tileManagers[a.source]){const t=null!==(o=null===(i=e.paint)||void 0===i?void 0:i["raster-fade-duration"])&&void 0!==o?o:a.paint.get("raster-fade-duration");this.tileManagers[a.source].setRasterFadeDuration(t);}}}_loadSprite(e,i=!1,o=void 0){this.imageManager.setLoaded(!1);const a=new AbortController;let r;this._spriteRequest=a,function(e,i,o,a){return t._(this,void 0,void 0,(function*(){const r=p(e),s=o>1?"@2x":"",l={},c={};for(const{id:e,url:o}of r){const r=yield i.transformRequest(m(o,s,".json"),"SpriteJSON");l[e]=t.k(r,a);const n=yield i.transformRequest(m(o,s,".png"),"SpriteImage");c[e]=u.getImage(n,a);}return yield Promise.all([...Object.values(l),...Object.values(c)]),function(e,i){return t._(this,void 0,void 0,(function*(){const t={};for(const o in e){t[o]={};const a=n.getImageCanvasContext((yield i[o]).data),r=(yield e[o]).data;for(const e in r){const{width:i,height:s,x:n,y:l,sdf:c,pixelRatio:h,stretchX:u,stretchY:d,content:_,textFitWidth:p,textFitHeight:m}=r[e];t[o][e]={data:null,pixelRatio:h,sdf:c,stretchX:u,stretchY:d,content:_,textFitWidth:p,textFitHeight:m,spriteData:{width:i,height:s,x:n,y:l,context:a}};}}return t}))}(l,c)}))}(e,this.map._requestManager,this.map.getPixelRatio(),this._spriteRequest).then((e=>{if(this._spriteRequest=null,e)for(const t in e){this._spritesImagesIds[t]=[];const o=this._spritesImagesIds[t]?this._spritesImagesIds[t].filter((t=>!(t in e))):[];for(const e of o)this.imageManager.removeImage(e),this._changedImages[e]=!0;for(const o in e[t]){const a="default"===t?o:`${t}:${o}`;this._spritesImagesIds[t].push(a),a in this.imageManager.images?this.imageManager.updateImage(a,e[t][o],!1):this.imageManager.addImage(a,e[t][o]),i&&(this._changedImages[a]=!0);}}})).catch((e=>{this._spriteRequest=null,r=e,a.signal.aborted||this.fire(new t.l(r));})).finally((()=>{this.imageManager.setLoaded(!0),this._availableImages=this.imageManager.listImages(),i&&(this._changed=!0),this.dispatcher.broadcast("SI",this._availableImages),this.fire(new t.n("data",{dataType:"style"})),o&&o(r);}));}_unloadSprite(){for(const e of Object.values(this._spritesImagesIds).flat())this.imageManager.removeImage(e),this._changedImages[e]=!0;this._spritesImagesIds={},this._availableImages=this.imageManager.listImages(),this._changed=!0,this.dispatcher.broadcast("SI",this._availableImages),this.fire(new t.n("data",{dataType:"style"}));}_validateLayer(e){const i=this.tileManagers[e.source];if(!i)return;const o=e.sourceLayer;if(!o)return;const a=i.getSource();("geojson"===a.type||a.vectorLayerIds&&!a.vectorLayerIds.includes(o))&&this.fire(new t.l(new Error(`Source layer "${o}" does not exist on source "${a.id}" as specified by style layer "${e.id}".`)));}loaded(){if(!this._loaded)return !1;if(Object.keys(this._updatedSources).length)return !1;for(const e in this.tileManagers)if(!this.tileManagers[e].loaded())return !1;return this.imageManager.isLoaded()}_serializeByIds(e,i=!1){const o=this._serializedAllLayers();if(!e||0===e.length)return Object.values(i?t.bV(o):o);const a=[];for(const r of e)if(o[r]){const e=i?t.bV(o[r]):o[r];a.push(e);}return a}_serializedAllLayers(){let e=this._serializedLayers;if(e)return e;e=this._serializedLayers={};const t=Object.keys(this._layers);for(const i of t){const t=this._layers[i];"custom"!==t.type&&(e[i]=t.serialize());}return e}hasTransitions(){var e,t,i;if(null===(e=this.light)||void 0===e?void 0:e.hasTransition())return !0;if(null===(t=this.sky)||void 0===t?void 0:t.hasTransition())return !0;if(null===(i=this.projection)||void 0===i?void 0:i.hasTransition())return !0;for(const e in this.tileManagers)if(this.tileManagers[e].hasTransition())return !0;for(const e in this._layers)if(this._layers[e].hasTransition())return !0;return !1}_checkLoaded(){if(!this._loaded)throw new Error("Style is not done loading.")}update(e){if(!this._loaded)return;const i=this._changed;if(i){const t=Object.keys(this._updatedLayers),i=Object.keys(this._removedLayers);(t.length||i.length)&&this._updateWorkerLayers(t,i);for(const e in this._updatedSources){const t=this._updatedSources[e];if("reload"===t)this._reloadSource(e);else {if("clear"!==t)throw new Error(`Invalid action ${t}`);this._clearSource(e);}}this._updateTilesForChangedImages(),this._updateTilesForChangedGlyphs();for(const t in this._updatedPaintProps)this._layers[t].updateTransitions(e);this.light.updateTransitions(e),this.sky.updateTransitions(e),this._resetUpdates();}const o={};for(const e in this.tileManagers){const t=this.tileManagers[e];o[e]=t.used,t.used=!1;}for(const t of this._order){const i=this._layers[t];i.recalculate(e,this._availableImages),!i.isHidden(e.zoom)&&i.source&&(this.tileManagers[i.source].used=!0);}for(const e in o){const i=this.tileManagers[e];!!o[e]!=!!i.used&&i.fire(new t.n("data",{sourceDataType:"visibility",dataType:"source",sourceId:e}));}this.light.recalculate(e),this.sky.recalculate(e),this.projection.recalculate(e),this.z=e.zoom,i&&this.fire(new t.n("data",{dataType:"style"}));}_updateTilesForChangedImages(){const e=Object.keys(this._changedImages);if(e.length){for(const t in this.tileManagers)this.tileManagers[t].reloadTilesForDependencies(["icons","patterns"],e);this._changedImages={};}}_updateTilesForChangedGlyphs(){if(this._glyphsDidChange){for(const e in this.tileManagers)this.tileManagers[e].reloadTilesForDependencies(["glyphs"],[""]);this._glyphsDidChange=!1;}}_updateWorkerLayers(e,t){this.dispatcher.broadcast("UL",{layers:this._serializeByIds(e,!1),removedIds:t});}_resetUpdates(){this._changed=!1,this._updatedLayers={},this._removedLayers={},this._updatedSources={},this._updatedPaintProps={},this._changedImages={},this._glyphsDidChange=!1;}setState(e,i={}){var o;this._checkLoaded();const a=this.serialize();if(e=i.transformStyle?i.transformStyle(a,e):e,(null===(o=i.validate)||void 0===o||o)&&Li(this,t.F(e)))return !1;(e=t.bV(e)).layers=t.bS(e.layers);const r=t.bW(a,e),s=this._getOperationsToPerform(r);if(s.unimplemented.length>0)throw new Error(`Unimplemented: ${s.unimplemented.join(", ")}.`);if(0===s.operations.length)return !1;for(const e of s.operations)e();return this.stylesheet=e,this._serializedLayers=null,this.fire(new t.n("style.load",{style:this})),!0}_getOperationsToPerform(e){const t=[],i=[];for(const o of e)switch(o.command){case "setCenter":case "setZoom":case "setBearing":case "setPitch":case "setRoll":continue;case "addLayer":t.push((()=>this.addLayer.apply(this,o.args)));break;case "removeLayer":t.push((()=>this.removeLayer.apply(this,o.args)));break;case "setPaintProperty":t.push((()=>this.setPaintProperty.apply(this,o.args)));break;case "setLayoutProperty":t.push((()=>this.setLayoutProperty.apply(this,o.args)));break;case "setFilter":t.push((()=>this.setFilter.apply(this,o.args)));break;case "addSource":t.push((()=>this.addSource.apply(this,o.args)));break;case "removeSource":t.push((()=>this.removeSource.apply(this,o.args)));break;case "setLayerZoomRange":t.push((()=>this.setLayerZoomRange.apply(this,o.args)));break;case "setLight":t.push((()=>this.setLight.apply(this,o.args)));break;case "setGeoJSONSourceData":t.push((()=>this.setGeoJSONSourceData.apply(this,o.args)));break;case "setGlyphs":t.push((()=>this.setGlyphs.apply(this,o.args)));break;case "setSprite":t.push((()=>this.setSprite.apply(this,o.args)));break;case "setTerrain":t.push((()=>this.map.setTerrain.apply(this,o.args)));break;case "setSky":t.push((()=>this.setSky.apply(this,o.args)));break;case "setProjection":this.setProjection.apply(this,o.args);break;case "setGlobalState":t.push((()=>this.setGlobalState.apply(this,o.args)));break;case "setTransition":t.push((()=>{}));break;default:i.push(o.command);}return {operations:t,unimplemented:i}}addImage(e,i){if(this.getImage(e))return this.fire(new t.l(new Error(`An image named "${e}" already exists.`)));this.imageManager.addImage(e,i),this._afterImageUpdated(e);}updateImage(e,t){this.imageManager.updateImage(e,t);}getImage(e){return this.imageManager.getImage(e)}removeImage(e){if(!this.getImage(e))return this.fire(new t.l(new Error(`An image named "${e}" does not exist.`)));this.imageManager.removeImage(e),this._afterImageUpdated(e);}_afterImageUpdated(e){this._availableImages=this.imageManager.listImages(),this._changedImages[e]=!0,this._changed=!0,this.dispatcher.broadcast("SI",this._availableImages),this.fire(new t.n("data",{dataType:"style"}));}listImages(){return this._checkLoaded(),this.imageManager.listImages()}addSource(e,i,o={}){var a;if(this._checkLoaded(),void 0!==this.tileManagers[e])throw new Error(`Source "${e}" already exists.`);if(!i.type)throw new Error(`The type property must be defined, but only the following properties were given: ${Object.keys(i).join(", ")}.`);if(["vector","raster","geojson","video","image"].includes(i.type)&&this._validate(t.F.source,`sources.${e}`,i,null,o))return;(null===(a=this.map)||void 0===a?void 0:a._collectResourceTiming)&&(i.collectResourceTiming=!0);const r=this.tileManagers[e]=new Fe(e,i,this.dispatcher);r.style=this,r.setEventedParent(this,(()=>({isSourceLoaded:r.loaded(),source:r.serialize(),sourceId:e}))),r.onAdd(this.map),this._changed=!0;}removeSource(e){if(this._checkLoaded(),void 0===this.tileManagers[e])throw new Error(`There is no source with this ID=${e}`);for(const i in this._layers)if(this._layers[i].source===e)return this.fire(new t.l(new Error(`Source "${e}" cannot be removed while layer "${i}" is using it.`)));const i=this.tileManagers[e];delete this.tileManagers[e],delete this._updatedSources[e],i.fire(new t.n("data",{sourceDataType:"metadata",dataType:"source",sourceId:e})),i.setEventedParent(null),i.onRemove(this.map),this._changed=!0;}setGeoJSONSourceData(e,t){if(this._checkLoaded(),void 0===this.tileManagers[e])throw new Error(`There is no source with this ID=${e}`);const i=this.tileManagers[e].getSource();if("geojson"!==i.type)throw new Error(`geojsonSource.type is ${i.type}, which is !== 'geojson`);i.setData(t),this._changed=!0;}getSource(e){var t;return null===(t=this.tileManagers[e])||void 0===t?void 0:t.getSource()}addLayer(e,i,o={}){this._checkLoaded();const a=e.id;if(this.getLayer(a))return void this.fire(new t.l(new Error(`Layer "${a}" already exists on this map.`)));let r;if("custom"===e.type){if(Li(this,t.bX(e)))return;r=t.bT(e,this._globalState);}else {if("source"in e&&"object"==typeof e.source&&(this.addSource(a,e.source),e=t.bV(e),e=t.e(e,{source:a})),this._validate(t.F.layer,`layers.${a}`,e,{arrayIndex:-1},o))return;r=t.bT(e,this._globalState),this._validateLayer(r),r.setEventedParent(this,{layer:{id:a}});}const s=i?this._order.indexOf(i):this._order.length;if(i&&-1===s)this.fire(new t.l(new Error(`Cannot add layer "${a}" before non-existing layer "${i}".`)));else {if(this._order.splice(s,0,a),this._layerOrderChanged=!0,this._layers[a]=r,this._removedLayers[a]&&r.source&&"custom"!==r.type){const e=this._removedLayers[a];delete this._removedLayers[a],e.type!==r.type?this._updatedSources[r.source]="clear":(this._updatedSources[r.source]="reload",this.tileManagers[r.source].pause());}this._updateLayer(r),r.onAdd&&r.onAdd(this.map);}}moveLayer(e,i){if(this._checkLoaded(),this._changed=!0,!this._layers[e])return void this.fire(new t.l(new Error(`The layer '${e}' does not exist in the map's style and cannot be moved.`)));if(e===i)return;const o=this._order.indexOf(e);this._order.splice(o,1);const a=i?this._order.indexOf(i):this._order.length;i&&-1===a?this.fire(new t.l(new Error(`Cannot move layer "${e}" before non-existing layer "${i}".`))):(this._order.splice(a,0,e),this._layerOrderChanged=!0);}removeLayer(e){this._checkLoaded();const i=this._layers[e];if(!i)return void this.fire(new t.l(new Error(`Cannot remove non-existing layer "${e}".`)));i.setEventedParent(null);const o=this._order.indexOf(e);this._order.splice(o,1),this._layerOrderChanged=!0,this._changed=!0,this._removedLayers[e]=i,delete this._layers[e],this._serializedLayers&&delete this._serializedLayers[e],delete this._updatedLayers[e],delete this._updatedPaintProps[e],i.onRemove&&i.onRemove(this.map);}getLayer(e){return this._layers[e]}getLayersOrder(){return [...this._order]}hasLayer(e){return e in this._layers}setLayerZoomRange(e,i,o){this._checkLoaded();const a=this.getLayer(e);a?a.minzoom===i&&a.maxzoom===o||(null!=i&&(a.minzoom=i),null!=o&&(a.maxzoom=o),this._updateLayer(a)):this.fire(new t.l(new Error(`Cannot set the zoom range of non-existing layer "${e}".`)));}setFilter(e,i,o={}){this._checkLoaded();const a=this.getLayer(e);if(a){if(!t.bQ(a.filter,i))return null==i?(a.setFilter(void 0),void this._updateLayer(a)):void(this._validate(t.F.filter,`layers.${a.id}.filter`,i,null,o)||(a.setFilter(t.bV(i)),this._updateLayer(a)))}else this.fire(new t.l(new Error(`Cannot filter non-existing layer "${e}".`)));}getFilter(e){return t.bV(this.getLayer(e).filter)}setLayoutProperty(e,i,o,a={}){this._checkLoaded();const r=this.getLayer(e);r?t.bQ(r.getLayoutProperty(i),o)||(r.setLayoutProperty(i,o,a),this._updateLayer(r)):this.fire(new t.l(new Error(`Cannot style non-existing layer "${e}".`)));}getLayoutProperty(e,i){const o=this.getLayer(e);if(o)return o.getLayoutProperty(i);this.fire(new t.l(new Error(`Cannot get style of non-existing layer "${e}".`)));}setPaintProperty(e,i,o,a={}){this._checkLoaded();const r=this.getLayer(e);r?t.bQ(r.getPaintProperty(i),o)||this._updatePaintProperty(r,i,o,a):this.fire(new t.l(new Error(`Cannot style non-existing layer "${e}".`)));}_updatePaintProperty(e,i,o,a={}){e.setPaintProperty(i,o,a)&&this._updateLayer(e),t.bU(e)&&"raster-fade-duration"===i&&this.tileManagers[e.source].setRasterFadeDuration(o),this._changed=!0,this._updatedPaintProps[e.id]=!0,this._serializedLayers=null;}getPaintProperty(e,t){return this.getLayer(e).getPaintProperty(t)}setFeatureState(e,i){this._checkLoaded();const o=e.source,a=e.sourceLayer,r=this.tileManagers[o];if(void 0===r)return void this.fire(new t.l(new Error(`The source '${o}' does not exist in the map's style.`)));const s=r.getSource().type;"geojson"===s&&a?this.fire(new t.l(new Error("GeoJSON sources cannot have a sourceLayer parameter."))):"vector"!==s||a?(void 0===e.id&&this.fire(new t.l(new Error("The feature id parameter must be provided."))),r.setFeatureState(a,e.id,i)):this.fire(new t.l(new Error("The sourceLayer parameter must be provided for vector source types.")));}removeFeatureState(e,i){this._checkLoaded();const o=e.source,a=this.tileManagers[o];if(void 0===a)return void this.fire(new t.l(new Error(`The source '${o}' does not exist in the map's style.`)));const r=a.getSource().type,s="vector"===r?e.sourceLayer:void 0;"vector"!==r||s?i&&"string"!=typeof e.id&&"number"!=typeof e.id?this.fire(new t.l(new Error("A feature id is required to remove its specific state property."))):a.removeFeatureState(s,e.id,i):this.fire(new t.l(new Error("The sourceLayer parameter must be provided for vector source types.")));}getFeatureState(e){this._checkLoaded();const i=e.source,o=e.sourceLayer,a=this.tileManagers[i];if(void 0!==a)return "vector"!==a.getSource().type||o?(void 0===e.id&&this.fire(new t.l(new Error("The feature id parameter must be provided."))),a.getFeatureState(o,e.id)):void this.fire(new t.l(new Error("The sourceLayer parameter must be provided for vector source types.")));this.fire(new t.l(new Error(`The source '${i}' does not exist in the map's style.`)));}getTransition(){var e;return t.e({duration:300,delay:0},null===(e=this.stylesheet)||void 0===e?void 0:e.transition)}serialize(){if(!this._loaded)return;const e=t.bY(this.tileManagers,(e=>e.serialize())),i=this._serializeByIds(this._order,!0),o=this.map.getTerrain()||void 0,a=this.stylesheet;return t.bZ({version:a.version,name:a.name,metadata:a.metadata,light:a.light,sky:a.sky,center:a.center,zoom:a.zoom,bearing:a.bearing,pitch:a.pitch,sprite:a.sprite,glyphs:a.glyphs,transition:a.transition,projection:a.projection,sources:e,layers:i,terrain:o},(e=>void 0!==e))}_updateLayer(e){this._updatedLayers[e.id]=!0,e.source&&!this._updatedSources[e.source]&&"raster"!==this.tileManagers[e.source].getSource().type&&(this._updatedSources[e.source]="reload",this.tileManagers[e.source].pause()),this._serializedLayers=null,this._changed=!0;}_flattenAndSortRenderedFeatures(e){const t=e=>"fill-extrusion"===this._layers[e].type,i={},o=[];for(let a=this._order.length-1;a>=0;a--){const r=this._order[a];if(t(r)){i[r]=a;for(const t of e){const e=t[r];if(e)for(const t of e)o.push(t);}}}o.sort(((e,t)=>t.intersectionZ-e.intersectionZ));const a=[];for(let r=this._order.length-1;r>=0;r--){const s=this._order[r];if(t(s))for(let e=o.length-1;e>=0;e--){const t=o[e].feature;if(i[t.layer.id]this.map.terrain.getElevation(e,t,i):void 0));return this.placement&&r.push(function(e,t,i,o,a,r,s){const n={},l=r.queryRenderedSymbols(o),c=[];for(const e of Object.keys(l).map(Number))c.push(s[e]);c.sort(N);for(const i of c){const o=i.featureIndex.lookupSymbolFeatures(l[i.bucketInstanceId],t,i.bucketIndex,i.sourceLayerIndex,{filterSpec:a.filter,globalState:a.globalState},a.layers,a.availableImages,e);for(const e in o){n[e]||(n[e]=[]);const t=o[e];t.sort(((e,t)=>{const o=i.featureSortOrder;if(o){const i=o.indexOf(e.featureIndex);return o.indexOf(t.featureIndex)-i}return t.featureIndex-e.featureIndex}));for(const i of t)n[e].push(i);}}return function(e,t,i){for(const o in e)for(const a of e[o])Z(a,i[t[o].source]);return e}(n,e,i)}(this._layers,s,this.tileManagers,e,l,this.placement.collisionIndex,this.placement.retainedQueryData)),this._flattenAndSortRenderedFeatures(r)}querySourceFeatures(e,i){(null==i?void 0:i.filter)&&this._validate(t.F.filter,"querySourceFeatures.filter",i.filter,null,i);const o=this.tileManagers[e];return o?function(e,t){const i=e.getRenderableIds().map((t=>e.getTileByID(t))),o=[],a={};for(const e of i){const i=e.tileID.canonical.key;a[i]||(a[i]=!0,e.querySourceFeatures(o,t));}return o}(o,i?Object.assign(Object.assign({},i),{globalState:this._globalState}):{globalState:this._globalState}):[]}getLight(){return this.light.getLight()}setLight(e,i={}){this._checkLoaded();const o=this.light.getLight();let a=!1;for(const i in e)if(!t.bQ(e[i],o[i])){a=!0;break}if(!a)return;const r={now:c(),transition:t.e({duration:300,delay:0},this.stylesheet.transition)};this.light.setLight(e,i),this.light.updateTransitions(r);}getProjection(){var e;return null===(e=this.stylesheet)||void 0===e?void 0:e.projection}setProjection(e){this._checkLoaded();const t=null!=e?e:{type:"mercator"};if(this.stylesheet.projection=e,this.projection){if(this.projection.name===t.type)return;this.projection.destroy(),delete this.projection;}this._setProjectionInternal(t.type);}getSky(){var e;return null===(e=this.stylesheet)||void 0===e?void 0:e.sky}setSky(e,i={}){this._checkLoaded();const o=this.getSky();let a=!1;if(!e&&!o)return;if(e&&!o)a=!0;else if(!e&&o)a=!0;else for(const i in e)if(!t.bQ(e[i],o[i])){a=!0;break}if(!a)return;const r={now:c(),transition:t.e({duration:300,delay:0},this.stylesheet.transition)};this.stylesheet.sky=e,this.sky.setSky(e,i),this.sky.updateTransitions(r);}_setProjectionInternal(e){const i=function(e,i){const o={constrainOverride:i};if(Array.isArray(e)){const t=new _i({type:e});return {projection:t,transform:new Ri(o),cameraHelper:new Ai(t)}}switch(e){case "mercator":return {projection:new Zt,transform:new Xt(o),cameraHelper:new Jt};case "globe":{const e=new _i({type:["interpolate",["linear"],["zoom"],11,"vertical-perspective",12,"mercator"]});return {projection:e,transform:new Ri(o),cameraHelper:new Ai(e)}}case "vertical-perspective":return {projection:new ui,transform:new zi(o),cameraHelper:new Di};default:return t.w(`Unknown projection name: ${e}. Falling back to mercator projection.`),{projection:new Zt,transform:new Xt(o),cameraHelper:new Jt}}}(e,this.map.transformConstrain);this.projection=i.projection,this.map.migrateProjection(i.transform,i.cameraHelper);for(const e in this.tileManagers)this.tileManagers[e].reload();}_validate(e,i,o,a,r={}){return !1!==(null==r?void 0:r.validate)&&Li(this,e.call(t.F,t.e({key:i,style:this.serialize(),value:o,styleSpec:t.x},a)))}_remove(e=!0){this._frameRequest&&(this._frameRequest.abort(),this._frameRequest=null),this._loadStyleRequest&&(this._loadStyleRequest.abort(),this._loadStyleRequest=null),this._spriteRequest&&(this._spriteRequest.abort(),this._spriteRequest=null),ce().off(se,this._rtlPluginLoaded);for(const e in this._layers)this._layers[e].setEventedParent(null);for(const e in this.tileManagers){const t=this.tileManagers[e];t.setEventedParent(null),t.onRemove(this.map);}this.imageManager.setEventedParent(null),this.setEventedParent(null),e&&this.dispatcher.broadcast("RM",void 0),this.dispatcher.remove(e);}_clearSource(e){this.tileManagers[e].clearTiles();}_reloadSource(e){this.tileManagers[e].resume(),this.tileManagers[e].reload();}_updateSources(e){for(const t in this.tileManagers)this.tileManagers[t].update(e,this.map.terrain);}_generateCollisionBoxes(){for(const e in this.tileManagers)this._reloadSource(e);}_updatePlacement(e,t,i,o,a=!1){let r=!1,s=!1;const n={};for(const t of this._order){const i=this._layers[t];if("symbol"!==i.type)continue;if(!n[i.source]){const e=this.tileManagers[i.source];n[i.source]=e.getRenderableIds(!0).map((t=>e.getTileByID(t))).sort(((e,t)=>t.tileID.overscaledZ-e.tileID.overscaledZ||(e.tileID.isLessThan(t.tileID)?-1:1)));}const o=this.crossTileSymbolIndex.addLayer(i,n[i.source],e.center.lng);r||(r=o);}if(this.crossTileSymbolIndex.pruneUnusedLayers(this._order),a||(a=this._layerOrderChanged||0===i),(a||!this.pauseablePlacement||this.pauseablePlacement.isDone()&&!this.placement.stillRecent(c(),e.zoom))&&(this.pauseablePlacement=new Et(e,this.map.terrain,this._order,a,t,i,o,this.placement),this._layerOrderChanged=!1),this.pauseablePlacement.isDone()?this.placement.setStale():(this.pauseablePlacement.continuePlacement(this._order,this._layers,n),this.pauseablePlacement.isDone()&&(this.placement=this.pauseablePlacement.commit(c()),s=!0),r&&this.pauseablePlacement.placement.setStale()),s||r)for(const e of this._order){const t=this._layers[e];"symbol"===t.type&&this.placement.updateLayerOpacities(t,n[t.source]);}return !this.pauseablePlacement.isDone()||this.placement.hasTransitions(c())}_releaseSymbolFadeTiles(){for(const e in this.tileManagers)this.tileManagers[e].releaseSymbolFadeTiles();}getImages(e,i){return t._(this,void 0,void 0,(function*(){const e=yield this.imageManager.getImages(i.icons);this._updateTilesForChangedImages();const t=this.tileManagers[i.source];return t&&t.setDependencies(i.tileID.key,i.type,i.icons),e}))}getGlyphs(e,i){return t._(this,void 0,void 0,(function*(){const e=yield this.glyphManager.getGlyphs(i.stacks),t=this.tileManagers[i.source];return t&&t.setDependencies(i.tileID.key,i.type,[""]),e}))}getGlyphsUrl(){return this.stylesheet.glyphs||null}setGlyphs(e,i={}){this._checkLoaded(),e&&this._validate(t.F.glyphs,"glyphs",e,null,i)||(this._glyphsDidChange=!0,this.stylesheet.glyphs=e,this.glyphManager.entries={},this.glyphManager.setURL(e));}getDashes(e,i){return t._(this,void 0,void 0,(function*(){const e={};for(const[t,o]of Object.entries(i.dashes))e[t]=this.lineAtlas.getDash(o.dasharray,o.round);return e}))}addSprite(e,i,o={},a){this._checkLoaded();const r=[{id:e,url:i}],s=[...p(this.stylesheet.sprite),...r];this._validate(t.F.sprite,"sprite",s,null,o)||(this.stylesheet.sprite=s,this._loadSprite(r,!0,a));}removeSprite(e){this._checkLoaded();const i=p(this.stylesheet.sprite);if(i.find((t=>t.id===e))){if(this._spritesImagesIds[e])for(const t of this._spritesImagesIds[e])this.imageManager.removeImage(t),this._changedImages[t]=!0;i.splice(i.findIndex((t=>t.id===e)),1),this.stylesheet.sprite=i.length>0?i:void 0,delete this._spritesImagesIds[e],this._availableImages=this.imageManager.listImages(),this._changed=!0,this.dispatcher.broadcast("SI",this._availableImages),this.fire(new t.n("data",{dataType:"style"}));}else this.fire(new t.l(new Error(`Sprite "${e}" doesn't exists on this map.`)));}getSprite(){return p(this.stylesheet.sprite)}setSprite(e,i={},o){this._checkLoaded(),e&&this._validate(t.F.sprite,"sprite",e,null,i)||(this.stylesheet.sprite=e,e?this._loadSprite(e,!0,o):(this._unloadSprite(),o&&o(null)));}destroy(){this._frameRequest&&(this._frameRequest.abort(),this._frameRequest=null),this._loadStyleRequest&&(this._loadStyleRequest.abort(),this._loadStyleRequest=null),this._spriteRequest&&(this._spriteRequest.abort(),this._spriteRequest=null);for(const e in this.tileManagers){const t=this.tileManagers[e];t.setEventedParent(null),t.onRemove(this.map);}this.tileManagers={},this.imageManager&&(this.imageManager.setEventedParent(null),this.imageManager.destroy(),this._availableImages=[],this._spritesImagesIds={}),this.glyphManager&&this.glyphManager.destroy();for(const e in this._layers){const t=this._layers[e];t.setEventedParent(null),t.onRemove&&t.onRemove(this.map);}this._setInitialValues(),this.setEventedParent(null),this.dispatcher.unregisterMessageHandler("GG"),this.dispatcher.unregisterMessageHandler("GI"),this.dispatcher.unregisterMessageHandler("GDA"),this.dispatcher.remove(!0),this._listeners={},this._oneTimeListeners={};}}var Bi=t.aS([{name:"a_pos",type:"Int16",components:2},{name:"a_texture_pos",type:"Int16",components:2}]);class Oi{constructor(){this.boundProgram=null,this.boundLayoutVertexBuffer=null,this.boundPaintVertexBuffers=[],this.boundIndexBuffer=null,this.boundVertexOffset=null,this.boundDynamicVertexBuffer=null,this.vao=null;}bind(e,t,i,o,a,r,s,n,l){this.context=e;let c=this.boundPaintVertexBuffers.length!==o.length;for(let e=0;!c&&e({u_texture:0,u_ele_delta:e,u_fog_matrix:i,u_fog_color:o?o.properties.get("fog-color"):t.bo.white,u_fog_ground_blend:o?o.properties.get("fog-ground-blend"):1,u_fog_ground_blend_opacity:r?0:o?o.calculateFogBlendOpacity(a):0,u_horizon_color:o?o.properties.get("horizon-color"):t.bo.white,u_horizon_fog_blend:o?o.properties.get("horizon-fog-blend"):1,u_is_globe_mode:r?1:0}),Ni={mainMatrix:"u_projection_matrix",tileMercatorCoords:"u_projection_tile_mercator_coords",clippingPlane:"u_projection_clipping_plane",projectionTransition:"u_projection_transition",fallbackMatrix:"u_projection_fallback_matrix"};function Zi(e){const t=[];for(const i of e){if(null===i)continue;const e=i.split(" ");t.push(e.pop());}return t}class Ui{constructor(e,i,o,a,r,s,n,l,c=[]){const h=e.gl;this.program=h.createProgram();const u=Zi(i.staticAttributes),d=o?o.getBinderAttributes():[],_=u.concat(d),p=Ft.prelude.staticUniforms?Zi(Ft.prelude.staticUniforms):[],m=n.staticUniforms?Zi(n.staticUniforms):[],f=i.staticUniforms?Zi(i.staticUniforms):[],g=o?o.getBinderUniforms():[],v=p.concat(m).concat(f).concat(g),x=[];for(const e of v)x.includes(e)||x.push(e);const b=o?o.defines():[];si(h)&&b.unshift("#version 300 es"),r&&b.push("#define OVERDRAW_INSPECTOR;"),s&&b.push("#define TERRAIN3D;"),l&&b.push(l),c&&b.push(...c);let y=b.concat(Ft.prelude.fragmentSource,n.fragmentSource,i.fragmentSource).join("\n"),w=b.concat(Ft.prelude.vertexSource,n.vertexSource,i.vertexSource).join("\n");si(h)||(y=function(e){return e.replace(/\bin\s/g,"varying ").replace("out highp vec4 fragColor;","").replace(/fragColor/g,"gl_FragColor").replace(/texture\(/g,"texture2D(")}(y),w=function(e){return e.replace(/\bin\s/g,"attribute ").replace(/\bout\s/g,"varying ").replace(/texture\(/g,"texture2D(")}(w));const T=h.createShader(h.FRAGMENT_SHADER);if(h.isContextLost())return void(this.failedToCreate=!0);if(h.shaderSource(T,y),h.compileShader(T),!h.getShaderParameter(T,h.COMPILE_STATUS))throw new Error(`Could not compile fragment shader: ${h.getShaderInfoLog(T)}`);h.attachShader(this.program,T);const P=h.createShader(h.VERTEX_SHADER);if(h.isContextLost())return void(this.failedToCreate=!0);if(h.shaderSource(P,w),h.compileShader(P),!h.getShaderParameter(P,h.COMPILE_STATUS))throw new Error(`Could not compile vertex shader: ${h.getShaderInfoLog(P)}`);h.attachShader(this.program,P),this.attributes={};const C={};this.numAttributes=_.length;for(let e=0;e({u_depth:new t.b_(e,i.u_depth),u_terrain:new t.b_(e,i.u_terrain),u_terrain_dim:new t.bp(e,i.u_terrain_dim),u_terrain_matrix:new t.c0(e,i.u_terrain_matrix),u_terrain_unpack:new t.c1(e,i.u_terrain_unpack),u_terrain_exaggeration:new t.bp(e,i.u_terrain_exaggeration)}))(e,C),this.projectionUniforms=((e,i)=>({u_projection_matrix:new t.c0(e,i.u_projection_matrix),u_projection_tile_mercator_coords:new t.c1(e,i.u_projection_tile_mercator_coords),u_projection_clipping_plane:new t.c1(e,i.u_projection_clipping_plane),u_projection_transition:new t.bp(e,i.u_projection_transition),u_projection_fallback_matrix:new t.c0(e,i.u_projection_fallback_matrix)}))(e,C),this.binderUniforms=o?o.getUniforms(e,C):[];}draw(e,t,i,o,a,r,s,n,l,c,h,u,d,_,p,m,f,g,v){var x;const b=e.gl;if(this.failedToCreate)return;if(e.program.set(this.program),e.setDepthMode(i),e.setStencilMode(o),e.setColorMode(a),e.setCullFace(r),n){e.activeTexture.set(b.TEXTURE2),b.bindTexture(b.TEXTURE_2D,n.depthTexture),e.activeTexture.set(b.TEXTURE3),b.bindTexture(b.TEXTURE_2D,n.texture);for(const e in this.terrainUniforms)this.terrainUniforms[e].set(n[e]);}if(l)for(const e in l)this.projectionUniforms[Ni[e]].set(l[e]);if(s)for(const e in this.fixedUniforms)this.fixedUniforms[e].set(s[e]);m&&m.setUniforms(e,this.binderUniforms,_,{zoom:p});let y=0;switch(t){case b.LINES:y=2;break;case b.TRIANGLES:y=3;break;case b.LINE_STRIP:y=1;}for(const i of d.get())i.vaos||(i.vaos={}),(x=i.vaos)[c]||(x[c]=new Oi),i.vaos[c].bind(e,this,h,m?m.getPaintVertexBuffers():[],u,i.vertexOffset,f,g,v),b.drawElements(t,i.primitiveLength*y,b.UNSIGNED_SHORT,i.primitiveOffset*y*2);}}function Gi(e,i,o){const a=1/t.aK(o,1,i.transform.tileZoom),r=Math.pow(2,o.tileID.overscaledZ),s=o.tileSize*Math.pow(2,i.transform.tileZoom)/r,n=s*(o.tileID.canonical.x+o.tileID.wrap*r),l=s*o.tileID.canonical.y;return {u_image:0,u_texsize:o.imageAtlasTexture.size,u_scale:[a,e.fromScale,e.toScale],u_fade:e.t,u_pixel_coord_upper:[n>>16,l>>16],u_pixel_coord_lower:[65535&n,65535&l]}}const Vi=(e,i,o,a)=>{const r=e.style.light,s=r.properties.get("position"),n=[s.x,s.y,s.z],l=t.c4();"viewport"===r.properties.get("anchor")&&t.c5(l,e.transform.bearingInRadians),t.c6(n,n,l);const c=e.transform.transformLightDirection(n),h=r.properties.get("color");return {u_lightpos:n,u_lightpos_globe:c,u_lightintensity:r.properties.get("intensity"),u_lightcolor:[h.r,h.g,h.b],u_vertical_gradient:+i,u_opacity:o,u_fill_translate:a}},Wi=(e,i,o,a,r,s,n)=>t.e(Vi(e,i,o,a),Gi(s,e,n),{u_height_factor:-Math.pow(2,r.overscaledZ)/n.tileSize/8}),qi=(e,i,o,a)=>t.e(Gi(i,e,o),{u_fill_translate:a}),$i=(e,t)=>({u_world:e,u_fill_translate:t}),Hi=(e,i,o,a,r)=>t.e(qi(e,i,o,r),{u_world:a}),Xi=(e,i,o,a,r)=>{const s=e.transform;let n,l,c=0;if("map"===o.paint.get("circle-pitch-alignment")){const e=t.aK(i,1,s.zoom);n=!0,l=[e,e],c=e/(t.a6*Math.pow(2,i.tileID.overscaledZ))*2*Math.PI*r;}else n=!1,l=s.pixelsToGLUnits;return {u_camera_to_center_distance:s.cameraToCenterDistance,u_scale_with_map:+("map"===o.paint.get("circle-pitch-scale")),u_pitch_with_map:+n,u_device_pixel_ratio:e.pixelRatio,u_extrude_scale:l,u_globe_extrude_scale:c,u_translate:a}},Ki=e=>({u_pixel_extrude_scale:[1/e.width,1/e.height]}),Yi=e=>({u_viewport_size:[e.width,e.height]}),Qi=(e,t=1)=>({u_color:e,u_overlay:0,u_overlay_scale:t}),Ji=(e,i,o,a)=>{const r=t.aK(e,1,i)/(t.a6*Math.pow(2,e.tileID.overscaledZ))*2*Math.PI*a;return {u_extrude_scale:t.aK(e,1,i),u_intensity:o,u_globe_extrude_scale:r}},eo=(e,i,o,a)=>{const r=t.O();t.c7(r,0,e.width,e.height,0,0,1);const s=e.context.gl;return {u_matrix:r,u_world:[s.drawingBufferWidth,s.drawingBufferHeight],u_image:o,u_color_ramp:a,u_opacity:i.paint.get("heatmap-opacity")}},to=(e,t,i)=>{const o=i.paint.get("hillshade-accent-color");let a;switch(i.paint.get("hillshade-method")){case "basic":a=4;break;case "combined":a=1;break;case "igor":a=2;break;case "multidirectional":a=3;break;default:a=0;}const r=i.getIlluminationProperties();for(let t=0;t{const o=i.stride,a=t.O();return t.c7(a,0,t.a6,-t.a6,0,0,1),t.Q(a,a,[0,-t.a6,0]),{u_matrix:a,u_image:1,u_dimension:[o,o],u_zoom:e.overscaledZ,u_unpack:i.getUnpackVector()}};function oo(e,i){const o=Math.pow(2,i.canonical.z),a=i.canonical.y;return [new t.a7(0,a/o).toLngLat().lat,new t.a7(0,(a+1)/o).toLngLat().lat]}const ao=(e,t,i=0)=>({u_image:0,u_unpack:t.getUnpackVector(),u_dimension:[t.stride,t.stride],u_elevation_stops:1,u_color_stops:4,u_color_ramp_size:i,u_opacity:e.paint.get("color-relief-opacity")}),ro=(e,i,o,a)=>{const r=e.transform;return {u_translation:uo(e,i,o),u_ratio:a/t.aK(i,1,r.zoom),u_device_pixel_ratio:e.pixelRatio,u_units_to_pixels:[1/r.pixelsToGLUnits[0],1/r.pixelsToGLUnits[1]]}},so=(e,i,o,a,r)=>t.e(ro(e,i,o,a),{u_image:0,u_image_height:r}),no=(e,i,o,a,r)=>{const s=e.transform,n=ho(i,s);return {u_translation:uo(e,i,o),u_texsize:i.imageAtlasTexture.size,u_ratio:a/t.aK(i,1,s.zoom),u_device_pixel_ratio:e.pixelRatio,u_image:0,u_scale:[n,r.fromScale,r.toScale],u_fade:r.t,u_units_to_pixels:[1/s.pixelsToGLUnits[0],1/s.pixelsToGLUnits[1]]}},lo=(e,i,o,a,r)=>{const s=ho(i,e.transform);return t.e(ro(e,i,o,a),{u_tileratio:s,u_crossfade_from:r.fromScale,u_crossfade_to:r.toScale,u_image:0,u_mix:r.t,u_lineatlas_width:e.lineAtlas.width,u_lineatlas_height:e.lineAtlas.height})},co=(e,i,o,a,r,s)=>{const n=ho(i,e.transform);return t.e(ro(e,i,o,a),{u_image:0,u_image_height:s,u_tileratio:n,u_crossfade_from:r.fromScale,u_crossfade_to:r.toScale,u_image_dash:1,u_mix:r.t,u_lineatlas_width:e.lineAtlas.width,u_lineatlas_height:e.lineAtlas.height})};function ho(e,i){return 1/t.aK(e,1,i.tileZoom)}function uo(e,i,o){return t.aL(e.transform,i,o.paint.get("line-translate"),o.paint.get("line-translate-anchor"))}const _o=(e,t,i,o,a)=>{return {u_tl_parent:e,u_scale_parent:t,u_buffer_scale:1,u_fade_t:i.mix,u_opacity:i.opacity*o.paint.get("raster-opacity"),u_image0:0,u_image1:1,u_brightness_low:o.paint.get("raster-brightness-min"),u_brightness_high:o.paint.get("raster-brightness-max"),u_saturation_factor:(s=o.paint.get("raster-saturation"),s>0?1-1/(1.001-s):-s),u_contrast_factor:(r=o.paint.get("raster-contrast"),r>0?1/(1-r):1+r),u_spin_weights:po(o.paint.get("raster-hue-rotate")),u_coords_top:[a[0].x,a[0].y,a[1].x,a[1].y],u_coords_bottom:[a[3].x,a[3].y,a[2].x,a[2].y]};var r,s;};function po(e){e*=Math.PI/180;const t=Math.sin(e),i=Math.cos(e);return [(2*i+1)/3,(-Math.sqrt(3)*t-i+1)/3,(Math.sqrt(3)*t-i+1)/3]}const mo=(e,t,i,o,a,r,s,n,l,c,h,u,d)=>{const _=s.transform;return {u_is_size_zoom_constant:+("constant"===e||"source"===e),u_is_size_feature_constant:+("constant"===e||"camera"===e),u_size_t:t?t.uSizeT:0,u_size:t?t.uSize:0,u_camera_to_center_distance:_.cameraToCenterDistance,u_pitch:_.pitch/360*2*Math.PI,u_rotate_symbol:+i,u_aspect_ratio:_.width/_.height,u_fade_change:s.options.fadeDuration?s.symbolFadeChange:1,u_label_plane_matrix:n,u_coord_matrix:l,u_is_text:+h,u_pitch_with_map:+o,u_is_along_line:a,u_is_variable_anchor:r,u_texsize:u,u_texture:0,u_translation:c,u_pitched_scale:d}},fo=(e,i,o,a,r,s,n,l,c,h,u,d,_,p)=>{const m=n.transform;return t.e(mo(e,i,o,a,r,s,n,l,c,h,u,d,p),{u_gamma_scale:a?Math.cos(m.pitch*Math.PI/180)*m.cameraToCenterDistance:1,u_device_pixel_ratio:n.pixelRatio,u_is_halo:_?1:0,u_is_plain:1})},go=(e,i,o,a,r,s,n,l,c,h,u,d,_)=>t.e(fo(e,i,o,a,r,s,n,l,c,h,!0,u,!0,_),{u_texsize_icon:d,u_texture_icon:1}),vo=(e,t)=>({u_opacity:e,u_color:t}),xo=(e,i,o,a,r)=>t.e(function(e,i,o,a){const r=o.imageManager.getPattern(e.from.toString()),s=o.imageManager.getPattern(e.to.toString()),{width:n,height:l}=o.imageManager.getPixelSize(),c=Math.pow(2,a.tileID.overscaledZ),h=a.tileSize*Math.pow(2,o.transform.tileZoom)/c,u=h*(a.tileID.canonical.x+a.tileID.wrap*c),d=h*a.tileID.canonical.y;return {u_image:0,u_pattern_tl_a:r.tl,u_pattern_br_a:r.br,u_pattern_tl_b:s.tl,u_pattern_br_b:s.br,u_texsize:[n,l],u_mix:i.t,u_pattern_size_a:r.displaySize,u_pattern_size_b:s.displaySize,u_scale_a:i.fromScale,u_scale_b:i.toScale,u_tile_units_to_pixels:1/t.aK(a,1,o.transform.tileZoom),u_pixel_coord_upper:[u>>16,d>>16],u_pixel_coord_lower:[65535&u,65535&d]}}(o,r,i,a),{u_opacity:e}),bo=(e,t)=>{},yo={fillExtrusion:(e,i)=>({u_lightpos:new t.c2(e,i.u_lightpos),u_lightpos_globe:new t.c2(e,i.u_lightpos_globe),u_lightintensity:new t.bp(e,i.u_lightintensity),u_lightcolor:new t.c2(e,i.u_lightcolor),u_vertical_gradient:new t.bp(e,i.u_vertical_gradient),u_opacity:new t.bp(e,i.u_opacity),u_fill_translate:new t.c3(e,i.u_fill_translate)}),fillExtrusionPattern:(e,i)=>({u_lightpos:new t.c2(e,i.u_lightpos),u_lightpos_globe:new t.c2(e,i.u_lightpos_globe),u_lightintensity:new t.bp(e,i.u_lightintensity),u_lightcolor:new t.c2(e,i.u_lightcolor),u_vertical_gradient:new t.bp(e,i.u_vertical_gradient),u_height_factor:new t.bp(e,i.u_height_factor),u_opacity:new t.bp(e,i.u_opacity),u_fill_translate:new t.c3(e,i.u_fill_translate),u_image:new t.b_(e,i.u_image),u_texsize:new t.c3(e,i.u_texsize),u_pixel_coord_upper:new t.c3(e,i.u_pixel_coord_upper),u_pixel_coord_lower:new t.c3(e,i.u_pixel_coord_lower),u_scale:new t.c2(e,i.u_scale),u_fade:new t.bp(e,i.u_fade)}),fill:(e,i)=>({u_fill_translate:new t.c3(e,i.u_fill_translate)}),fillPattern:(e,i)=>({u_image:new t.b_(e,i.u_image),u_texsize:new t.c3(e,i.u_texsize),u_pixel_coord_upper:new t.c3(e,i.u_pixel_coord_upper),u_pixel_coord_lower:new t.c3(e,i.u_pixel_coord_lower),u_scale:new t.c2(e,i.u_scale),u_fade:new t.bp(e,i.u_fade),u_fill_translate:new t.c3(e,i.u_fill_translate)}),fillOutline:(e,i)=>({u_world:new t.c3(e,i.u_world),u_fill_translate:new t.c3(e,i.u_fill_translate)}),fillOutlinePattern:(e,i)=>({u_world:new t.c3(e,i.u_world),u_image:new t.b_(e,i.u_image),u_texsize:new t.c3(e,i.u_texsize),u_pixel_coord_upper:new t.c3(e,i.u_pixel_coord_upper),u_pixel_coord_lower:new t.c3(e,i.u_pixel_coord_lower),u_scale:new t.c2(e,i.u_scale),u_fade:new t.bp(e,i.u_fade),u_fill_translate:new t.c3(e,i.u_fill_translate)}),circle:(e,i)=>({u_camera_to_center_distance:new t.bp(e,i.u_camera_to_center_distance),u_scale_with_map:new t.b_(e,i.u_scale_with_map),u_pitch_with_map:new t.b_(e,i.u_pitch_with_map),u_extrude_scale:new t.c3(e,i.u_extrude_scale),u_device_pixel_ratio:new t.bp(e,i.u_device_pixel_ratio),u_globe_extrude_scale:new t.bp(e,i.u_globe_extrude_scale),u_translate:new t.c3(e,i.u_translate)}),collisionBox:(e,i)=>({u_pixel_extrude_scale:new t.c3(e,i.u_pixel_extrude_scale)}),collisionCircle:(e,i)=>({u_viewport_size:new t.c3(e,i.u_viewport_size)}),debug:(e,i)=>({u_color:new t.b$(e,i.u_color),u_overlay:new t.b_(e,i.u_overlay),u_overlay_scale:new t.bp(e,i.u_overlay_scale)}),depth:bo,clippingMask:bo,heatmap:(e,i)=>({u_extrude_scale:new t.bp(e,i.u_extrude_scale),u_intensity:new t.bp(e,i.u_intensity),u_globe_extrude_scale:new t.bp(e,i.u_globe_extrude_scale)}),heatmapTexture:(e,i)=>({u_matrix:new t.c0(e,i.u_matrix),u_world:new t.c3(e,i.u_world),u_image:new t.b_(e,i.u_image),u_color_ramp:new t.b_(e,i.u_color_ramp),u_opacity:new t.bp(e,i.u_opacity)}),hillshade:(e,i)=>({u_image:new t.b_(e,i.u_image),u_latrange:new t.c3(e,i.u_latrange),u_exaggeration:new t.bp(e,i.u_exaggeration),u_altitudes:new t.c9(e,i.u_altitudes),u_azimuths:new t.c9(e,i.u_azimuths),u_accent:new t.b$(e,i.u_accent),u_method:new t.b_(e,i.u_method),u_shadows:new t.c8(e,i.u_shadows),u_highlights:new t.c8(e,i.u_highlights)}),hillshadePrepare:(e,i)=>({u_matrix:new t.c0(e,i.u_matrix),u_image:new t.b_(e,i.u_image),u_dimension:new t.c3(e,i.u_dimension),u_zoom:new t.bp(e,i.u_zoom),u_unpack:new t.c1(e,i.u_unpack)}),colorRelief:(e,i)=>({u_image:new t.b_(e,i.u_image),u_unpack:new t.c1(e,i.u_unpack),u_dimension:new t.c3(e,i.u_dimension),u_elevation_stops:new t.b_(e,i.u_elevation_stops),u_color_stops:new t.b_(e,i.u_color_stops),u_color_ramp_size:new t.b_(e,i.u_color_ramp_size),u_opacity:new t.bp(e,i.u_opacity)}),line:(e,i)=>({u_translation:new t.c3(e,i.u_translation),u_ratio:new t.bp(e,i.u_ratio),u_device_pixel_ratio:new t.bp(e,i.u_device_pixel_ratio),u_units_to_pixels:new t.c3(e,i.u_units_to_pixels)}),lineGradient:(e,i)=>({u_translation:new t.c3(e,i.u_translation),u_ratio:new t.bp(e,i.u_ratio),u_device_pixel_ratio:new t.bp(e,i.u_device_pixel_ratio),u_units_to_pixels:new t.c3(e,i.u_units_to_pixels),u_image:new t.b_(e,i.u_image),u_image_height:new t.bp(e,i.u_image_height)}),linePattern:(e,i)=>({u_translation:new t.c3(e,i.u_translation),u_texsize:new t.c3(e,i.u_texsize),u_ratio:new t.bp(e,i.u_ratio),u_device_pixel_ratio:new t.bp(e,i.u_device_pixel_ratio),u_image:new t.b_(e,i.u_image),u_units_to_pixels:new t.c3(e,i.u_units_to_pixels),u_scale:new t.c2(e,i.u_scale),u_fade:new t.bp(e,i.u_fade)}),lineSDF:(e,i)=>({u_translation:new t.c3(e,i.u_translation),u_ratio:new t.bp(e,i.u_ratio),u_device_pixel_ratio:new t.bp(e,i.u_device_pixel_ratio),u_units_to_pixels:new t.c3(e,i.u_units_to_pixels),u_image:new t.b_(e,i.u_image),u_mix:new t.bp(e,i.u_mix),u_tileratio:new t.bp(e,i.u_tileratio),u_crossfade_from:new t.bp(e,i.u_crossfade_from),u_crossfade_to:new t.bp(e,i.u_crossfade_to),u_lineatlas_width:new t.bp(e,i.u_lineatlas_width),u_lineatlas_height:new t.bp(e,i.u_lineatlas_height)}),lineGradientSDF:(e,i)=>({u_translation:new t.c3(e,i.u_translation),u_ratio:new t.bp(e,i.u_ratio),u_device_pixel_ratio:new t.bp(e,i.u_device_pixel_ratio),u_units_to_pixels:new t.c3(e,i.u_units_to_pixels),u_image:new t.b_(e,i.u_image),u_image_height:new t.bp(e,i.u_image_height),u_tileratio:new t.bp(e,i.u_tileratio),u_crossfade_from:new t.bp(e,i.u_crossfade_from),u_crossfade_to:new t.bp(e,i.u_crossfade_to),u_image_dash:new t.b_(e,i.u_image_dash),u_mix:new t.bp(e,i.u_mix),u_lineatlas_width:new t.bp(e,i.u_lineatlas_width),u_lineatlas_height:new t.bp(e,i.u_lineatlas_height)}),raster:(e,i)=>({u_tl_parent:new t.c3(e,i.u_tl_parent),u_scale_parent:new t.bp(e,i.u_scale_parent),u_buffer_scale:new t.bp(e,i.u_buffer_scale),u_fade_t:new t.bp(e,i.u_fade_t),u_opacity:new t.bp(e,i.u_opacity),u_image0:new t.b_(e,i.u_image0),u_image1:new t.b_(e,i.u_image1),u_brightness_low:new t.bp(e,i.u_brightness_low),u_brightness_high:new t.bp(e,i.u_brightness_high),u_saturation_factor:new t.bp(e,i.u_saturation_factor),u_contrast_factor:new t.bp(e,i.u_contrast_factor),u_spin_weights:new t.c2(e,i.u_spin_weights),u_coords_top:new t.c1(e,i.u_coords_top),u_coords_bottom:new t.c1(e,i.u_coords_bottom)}),symbolIcon:(e,i)=>({u_is_size_zoom_constant:new t.b_(e,i.u_is_size_zoom_constant),u_is_size_feature_constant:new t.b_(e,i.u_is_size_feature_constant),u_size_t:new t.bp(e,i.u_size_t),u_size:new t.bp(e,i.u_size),u_camera_to_center_distance:new t.bp(e,i.u_camera_to_center_distance),u_pitch:new t.bp(e,i.u_pitch),u_rotate_symbol:new t.b_(e,i.u_rotate_symbol),u_aspect_ratio:new t.bp(e,i.u_aspect_ratio),u_fade_change:new t.bp(e,i.u_fade_change),u_label_plane_matrix:new t.c0(e,i.u_label_plane_matrix),u_coord_matrix:new t.c0(e,i.u_coord_matrix),u_is_text:new t.b_(e,i.u_is_text),u_pitch_with_map:new t.b_(e,i.u_pitch_with_map),u_is_along_line:new t.b_(e,i.u_is_along_line),u_is_variable_anchor:new t.b_(e,i.u_is_variable_anchor),u_texsize:new t.c3(e,i.u_texsize),u_texture:new t.b_(e,i.u_texture),u_translation:new t.c3(e,i.u_translation),u_pitched_scale:new t.bp(e,i.u_pitched_scale)}),symbolSDF:(e,i)=>({u_is_size_zoom_constant:new t.b_(e,i.u_is_size_zoom_constant),u_is_size_feature_constant:new t.b_(e,i.u_is_size_feature_constant),u_size_t:new t.bp(e,i.u_size_t),u_size:new t.bp(e,i.u_size),u_camera_to_center_distance:new t.bp(e,i.u_camera_to_center_distance),u_pitch:new t.bp(e,i.u_pitch),u_rotate_symbol:new t.b_(e,i.u_rotate_symbol),u_aspect_ratio:new t.bp(e,i.u_aspect_ratio),u_fade_change:new t.bp(e,i.u_fade_change),u_label_plane_matrix:new t.c0(e,i.u_label_plane_matrix),u_coord_matrix:new t.c0(e,i.u_coord_matrix),u_is_text:new t.b_(e,i.u_is_text),u_pitch_with_map:new t.b_(e,i.u_pitch_with_map),u_is_along_line:new t.b_(e,i.u_is_along_line),u_is_variable_anchor:new t.b_(e,i.u_is_variable_anchor),u_texsize:new t.c3(e,i.u_texsize),u_texture:new t.b_(e,i.u_texture),u_gamma_scale:new t.bp(e,i.u_gamma_scale),u_device_pixel_ratio:new t.bp(e,i.u_device_pixel_ratio),u_is_halo:new t.b_(e,i.u_is_halo),u_is_plain:new t.b_(e,i.u_is_plain),u_translation:new t.c3(e,i.u_translation),u_pitched_scale:new t.bp(e,i.u_pitched_scale)}),symbolTextAndIcon:(e,i)=>({u_is_size_zoom_constant:new t.b_(e,i.u_is_size_zoom_constant),u_is_size_feature_constant:new t.b_(e,i.u_is_size_feature_constant),u_size_t:new t.bp(e,i.u_size_t),u_size:new t.bp(e,i.u_size),u_camera_to_center_distance:new t.bp(e,i.u_camera_to_center_distance),u_pitch:new t.bp(e,i.u_pitch),u_rotate_symbol:new t.b_(e,i.u_rotate_symbol),u_aspect_ratio:new t.bp(e,i.u_aspect_ratio),u_fade_change:new t.bp(e,i.u_fade_change),u_label_plane_matrix:new t.c0(e,i.u_label_plane_matrix),u_coord_matrix:new t.c0(e,i.u_coord_matrix),u_is_text:new t.b_(e,i.u_is_text),u_pitch_with_map:new t.b_(e,i.u_pitch_with_map),u_is_along_line:new t.b_(e,i.u_is_along_line),u_is_variable_anchor:new t.b_(e,i.u_is_variable_anchor),u_texsize:new t.c3(e,i.u_texsize),u_texsize_icon:new t.c3(e,i.u_texsize_icon),u_texture:new t.b_(e,i.u_texture),u_texture_icon:new t.b_(e,i.u_texture_icon),u_gamma_scale:new t.bp(e,i.u_gamma_scale),u_device_pixel_ratio:new t.bp(e,i.u_device_pixel_ratio),u_is_halo:new t.b_(e,i.u_is_halo),u_translation:new t.c3(e,i.u_translation),u_pitched_scale:new t.bp(e,i.u_pitched_scale)}),background:(e,i)=>({u_opacity:new t.bp(e,i.u_opacity),u_color:new t.b$(e,i.u_color)}),backgroundPattern:(e,i)=>({u_opacity:new t.bp(e,i.u_opacity),u_image:new t.b_(e,i.u_image),u_pattern_tl_a:new t.c3(e,i.u_pattern_tl_a),u_pattern_br_a:new t.c3(e,i.u_pattern_br_a),u_pattern_tl_b:new t.c3(e,i.u_pattern_tl_b),u_pattern_br_b:new t.c3(e,i.u_pattern_br_b),u_texsize:new t.c3(e,i.u_texsize),u_mix:new t.bp(e,i.u_mix),u_pattern_size_a:new t.c3(e,i.u_pattern_size_a),u_pattern_size_b:new t.c3(e,i.u_pattern_size_b),u_scale_a:new t.bp(e,i.u_scale_a),u_scale_b:new t.bp(e,i.u_scale_b),u_pixel_coord_upper:new t.c3(e,i.u_pixel_coord_upper),u_pixel_coord_lower:new t.c3(e,i.u_pixel_coord_lower),u_tile_units_to_pixels:new t.bp(e,i.u_tile_units_to_pixels)}),terrain:(e,i)=>({u_texture:new t.b_(e,i.u_texture),u_ele_delta:new t.bp(e,i.u_ele_delta),u_fog_matrix:new t.c0(e,i.u_fog_matrix),u_fog_color:new t.b$(e,i.u_fog_color),u_fog_ground_blend:new t.bp(e,i.u_fog_ground_blend),u_fog_ground_blend_opacity:new t.bp(e,i.u_fog_ground_blend_opacity),u_horizon_color:new t.b$(e,i.u_horizon_color),u_horizon_fog_blend:new t.bp(e,i.u_horizon_fog_blend),u_is_globe_mode:new t.bp(e,i.u_is_globe_mode)}),terrainDepth:(e,i)=>({u_ele_delta:new t.bp(e,i.u_ele_delta)}),terrainCoords:(e,i)=>({u_texture:new t.b_(e,i.u_texture),u_terrain_coords_id:new t.bp(e,i.u_terrain_coords_id),u_ele_delta:new t.bp(e,i.u_ele_delta)}),projectionErrorMeasurement:(e,i)=>({u_input:new t.bp(e,i.u_input),u_output_expected:new t.bp(e,i.u_output_expected)}),atmosphere:(e,i)=>({u_sun_pos:new t.c2(e,i.u_sun_pos),u_atmosphere_blend:new t.bp(e,i.u_atmosphere_blend),u_globe_position:new t.c2(e,i.u_globe_position),u_globe_radius:new t.bp(e,i.u_globe_radius),u_inv_proj_matrix:new t.c0(e,i.u_inv_proj_matrix)}),sky:(e,i)=>({u_sky_color:new t.b$(e,i.u_sky_color),u_horizon_color:new t.b$(e,i.u_horizon_color),u_horizon:new t.c3(e,i.u_horizon),u_horizon_normal:new t.c3(e,i.u_horizon_normal),u_sky_horizon_blend:new t.bp(e,i.u_sky_horizon_blend),u_sky_blend:new t.bp(e,i.u_sky_blend)})};class wo{constructor(e,t,i){this.context=e;const o=e.gl;this.buffer=o.createBuffer(),this.dynamicDraw=Boolean(i),this.context.unbindVAO(),e.bindElementBuffer.set(this.buffer),o.bufferData(o.ELEMENT_ARRAY_BUFFER,t.arrayBuffer,this.dynamicDraw?o.DYNAMIC_DRAW:o.STATIC_DRAW),this.dynamicDraw||t.freeBufferAfterUpload();}bind(){this.context.bindElementBuffer.set(this.buffer);}updateData(e){const t=this.context.gl;if(!this.dynamicDraw)throw new Error("Attempted to update data while not in dynamic mode.");this.context.unbindVAO(),this.bind(),t.bufferSubData(t.ELEMENT_ARRAY_BUFFER,0,e.arrayBuffer);}destroy(){this.buffer&&(this.context.gl.deleteBuffer(this.buffer),delete this.buffer);}}const To={Int8:"BYTE",Uint8:"UNSIGNED_BYTE",Int16:"SHORT",Uint16:"UNSIGNED_SHORT",Int32:"INT",Uint32:"UNSIGNED_INT",Float32:"FLOAT"};class Po{constructor(e,t,i,o){this.length=t.length,this.attributes=i,this.itemSize=t.bytesPerElement,this.dynamicDraw=o,this.context=e;const a=e.gl;this.buffer=a.createBuffer(),e.bindVertexBuffer.set(this.buffer),a.bufferData(a.ARRAY_BUFFER,t.arrayBuffer,this.dynamicDraw?a.DYNAMIC_DRAW:a.STATIC_DRAW),this.dynamicDraw||t.freeBufferAfterUpload();}bind(){this.context.bindVertexBuffer.set(this.buffer);}updateData(e){if(e.length!==this.length)throw new Error(`Length of new data is ${e.length}, which doesn't match current length of ${this.length}`);const t=this.context.gl;this.bind(),t.bufferSubData(t.ARRAY_BUFFER,0,e.arrayBuffer);}enableAttributes(e,t){for(const i of this.attributes){const o=t.attributes[i.name];void 0!==o&&e.enableVertexAttribArray(o);}}setVertexAttribPointers(e,t,i){for(const o of this.attributes){const a=t.attributes[o.name];void 0!==a&&e.vertexAttribPointer(a,o.components,e[To[o.type]],!1,this.itemSize,o.offset+this.itemSize*(i||0));}}destroy(){this.buffer&&(this.context.gl.deleteBuffer(this.buffer),delete this.buffer);}}class Co{constructor(e){this.gl=e.gl,this.default=this.getDefault(),this.current=this.default,this.dirty=!1;}get(){return this.current}set(e){}getDefault(){return this.default}setDefault(){this.set(this.default);}}class Mo extends Co{getDefault(){return t.bo.transparent}set(e){const t=this.current;(e.r!==t.r||e.g!==t.g||e.b!==t.b||e.a!==t.a||this.dirty)&&(this.gl.clearColor(e.r,e.g,e.b,e.a),this.current=e,this.dirty=!1);}}class Io extends Co{getDefault(){return 1}set(e){(e!==this.current||this.dirty)&&(this.gl.clearDepth(e),this.current=e,this.dirty=!1);}}class Eo extends Co{getDefault(){return 0}set(e){(e!==this.current||this.dirty)&&(this.gl.clearStencil(e),this.current=e,this.dirty=!1);}}class So extends Co{getDefault(){return [!0,!0,!0,!0]}set(e){const t=this.current;(e[0]!==t[0]||e[1]!==t[1]||e[2]!==t[2]||e[3]!==t[3]||this.dirty)&&(this.gl.colorMask(e[0],e[1],e[2],e[3]),this.current=e,this.dirty=!1);}}class zo extends Co{getDefault(){return !0}set(e){(e!==this.current||this.dirty)&&(this.gl.depthMask(e),this.current=e,this.dirty=!1);}}class Ro extends Co{getDefault(){return 255}set(e){(e!==this.current||this.dirty)&&(this.gl.stencilMask(e),this.current=e,this.dirty=!1);}}class Do extends Co{getDefault(){return {func:this.gl.ALWAYS,ref:0,mask:255}}set(e){const t=this.current;(e.func!==t.func||e.ref!==t.ref||e.mask!==t.mask||this.dirty)&&(this.gl.stencilFunc(e.func,e.ref,e.mask),this.current=e,this.dirty=!1);}}class Ao extends Co{getDefault(){const e=this.gl;return [e.KEEP,e.KEEP,e.KEEP]}set(e){const t=this.current;(e[0]!==t[0]||e[1]!==t[1]||e[2]!==t[2]||this.dirty)&&(this.gl.stencilOp(e[0],e[1],e[2]),this.current=e,this.dirty=!1);}}class Lo extends Co{getDefault(){return !1}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;e?t.enable(t.STENCIL_TEST):t.disable(t.STENCIL_TEST),this.current=e,this.dirty=!1;}}class Fo extends Co{getDefault(){return [0,1]}set(e){const t=this.current;(e[0]!==t[0]||e[1]!==t[1]||this.dirty)&&(this.gl.depthRange(e[0],e[1]),this.current=e,this.dirty=!1);}}class ko extends Co{getDefault(){return !1}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;e?t.enable(t.DEPTH_TEST):t.disable(t.DEPTH_TEST),this.current=e,this.dirty=!1;}}class Bo extends Co{getDefault(){return this.gl.LESS}set(e){(e!==this.current||this.dirty)&&(this.gl.depthFunc(e),this.current=e,this.dirty=!1);}}class Oo extends Co{getDefault(){return !1}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;e?t.enable(t.BLEND):t.disable(t.BLEND),this.current=e,this.dirty=!1;}}class jo extends Co{getDefault(){const e=this.gl;return [e.ONE,e.ZERO]}set(e){const t=this.current;(e[0]!==t[0]||e[1]!==t[1]||this.dirty)&&(this.gl.blendFunc(e[0],e[1]),this.current=e,this.dirty=!1);}}class No extends Co{getDefault(){return t.bo.transparent}set(e){const t=this.current;(e.r!==t.r||e.g!==t.g||e.b!==t.b||e.a!==t.a||this.dirty)&&(this.gl.blendColor(e.r,e.g,e.b,e.a),this.current=e,this.dirty=!1);}}class Zo extends Co{getDefault(){return this.gl.FUNC_ADD}set(e){(e!==this.current||this.dirty)&&(this.gl.blendEquation(e),this.current=e,this.dirty=!1);}}class Uo extends Co{getDefault(){return !1}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;e?t.enable(t.CULL_FACE):t.disable(t.CULL_FACE),this.current=e,this.dirty=!1;}}class Go extends Co{getDefault(){return this.gl.BACK}set(e){(e!==this.current||this.dirty)&&(this.gl.cullFace(e),this.current=e,this.dirty=!1);}}class Vo extends Co{getDefault(){return this.gl.CCW}set(e){(e!==this.current||this.dirty)&&(this.gl.frontFace(e),this.current=e,this.dirty=!1);}}class Wo extends Co{getDefault(){return null}set(e){(e!==this.current||this.dirty)&&(this.gl.useProgram(e),this.current=e,this.dirty=!1);}}class qo extends Co{getDefault(){return this.gl.TEXTURE0}set(e){(e!==this.current||this.dirty)&&(this.gl.activeTexture(e),this.current=e,this.dirty=!1);}}class $o extends Co{getDefault(){const e=this.gl;return [0,0,e.drawingBufferWidth,e.drawingBufferHeight]}set(e){const t=this.current;(e[0]!==t[0]||e[1]!==t[1]||e[2]!==t[2]||e[3]!==t[3]||this.dirty)&&(this.gl.viewport(e[0],e[1],e[2],e[3]),this.current=e,this.dirty=!1);}}class Ho extends Co{getDefault(){return null}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;t.bindFramebuffer(t.FRAMEBUFFER,e),this.current=e,this.dirty=!1;}}class Xo extends Co{getDefault(){return null}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;t.bindRenderbuffer(t.RENDERBUFFER,e),this.current=e,this.dirty=!1;}}class Ko extends Co{getDefault(){return null}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;t.bindTexture(t.TEXTURE_2D,e),this.current=e,this.dirty=!1;}}class Yo extends Co{getDefault(){return null}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;t.bindBuffer(t.ARRAY_BUFFER,e),this.current=e,this.dirty=!1;}}class Qo extends Co{getDefault(){return null}set(e){const t=this.gl;t.bindBuffer(t.ELEMENT_ARRAY_BUFFER,e),this.current=e,this.dirty=!1;}}class Jo extends Co{getDefault(){return null}set(e){var t;if(e===this.current&&!this.dirty)return;const i=this.gl;si(i)?i.bindVertexArray(e):null===(t=i.getExtension("OES_vertex_array_object"))||void 0===t||t.bindVertexArrayOES(e),this.current=e,this.dirty=!1;}}class ea extends Co{getDefault(){return 4}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;t.pixelStorei(t.UNPACK_ALIGNMENT,e),this.current=e,this.dirty=!1;}}class ta extends Co{getDefault(){return !1}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,e),this.current=e,this.dirty=!1;}}class ia extends Co{getDefault(){return !1}set(e){if(e===this.current&&!this.dirty)return;const t=this.gl;t.pixelStorei(t.UNPACK_FLIP_Y_WEBGL,e),this.current=e,this.dirty=!1;}}class oa extends Co{constructor(e,t){super(e),this.context=e,this.parent=t;}getDefault(){return null}}class aa extends oa{setDirty(){this.dirty=!0;}set(e){if(e===this.current&&!this.dirty)return;this.context.bindFramebuffer.set(this.parent);const t=this.gl;t.framebufferTexture2D(t.FRAMEBUFFER,t.COLOR_ATTACHMENT0,t.TEXTURE_2D,e,0),this.current=e,this.dirty=!1;}}class ra extends oa{set(e){if(e===this.current&&!this.dirty)return;this.context.bindFramebuffer.set(this.parent);const t=this.gl;t.framebufferRenderbuffer(t.FRAMEBUFFER,t.DEPTH_ATTACHMENT,t.RENDERBUFFER,e),this.current=e,this.dirty=!1;}}class sa extends oa{set(e){if(e===this.current&&!this.dirty)return;this.context.bindFramebuffer.set(this.parent);const t=this.gl;t.framebufferRenderbuffer(t.FRAMEBUFFER,t.DEPTH_STENCIL_ATTACHMENT,t.RENDERBUFFER,e),this.current=e,this.dirty=!1;}}const na="Framebuffer is not complete";class la{constructor(e,t,i,o,a){this.context=e,this.width=t,this.height=i;const r=e.gl,s=this.framebuffer=r.createFramebuffer();if(this.colorAttachment=new aa(e,s),o)this.depthAttachment=a?new sa(e,s):new ra(e,s);else if(a)throw new Error("Stencil cannot be set without depth");if(r.checkFramebufferStatus(r.FRAMEBUFFER)!==r.FRAMEBUFFER_COMPLETE)throw new Error(na)}destroy(){const e=this.context.gl,t=this.colorAttachment.get();if(t&&e.deleteTexture(t),this.depthAttachment){const t=this.depthAttachment.get();t&&e.deleteRenderbuffer(t);}e.deleteFramebuffer(this.framebuffer);}}class ca{constructor(e){var t,i;if(this.gl=e,this.clearColor=new Mo(this),this.clearDepth=new Io(this),this.clearStencil=new Eo(this),this.colorMask=new So(this),this.depthMask=new zo(this),this.stencilMask=new Ro(this),this.stencilFunc=new Do(this),this.stencilOp=new Ao(this),this.stencilTest=new Lo(this),this.depthRange=new Fo(this),this.depthTest=new ko(this),this.depthFunc=new Bo(this),this.blend=new Oo(this),this.blendFunc=new jo(this),this.blendColor=new No(this),this.blendEquation=new Zo(this),this.cullFace=new Uo(this),this.cullFaceSide=new Go(this),this.frontFace=new Vo(this),this.program=new Wo(this),this.activeTexture=new qo(this),this.viewport=new $o(this),this.bindFramebuffer=new Ho(this),this.bindRenderbuffer=new Xo(this),this.bindTexture=new Ko(this),this.bindVertexBuffer=new Yo(this),this.bindElementBuffer=new Qo(this),this.bindVertexArray=new Jo(this),this.pixelStoreUnpack=new ea(this),this.pixelStoreUnpackPremultiplyAlpha=new ta(this),this.pixelStoreUnpackFlipY=new ia(this),this.extTextureFilterAnisotropic=e.getExtension("EXT_texture_filter_anisotropic"),this.extTextureFilterAnisotropic&&(this.extTextureFilterAnisotropicMax=e.getParameter(this.extTextureFilterAnisotropic.MAX_TEXTURE_MAX_ANISOTROPY_EXT)),this.maxTextureSize=e.getParameter(e.MAX_TEXTURE_SIZE),si(e)){this.HALF_FLOAT=e.HALF_FLOAT;const o=e.getExtension("EXT_color_buffer_half_float");this.RGBA16F=null!==(t=e.RGBA16F)&&void 0!==t?t:null==o?void 0:o.RGBA16F_EXT,this.RGB16F=null!==(i=e.RGB16F)&&void 0!==i?i:null==o?void 0:o.RGB16F_EXT,e.getExtension("EXT_color_buffer_float");}else {e.getExtension("EXT_color_buffer_half_float"),e.getExtension("OES_texture_half_float_linear");const t=e.getExtension("OES_texture_half_float");this.HALF_FLOAT=null==t?void 0:t.HALF_FLOAT_OES;}}setDefault(){this.unbindVAO(),this.clearColor.setDefault(),this.clearDepth.setDefault(),this.clearStencil.setDefault(),this.colorMask.setDefault(),this.depthMask.setDefault(),this.stencilMask.setDefault(),this.stencilFunc.setDefault(),this.stencilOp.setDefault(),this.stencilTest.setDefault(),this.depthRange.setDefault(),this.depthTest.setDefault(),this.depthFunc.setDefault(),this.blend.setDefault(),this.blendFunc.setDefault(),this.blendColor.setDefault(),this.blendEquation.setDefault(),this.cullFace.setDefault(),this.cullFaceSide.setDefault(),this.frontFace.setDefault(),this.program.setDefault(),this.activeTexture.setDefault(),this.bindFramebuffer.setDefault(),this.pixelStoreUnpack.setDefault(),this.pixelStoreUnpackPremultiplyAlpha.setDefault(),this.pixelStoreUnpackFlipY.setDefault();}setDirty(){this.clearColor.dirty=!0,this.clearDepth.dirty=!0,this.clearStencil.dirty=!0,this.colorMask.dirty=!0,this.depthMask.dirty=!0,this.stencilMask.dirty=!0,this.stencilFunc.dirty=!0,this.stencilOp.dirty=!0,this.stencilTest.dirty=!0,this.depthRange.dirty=!0,this.depthTest.dirty=!0,this.depthFunc.dirty=!0,this.blend.dirty=!0,this.blendFunc.dirty=!0,this.blendColor.dirty=!0,this.blendEquation.dirty=!0,this.cullFace.dirty=!0,this.cullFaceSide.dirty=!0,this.frontFace.dirty=!0,this.program.dirty=!0,this.activeTexture.dirty=!0,this.viewport.dirty=!0,this.bindFramebuffer.dirty=!0,this.bindRenderbuffer.dirty=!0,this.bindTexture.dirty=!0,this.bindVertexBuffer.dirty=!0,this.bindElementBuffer.dirty=!0,this.bindVertexArray.dirty=!0,this.pixelStoreUnpack.dirty=!0,this.pixelStoreUnpackPremultiplyAlpha.dirty=!0,this.pixelStoreUnpackFlipY.dirty=!0;}createIndexBuffer(e,t){return new wo(this,e,t)}createVertexBuffer(e,t,i){return new Po(this,e,t,i)}createRenderbuffer(e,t,i){const o=this.gl,a=o.createRenderbuffer();return this.bindRenderbuffer.set(a),o.renderbufferStorage(o.RENDERBUFFER,e,t,i),this.bindRenderbuffer.set(null),a}createFramebuffer(e,t,i,o){return new la(this,e,t,i,o)}clear({color:e,depth:t,stencil:i}){const o=this.gl;let a=0;e&&(a|=o.COLOR_BUFFER_BIT,this.clearColor.set(e),this.colorMask.set([!0,!0,!0,!0])),void 0!==t&&(a|=o.DEPTH_BUFFER_BIT,this.depthRange.set([0,1]),this.clearDepth.set(t),this.depthMask.set(!0)),void 0!==i&&(a|=o.STENCIL_BUFFER_BIT,this.clearStencil.set(i),this.stencilMask.set(255)),o.clear(a);}setCullFace(e){!1===e.enable?this.cullFace.set(!1):(this.cullFace.set(!0),this.cullFaceSide.set(e.mode),this.frontFace.set(e.frontFace));}setDepthMode(e){e.func!==this.gl.ALWAYS||e.mask?(this.depthTest.set(!0),this.depthFunc.set(e.func),this.depthMask.set(e.mask),this.depthRange.set(e.range)):this.depthTest.set(!1);}setStencilMode(e){e.test.func!==this.gl.ALWAYS||e.mask?(this.stencilTest.set(!0),this.stencilMask.set(e.mask),this.stencilOp.set([e.fail,e.depthFail,e.pass]),this.stencilFunc.set({func:e.test.func,ref:e.ref,mask:e.test.mask})):this.stencilTest.set(!1);}setColorMode(e){t.bQ(e.blendFunction,ei.Replace)?this.blend.set(!1):(this.blend.set(!0),this.blendFunc.set(e.blendFunction),this.blendColor.set(e.blendColor)),this.colorMask.set(e.mask);}createVertexArray(){var e;return si(this.gl)?this.gl.createVertexArray():null===(e=this.gl.getExtension("OES_vertex_array_object"))||void 0===e?void 0:e.createVertexArrayOES()}deleteVertexArray(e){var t;si(this.gl)?this.gl.deleteVertexArray(e):null===(t=this.gl.getExtension("OES_vertex_array_object"))||void 0===t||t.deleteVertexArrayOES(e);}unbindVAO(){this.bindVertexArray.set(null);}}let ha;function ua(e,i,o,a,r){var s,n;const l=e.context,c=e.transform,h=l.gl,u=e.useProgram("collisionBox"),d=[];let _=0,p=0;for(const t of a){const a=i.getTile(t).getBucket(o);if(!a)continue;const n=r?a.textCollisionBox:a.iconCollisionBox,m=a.collisionCircleArray;m.length>0&&(d.push({circleArray:m,circleOffset:p,coord:t}),_+=m.length/4,p=_),n&&u.draw(l,h.LINES,oi.disabled,ri.disabled,e.colorModeForRenderPass(),ii.disabled,Ki(e.transform),null===(s=e.style.map.terrain)||void 0===s?void 0:s.getTerrainData(t),c.getProjectionData({overscaledTileID:t,applyGlobeMatrix:!0,applyTerrainMatrix:!0}),o.id,n.layoutVertexBuffer,n.indexBuffer,n.segments,null,e.transform.zoom,null,null,n.collisionVertexBuffer);}if(!r||!d.length)return;const m=e.useProgram("collisionCircle"),f=new t.ca;f.resize(4*_),f._trim();let g=0;for(const e of d)for(let t=0;t=0&&(f[g.associatedIconIndex]={shiftedAnchor:E,angle:S});}else st(g.numGlyphs,p);}if(c){m.clear();const i=e.icon.placedSymbolArray;for(let e=0;ee.style.map.terrain.getElevation(l,t,i):null,s="map"===o.layout.get("text-rotation-alignment");$e(c,e,r,j,i,b,h,s,l.toUnwrapped(),v.width,v.height,Z,a);}const W=r&&M||V,q=y||W?da:b?j:e.transform.clipSpaceToPixelsMatrix,$=m&&0!==o.paint.get(r?"text-halo-width":"icon-halo-width").constantOr(1);let H;H=m?c.iconsInText?go(f.kind,z,w,b,y,W,e,q,N,Z,D,k,E):fo(f.kind,z,w,b,y,W,e,q,N,Z,r,D,$,E):mo(f.kind,z,w,b,y,W,e,q,N,Z,r,D,E);const X={program:S,buffers:u,uniformValues:H,projectionData:U,atlasTexture:A,atlasTextureIcon:B,atlasInterpolation:L,atlasInterpolationIcon:F,isSDF:m,hasHalo:$};if(T&&c.canOverlap){P=!0;const e=u.segments.get();for(const i of e)I.push({segments:new t.aV([i]),sortKey:i.sortKey,state:X,terrainData:R});}else I.push({segments:u.segments,sortKey:0,state:X,terrainData:R});}P&&I.sort(((e,t)=>e.sortKey-t.sortKey));const S=null!==(m=o.paint.get(r?"text-halo-width":"icon-halo-width").constantOr(null))&&void 0!==m?m:1/0,z=o.layout.get("text-letter-spacing").constantOr(0)*t.aJ<0||S>1;for(const t of I){const i=t.state;f.activeTexture.set(g.TEXTURE0),i.atlasTexture.bind(i.atlasInterpolation,g.CLAMP_TO_EDGE),i.atlasTextureIcon&&(f.activeTexture.set(g.TEXTURE1),i.atlasTextureIcon&&i.atlasTextureIcon.bind(i.atlasInterpolationIcon,g.CLAMP_TO_EDGE));const a=i.isSDF&&i.hasHalo;if(a){const a=i.uniformValues;a.u_is_halo=1,z&&(a.u_is_plain=0,va(i.buffers,t.segments,o,e,i.program,C,u,d,a,i.projectionData,t.terrainData),a.u_is_halo=0,a.u_is_plain=1);}va(i.buffers,t.segments,o,e,i.program,C,u,d,i.uniformValues,i.projectionData,t.terrainData),a&&!z&&(i.uniformValues.u_is_halo=0);}}function va(e,t,i,o,a,r,s,n,l,c,h){const u=o.context;a.draw(u,u.gl.TRIANGLES,r,s,n,ii.backCCW,l,h,c,i.id,e.layoutVertexBuffer,e.indexBuffer,t,i.paint,o.transform.zoom,e.programConfigurations.get(i.id),e.dynamicLayoutVertexBuffer,e.opacityVertexBuffer);}function xa(e,i,o,a,r){const s=e.context,n=s.gl,l=ri.disabled,c=new ei([n.ONE,n.ONE],t.bo.transparent,[!0,!0,!0,!0]),h=i.getBucket(o);if(!h)return;const u=a.key;let d=o.heatmapFbos.get(u);d||(d=ya(s,i.tileSize,i.tileSize),o.heatmapFbos.set(u,d)),s.bindFramebuffer.set(d.framebuffer),s.viewport.set([0,0,i.tileSize,i.tileSize]),s.clear({color:t.bo.transparent});const _=h.programConfigurations.get(o.id),p=e.useProgram("heatmap",_,!r),m=e.transform.getProjectionData({overscaledTileID:i.tileID,applyGlobeMatrix:!0,applyTerrainMatrix:!0}),f=e.style.map.terrain.getTerrainData(a);p.draw(s,n.TRIANGLES,oi.disabled,l,c,ii.disabled,Ji(i,e.transform.zoom,o.paint.get("heatmap-intensity"),1),f,m,o.id,h.layoutVertexBuffer,h.indexBuffer,h.segments,o.paint,e.transform.zoom,_);}function ba(e,t,i,o,a){const r=e.context,s=r.gl,n=e.transform;r.setColorMode(e.colorModeForRenderPass());const l=wa(r,t),c=i.key,h=t.heatmapFbos.get(c);if(!h)return;r.activeTexture.set(s.TEXTURE0),s.bindTexture(s.TEXTURE_2D,h.colorAttachment.get()),r.activeTexture.set(s.TEXTURE1),l.bind(s.LINEAR,s.CLAMP_TO_EDGE);const u=n.getProjectionData({overscaledTileID:i,applyTerrainMatrix:a,applyGlobeMatrix:!o});e.useProgram("heatmapTexture").draw(r,s.TRIANGLES,oi.disabled,ri.disabled,e.colorModeForRenderPass(),ii.disabled,eo(e,t,0,1),null,u,t.id,e.rasterBoundsBuffer,e.quadTriangleIndexBuffer,e.rasterBoundsSegments,t.paint,n.zoom),h.destroy(),t.heatmapFbos.delete(c);}function ya(e,t,i){var o,a;const r=e.gl,s=r.createTexture();r.bindTexture(r.TEXTURE_2D,s),r.texParameteri(r.TEXTURE_2D,r.TEXTURE_WRAP_S,r.CLAMP_TO_EDGE),r.texParameteri(r.TEXTURE_2D,r.TEXTURE_WRAP_T,r.CLAMP_TO_EDGE),r.texParameteri(r.TEXTURE_2D,r.TEXTURE_MIN_FILTER,r.LINEAR),r.texParameteri(r.TEXTURE_2D,r.TEXTURE_MAG_FILTER,r.LINEAR);const n=null!==(o=e.HALF_FLOAT)&&void 0!==o?o:r.UNSIGNED_BYTE,l=null!==(a=e.RGBA16F)&&void 0!==a?a:r.RGBA;r.texImage2D(r.TEXTURE_2D,0,l,t,i,0,r.RGBA,n,null);const c=e.createFramebuffer(t,i,!1,!1);return c.colorAttachment.set(s),c}function wa(e,i){return i.colorRampTexture||(i.colorRampTexture=new t.T(e,i.colorRamp,e.gl.RGBA)),i.colorRampTexture}function Ta(e,i,o,a,r,s,n,l){let c=256;if(r.stepInterpolant){const a=i.getSource().maxzoom,r=n.canonical.z===a?Math.ceil(1<e.options.anisotropicFilterPitch&&_.texParameterf(_.TEXTURE_2D,d.extTextureFilterAnisotropic.TEXTURE_MAX_ANISOTROPY_EXT,d.extTextureFilterAnisotropicMax);const S=null===(h=e.style.map.terrain)||void 0===h?void 0:h.getTerrainData(T),z=m.getProjectionData({overscaledTileID:T,aligned:v,applyGlobeMatrix:!c,applyTerrainMatrix:!0}),R=_o(I,M,E.fadeMix,i,n),D=f.getMeshFromTileID(d,T.canonical,r,s,"raster");p.draw(d,_.TRIANGLES,o,a?a[T.overscaledZ]:ri.disabled,g,l?ii.frontCCW:ii.backCCW,R,S,z,i.id,D.vertexBuffer,D.indexBuffer,D.segments);}}function ka(e,i,o,a){const r={parentTile:null,parentScaleBy:1,parentTopLeft:[0,0],fadeValues:{tileOpacity:1,parentTileOpacity:1,fadeMix:{opacity:1,mix:0}}};if(0===o||a)return r;if(e.fadingParentID){const a=i.getLoadedTile(e.fadingParentID);if(!a)return r;const s=Math.pow(2,a.tileID.overscaledZ-e.tileID.overscaledZ),n=[e.tileID.canonical.x*s%1,e.tileID.canonical.y*s%1],l=function(e,i,o){const a=c(),r=(a-i.timeAdded)/o,s=e.fadingDirection===ue.Incoming,n=t.al((a-e.timeAdded)/o,0,1),l=t.al(1-r,0,1),h=s?n:l;return {tileOpacity:h,parentTileOpacity:s?l:n,fadeMix:{opacity:1,mix:1-h}}}(e,a,o);return {parentTile:a,parentScaleBy:s,parentTopLeft:n,fadeValues:l}}if(e.selfFading){const i=function(e,i){const o=(c()-e.timeAdded)/i,a=t.al(o,0,1);return {tileOpacity:a,fadeMix:{opacity:a,mix:0}}}(e,o);return {parentTile:null,parentScaleBy:1,parentTopLeft:[0,0],fadeValues:i}}return r}const Ba=new t.bo(1,0,0,1),Oa=new t.bo(0,1,0,1),ja=new t.bo(0,0,1,1),Na=new t.bo(1,0,1,1),Za=new t.bo(0,1,1,1);function Ua(e,t,i,o){Va(e,0,t+i/2,e.transform.width,i,o);}function Ga(e,t,i,o){Va(e,t-i/2,0,i,e.transform.height,o);}function Va(e,t,i,o,a,r){const s=e.context,n=s.gl;n.enable(n.SCISSOR_TEST),n.scissor(t*e.pixelRatio,i*e.pixelRatio,o*e.pixelRatio,a*e.pixelRatio),s.clear({color:r}),n.disable(n.SCISSOR_TEST);}function Wa(e,i,o){var a;const r=e.context,s=r.gl,n=e.useProgram("debug"),l=oi.disabled,c=ri.disabled,h=e.colorModeForRenderPass(),u="$debug",d=null===(a=e.style.map.terrain)||void 0===a?void 0:a.getTerrainData(o);r.activeTexture.set(s.TEXTURE0);const _=i.getTileByID(o.key).latestRawTileData,p=Math.floor(((null==_?void 0:_.byteLength)||0)/1024),m=i.getTile(o).tileSize,f=512/Math.min(m,512)*(o.overscaledZ/e.transform.zoom)*.5;let g=o.canonical.toString();o.overscaledZ!==o.canonical.z&&(g+=` => ${o.overscaledZ}`),function(e,t){e.initDebugOverlayCanvas();const i=e.debugOverlayCanvas,o=e.context.gl,a=e.debugOverlayCanvas.getContext("2d");a.clearRect(0,0,i.width,i.height),a.shadowColor="white",a.shadowBlur=2,a.lineWidth=1.5,a.strokeStyle="white",a.textBaseline="top",a.font="bold 36px Open Sans, sans-serif",a.fillText(t,5,5),a.strokeText(t,5,5),e.debugOverlayTexture.update(i),e.debugOverlayTexture.bind(o.LINEAR,o.CLAMP_TO_EDGE);}(e,`${g} ${p}kB`);const v=e.transform.getProjectionData({overscaledTileID:o,applyGlobeMatrix:!0,applyTerrainMatrix:!0});n.draw(r,s.TRIANGLES,l,c,ei.alphaBlended,ii.disabled,Qi(t.bo.transparent,f),null,v,u,e.debugBuffer,e.quadTriangleIndexBuffer,e.debugSegments),n.draw(r,s.LINE_STRIP,l,c,h,ii.disabled,Qi(t.bo.red),d,v,u,e.debugBuffer,e.tileBorderIndexBuffer,e.debugSegments);}function qa(e,t,i,o){const{isRenderingGlobe:a}=o,r=e.context,s=r.gl,n=e.transform,l=e.colorModeForRenderPass(),c=e.getDepthModeFor3D(),h=e.useProgram("terrain");r.bindFramebuffer.set(null),r.viewport.set([0,0,e.width,e.height]);for(const o of i){const i=t.getTerrainMesh(o.tileID),u=e.renderToTexture.getTexture(o),d=t.getTerrainData(o.tileID);r.activeTexture.set(s.TEXTURE0),s.bindTexture(s.TEXTURE_2D,u.texture);const _=t.getMeshFrameDelta(n.zoom),p=n.calculateFogMatrix(o.tileID.toUnwrapped()),m=ji(_,p,e.style.sky,n.pitch,a),f=n.getProjectionData({overscaledTileID:o.tileID,applyTerrainMatrix:!1,applyGlobeMatrix:!0});h.draw(r,s.TRIANGLES,c,ri.disabled,l,ii.backCCW,m,d,f,"terrain",i.vertexBuffer,i.indexBuffer,i.segments);}}function $a(e,i){if(!i.mesh){const o=new t.aU;o.emplaceBack(-1,-1),o.emplaceBack(1,-1),o.emplaceBack(1,1),o.emplaceBack(-1,1);const a=new t.aW;a.emplaceBack(0,1,2),a.emplaceBack(0,2,3),i.mesh=new Bt(e.createVertexBuffer(o,Ot.members),e.createIndexBuffer(a),t.aV.simpleSegment(0,0,o.length,a.length));}return i.mesh}const Ha={symbol:function(e,i,o,a,r,s){if("translucent"!==e.renderPass)return;const{isRenderingToTexture:n}=s,l=ri.disabled,c=e.colorModeForRenderPass();(o._unevaluatedLayout.hasValue("text-variable-anchor")||o._unevaluatedLayout.hasValue("text-variable-anchor-offset"))&&function(e,i,o,a,r,s,n,l,c){var h;const u=i.transform,d=i.style.map.terrain,_="map"===r,p="map"===s;for(const r of e){const e=a.getTile(r),s=e.getBucket(o);if(!(null===(h=null==s?void 0:s.text)||void 0===h?void 0:h.segments.get().length))continue;const m=t.aw(s.textSizeData,u.zoom),f=t.aK(e,1,i.transform.zoom),g=Ze(_,i.transform,f),v="none"!==o.layout.get("icon-text-fit")&&s.hasIconData();if(m){const i=Math.pow(2,u.zoom-e.tileID.overscaledZ),o=d?(e,t)=>d.getElevation(r,e,t):null;ma(s,_,p,c,u,g,i,m,v,t.aL(u,e,n,l),r.toUnwrapped(),o);}}}(a,e,o,i,o.layout.get("text-rotation-alignment"),o.layout.get("text-pitch-alignment"),o.paint.get("text-translate"),o.paint.get("text-translate-anchor"),r),0!==o.paint.get("icon-opacity").constantOr(1)&&ga(e,i,o,a,!1,o.paint.get("icon-translate"),o.paint.get("icon-translate-anchor"),o.layout.get("icon-rotation-alignment"),o.layout.get("icon-pitch-alignment"),o.layout.get("icon-keep-upright"),l,c,n),0!==o.paint.get("text-opacity").constantOr(1)&&ga(e,i,o,a,!0,o.paint.get("text-translate"),o.paint.get("text-translate-anchor"),o.layout.get("text-rotation-alignment"),o.layout.get("text-pitch-alignment"),o.layout.get("text-keep-upright"),l,c,n),i.map.showCollisionBoxes&&(ua(e,i,o,a,!0),ua(e,i,o,a,!1));},circle:function(e,i,o,a,r){var s;if("translucent"!==e.renderPass)return;const{isRenderingToTexture:n}=r,l=o.paint.get("circle-opacity"),c=o.paint.get("circle-stroke-width"),h=o.paint.get("circle-stroke-opacity"),u=!o.layout.get("circle-sort-key").isConstant();if(0===l.constantOr(1)&&(0===c.constantOr(1)||0===h.constantOr(1)))return;const d=e.context,_=d.gl,p=e.transform,m=e.getDepthModeForSublayer(0,oi.ReadOnly),f=ri.disabled,g=e.colorModeForRenderPass(),v=[],x=p.getCircleRadiusCorrection();for(const r of a){const a=i.getTile(r),l=a.getBucket(o);if(!l)continue;const c=o.paint.get("circle-translate"),h=o.paint.get("circle-translate-anchor"),d=t.aL(p,a,c,h),_=l.programConfigurations.get(o.id),m=e.useProgram("circle",_),f=l.layoutVertexBuffer,g=l.indexBuffer,b=null===(s=e.style.map.terrain)||void 0===s?void 0:s.getTerrainData(r),y={programConfiguration:_,program:m,layoutVertexBuffer:f,indexBuffer:g,uniformValues:Xi(e,a,o,d,x),terrainData:b,projectionData:p.getProjectionData({overscaledTileID:r,applyGlobeMatrix:!n,applyTerrainMatrix:!0})};if(u){const e=l.segments.get();for(const i of e)v.push({segments:new t.aV([i]),sortKey:i.sortKey,state:y});}else v.push({segments:l.segments,sortKey:0,state:y});}u&&v.sort(((e,t)=>e.sortKey-t.sortKey));for(const t of v){const{programConfiguration:i,program:a,layoutVertexBuffer:r,indexBuffer:s,uniformValues:n,terrainData:l,projectionData:c}=t.state;a.draw(d,_.TRIANGLES,m,f,g,ii.backCCW,n,l,c,o.id,r,s,t.segments,o.paint,e.transform.zoom,i);}},heatmap:function(e,i,o,a,r){if(0===o.paint.get("heatmap-opacity"))return;const s=e.context,{isRenderingToTexture:n,isRenderingGlobe:l}=r;if(e.style.map.terrain){for(const t of a){const a=i.getTile(t);i.hasRenderableParent(t)||("offscreen"===e.renderPass?xa(e,a,o,t,l):"translucent"===e.renderPass&&ba(e,o,t,n,l));}s.viewport.set([0,0,e.width,e.height]);}else "offscreen"===e.renderPass?function(e,i,o,a){const r=e.context,s=r.gl,n=e.transform,l=ri.disabled,c=new ei([s.ONE,s.ONE],t.bo.transparent,[!0,!0,!0,!0]);((function(e,i,o){const a=e.gl;e.activeTexture.set(a.TEXTURE1),e.viewport.set([0,0,i.width/4,i.height/4]);let r=o.heatmapFbos.get(t.cd);r?(a.bindTexture(a.TEXTURE_2D,r.colorAttachment.get()),e.bindFramebuffer.set(r.framebuffer)):(r=ya(e,i.width/4,i.height/4),o.heatmapFbos.set(t.cd,r));}))(r,e,o),r.clear({color:t.bo.transparent});for(const t of a){if(i.hasRenderableParent(t))continue;const a=i.getTile(t),h=a.getBucket(o);if(!h)continue;const u=h.programConfigurations.get(o.id),d=e.useProgram("heatmap",u),_=n.getProjectionData({overscaledTileID:t,applyGlobeMatrix:!0,applyTerrainMatrix:!1}),p=n.getCircleRadiusCorrection();d.draw(r,s.TRIANGLES,oi.disabled,l,c,ii.backCCW,Ji(a,n.zoom,o.paint.get("heatmap-intensity"),p),null,_,o.id,h.layoutVertexBuffer,h.indexBuffer,h.segments,o.paint,n.zoom,u);}r.viewport.set([0,0,e.width,e.height]);}(e,i,o,a):"translucent"===e.renderPass&&function(e,i){const o=e.context,a=o.gl;o.setColorMode(e.colorModeForRenderPass());const r=i.heatmapFbos.get(t.cd);r&&(o.activeTexture.set(a.TEXTURE0),a.bindTexture(a.TEXTURE_2D,r.colorAttachment.get()),o.activeTexture.set(a.TEXTURE1),wa(o,i).bind(a.LINEAR,a.CLAMP_TO_EDGE),e.useProgram("heatmapTexture").draw(o,a.TRIANGLES,oi.disabled,ri.disabled,e.colorModeForRenderPass(),ii.disabled,eo(e,i,0,1),null,null,i.id,e.viewportBuffer,e.quadTriangleIndexBuffer,e.viewportSegments,i.paint,e.transform.zoom));}(e,o);},line:function(e,t,i,o,a){var r;if("translucent"!==e.renderPass)return;const{isRenderingToTexture:s}=a,n=i.paint.get("line-opacity"),l=i.paint.get("line-width");if(0===n.constantOr(1)||0===l.constantOr(1))return;const c=e.getDepthModeForSublayer(0,oi.ReadOnly),h=e.colorModeForRenderPass(),u=i.paint.get("line-dasharray"),d=u.constantOr(1),_=i.paint.get("line-pattern"),p=_.constantOr(1),m=i.paint.get("line-gradient"),f=i.getCrossfadeParameters();let g;g=p?"linePattern":d&&m?"lineGradientSDF":d?"lineSDF":m?"lineGradient":"line";const v=e.context,x=v.gl,b=e.transform;let y=!0;for(const a of o){const o=t.getTile(a);if(p&&!o.patternsLoaded())continue;const n=o.getBucket(i);if(!n)continue;const l=n.programConfigurations.get(i.id),w=e.context.program.get(),T=e.useProgram(g,l),P=y||T.program!==w,C=null===(r=e.style.map.terrain)||void 0===r?void 0:r.getTerrainData(a),M=_.constantOr(null),I=null==u?void 0:u.constantOr(null);if(M&&o.imageAtlas){const e=o.imageAtlas,t=e.patternPositions[M.to.toString()],i=e.patternPositions[M.from.toString()];t&&i&&l.setConstantPatternPositions(t,i);}else if(I){const t="round"===i.layout.get("line-cap").constantOr(null),o=e.lineAtlas.getDash(I.to,t),a=e.lineAtlas.getDash(I.from,t);l.setConstantDashPositions(o,a);}const E=b.getProjectionData({overscaledTileID:a,applyGlobeMatrix:!s,applyTerrainMatrix:!0}),S=b.getPixelScale();let z;p?(z=no(e,o,i,S,f),Pa(v,x,o,l,f)):d&&m?(z=co(e,o,i,S,f,n.lineClipsArray.length),Ia(e,t,v,x,i,n,a,l,f)):d?(z=lo(e,o,i,S,f),Ca(e,v,x,l,P,f)):m?(z=so(e,o,i,S,n.lineClipsArray.length),Ma(e,t,v,x,i,n,a)):z=ro(e,o,i,S);const R=e.stencilModeForClipping(a);T.draw(v,x.TRIANGLES,c,R,h,ii.disabled,z,C,E,i.id,n.layoutVertexBuffer,n.indexBuffer,n.segments,i.paint,e.transform.zoom,l,n.layoutVertexBuffer2),y=!1;}},fill:function(e,i,o,a,r){const s=o.paint.get("fill-color"),n=o.paint.get("fill-opacity");if(0===n.constantOr(1))return;const{isRenderingToTexture:l}=r,c=e.colorModeForRenderPass(),h=o.paint.get("fill-pattern"),u=e.opaquePassEnabledForLayer()&&!h.constantOr(1)&&1===s.constantOr(t.bo.transparent).a&&1===n.constantOr(0)?"opaque":"translucent";if(e.renderPass===u){const t=e.getDepthModeForSublayer(1,"opaque"===e.renderPass?oi.ReadWrite:oi.ReadOnly);Sa(e,i,o,a,t,c,!1,l);}if("translucent"===e.renderPass&&o.paint.get("fill-antialias")){const t=e.getDepthModeForSublayer(o.getPaintProperty("fill-outline-color")?2:0,oi.ReadOnly);Sa(e,i,o,a,t,c,!0,l);}},fillExtrusion:function(e,t,i,o,a){const r=i.paint.get("fill-extrusion-opacity");if(0===r)return;const{isRenderingToTexture:s}=a;if("translucent"===e.renderPass){const a=new oi(e.context.gl.LEQUAL,oi.ReadWrite,e.depthRangeFor3D);if(1!==r||i.paint.get("fill-extrusion-pattern").constantOr(1))za(e,t,i,o,a,ri.disabled,ei.disabled,s),za(e,t,i,o,a,e.stencilModeFor3D(),e.colorModeForRenderPass(),s);else {const r=e.colorModeForRenderPass();za(e,t,i,o,a,ri.disabled,r,s);}}},hillshade:function(e,i,o,a,r){if("offscreen"!==e.renderPass&&"translucent"!==e.renderPass)return;const{isRenderingToTexture:s}=r,n=e.context,l=e.style.projection.useSubdivision,c=e.getDepthModeForSublayer(0,oi.ReadOnly),h=e.colorModeForRenderPass();if("offscreen"===e.renderPass)!function(e,i,o,a,r,s,n){const l=e.context,c=l.gl,h="nearest"===a.paint.get("resampling")?c.NEAREST:c.LINEAR;for(const u of o){const o=i.getTile(u),d=o.dem;if(!(null==d?void 0:d.data))continue;if(!o.needsHillshadePrepare)continue;const _=d.dim,p=d.stride,m=d.getPixels();if(l.activeTexture.set(c.TEXTURE1),l.pixelStoreUnpackPremultiplyAlpha.set(!1),o.demTexture||(o.demTexture=e.getTileTexture(p)),o.demTexture){const e=o.demTexture;e.update(m,{premultiply:!1}),e.bind(c.NEAREST,c.CLAMP_TO_EDGE);}else o.demTexture=new t.T(l,m,c.RGBA,{premultiply:!1}),o.demTexture.bind(c.NEAREST,c.CLAMP_TO_EDGE);l.activeTexture.set(c.TEXTURE0);let f=o.fbo;if(!f){const e=new t.T(l,{width:_,height:_,data:null},c.RGBA);e.bind(h,c.CLAMP_TO_EDGE),f=o.fbo=l.createFramebuffer(_,_,!0,!1),f.colorAttachment.set(e.texture);}l.bindFramebuffer.set(f.framebuffer),l.viewport.set([0,0,_,_]),e.useProgram("hillshadePrepare").draw(l,c.TRIANGLES,r,s,n,ii.disabled,io(o.tileID,d),null,null,a.id,e.rasterBoundsBuffer,e.quadTriangleIndexBuffer,e.rasterBoundsSegments),o.needsHillshadePrepare=!1;}}(e,i,a,o,c,ri.disabled,h),n.viewport.set([0,0,e.width,e.height]);else if("translucent"===e.renderPass)if(l){const[t,r,n]=e.stencilConfigForOverlapTwoPass(a);Ra(e,i,o,n,t,c,h,!1,s),Ra(e,i,o,n,r,c,h,!0,s);}else {const[t,r]=e.getStencilConfigForOverlapAndUpdateStencilID(a);Ra(e,i,o,r,t,c,h,!1,s);}},colorRelief:function(e,t,i,o,a){if("translucent"!==e.renderPass)return;if(!o.length)return;const{isRenderingToTexture:r}=a,s=e.style.projection.useSubdivision,n=e.getDepthModeForSublayer(0,oi.ReadOnly),l=e.colorModeForRenderPass();if(s){const[a,s,c]=e.stencilConfigForOverlapTwoPass(o);Aa(e,t,i,c,a,n,l,!1,r),Aa(e,t,i,c,s,n,l,!0,r);}else {const[a,s]=e.getStencilConfigForOverlapAndUpdateStencilID(o);Aa(e,t,i,s,a,n,l,!1,r);}},raster:function(e,t,i,o,a){if("translucent"!==e.renderPass)return;if(0===i.paint.get("raster-opacity"))return;if(!o.length)return;const{isRenderingToTexture:r}=a,s=t.getSource(),n=e.style.projection.useSubdivision;if(s instanceof te)Fa(e,t,i,o,null,!1,!1,s.tileCoords,s.flippedWindingOrder,r);else if(n){const[a,s,n]=e.stencilConfigForOverlapTwoPass(o);Fa(e,t,i,n,a,!1,!0,La,!1,r),Fa(e,t,i,n,s,!0,!0,La,!1,r);}else {const[a,s]=e.getStencilConfigForOverlapAndUpdateStencilID(o);Fa(e,t,i,s,a,!1,!0,La,!1,r);}},background:function(e,t,i,o,a){var r;const s=i.paint.get("background-color"),n=i.paint.get("background-opacity");if(0===n)return;const{isRenderingToTexture:l}=a,c=e.context,h=c.gl,u=e.style.projection,d=e.transform,_=d.tileSize,p=i.paint.get("background-pattern");if(e.isPatternMissing(p))return;const m=!p&&1===s.a&&1===n&&e.opaquePassEnabledForLayer()?"opaque":"translucent";if(e.renderPass!==m)return;const f=ri.disabled,g=e.getDepthModeForSublayer(0,"opaque"===m?oi.ReadWrite:oi.ReadOnly),v=e.colorModeForRenderPass(),x=e.useProgram(p?"backgroundPattern":"background"),b=o||Me(d,{tileSize:_,terrain:e.style.map.terrain});p&&(c.activeTexture.set(h.TEXTURE0),e.imageManager.bind(e.context));const y=i.getCrossfadeParameters();for(const t of b){const o=d.getProjectionData({overscaledTileID:t,applyGlobeMatrix:!l,applyTerrainMatrix:!0}),a=p?xo(n,e,p,{tileID:t,tileSize:_},y):vo(n,s),m=null===(r=e.style.map.terrain)||void 0===r?void 0:r.getTerrainData(t),b=u.getMeshFromTileID(c,t.canonical,!1,!0,"raster");x.draw(c,h.TRIANGLES,g,f,v,ii.backCCW,a,m,o,i.id,b.vertexBuffer,b.indexBuffer,b.segments);}},sky:function(e,t){const i=e.context,o=i.gl,a=((e,t,i)=>{const o=Math.cos(t.rollInRadians),a=Math.sin(t.rollInRadians),r=ge(t),s=t.getProjectionData({overscaledTileID:null,applyGlobeMatrix:!0,applyTerrainMatrix:!0}).projectionTransition;return {u_sky_color:e.properties.get("sky-color"),u_horizon_color:e.properties.get("horizon-color"),u_horizon:[(t.width/2-r*a)*i,(t.height/2+r*o)*i],u_horizon_normal:[-a,o],u_sky_horizon_blend:e.properties.get("sky-horizon-blend")*t.height/2*i,u_sky_blend:s}})(t,e.style.map.transform,e.pixelRatio),r=new oi(o.LEQUAL,oi.ReadWrite,[0,1]),s=ri.disabled,n=e.colorModeForRenderPass(),l=e.useProgram("sky"),c=$a(i,t);l.draw(i,o.TRIANGLES,r,s,n,ii.disabled,a,null,void 0,"sky",c.vertexBuffer,c.indexBuffer,c.segments);},atmosphere:function(e,i,o){const a=e.context,r=a.gl,s=e.useProgram("atmosphere"),n=new oi(r.LEQUAL,oi.ReadOnly,[0,1]),l=e.transform,c=function(e,i){const o=e.properties.get("position"),a=[-o.x,-o.y,-o.z],r=t.ap(new Float64Array(16));return "map"===e.properties.get("anchor")&&(t.be(r,r,i.rollInRadians),t.bf(r,r,-i.pitchInRadians),t.be(r,r,i.bearingInRadians),t.bf(r,r,i.center.lat*Math.PI/180),t.bI(r,r,-i.center.lng*Math.PI/180)),t.cg(a,a,r),a}(o,e.transform),h=l.getProjectionData({overscaledTileID:null,applyGlobeMatrix:!0,applyTerrainMatrix:!0}),u=i.properties.get("atmosphere-blend")*h.projectionTransition;if(0===u)return;const d=gi(l.worldSize,l.center.lat),_=l.inverseProjectionMatrix,p=new Float64Array(4);p[3]=1,t.aE(p,p,l.modelViewProjectionMatrix),p[0]/=p[3],p[1]/=p[3],p[2]/=p[3],p[3]=1,t.aE(p,p,_),p[0]/=p[3],p[1]/=p[3],p[2]/=p[3],p[3]=1;const m=((e,t,i,o,a)=>({u_sun_pos:e,u_atmosphere_blend:t,u_globe_position:i,u_globe_radius:o,u_inv_proj_matrix:a}))(c,u,[p[0],p[1],p[2]],d,_),f=$a(a,i);s.draw(a,r.TRIANGLES,n,ri.disabled,ei.alphaBlended,ii.disabled,m,null,null,"atmosphere",f.vertexBuffer,f.indexBuffer,f.segments);},custom:function(e,t,i,o){const{isRenderingGlobe:a}=o,r=e.context,s=i.implementation,n=e.style.projection,l=e.transform,c=l.getProjectionDataForCustomLayer(a),h={farZ:l.farZ,nearZ:l.nearZ,fov:l.fov*Math.PI/180,modelViewProjectionMatrix:l.modelViewProjectionMatrix,projectionMatrix:l.projectionMatrix,shaderData:{variantName:n.shaderVariantName,vertexShaderPrelude:`const float PI = 3.141592653589793;\nuniform mat4 u_projection_matrix;\n${n.shaderPreludeCode.vertexSource}`,define:n.shaderDefine},defaultProjectionData:c},u=s.renderingMode?s.renderingMode:"2d";if("offscreen"===e.renderPass){const t=s.prerender;t&&(e.setCustomLayerDefaults(),r.setColorMode(e.colorModeForRenderPass()),t.call(s,r.gl,h),r.setDirty(),e.setBaseState());}else if("translucent"===e.renderPass){e.setCustomLayerDefaults(),r.setColorMode(e.colorModeForRenderPass()),r.setStencilMode(ri.disabled);const t="3d"===u?e.getDepthModeFor3D():e.getDepthModeForSublayer(0,oi.ReadOnly);r.setDepthMode(t),s.render(r.gl,h),r.setDirty(),e.setBaseState(),r.bindFramebuffer.set(null);}},debug:function(e,t,i){for(const o of i)Wa(e,t,o);},debugPadding:function(e){const t=e.transform.padding;Ua(e,e.transform.height-(t.top||0),3,Ba),Ua(e,t.bottom||0,3,Oa),Ga(e,t.left||0,3,ja),Ga(e,e.transform.width-(t.right||0),3,Na);const i=e.transform.centerPoint;!function(e,t,i,o){Va(e,t-1,i-10,2,20,o),Va(e,t-10,i-1,20,2,o);}(e,i.x,e.transform.height-i.y,Za);},terrainDepth:function(e,i){const o=e.context,a=o.gl,r=e.transform,s=ei.unblended,n=new oi(a.LEQUAL,oi.ReadWrite,[0,1]),l=i.tileManager.getRenderableTiles(),c=e.useProgram("terrainDepth");o.bindFramebuffer.set(i.getFramebuffer("depth").framebuffer),o.viewport.set([0,0,e.width/devicePixelRatio,e.height/devicePixelRatio]),o.clear({color:t.bo.transparent,depth:1});for(const e of l){const t=i.getTerrainMesh(e.tileID),l=i.getTerrainData(e.tileID),h=r.getProjectionData({overscaledTileID:e.tileID,applyTerrainMatrix:!1,applyGlobeMatrix:!0}),u={u_ele_delta:i.getMeshFrameDelta(r.zoom)};c.draw(o,a.TRIANGLES,n,ri.disabled,s,ii.backCCW,u,l,h,"terrain",t.vertexBuffer,t.indexBuffer,t.segments);}o.bindFramebuffer.set(null),o.viewport.set([0,0,e.width,e.height]);},terrainCoords:function(e,i){const o=e.context,a=o.gl,r=e.transform,s=ei.unblended,n=new oi(a.LEQUAL,oi.ReadWrite,[0,1]),l=i.getCoordsTexture(),c=i.tileManager.getRenderableTiles(),h=e.useProgram("terrainCoords");o.bindFramebuffer.set(i.getFramebuffer("coords").framebuffer),o.viewport.set([0,0,e.width/devicePixelRatio,e.height/devicePixelRatio]),o.clear({color:t.bo.transparent,depth:1}),i.coordsIndex=[];for(const e of c){const t=i.getTerrainMesh(e.tileID),c=i.getTerrainData(e.tileID);o.activeTexture.set(a.TEXTURE0),a.bindTexture(a.TEXTURE_2D,l.texture);const u={u_terrain_coords_id:(255-i.coordsIndex.length)/255,u_texture:0,u_ele_delta:i.getMeshFrameDelta(r.zoom)},d=r.getProjectionData({overscaledTileID:e.tileID,applyTerrainMatrix:!1,applyGlobeMatrix:!0});h.draw(o,a.TRIANGLES,n,ri.disabled,s,ii.backCCW,u,c,d,"terrain",t.vertexBuffer,t.indexBuffer,t.segments),i.coordsIndex.push(e.tileID.key);}o.bindFramebuffer.set(null),o.viewport.set([0,0,e.width,e.height]);}};class Xa{constructor(e,i){this.drawFunctions=Ha,this.context=new ca(e),this.transform=i,this._tileTextures={},this.terrainFacilitator={depthDirty:!0,coordsDirty:!1,matrix:t.ap(new Float64Array(16)),renderTime:0},this.setup(),this.numSublayers=Fe.maxOverzooming+Fe.maxUnderzooming+1,this.depthEpsilon=1/Math.pow(2,16),this.crossTileSymbolIndex=new At;}resize(e,t,i){if(this.width=Math.floor(e*i),this.height=Math.floor(t*i),this.pixelRatio=i,this.context.viewport.set([0,0,this.width,this.height]),this.style)for(const e of this.style._order)this.style._layers[e].resize();}setup(){const e=this.context,i=new t.aU;i.emplaceBack(0,0),i.emplaceBack(t.a6,0),i.emplaceBack(0,t.a6),i.emplaceBack(t.a6,t.a6),this.tileExtentBuffer=e.createVertexBuffer(i,Ot.members),this.tileExtentSegments=t.aV.simpleSegment(0,0,4,2);const o=new t.aU;o.emplaceBack(0,0),o.emplaceBack(t.a6,0),o.emplaceBack(0,t.a6),o.emplaceBack(t.a6,t.a6),this.debugBuffer=e.createVertexBuffer(o,Ot.members),this.debugSegments=t.aV.simpleSegment(0,0,4,5);const a=new t.ch;a.emplaceBack(0,0,0,0),a.emplaceBack(t.a6,0,t.a6,0),a.emplaceBack(0,t.a6,0,t.a6),a.emplaceBack(t.a6,t.a6,t.a6,t.a6),this.rasterBoundsBuffer=e.createVertexBuffer(a,Bi.members),this.rasterBoundsSegments=t.aV.simpleSegment(0,0,4,2);const r=new t.aU;r.emplaceBack(0,0),r.emplaceBack(t.a6,0),r.emplaceBack(0,t.a6),r.emplaceBack(t.a6,t.a6),this.rasterBoundsBufferPosOnly=e.createVertexBuffer(r,Ot.members),this.rasterBoundsSegmentsPosOnly=t.aV.simpleSegment(0,0,4,5);const s=new t.aU;s.emplaceBack(0,0),s.emplaceBack(1,0),s.emplaceBack(0,1),s.emplaceBack(1,1),this.viewportBuffer=e.createVertexBuffer(s,Ot.members),this.viewportSegments=t.aV.simpleSegment(0,0,4,2);const n=new t.ci;n.emplaceBack(0),n.emplaceBack(1),n.emplaceBack(3),n.emplaceBack(2),n.emplaceBack(0),this.tileBorderIndexBuffer=e.createIndexBuffer(n);const l=new t.aW;l.emplaceBack(1,0,2),l.emplaceBack(1,2,3),this.quadTriangleIndexBuffer=e.createIndexBuffer(l);const c=this.context.gl;this.stencilClearMode=new ri({func:c.ALWAYS,mask:0},0,255,c.ZERO,c.ZERO,c.ZERO),this.tileExtentMesh=new Bt(this.tileExtentBuffer,this.quadTriangleIndexBuffer,this.tileExtentSegments);}clearStencil(){const e=this.context,i=e.gl;this.nextStencilID=1,this.currentStencilSource=void 0;const o=t.O();t.c7(o,0,this.width,this.height,0,0,1),t.S(o,o,[i.drawingBufferWidth,i.drawingBufferHeight,0]);const a={mainMatrix:o,tileMercatorCoords:[0,0,1,1],clippingPlane:[0,0,0,0],projectionTransition:0,fallbackMatrix:o};this.useProgram("clippingMask",null,!0).draw(e,i.TRIANGLES,oi.disabled,this.stencilClearMode,ei.disabled,ii.disabled,null,null,a,"$clipping",this.viewportBuffer,this.quadTriangleIndexBuffer,this.viewportSegments);}_renderTileClippingMasks(e,t,i){if(this.currentStencilSource===e.source||!e.isTileClipped()||!(null==t?void 0:t.length))return;this.currentStencilSource=e.source,this.nextStencilID+t.length>256&&this.clearStencil();const o=this.context;o.setColorMode(ei.disabled),o.setDepthMode(oi.disabled);const a={};for(const e of t)a[e.key]=this.nextStencilID++;this._renderTileMasks(a,t,i,!0),this._renderTileMasks(a,t,i,!1),this._tileClippingMaskIDs=a;}_renderTileMasks(e,t,i,o){var a;const r=this.context,s=r.gl,n=this.style.projection,l=this.transform,c=this.useProgram("clippingMask");for(const h of t){const t=e[h.key],u=null===(a=this.style.map.terrain)||void 0===a?void 0:a.getTerrainData(h),d=n.getMeshFromTileID(this.context,h.canonical,o,!0,"stencil"),_=l.getProjectionData({overscaledTileID:h,applyGlobeMatrix:!i,applyTerrainMatrix:!0});c.draw(r,s.TRIANGLES,oi.disabled,new ri({func:s.ALWAYS,mask:0},t,255,s.KEEP,s.KEEP,s.REPLACE),ei.disabled,i?ii.disabled:ii.backCCW,null,u,_,"$clipping",d.vertexBuffer,d.indexBuffer,d.segments);}}_renderTilesDepthBuffer(){var e;const t=this.context,i=t.gl,o=this.style.projection,a=this.transform,r=this.useProgram("depth"),s=this.getDepthModeFor3D(),n=Me(a,{tileSize:a.tileSize});for(const l of n){const n=null===(e=this.style.map.terrain)||void 0===e?void 0:e.getTerrainData(l),c=o.getMeshFromTileID(this.context,l.canonical,!0,!0,"raster"),h=a.getProjectionData({overscaledTileID:l,applyGlobeMatrix:!0,applyTerrainMatrix:!0});r.draw(t,i.TRIANGLES,s,ri.disabled,ei.disabled,ii.backCCW,null,n,h,"$clipping",c.vertexBuffer,c.indexBuffer,c.segments);}}stencilModeFor3D(){this.currentStencilSource=void 0,this.nextStencilID+1>256&&this.clearStencil();const e=this.nextStencilID++,t=this.context.gl;return new ri({func:t.NOTEQUAL,mask:255},e,255,t.KEEP,t.KEEP,t.REPLACE)}stencilModeForClipping(e){const t=this.context.gl;return new ri({func:t.EQUAL,mask:255},this._tileClippingMaskIDs[e.key],0,t.KEEP,t.KEEP,t.REPLACE)}getStencilConfigForOverlapAndUpdateStencilID(e){const t=this.context.gl,i=e.sort(((e,t)=>t.overscaledZ-e.overscaledZ)),o=i[i.length-1].overscaledZ,a=i[0].overscaledZ-o+1;if(a>1){this.currentStencilSource=void 0,this.nextStencilID+a>256&&this.clearStencil();const e={};for(let i=0;it.overscaledZ-e.overscaledZ)),o=i[i.length-1].overscaledZ,a=i[0].overscaledZ-o+1;if(this.clearStencil(),a>1){const e={},r={};for(let i=0;i0};for(const e in n){const t=n[e];t.used&&t.prepare(this.context),l[e]=t.getVisibleCoordinates(!1),h[e]=l[e].slice().reverse(),u[e]=t.getVisibleCoordinates(!0).reverse();}this.opaquePassCutoff=1/0;for(let e=0;ethis.useProgram(e)}),this.context.viewport.set([0,0,this.width,this.height]),this.context.bindFramebuffer.set(null),this.context.clear({color:i.showOverdrawInspector?t.bo.black:t.bo.transparent,depth:1}),this.clearStencil(),this.style.sky&&this.drawFunctions.sky(this,this.style.sky),this._showOverdrawInspector=i.showOverdrawInspector,this.depthRangeFor3D=[0,1-(e._order.length+2)*this.numSublayers*this.depthEpsilon],!this.renderToTexture)for(this.renderPass="opaque",this.currentLayer=s.length-1;this.currentLayer>=0;this.currentLayer--){const e=this.style._layers[s[this.currentLayer]],t=n[e.source],i=l[e.source];this._renderTileClippingMasks(e,i,!1),this.renderLayer(this,t,e,i,d);}this.renderPass="translucent";let _=!1;for(this.currentLayer=0;this.currentLayeri.source&&!i.isHidden(t)?[e.tileManagers[i.source]]:[])),a=o.filter((e=>"vector"===e.getSource().type)),r=o.filter((e=>"vector"!==e.getSource().type)),s=e=>{(!i||i.getSource().maxzoom0?t.pop():null}isPatternMissing(e){if(!e)return !1;if(!e.from||!e.to)return !0;const t=this.imageManager.getPattern(e.from.toString()),i=this.imageManager.getPattern(e.to.toString());return !t||!i}useProgram(e,t,i=!1,o=[]){var a;this.cache||(this.cache={});const r=!!this.style.map.terrain,s=this.style.projection,n=i?Ft.projectionMercator:s.shaderPreludeCode,l=i?jt:s.shaderDefine,c=e+(t?t.cacheKey:"")+`/${i?Nt:s.shaderVariantName}`+(this._showOverdrawInspector?"/overdraw":"")+(r?"/terrain":"")+(o?`/${o.join("/")}`:"");return (a=this.cache)[c]||(a[c]=new Ui(this.context,Ft[e],t,yo[e],this._showOverdrawInspector,r,n,l,o)),this.cache[c]}setCustomLayerDefaults(){this.context.unbindVAO(),this.context.cullFace.setDefault(),this.context.activeTexture.setDefault(),this.context.pixelStoreUnpack.setDefault(),this.context.pixelStoreUnpackPremultiplyAlpha.setDefault(),this.context.pixelStoreUnpackFlipY.setDefault();}setBaseState(){const e=this.context.gl;this.context.cullFace.set(!1),this.context.viewport.set([0,0,this.width,this.height]),this.context.blendEquation.set(e.FUNC_ADD);}initDebugOverlayCanvas(){null==this.debugOverlayCanvas&&(this.debugOverlayCanvas=document.createElement("canvas"),this.debugOverlayCanvas.width=512,this.debugOverlayCanvas.height=512,this.debugOverlayTexture=new t.T(this.context,this.debugOverlayCanvas,this.context.gl.RGBA));}destroy(){var e,t;if(this._tileTextures){for(const e in this._tileTextures){const t=this._tileTextures[e];if(t)for(const e of t)e.destroy();}this._tileTextures={};}if(this.tileExtentBuffer&&this.tileExtentBuffer.destroy(),this.debugBuffer&&this.debugBuffer.destroy(),this.rasterBoundsBuffer&&this.rasterBoundsBuffer.destroy(),this.rasterBoundsBufferPosOnly&&this.rasterBoundsBufferPosOnly.destroy(),this.viewportBuffer&&this.viewportBuffer.destroy(),this.tileBorderIndexBuffer&&this.tileBorderIndexBuffer.destroy(),this.quadTriangleIndexBuffer&&this.quadTriangleIndexBuffer.destroy(),this.tileExtentMesh&&(null===(e=this.tileExtentMesh.vertexBuffer)||void 0===e||e.destroy()),this.tileExtentMesh&&(null===(t=this.tileExtentMesh.indexBuffer)||void 0===t||t.destroy()),this.debugOverlayTexture&&this.debugOverlayTexture.destroy(),this.cache){for(const e in this.cache){const t=this.cache[e];(null==t?void 0:t.program)&&this.context.gl.deleteProgram(t.program);}this.cache={};}this.context&&this.context.setDefault();}overLimit(){const{drawingBufferWidth:e,drawingBufferHeight:t}=this.context.gl;return this.width!==e||this.height!==t}}function Ka(e,t){let i,o=!1,a=null;const r=()=>{a=null,o&&(e(...i),a=setTimeout(r,t),o=!1);};return (...e)=>(o=!0,i=e,a||r(),a)}Xa.MAX_TEXTURE_POOL_SIZE_PER_BUCKET=50;class Ya{constructor(e){this._getCurrentHash=()=>{const e=window.location.hash.replace("#","");if(this._hashName){let t;const i=e.split("&").map((e=>e.split("=")));for(const e of i)e[0]===this._hashName&&(t=e);return (t&&t[1]||"").split("/")}return e.split("/")},this._onHashChange=()=>{const e=this._getCurrentHash();if(!this._isValidHash(e))return !1;const t=this._map.dragRotate.isEnabled()&&this._map.touchZoomRotate.isEnabled()?+(e[3]||0):this._map.getBearing();return this._map.jumpTo({center:[+e[2],+e[1]],zoom:+e[0],bearing:t,pitch:+(e[4]||0)}),!0},this._updateHashUnthrottled=()=>{const e=window.location.href.replace(/(#.*)?$/,this.getHashString());window.history.replaceState(window.history.state,null,e);},this._removeHash=()=>{const e=this._getCurrentHash();if(0===e.length)return;const t=e.join("/");let i=t;i.split("&").length>0&&(i=i.split("&")[0]),this._hashName&&(i=`${this._hashName}=${t}`);let o=window.location.hash.replace(i,"");o.startsWith("#&")?o=o.slice(0,1)+o.slice(2):"#"===o&&(o="");let a=window.location.href.replace(/(#.+)?$/,o);a=a.replace("&&","&"),window.history.replaceState(window.history.state,null,a);},this._updateHash=Ka(this._updateHashUnthrottled,300),this._hashName=e&&encodeURIComponent(e);}addTo(e){return this._map=e,addEventListener("hashchange",this._onHashChange,!1),this._map.on("moveend",this._updateHash),this}remove(){return removeEventListener("hashchange",this._onHashChange,!1),this._map.off("moveend",this._updateHash),clearTimeout(this._updateHash()),this._removeHash(),delete this._map,this}getHashString(e){const t=this._map.getCenter(),i=Math.round(100*this._map.getZoom())/100,o=Math.ceil((i*Math.LN2+Math.log(512/360/.5))/Math.LN10),a=Math.pow(10,o),r=Math.round(t.lng*a)/a,s=Math.round(t.lat*a)/a,n=this._map.getBearing(),l=this._map.getPitch();let c="";if(c+=e?`/${r}/${s}/${i}`:`${i}/${s}/${r}`,(n||l)&&(c+="/"+Math.round(10*n)/10),l&&(c+=`/${Math.round(l)}`),this._hashName){const e=this._hashName;let t=!1;const i=window.location.hash.slice(1).split("&").map((i=>{const o=i.split("=")[0];return o===e?(t=!0,`${o}=${c}`):i})).filter((e=>e));return t||i.push(`${e}=${c}`),`#${i.join("&")}`}return `#${c}`}_isValidHash(e){if(e.length<3||e.some(isNaN))return !1;try{new t.W(+e[2],+e[1]);}catch(e){return !1}const i=+e[0],o=+(e[3]||0),a=+(e[4]||0);return i>=this._map.getMinZoom()&&i<=this._map.getMaxZoom()&&o>=-180&&o<=180&&a>=this._map.getMinPitch()&&a<=this._map.getMaxPitch()}}const Qa={linearity:.3,easing:t.cv(0,0,.3,1)},Ja=t.e({deceleration:2500,maxSpeed:1400},Qa),er=t.e({deceleration:20,maxSpeed:1400},Qa),tr=t.e({deceleration:1e3,maxSpeed:360},Qa),ir=t.e({deceleration:1e3,maxSpeed:90},Qa),or=t.e({deceleration:1e3,maxSpeed:360},Qa);class ar{constructor(e){this._map=e,this.clear();}clear(){this._inertiaBuffer=[];}record(e){this._drainInertiaBuffer(),this._inertiaBuffer.push({time:c(),settings:e});}_drainInertiaBuffer(){const e=this._inertiaBuffer,t=c();for(;e.length>0&&t-e[0].time>160;)e.shift();}_onMoveEnd(e){if(this._drainInertiaBuffer(),this._inertiaBuffer.length<2)return;const i={zoom:0,bearing:0,pitch:0,roll:0,pan:new t.P(0,0),pinchAround:void 0,around:void 0};for(const{settings:e}of this._inertiaBuffer)i.zoom+=e.zoomDelta||0,i.bearing+=e.bearingDelta||0,i.pitch+=e.pitchDelta||0,i.roll+=e.rollDelta||0,e.panDelta&&i.pan._add(e.panDelta),e.around&&(i.around=e.around),e.pinchAround&&(i.pinchAround=e.pinchAround);const o=this._inertiaBuffer[this._inertiaBuffer.length-1].time-this._inertiaBuffer[0].time,a={};if(i.pan.mag()){const r=sr(i.pan.mag(),o,t.e({},Ja,e||{})),s=i.pan.mult(r.amount/i.pan.mag()),n=this._map.cameraHelper.handlePanInertia(s,this._map.transform);a.center=n.easingCenter,a.offset=n.easingOffset,rr(a,r);}if(i.zoom){const e=sr(i.zoom,o,er);a.zoom=t.cw(this._map.transform.zoom+e.amount,this._map.getZoomSnap(),e.amount),rr(a,e);}if(i.bearing){const e=sr(i.bearing,o,tr);a.bearing=this._map.transform.bearing+t.al(e.amount,-179,179),rr(a,e);}if(i.pitch){const e=sr(i.pitch,o,ir);a.pitch=this._map.transform.pitch+e.amount,rr(a,e);}if(i.roll){const e=sr(i.roll,o,or);a.roll=this._map.transform.roll+t.al(e.amount,-179,179),rr(a,e);}if(a.zoom||a.bearing){const e=void 0===i.pinchAround?i.around:i.pinchAround;a.around=e?this._map.unproject(e):this._map.getCenter();}return this.clear(),t.e(a,{noMoveStart:!0})}}function rr(e,t){(!e.duration||e.durationi.unproject(e))),n=r.reduce(((e,t,i,o)=>e.add(t.div(o.length))),new t.P(0,0));super(e,{points:r,point:n,lngLats:s,lngLat:i.unproject(n),originalEvent:o}),this._defaultPrevented=!1;}}class cr extends t.n{preventDefault(){this._defaultPrevented=!0;}get defaultPrevented(){return this._defaultPrevented}constructor(e,t,i){super(e,{originalEvent:i}),this._defaultPrevented=!1;}}class hr{constructor(e,t){this._map=e,this._clickTolerance=t.clickTolerance;}reset(){delete this._mousedownPos;}wheel(e){return this._firePreventable(new cr(e.type,this._map,e))}mousedown(e,t){return this._mousedownPos=t,this._firePreventable(new nr(e.type,this._map,e))}mouseup(e){this._map.fire(new nr(e.type,this._map,e));}click(e,t){this._mousedownPos&&this._mousedownPos.dist(t)>=this._clickTolerance||this._map.fire(new nr(e.type,this._map,e));}dblclick(e){return this._firePreventable(new nr(e.type,this._map,e))}mouseover(e){this._map.fire(new nr(e.type,this._map,e));}mouseout(e){this._map.fire(new nr(e.type,this._map,e));}touchstart(e){return this._firePreventable(new lr(e.type,this._map,e))}touchmove(e){this._map.fire(new lr(e.type,this._map,e));}touchend(e){this._map.fire(new lr(e.type,this._map,e));}touchcancel(e){this._map.fire(new lr(e.type,this._map,e));}_firePreventable(e){if(this._map.fire(e),e.defaultPrevented)return {}}isEnabled(){return !0}isActive(){return !1}enable(){}disable(){}}class ur{constructor(e){this._map=e;}reset(){this._delayContextMenu=!1,this._ignoreContextMenu=!0,delete this._contextMenuEvent;}mousemove(e){this._map.fire(new nr(e.type,this._map,e));}mousedown(){this._delayContextMenu=!0,this._ignoreContextMenu=!1;}mouseup(){this._delayContextMenu=!1,this._contextMenuEvent&&(this._map.fire(new nr("contextmenu",this._map,this._contextMenuEvent)),delete this._contextMenuEvent);}contextmenu(e){this._delayContextMenu?this._contextMenuEvent=e:this._ignoreContextMenu||this._map.fire(new nr(e.type,this._map,e)),this._map.listens("contextmenu")&&e.preventDefault();}isEnabled(){return !0}isActive(){return !1}enable(){}disable(){}}class dr{constructor(e){this._map=e;}get transform(){return this._map._requestedCameraState||this._map.transform}get center(){return {lng:this.transform.center.lng,lat:this.transform.center.lat}}get zoom(){return this.transform.zoom}get pitch(){return this.transform.pitch}get bearing(){return this.transform.bearing}unproject(e){return this.transform.screenPointToLocation(t.P.convert(e),this._map.terrain)}}class _r{constructor(e,t){this._map=e,this._tr=new dr(e),this._el=e.getCanvasContainer(),this._container=e.getContainer(),this._clickTolerance=t.clickTolerance||1,t.boxZoom&&"object"==typeof t.boxZoom&&(this._boxZoomEnd=t.boxZoom.boxZoomEnd);}isEnabled(){return !!this._enabled}isActive(){return !!this._active}enable(){this.isEnabled()||(this._enabled=!0);}disable(){this.isEnabled()&&(this._enabled=!1);}mousedown(e,t){this.isEnabled()&&e.shiftKey&&0===e.button&&(d.disableDrag(),this._startPos=this._lastPos=t,this._active=!0);}mousemoveWindow(e,t){if(!this._active)return;const i=t;if(this._lastPos.equals(i)||!this._box&&i.dist(this._startPos)e.fitScreenCoordinates(o,a,this._tr.bearing,{linear:!0})};this._fireEvent("boxzoomcancel",e);}keydown(e){this._active&&27===e.keyCode&&(this.reset(),this._fireEvent("boxzoomcancel",e));}reset(){this._active=!1,this._container.classList.remove("maplibregl-crosshair"),this._box&&(this._box.remove(),this._box=null),d.enableDrag(),delete this._startPos,delete this._lastPos;}_fireEvent(e,i){return this._map.fire(new t.n(e,{originalEvent:i}))}}function pr(e,t){if(e.length!==t.length)throw new Error(`The number of touches and points are not equal - touches ${e.length}, points ${t.length}`);const i={};for(let o=0;othis.numTouches)&&(this.aborted=!0),this.aborted||(void 0===this.startTime&&(this.startTime=e.timeStamp),o.length===this.numTouches&&(this.centroid=function(e){const i=new t.P(0,0);for(const t of e)i._add(t);return i.div(e.length)}(i),this.touches=pr(o,i)));}touchmove(e,t,i){if(this.aborted||!this.centroid)return;const o=pr(i,t);for(const e in this.touches){const t=o[e];(!t||t.dist(this.touches[e])>30)&&(this.aborted=!0);}}touchend(e,t,i){if((!this.centroid||e.timeStamp-this.startTime>500)&&(this.aborted=!0),0===i.length){const e=!this.aborted&&this.centroid;if(this.reset(),e)return e}}}class fr{constructor(e){this.singleTap=new mr(e),this.numTaps=e.numTaps,this.reset();}reset(){this.lastTime=1/0,delete this.lastTap,this.count=0,this.singleTap.reset();}touchstart(e,t,i){this.singleTap.touchstart(e,t,i);}touchmove(e,t,i){this.singleTap.touchmove(e,t,i);}touchend(e,t,i){const o=this.singleTap.touchend(e,t,i);if(o){const t=e.timeStamp-this.lastTime<500,i=!this.lastTap||this.lastTap.dist(o)<30;if(t&&i||this.reset(),this.count++,this.lastTime=e.timeStamp,this.lastTap=o,this.count===this.numTaps)return this.reset(),o}}}class gr{constructor(e){this._tr=new dr(e),this._zoomIn=new fr({numTouches:1,numTaps:2}),this._zoomOut=new fr({numTouches:2,numTaps:1}),this.reset();}reset(){this._active=!1,this._zoomIn.reset(),this._zoomOut.reset();}touchstart(e,t,i){this._zoomIn.touchstart(e,t,i),this._zoomOut.touchstart(e,t,i);}touchmove(e,t,i){this._zoomIn.touchmove(e,t,i),this._zoomOut.touchmove(e,t,i);}touchend(e,i,o){const a=this._zoomIn.touchend(e,i,o),r=this._zoomOut.touchend(e,i,o),s=this._tr;return a?(this._active=!0,e.preventDefault(),setTimeout((()=>this.reset()),0),{cameraAnimation:i=>i.easeTo({duration:300,zoom:t.cw(s.zoom+1,i.getZoomSnap()),around:s.unproject(a)},{originalEvent:e})}):r?(this._active=!0,e.preventDefault(),setTimeout((()=>this.reset()),0),{cameraAnimation:i=>i.easeTo({duration:300,zoom:t.cw(s.zoom-1,i.getZoomSnap()),around:s.unproject(r)},{originalEvent:e})}):void 0}touchcancel(){this.reset();}enable(){this._enabled=!0;}disable(){this._enabled=!1,this.reset();}isEnabled(){return this._enabled}isActive(){return this._active}}class vr{constructor(e){this._enabled=!!e.enable,this._moveStateManager=e.moveStateManager,this._clickTolerance=e.clickTolerance||1,this._moveFunction=e.move,this._activateOnStart=!!e.activateOnStart,e.assignEvents(this),this.reset();}reset(e){this._active=!1,this._moved=!1,delete this._lastPoint,this._moveStateManager.endMove(e);}_move(...e){const t=this._moveFunction(...e);if(t.bearingDelta||t.pitchDelta||t.rollDelta||t.around||t.panDelta)return this._active=!0,t}dragStart(e,t){this.isEnabled()&&!this._lastPoint&&this._moveStateManager.isValidStartEvent(e)&&(this._moveStateManager.startMove(e),this._lastPoint=Array.isArray(t)?t[0]:t,this._activateOnStart&&this._lastPoint&&(this._active=!0));}dragMove(e,t){if(!this.isEnabled())return;const i=this._lastPoint;if(!i)return;if(e.preventDefault(),!this._moveStateManager.isValidMoveEvent(e))return void this.reset(e);const o=Array.isArray(t)?t[0]:t;return !this._moved&&o.dist(i)!0}),t=new Tr){this.mouseMoveStateManager=e,this.oneFingerTouchMoveStateManager=t;}_executeRelevantHandler(e,t,i){return e instanceof MouseEvent?t(e):"undefined"!=typeof TouchEvent&&e instanceof TouchEvent?i(e):void 0}startMove(e){this._executeRelevantHandler(e,(e=>{this.mouseMoveStateManager.startMove(e);}),(e=>{this.oneFingerTouchMoveStateManager.startMove(e);}));}endMove(e){this._executeRelevantHandler(e,(e=>{this.mouseMoveStateManager.endMove(e);}),(e=>{this.oneFingerTouchMoveStateManager.endMove(e);}));}isValidStartEvent(e){return this._executeRelevantHandler(e,(e=>this.mouseMoveStateManager.isValidStartEvent(e)),(e=>this.oneFingerTouchMoveStateManager.isValidStartEvent(e)))}isValidMoveEvent(e){return this._executeRelevantHandler(e,(e=>this.mouseMoveStateManager.isValidMoveEvent(e)),(e=>this.oneFingerTouchMoveStateManager.isValidMoveEvent(e)))}isValidEndEvent(e){return this._executeRelevantHandler(e,(e=>this.mouseMoveStateManager.isValidEndEvent(e)),(e=>this.oneFingerTouchMoveStateManager.isValidEndEvent(e)))}}const Cr=e=>{e.mousedown=e.dragStart,e.mousemoveWindow=e.dragMove,e.mouseup=e.dragEnd,e.contextmenu=e=>{e.preventDefault();};};class Mr{constructor(e,t){this._clickTolerance=e.clickTolerance||1,this._map=t,this.reset();}reset(){this._active=!1,this._touches={},this._sum=new t.P(0,0);}_shouldBePrevented(e){return e<(this._map.cooperativeGestures.isEnabled()?2:1)}touchstart(e,t,i){return this._calculateTransform(e,t,i)}touchmove(e,t,i){if(this._active){if(!this._shouldBePrevented(i.length))return e.preventDefault(),this._calculateTransform(e,t,i);this._map.cooperativeGestures.notifyGestureBlocked("touch_pan",e);}}touchend(e,t,i){this._calculateTransform(e,t,i),this._active&&this._shouldBePrevented(i.length)&&this.reset();}touchcancel(){this.reset();}_calculateTransform(e,i,o){o.length>0&&(this._active=!0);const a=pr(o,i),r=new t.P(0,0),s=new t.P(0,0);let n=0;for(const e in a){const t=a[e],i=this._touches[e];i&&(r._add(t),s._add(t.sub(i)),n++,a[e]=t);}if(this._touches=a,this._shouldBePrevented(n)||!s.mag())return;const l=s.div(n);return this._sum._add(l),this._sum.mag()Math.abs(e.x)}class Lr extends Ir{constructor(e){super(),this._currentTouchCount=0,this._map=e;}reset(){super.reset(),this._valid=void 0,delete this._firstMove,delete this._lastPoints;}touchstart(e,t,i){super.touchstart(e,t,i),this._currentTouchCount=i.length;}_start(e){this._lastPoints=e,Ar(e[0].sub(e[1]))&&(this._valid=!1);}_move(e,t,i){if(this._map.cooperativeGestures.isEnabled()&&this._currentTouchCount<3)return;const o=e[0].sub(this._lastPoints[0]),a=e[1].sub(this._lastPoints[1]);return this._valid=this.gestureBeginsVertically(o,a,i.timeStamp),this._valid?(this._lastPoints=e,this._active=!0,{pitchDelta:(o.y+a.y)/2*-.5}):void 0}gestureBeginsVertically(e,t,i){if(void 0!==this._valid)return this._valid;const o=e.mag()>=2,a=t.mag()>=2;if(!o&&!a)return;if(!o||!a)return void 0===this._firstMove&&(this._firstMove=i),i-this._firstMove<100&&void 0;const r=e.y>0==t.y>0;return Ar(e)&&Ar(t)&&r}}const Fr={panStep:100,bearingStep:15,pitchStep:10};class kr{constructor(e){this._tr=new dr(e);const t=Fr;this._panStep=t.panStep,this._bearingStep=t.bearingStep,this._pitchStep=t.pitchStep,this._rotationDisabled=!1;}reset(){this._active=!1;}keydown(e){if(e.altKey||e.ctrlKey||e.metaKey)return;let i=0,o=0,a=0,r=0,s=0;switch(e.keyCode){case 61:case 107:case 171:case 187:i=1;break;case 189:case 109:case 173:i=-1;break;case 37:e.shiftKey?o=-1:(e.preventDefault(),r=-1);break;case 39:e.shiftKey?o=1:(e.preventDefault(),r=1);break;case 38:e.shiftKey?a=1:(e.preventDefault(),s=-1);break;case 40:e.shiftKey?a=-1:(e.preventDefault(),s=1);break;default:return}return this._rotationDisabled&&(o=0,a=0),{cameraAnimation:n=>{const l=this._tr;n.easeTo({duration:300,easeId:"keyboardHandler",easing:Br,zoom:i?t.cw(l.zoom+i*(e.shiftKey?2:1),n.getZoomSnap()):l.zoom,bearing:l.bearing+o*this._bearingStep,pitch:l.pitch+a*this._pitchStep,offset:[-r*this._panStep,-s*this._panStep],center:l.center},{originalEvent:e});}}}enable(){this._enabled=!0;}disable(){this._enabled=!1,this.reset();}isEnabled(){return this._enabled}isActive(){return this._active}disableRotation(){this._rotationDisabled=!0;}enableRotation(){this._rotationDisabled=!1;}}function Br(e){return e*(2-e)}const Or=4.000244140625,jr=1/450;class Nr{constructor(e,t){this._onTimeout=e=>{this._type="wheel",this._delta-=this._lastValue,this._active||this._start(e);},this._map=e,this._tr=new dr(e),this._triggerRenderFrame=t,this._delta=0,this._defaultZoomRate=.01,this._wheelZoomRate=jr;}setZoomRate(e){this._defaultZoomRate=e;}setWheelZoomRate(e){this._wheelZoomRate=e;}isEnabled(){return !!this._enabled}isActive(){return !!this._active||void 0!==this._finishTimeout}isZooming(){return !!this._zooming}enable(e){this.isEnabled()||(this._enabled=!0,this._aroundCenter=!!e&&"center"===e.around);}disable(){this.isEnabled()&&(this._enabled=!1);}_shouldBePrevented(e){return !!this._map.cooperativeGestures.isEnabled()&&!(e.ctrlKey||this._map.cooperativeGestures.isBypassed(e))}wheel(e){if(!this.isEnabled())return;if(this._shouldBePrevented(e))return void this._map.cooperativeGestures.notifyGestureBlocked("wheel_zoom",e);let t=e.deltaMode===WheelEvent.DOM_DELTA_LINE?40*e.deltaY:e.deltaY;const i=c(),o=i-(this._lastWheelEventTime||0);this._lastWheelEventTime=i,0!==t&&t%Or==0?this._type="wheel":0!==t&&Math.abs(t)<4?this._type="trackpad":o>400?(this._type=null,this._lastValue=t,this._timeout=setTimeout(this._onTimeout,40,e)):this._type||(this._type=Math.abs(o*t)<200?"trackpad":"wheel",this._timeout&&(clearTimeout(this._timeout),this._timeout=null,t+=this._lastValue)),e.shiftKey&&t&&(t/=4),this._type&&(this._lastWheelEvent=e,this._delta-=t,this._active||this._start(e)),e.preventDefault();}_start(e){if(!this._delta)return;this._needsRerender=!1,this._active=!0,this.isZooming()||(this._zooming=!0),this._finishTimeout&&(clearTimeout(this._finishTimeout),delete this._finishTimeout);const i=d.mousePos(this._map.getCanvas(),e),o=this._tr;this._aroundPoint=this._aroundCenter?o.transform.locationToScreenPoint(t.W.convert(o.center)):i,this._needsRerender||(this._needsRerender=!0,this._triggerRenderFrame());}renderFrame(){if(!this._needsRerender)return;if(this._needsRerender=!1,!this.isActive())return;const e=this._tr.transform;if("number"==typeof this._lastExpectedZoom){const t=e.zoom-this._lastExpectedZoom;"number"==typeof this._startZoom&&(this._startZoom+=t),"number"==typeof this._targetZoom&&(this._targetZoom+=t);}if(0!==this._delta){const i="wheel"===this._type&&Math.abs(this._delta)>Or?this._wheelZoomRate:this._defaultZoomRate;let o=2/(1+Math.exp(-Math.abs(this._delta*i)));this._delta<0&&0!==o&&(o=1/o);const a="number"!=typeof this._targetZoom?e.scale:t.ao(this._targetZoom),r=e.applyConstrain(e.getCameraLngLat(),t.ar(a*o)).zoom,s=this._map.getZoomSnap();if("wheel"===this._type&&s>0){const i=t.cw(e.zoom,s);this._targetZoom=t.cw(r,s,r-i);}else this._targetZoom=r;"wheel"===this._type&&(this._startZoom=e.zoom,this._easing=this._smoothOutEasing(200)),this._delta=0;}const i="number"!=typeof this._targetZoom?e.zoom:this._targetZoom,o=this._startZoom,a=this._easing;let r,s=!1;if("wheel"===this._type&&o&&a){const e=c()-this._lastWheelEventTime,n=Math.min((e+5)/200,1),l=a(n);r=t.H.number(o,i,l),n<1?this._needsRerender=!0:s=!0;}else r=i,s=!0;return this._active=!0,s&&(this._active=!1,this._finishTimeout=setTimeout((()=>{this._zooming=!1,this._triggerRenderFrame(),delete this._targetZoom,delete this._lastExpectedZoom,delete this._finishTimeout;}),200)),this._lastExpectedZoom=r,{noInertia:!0,needsRenderFrame:!s,zoomDelta:r-e.zoom,around:this._aroundPoint,originalEvent:this._lastWheelEvent}}_smoothOutEasing(e){let i=t.cy;if(this._prevEase){const e=this._prevEase,o=(c()-e.start)/e.duration,a=e.easing(o+.01)-e.easing(o),r=.27/Math.sqrt(a*a+1e-4)*.01,s=Math.sqrt(.0729-r*r);i=t.cv(r,s,.25,1);}return this._prevEase={start:c(),duration:e,easing:i},i}reset(){this._active=!1,this._zooming=!1,delete this._targetZoom,delete this._lastExpectedZoom,this._finishTimeout&&(clearTimeout(this._finishTimeout),delete this._finishTimeout);}}class Zr{constructor(e,t){this._clickZoom=e,this._tapZoom=t;}enable(){this._clickZoom.enable(),this._tapZoom.enable();}disable(){this._clickZoom.disable(),this._tapZoom.disable();}isEnabled(){return this._clickZoom.isEnabled()&&this._tapZoom.isEnabled()}isActive(){return this._clickZoom.isActive()||this._tapZoom.isActive()}}class Ur{constructor(e){this._tr=new dr(e),this.reset();}reset(){this._active=!1;}dblclick(e,i){return e.preventDefault(),{cameraAnimation:o=>{o.easeTo({duration:300,zoom:t.cw(this._tr.zoom+(e.shiftKey?-1:1),o.getZoomSnap()),around:this._tr.unproject(i)},{originalEvent:e});}}}enable(){this._enabled=!0;}disable(){this._enabled=!1,this.reset();}isEnabled(){return this._enabled}isActive(){return this._active}}class Gr{constructor(){this._tap=new fr({numTouches:1,numTaps:1}),this._zoomRate=1,this.reset();}setZoomRate(e){this._zoomRate=null!=e?e:1;}reset(){this._active=!1,delete this._swipePoint,delete this._swipeTouch,delete this._tapTime,delete this._tapPoint,this._tap.reset();}touchstart(e,t,i){if(!this._swipePoint)if(this._tapTime){const o=t[0],a=e.timeStamp-this._tapTime<500,r=this._tapPoint.dist(o)<30;a&&r?i.length>0&&(this._swipePoint=o,this._swipeTouch=i[0].identifier):this.reset();}else this._tap.touchstart(e,t,i);}touchmove(e,t,i){if(this._tapTime){if(this._swipePoint){if(i[0].identifier!==this._swipeTouch)return;const o=t[0],a=o.y-this._swipePoint.y;return this._swipePoint=o,e.preventDefault(),this._active=!0,{zoomDelta:a/128*this._zoomRate}}}else this._tap.touchmove(e,t,i);}touchend(e,t,i){if(this._tapTime)this._swipePoint&&0===i.length&&this.reset();else {const o=this._tap.touchend(e,t,i);o&&(this._tapTime=e.timeStamp,this._tapPoint=o);}}touchcancel(){this.reset();}enable(){this._enabled=!0;}disable(){this._enabled=!1,this.reset();}isEnabled(){return this._enabled}isActive(){return this._active}}class Vr{constructor(e,t,i){this._el=e,this._mousePan=t,this._touchPan=i;}enable(e){this._inertiaOptions=e||{},this._mousePan.enable(),this._touchPan.enable(),this._el.classList.add("maplibregl-touch-drag-pan");}disable(){this._mousePan.disable(),this._touchPan.disable(),this._el.classList.remove("maplibregl-touch-drag-pan");}isEnabled(){return this._mousePan.isEnabled()&&this._touchPan.isEnabled()}isActive(){return this._mousePan.isActive()||this._touchPan.isActive()}}class Wr{constructor(e,t,i,o){this._pitchWithRotate=e.pitchWithRotate,this._rollEnabled=e.rollEnabled,this._mouseRotate=t,this._mousePitch=i,this._mouseRoll=o;}enable(){this._mouseRotate.enable(),this._pitchWithRotate&&this._mousePitch.enable(),this._rollEnabled&&this._mouseRoll.enable();}disable(){this._mouseRotate.disable(),this._mousePitch.disable(),this._mouseRoll.disable();}isEnabled(){return this._mouseRotate.isEnabled()&&(!this._pitchWithRotate||this._mousePitch.isEnabled())&&(!this._rollEnabled||this._mouseRoll.isEnabled())}isActive(){return this._mouseRotate.isActive()||this._mousePitch.isActive()||this._mouseRoll.isActive()}}class qr{constructor(e,t,i,o){this._el=e,this._touchZoom=t,this._touchRotate=i,this._tapDragZoom=o,this._rotationDisabled=!1,this._enabled=!0;}enable(e){this._touchZoom.enable(e),this._rotationDisabled||this._touchRotate.enable(e),this._tapDragZoom.enable(),this._el.classList.add("maplibregl-touch-zoom-rotate");}disable(){this._touchZoom.disable(),this._touchRotate.disable(),this._tapDragZoom.disable(),this._el.classList.remove("maplibregl-touch-zoom-rotate");}isEnabled(){return this._touchZoom.isEnabled()&&(this._rotationDisabled||this._touchRotate.isEnabled())&&this._tapDragZoom.isEnabled()}isActive(){return this._touchZoom.isActive()||this._touchRotate.isActive()||this._tapDragZoom.isActive()}setZoomRate(e){this._touchZoom.setZoomRate(e),this._tapDragZoom.setZoomRate(e);}setZoomThreshold(e){this._touchZoom.setZoomThreshold(e);}disableRotation(){this._rotationDisabled=!0,this._touchRotate.disable();}enableRotation(){this._rotationDisabled=!1,this._touchZoom.isEnabled()&&this._touchRotate.enable();}}class $r{constructor(e,t){this._bypassKey=navigator.userAgent.includes("Mac")?"metaKey":"ctrlKey",this._map=e,this._options=t,this._enabled=!1;}isActive(){return !1}reset(){}_setupUI(){if(this._container)return;const e=this._map.getCanvasContainer();e.classList.add("maplibregl-cooperative-gestures"),this._container=d.create("div","maplibregl-cooperative-gesture-screen",e);let t=this._map._getUIString("CooperativeGesturesHandler.WindowsHelpText");"metaKey"===this._bypassKey&&(t=this._map._getUIString("CooperativeGesturesHandler.MacHelpText"));const i=this._map._getUIString("CooperativeGesturesHandler.MobileHelpText"),o=document.createElement("div");o.className="maplibregl-desktop-message",o.textContent=t,this._container.appendChild(o);const a=document.createElement("div");a.className="maplibregl-mobile-message",a.textContent=i,this._container.appendChild(a),this._container.setAttribute("aria-hidden","true");}_destroyUI(){this._container&&(this._container.remove(),this._map.getCanvasContainer().classList.remove("maplibregl-cooperative-gestures")),delete this._container;}enable(){this._setupUI(),this._enabled=!0;}disable(){this._enabled=!1,this._destroyUI();}isEnabled(){return this._enabled}isBypassed(e){return e[this._bypassKey]}notifyGestureBlocked(e,i){this._enabled&&(this._map.fire(new t.n("cooperativegestureprevented",{gestureType:e,originalEvent:i})),this._container.classList.add("maplibregl-show"),setTimeout((()=>{this._container.classList.remove("maplibregl-show");}),100));}}const Hr=e=>e.zoom||e.drag||e.roll||e.pitch||e.rotate;class Xr extends t.n{}function Kr(e){var t;return (null===(t=e.panDelta)||void 0===t?void 0:t.mag())||e.zoomDelta||e.bearingDelta||e.pitchDelta||e.rollDelta}class Yr{get _ownerDocument(){var e;return (null===(e=this._el)||void 0===e?void 0:e.ownerDocument)||document}get _ownerWindow(){var e,t;return (null===(t=null===(e=this._el)||void 0===e?void 0:e.ownerDocument)||void 0===t?void 0:t.defaultView)||window}constructor(e,i){this.handleWindowEvent=e=>{this.handleEvent(e,`${e.type}Window`);},this.handleEvent=(e,i)=>{if("blur"===e.type)return void this.stop(!0);this._updatingCamera=!0;const o="renderFrame"===e.type?void 0:e,a={needsRenderFrame:!1},r={},s={};for(const{handlerName:n,handler:l,allowed:c}of this._handlers){if(!l.isEnabled())continue;let h;if(this._blockedByActive(s,c,n))l.reset();else if(l[i||e.type]){if(t.cz(e,i||e.type)){const t=d.mousePos(this._map.getCanvas(),e);h=l[i||e.type](e,t);}else if(t.cA(e,i||e.type)){const t=this._getMapTouches(e.touches),o=d.touchPos(this._map.getCanvas(),t);h=l[i||e.type](e,o,t);}else t.cB(i||e.type)||(h=l[i||e.type](e));this.mergeHandlerResult(a,r,h,n,o),(null==h?void 0:h.needsRenderFrame)&&this._triggerRenderFrame();}(h||l.isActive())&&(s[n]=l);}const n={};for(const e in this._previousActiveHandlers)s[e]||(n[e]=o);this._previousActiveHandlers=s,(Object.keys(n).length||Kr(a))&&(this._changes.push([a,r,n]),this._triggerRenderFrame()),(Object.keys(s).length||Kr(a))&&this._map._stop(!0),this._updatingCamera=!1;const{cameraAnimation:l}=a;l&&(this._inertia.clear(),this._fireEvents({},{},!0),this._changes=[],l(this._map));},this._map=e,this._el=this._map.getCanvasContainer(),this._handlers=[],this._handlersById={},this._changes=[],this._inertia=new ar(e),this._bearingSnap=i.bearingSnap,this._previousActiveHandlers={},this._eventsInProgress={},this._addDefaultHandlers(i);const o=this._el;this._listeners=[[o,"touchstart",{passive:!0}],[o,"touchmove",{passive:!1}],[o,"touchend",void 0],[o,"touchcancel",void 0],[o,"mousedown",void 0],[o,"mousemove",void 0],[o,"mouseup",void 0],[this._ownerDocument,"mousemove",{capture:!0}],[this._ownerDocument,"mouseup",void 0],[o,"mouseover",void 0],[o,"mouseout",void 0],[o,"dblclick",void 0],[o,"click",void 0],[o,"keydown",{capture:!1}],[o,"keyup",void 0],[o,"wheel",{passive:!1}],[o,"contextmenu",void 0],[this._ownerWindow,"blur",void 0]];for(const[e,t,i]of this._listeners)e.addEventListener(t,e===this._ownerDocument?this.handleWindowEvent:this.handleEvent,i);}destroy(){for(const[e,t,i]of this._listeners)e.removeEventListener(t,e===this._ownerDocument?this.handleWindowEvent:this.handleEvent,i);}_addDefaultHandlers(e){const i=this._map,o=i.getCanvasContainer();this._add("mapEvent",new hr(i,e));const a=i.boxZoom=new _r(i,e);this._add("boxZoom",a),e.interactive&&e.boxZoom&&a.enable();const r=i.cooperativeGestures=new $r(i,e.cooperativeGestures);this._add("cooperativeGestures",r),e.cooperativeGestures&&r.enable();const s=new gr(i),n=new Ur(i);i.doubleClickZoom=new Zr(n,s),this._add("tapZoom",s),this._add("clickZoom",n),e.interactive&&e.doubleClickZoom&&i.doubleClickZoom.enable();const l=new Gr;this._add("tapDragZoom",l);const c=i.touchPitch=new Lr(i);this._add("touchPitch",c),e.interactive&&e.touchPitch&&i.touchPitch.enable(e.touchPitch);const h=()=>i.project(i.getCenter()),u=function({enable:e,clickTolerance:i,aroundCenter:o=!0,minPixelCenterThreshold:a=100,rotateDegreesPerPixelMoved:r=.8},s){const n=new wr({checkCorrectEvent:e=>0===e.button&&e.ctrlKey||2===e.button&&!e.ctrlKey});return new vr({clickTolerance:i,move:(e,i)=>{const n=s();if(o&&Math.abs(n.y-e.y)>a)return {bearingDelta:t.cx(new t.P(e.x,i.y),i,n)};let l=(i.x-e.x)*r;return o&&i.y0===e.button&&e.ctrlKey||2===e.button});return new vr({clickTolerance:t,move:(e,t)=>({pitchDelta:(t.y-e.y)*i}),moveStateManager:o,enable:e,assignEvents:Cr})}(e),_=function({enable:e,clickTolerance:t,rollDegreesPerPixelMoved:i=.3},o){const a=new wr({checkCorrectEvent:e=>2===e.button&&e.ctrlKey});return new vr({clickTolerance:t,move:(e,t)=>{const a=o();let r=(t.x-e.x)*i;return t.y0===e.button&&!e.ctrlKey});return new vr({clickTolerance:t,move:(e,t)=>({around:t,panDelta:t.sub(e)}),activateOnStart:!0,moveStateManager:i,enable:e,assignEvents:Cr})}(e),m=new Mr(e,i);i.dragPan=new Vr(o,p,m),this._add("mousePan",p),this._add("touchPan",m,["touchZoom","touchRotate"]),e.interactive&&e.dragPan&&i.dragPan.enable(e.dragPan);const f=new Dr,g=new zr;i.touchZoomRotate=new qr(o,g,f,l),this._add("touchRotate",f,["touchPan","touchZoom"]),this._add("touchZoom",g,["touchPan","touchRotate"]),e.interactive&&e.touchZoomRotate&&i.touchZoomRotate.enable(e.touchZoomRotate),this._add("blockableMapEvent",new ur(i));const v=i.scrollZoom=new Nr(i,(()=>this._triggerRenderFrame()));this._add("scrollZoom",v,["mousePan"]),e.interactive&&e.scrollZoom&&i.scrollZoom.enable(e.scrollZoom);const x=i.keyboard=new kr(i);this._add("keyboard",x),e.interactive&&e.keyboard&&i.keyboard.enable();}_add(e,t,i){this._handlers.push({handlerName:e,handler:t,allowed:i}),this._handlersById[e]=t;}stop(e){if(!this._updatingCamera){for(const{handler:e}of this._handlers)e.reset();this._inertia.clear(),this._fireEvents({},{},e),this._changes=[];}}isActive(){for(const{handler:e}of this._handlers)if(e.isActive())return !0;return !1}isZooming(){return !!this._eventsInProgress.zoom||this._map.scrollZoom.isZooming()}isRotating(){return !!this._eventsInProgress.rotate}isMoving(){return Boolean(Hr(this._eventsInProgress))||this.isZooming()}_blockedByActive(e,t,i){for(const o in e)if(o!==i&&!(null==t?void 0:t.includes(o)))return !0;return !1}_getMapTouches(e){const t=[];for(const i of e)this._el.contains(i.target)&&t.push(i);return t}mergeHandlerResult(e,i,o,a,r){if(!o)return;t.e(e,o);const s={handlerName:a,originalEvent:o.originalEvent||r};void 0!==o.zoomDelta&&(i.zoom=s),void 0!==o.panDelta&&(i.drag=s),void 0!==o.rollDelta&&(i.roll=s),void 0!==o.pitchDelta&&(i.pitch=s),void 0!==o.bearingDelta&&(i.rotate=s);}_applyChanges(){const e={},i={},o={};for(const[a,r,s]of this._changes)a.panDelta&&(e.panDelta=(e.panDelta||new t.P(0,0))._add(a.panDelta)),a.zoomDelta&&(e.zoomDelta=(e.zoomDelta||0)+a.zoomDelta),a.bearingDelta&&(e.bearingDelta=(e.bearingDelta||0)+a.bearingDelta),a.pitchDelta&&(e.pitchDelta=(e.pitchDelta||0)+a.pitchDelta),a.rollDelta&&(e.rollDelta=(e.rollDelta||0)+a.rollDelta),void 0!==a.around&&(e.around=a.around),void 0!==a.pinchAround&&(e.pinchAround=a.pinchAround),a.noInertia&&(e.noInertia=a.noInertia),t.e(i,r),t.e(o,s);this._updateMapTransform(e,i,o),this._changes=[];}_updateMapTransform(e,t,i){const o=this._map,a=o._getTransformForUpdate(),r=o.terrain;if(!(Kr(e)||r&&this._terrainMovement))return void this._fireEvents(t,i,!0);o._stop(!0);let{panDelta:s,zoomDelta:n,bearingDelta:l,pitchDelta:c,rollDelta:h,around:u,pinchAround:d}=e;void 0!==d&&(u=d),u||(u=o.transform.centerPoint),r&&!a.isPointOnMapSurface(u)&&(u=a.centerPoint);const _={panDelta:s,zoomDelta:n,rollDelta:h,pitchDelta:c,bearingDelta:l,around:u};this._map.cameraHelper.useGlobeControls&&!a.isPointOnMapSurface(u)&&(u=a.centerPoint);const p=u.distSqr(a.centerPoint)<.01?a.center:a.screenPointToLocation(s?u.sub(s):u);this._handleMapControls({terrain:r,tr:a,deltasForHelper:_,preZoomAroundLoc:p,combinedEventsInProgress:t,panDelta:s}),o._applyUpdatedTransform(a),this._map._update(),e.noInertia||this._inertia.record(e),this._fireEvents(t,i,!0);}_handleMapControls({terrain:e,tr:t,deltasForHelper:i,preZoomAroundLoc:o,combinedEventsInProgress:a,panDelta:r}){const s=this._map.cameraHelper;if(s.handleMapControlsRollPitchBearingZoom(i,t),e)return s.useGlobeControls?(this._terrainMovement||!a.drag&&!a.zoom||(this._terrainMovement=!0,this._map._elevationFreeze=!0),void s.handleMapControlsPan(i,t,o)):this._terrainMovement||!a.drag&&!a.zoom?void(a.drag&&this._terrainMovement&&r?t.setCenter(t.screenPointToLocation(t.centerPoint.sub(r))):s.handleMapControlsPan(i,t,o)):(this._terrainMovement=!0,this._map._elevationFreeze=!0,void s.handleMapControlsPan(i,t,o));s.handleMapControlsPan(i,t,o);}_fireEvents(e,i,o){const a=Hr(this._eventsInProgress),r=Hr(e),s={};for(const t in e){const{originalEvent:i}=e[t];this._eventsInProgress[t]||(s[`${t}start`]=i),this._eventsInProgress[t]=e[t];}!a&&r&&this._fireEvent("movestart",r.originalEvent);for(const e in s)this._fireEvent(e,s[e]);r&&this._fireEvent("move",r.originalEvent);for(const t in e){const{originalEvent:i}=e[t];this._fireEvent(t,i);}const l={};let c;for(const e in this._eventsInProgress){const{handlerName:t,originalEvent:o}=this._eventsInProgress[e];this._handlersById[t].isActive()||(delete this._eventsInProgress[e],c=i[t]||o,l[`${e}end`]=c);}for(const e in l)this._fireEvent(e,l[e]);const h=Hr(this._eventsInProgress),u=(a||r)&&!h;if(u&&this._terrainMovement){this._map._elevationFreeze=!1,this._terrainMovement=!1;const e=this._map._getTransformForUpdate();this._map.getCenterClampedToGround()&&e.recalculateZoomAndCenter(this._map.terrain),this._map._applyUpdatedTransform(e);}if(o&&u){this._updatingCamera=!0;const e=this._inertia._onMoveEnd(this._map.dragPan._inertiaOptions),i=e=>0!==e&&-this._bearingSnap{delete this._frameId,this.handleEvent(new Xr("renderFrame",{timeStamp:e})),this._applyChanges();}))}_triggerRenderFrame(){void 0===this._frameId&&(this._frameId=this._requestFrame());}}class Qr extends t.E{constructor(e,t,i){super(),this._renderFrameCallback=()=>{const e=Math.min((c()-this._easeStart)/this._easeOptions.duration,1);this._onEaseFrame(this._easeOptions.easing(e)),e<1&&this._easeFrameId?this._easeFrameId=this._requestRenderFrame(this._renderFrameCallback):this.stop();},this._moving=!1,this._zooming=!1,this.transform=e,this._bearingSnap=i.bearingSnap,this._zoomSnap=i.zoomSnap,this.cameraHelper=t,this.on("moveend",(()=>{delete this._requestedCameraState;}));}migrateProjection(e,t){e.apply(this.transform,!0),this.transform=e,this.cameraHelper=t;}getCenter(){return new t.W(this.transform.center.lng,this.transform.center.lat)}setCenter(e,t){return this.jumpTo({center:e},t)}getCenterElevation(){return this.transform.elevation}setCenterElevation(e,t){return this.jumpTo({elevation:e},t),this}getCenterClampedToGround(){return this._centerClampedToGround}setCenterClampedToGround(e){this._centerClampedToGround=e;}panBy(e,i,o){return e=t.P.convert(e).mult(-1),this.panTo(this.transform.center,t.e({offset:e},i),o)}panTo(e,i,o){return this.easeTo(t.e({center:e},i),o)}getZoom(){return this.transform.zoom}setZoom(e,t){return this.jumpTo({zoom:e},t),this}zoomTo(e,i,o){return this.easeTo(t.e({zoom:e},i),o)}zoomIn(e,i){return this.zoomTo(t.cw(this.getZoom()+1,this._zoomSnap),e,i),this}zoomOut(e,i){return this.zoomTo(t.cw(this.getZoom()-1,this._zoomSnap),e,i),this}getVerticalFieldOfView(){return this.transform.fov}setVerticalFieldOfView(e,i){return e!=this.transform.fov&&(this.transform.setFov(e),this.fire(new t.n("movestart",i)).fire(new t.n("move",i)).fire(new t.n("moveend",i))),this}getBearing(){return this.transform.bearing}setZoomSnap(e){return this._zoomSnap=e,this}getZoomSnap(){return this._zoomSnap}setBearing(e,t){return this.jumpTo({bearing:e},t),this}getPadding(){return this.transform.padding}setPadding(e,t){return this.jumpTo({padding:e},t),this}rotateTo(e,i,o){return this.easeTo(t.e({bearing:e},i),o)}resetNorth(e,i){return this.rotateTo(0,t.e({duration:1e3},e),i),this}resetNorthPitch(e,i){return this.easeTo(t.e({bearing:0,pitch:0,roll:0,duration:1e3},e),i),this}snapToNorth(e,t){return Math.abs(this.getBearing()){f.easeFunc(t),this.terrain&&!e.freezeElevation&&this._updateElevation(t),this._applyUpdatedTransform(o),this._fireMoveEvents(i);}),(t=>{this.terrain&&e.freezeElevation&&this._finalizeElevation(),this._afterEase(i,t);}),e),this}_prepareEase(e,i,o={}){this._moving=!0,i||o.moving||this.fire(new t.n("movestart",e)),this._zooming&&!o.zooming&&this.fire(new t.n("zoomstart",e)),this._rotating&&!o.rotating&&this.fire(new t.n("rotatestart",e)),this._pitching&&!o.pitching&&this.fire(new t.n("pitchstart",e)),this._rolling&&!o.rolling&&this.fire(new t.n("rollstart",e));}_prepareElevation(e){this._elevationCenter=e,this._elevationStart=this.transform.elevation,this._elevationTarget=this.terrain.getElevationForLngLatZoom(e,this.transform.tileZoom),this._elevationFreeze=!0;}_updateElevation(e){void 0!==this._elevationStart&&void 0!==this._elevationCenter||this._prepareElevation(this.transform.center),this.transform.setMinElevationForCurrentTile(this.terrain.getMinTileElevationForLngLatZoom(this._elevationCenter,this.transform.tileZoom));const i=this.terrain.getElevationForLngLatZoom(this._elevationCenter,this.transform.tileZoom);if(e<1&&i!==this._elevationTarget){const t=this._elevationTarget-this._elevationStart;this._elevationStart+=e*(t-(i-(t*e+this._elevationStart))/(1-e)),this._elevationTarget=i;}this.transform.setElevation(t.H.number(this._elevationStart,this._elevationTarget,e));}_finalizeElevation(){this._elevationFreeze=!1,this.getCenterClampedToGround()&&this.transform.recalculateZoomAndCenter(this.terrain);}_getTransformForUpdate(){return this.transformCameraUpdate||this.terrain?(this._requestedCameraState||(this._requestedCameraState=this.transform.clone()),this._requestedCameraState):this.transform}_elevateCameraIfInsideTerrain(e){if(!this.terrain&&e.elevation>=0&&e.pitch<=90)return {};const t=e.getCameraLngLat(),i=e.getCameraAltitude(),o=this.terrain?this.terrain.getElevationForLngLatZoom(t,e.zoom):0;if(ithis._elevateCameraIfInsideTerrain(e))),this.transformCameraUpdate&&t.push((e=>this.transformCameraUpdate(e))),!t.length)return;const i=e.clone();for(const e of t){const t=i.clone(),{center:o,zoom:a,roll:r,pitch:s,bearing:n,elevation:l}=e(t);o&&t.setCenter(o),void 0!==l&&t.setElevation(l),void 0!==a&&t.setZoom(a),void 0!==r&&t.setRoll(r),void 0!==s&&t.setPitch(s),void 0!==n&&t.setBearing(n),i.apply(t,!1);}this.transform.apply(i,!1);}_fireMoveEvents(e){this.fire(new t.n("move",e)),this._zooming&&this.fire(new t.n("zoom",e)),this._rotating&&this.fire(new t.n("rotate",e)),this._pitching&&this.fire(new t.n("pitch",e)),this._rolling&&this.fire(new t.n("roll",e));}_afterEase(e,i){if(this._easeId&&i&&this._easeId===i)return;delete this._easeId;const o=this._zooming,a=this._rotating,r=this._pitching,s=this._rolling;this._moving=!1,this._zooming=!1,this._rotating=!1,this._pitching=!1,this._rolling=!1,this._padding=!1,o&&this.fire(new t.n("zoomend",e)),a&&this.fire(new t.n("rotateend",e)),r&&this.fire(new t.n("pitchend",e)),s&&this.fire(new t.n("rollend",e)),this.fire(new t.n("moveend",e));}flyTo(e,i){if(!e.essential&&n.prefersReducedMotion){const o=t.V(e,["center","zoom","bearing","pitch","roll","elevation","padding"]);return this.jumpTo(o,i)}this.stop(),"zoom"in(e=t.e({offset:[0,0],speed:1.2,curve:1.42,easing:t.cy},e))&&this._zoomSnap&&(e.zoom=t.cw(e.zoom,this._zoomSnap));const o=this._getTransformForUpdate(),a=o.bearing,r=o.pitch,s=o.roll,l=o.padding,c="bearing"in e?this._normalizeBearing(e.bearing,a):a,h="pitch"in e?+e.pitch:r,u="roll"in e?this._normalizeBearing(e.roll,s):s,d="padding"in e?e.padding:o.padding,_=t.P.convert(e.offset);let p=o.centerPoint.add(_);const m=o.screenPointToLocation(p),f=this.cameraHelper.handleFlyTo(o,{bearing:c,pitch:h,roll:u,padding:d,locationAtOffset:m,offsetAsPoint:_,center:e.center,minZoom:e.minZoom,zoom:e.zoom});let g=e.curve;const v=Math.max(o.width,o.height),x=v/f.scaleOfZoom,b=f.pixelPathLength;"number"==typeof f.scaleOfMinZoom&&(g=Math.sqrt(v/f.scaleOfMinZoom/b*2));const y=g*g;function w(e){const t=(x*x-v*v+(e?-1:1)*y*y*b*b)/(2*(e?x:v)*y*b);return Math.log(Math.sqrt(t*t+1)-t)}function T(e){return (Math.exp(e)-Math.exp(-e))/2}function P(e){return (Math.exp(e)+Math.exp(-e))/2}const C=w(!1);let M=function(e){return P(C)/P(C+g*e)},I=function(e){return v*((P(C)*(T(t=C+g*e)/P(t))-T(C))/y)/b;var t;},E=(w(!0)-C)/g;if(Math.abs(b)<2e-6||!isFinite(E)){if(Math.abs(v-x)<1e-6)return this.easeTo(e,i);const t=x0,M=e=>Math.exp(t*g*e);}return e.duration="duration"in e?+e.duration:1e3*E/("screenSpeed"in e?+e.screenSpeed/g:+e.speed),e.maxDuration&&e.duration>e.maxDuration&&(e.duration=0),this._zooming=!0,this._rotating=a!==c,this._pitching=h!==r,this._rolling=u!==s,this._padding=!o.isPaddingEqual(d),this._prepareEase(i,!1),this.terrain&&this._prepareElevation(f.targetCenter),this._ease((n=>{const m=n*E,g=1/M(m),v=I(m);this._rotating&&o.setBearing(t.H.number(a,c,n)),this._pitching&&o.setPitch(t.H.number(r,h,n)),this._rolling&&o.setRoll(t.H.number(s,u,n)),this._padding&&(o.interpolatePadding(l,d,n),p=o.centerPoint.add(_)),f.easeFunc(n,g,v,p),this.terrain&&!e.freezeElevation&&this._updateElevation(n),this._applyUpdatedTransform(o),this._fireMoveEvents(i);}),(()=>{this.terrain&&e.freezeElevation&&this._finalizeElevation(),this._afterEase(i);}),e),this}isEasing(){return !!this._easeFrameId}stop(){return this._stop()}_stop(e,t){var i;if(this._easeFrameId&&(this._cancelRenderFrame(this._easeFrameId),delete this._easeFrameId,delete this._onEaseFrame),this._onEaseEnd){const e=this._onEaseEnd;delete this._onEaseEnd,e.call(this,t);}return e||null===(i=this.handlers)||void 0===i||i.stop(!1),this}_ease(e,t,i){!1===i.animate||0===i.duration?(e(1),t()):(this._easeStart=c(),this._easeOptions=i,this._onEaseFrame=e,this._onEaseEnd=t,this._easeFrameId=this._requestRenderFrame(this._renderFrameCallback));}_normalizeBearing(e,i){e=t.X(e,-180,180);const o=Math.abs(e-i);return Math.abs(e-360-i)MapLibre'};class es{constructor(e=Jr){this._toggleAttribution=()=>{this._container.classList.contains("maplibregl-compact")&&(this._container.classList.contains("maplibregl-compact-show")?(this._container.setAttribute("open",""),this._container.classList.remove("maplibregl-compact-show")):(this._container.classList.add("maplibregl-compact-show"),this._container.removeAttribute("open")));},this._updateData=e=>{!e||"metadata"!==e.sourceDataType&&"visibility"!==e.sourceDataType&&"style"!==e.dataType&&"terrain"!==e.type||this._updateAttributions();},this._updateCompact=()=>{this._map.getCanvasContainer().offsetWidth<=640||this._compact?!1===this._compact?this._container.setAttribute("open",""):this._container.classList.contains("maplibregl-compact")||this._container.classList.contains("maplibregl-attrib-empty")||(this._container.setAttribute("open",""),this._container.classList.add("maplibregl-compact","maplibregl-compact-show")):(this._container.setAttribute("open",""),this._container.classList.contains("maplibregl-compact")&&this._container.classList.remove("maplibregl-compact","maplibregl-compact-show"));},this._updateCompactMinimize=()=>{this._container.classList.contains("maplibregl-compact")&&this._container.classList.contains("maplibregl-compact-show")&&this._container.classList.remove("maplibregl-compact-show");},this.options=e;}getDefaultPosition(){return "bottom-right"}onAdd(e){return this._map=e,this._compact=this.options.compact,this._container=d.create("details","maplibregl-ctrl maplibregl-ctrl-attrib"),this._compactButton=d.create("summary","maplibregl-ctrl-attrib-button",this._container),this._compactButton.addEventListener("click",this._toggleAttribution),this._setElementTitle(this._compactButton,"ToggleAttribution"),this._innerContainer=d.create("div","maplibregl-ctrl-attrib-inner",this._container),this._updateAttributions(),this._updateCompact(),this._map.on("styledata",this._updateData),this._map.on("sourcedata",this._updateData),this._map.on("terrain",this._updateData),this._map.on("resize",this._updateCompact),this._map.on("drag",this._updateCompactMinimize),this._container}onRemove(){this._container.remove(),this._map.off("styledata",this._updateData),this._map.off("sourcedata",this._updateData),this._map.off("terrain",this._updateData),this._map.off("resize",this._updateCompact),this._map.off("drag",this._updateCompactMinimize),this._map=void 0,this._compact=void 0,this._attribHTML=void 0;}_setElementTitle(e,t){const i=this._map._getUIString(`AttributionControl.${t}`);e.title=i,e.setAttribute("aria-label",i);}_updateAttributions(){if(!this._map.style)return;let e=[];if(this.options.customAttribution&&(Array.isArray(this.options.customAttribution)?e=e.concat(this.options.customAttribution.map((e=>"string"!=typeof e?"":e))):"string"==typeof this.options.customAttribution&&e.push(this.options.customAttribution)),this._map.style.stylesheet){const e=this._map.style.stylesheet;this.styleOwner=e.owner,this.styleId=e.id;}const t=this._map.style.tileManagers;for(const i in t){const o=t[i];if(o.used||o.usedForTerrain){const t=o.getSource();t.attribution&&!e.includes(t.attribution)&&e.push(t.attribution);}}e=e.filter((e=>String(e).trim())),e.sort(((e,t)=>e.length-t.length)),e=e.filter(((t,i)=>{for(let o=i+1;o{const e=this._container.children;if(e.length){const t=e[0];this._map.getCanvasContainer().offsetWidth<=640||this._compact?!1!==this._compact&&t.classList.add("maplibregl-compact"):t.classList.remove("maplibregl-compact");}},this.options=e;}getDefaultPosition(){return "bottom-left"}onAdd(e){var t;this._map=e,this._compact=null===(t=this.options)||void 0===t?void 0:t.compact,this._container=d.create("div","maplibregl-ctrl");const i=d.create("a","maplibregl-ctrl-logo");return i.target="_blank",i.rel="noopener nofollow",i.href="https://maplibre.org/",i.setAttribute("aria-label",this._map._getUIString("LogoControl.Title")),i.setAttribute("rel","noopener nofollow"),this._container.appendChild(i),this._container.style.display="block",this._map.on("resize",this._updateCompact),this._updateCompact(),this._container}onRemove(){this._container.remove(),this._map.off("resize",this._updateCompact),this._map=void 0,this._compact=void 0;}}class is{constructor(){this._queue=[],this._id=0,this._cleared=!1,this._currentlyRunning=!1;}add(e){const t=++this._id;return this._queue.push({callback:e,id:t,cancelled:!1}),t}remove(e){const t=this._currentlyRunning,i=t?this._queue.concat(t):this._queue;for(const t of i)if(t.id===e)return void(t.cancelled=!0)}run(e=0){if(this._currentlyRunning)throw new Error("Attempting to run(), but is already running.");const t=this._currentlyRunning=this._queue;this._queue=[];for(const i of t)if(!i.cancelled&&(i.callback(e),this._cleared))break;this._cleared=!1,this._currentlyRunning=!1;}clear(){this._currentlyRunning&&(this._cleared=!0),this._queue=[];}}var os=t.aS([{name:"a_pos3d",type:"Int16",components:3}]);class as extends t.E{constructor(e){super(),this._lastTilesetChange=c(),this.tileManager=e,this._tiles={},this._renderableTilesKeys=[],this._sourceTileCache={},this.minzoom=0,this.maxzoom=22,this.deltaZoom=1,this.tileSize=e._source.tileSize*2**this.deltaZoom,e.usedForTerrain=!0,e.tileSize=this.tileSize;}destruct(){this.tileManager.usedForTerrain=!1,this.tileManager.tileSize=null;}getSource(){return this.tileManager._source}update(e,i){this.tileManager.update(e,i),this._renderableTilesKeys=[];const o={};for(const a of Me(e,{tileSize:this.tileSize,minzoom:this.minzoom,maxzoom:this.maxzoom,reparseOverscaled:!1,terrain:i,calculateTileZoom:this.tileManager._source.calculateTileZoom}))o[a.key]=!0,this._renderableTilesKeys.push(a.key),this._tiles[a.key]||(a.terrainRttPosMatrix32f=new Float64Array(16),t.c7(a.terrainRttPosMatrix32f,0,t.a6,t.a6,0,0,1),this._tiles[a.key]=new de(a,this.tileSize),this._lastTilesetChange=c());for(const e in this._tiles)o[e]||delete this._tiles[e];}freeRtt(e){for(const t in this._tiles){const i=this._tiles[t];(!e||i.tileID.equals(e)||i.tileID.isChildOf(e)||e.isChildOf(i.tileID))&&(i.rtt=[]);}}getRenderableTiles(){return this._renderableTilesKeys.map((e=>this.getTileByID(e)))}getTileByID(e){return this._tiles[e]}getTerrainCoords(e,t){return t?this._getTerrainCoordsForTileRanges(e,t):this._getTerrainCoordsForRegularTile(e)}_getTerrainCoordsForRegularTile(e){const i={};for(const o of this._renderableTilesKeys){const a=this._tiles[o].tileID,r=e.clone(),s=t.bj();if(a.canonical.equals(e.canonical))t.c7(s,0,t.a6,t.a6,0,0,1);else if(a.canonical.isChildOf(e.canonical)){const i=a.canonical.z-e.canonical.z,o=a.canonical.x-(a.canonical.x>>i<>i<>i;t.c7(s,0,n,n,0,0,1),t.Q(s,s,[-o*n,-r*n,0]);}else {if(!e.canonical.isChildOf(a.canonical))continue;{const i=e.canonical.z-a.canonical.z,o=e.canonical.x-(e.canonical.x>>i<>i<>i;t.c7(s,0,t.a6,t.a6,0,0,1),t.Q(s,s,[o*n,r*n,0]),t.S(s,s,[1/2**i,1/2**i,0]);}}r.terrainRttPosMatrix32f=new Float32Array(s),i[o]=r;}return i}_getTerrainCoordsForTileRanges(e,i){const o={};for(const a of this._renderableTilesKeys){const r=this._tiles[a].tileID;if(!this._isWithinTileRanges(r,i))continue;const s=e.clone(),n=t.bj();if(r.canonical.z===e.canonical.z){const i=e.canonical.x-r.canonical.x+e.wrap*(1<e.canonical.z){const i=r.canonical.z-e.canonical.z,o=r.canonical.x-(r.canonical.x>>i<>i<>i),l=e.canonical.y-(r.canonical.y>>i),c=t.a6>>i;t.c7(n,0,c,c,0,0,1),t.Q(n,n,[-o*c+s*t.a6,-a*c+l*t.a6,0]);}else {const i=e.canonical.z-r.canonical.z,o=e.canonical.x-(e.canonical.x>>i<>i<>i)-r.canonical.x,l=(e.canonical.y>>i)-r.canonical.y,c=t.a6<a.maxzoom&&(r=a.maxzoom),r=a.minzoom&&!(null==s?void 0:s.dem);)s=this.findTileInCaches(e.scaledTo(r--).key);return s}findTileInCaches(e){let t=this.tileManager.getTileByID(e);return t||(t=this.tileManager._outOfViewCache.getByKey(e),t)}anyTilesAfterTime(e=Date.now()){return this._lastTilesetChange>=e}_isWithinTileRanges(e,t){const i=t[e.canonical.z];return !!i&&(e.wrap>i.minWrap||e.wrap=i.minTileXWrapped&&e.canonical.x<=i.maxTileXWrapped&&e.canonical.y>=i.minTileY&&e.canonical.y<=i.maxTileY)}}class rs{constructor(e,t,i){this._meshCache={},this.painter=e,this.tileManager=new as(t),this.options=i,this.exaggeration="number"==typeof i.exaggeration?i.exaggeration:1,this.qualityFactor=2,this.meshSize=128,this._demMatrixCache={},this.coordsIndex=[],this._coordsTextureSize=1024;}destroy(){this._fbo&&(this._fbo.destroy(),this._fbo=null),this._fboCoordsTexture&&(this._fboCoordsTexture.destroy(),this._fboCoordsTexture=null),this._fboDepthTexture&&(this._fboDepthTexture.destroy(),this._fboDepthTexture=null),this._emptyDemTexture&&(this._emptyDemTexture.destroy(),this._emptyDemTexture=null),this._emptyDepthTexture&&(this._emptyDepthTexture.destroy(),this._emptyDepthTexture=null),this._coordsTexture&&(this._coordsTexture.destroy(),this._coordsTexture=null);for(const e in this._meshCache)this._meshCache[e].destroy();this._meshCache={},this.tileManager.destruct();}getDEMElevation(e,i,o,a=t.a6){var r;const s=e.normalizeCoordinates(i,o,a);if(!s)return 0;const n=this.getTerrainData(s.tileID),l=null===(r=n.tile)||void 0===r?void 0:r.dem;if(!l)return 0;const c=t.cC([],[s.x/a*t.a6,s.y/a*t.a6],n.u_terrain_matrix),h=[c[0]*l.dim,c[1]*l.dim],u=Math.floor(h[0]),d=Math.floor(h[1]),_=h[0]-u,p=h[1]-d;return l.get(u,d)*(1-_)*(1-p)+l.get(u+1,d)*_*(1-p)+l.get(u,d+1)*(1-_)*p+l.get(u+1,d+1)*_*p}getElevationForLngLatZoom(e,i){if(!t.cD(i,e.wrap()))return 0;const{tileID:o,mercatorX:a,mercatorY:r}=this._getOverscaledTileIDFromLngLatZoom(e,i);return this.getElevation(o,a%t.a6,r%t.a6,t.a6)}getElevationForLngLat(e,t){const i=Me(t,{maxzoom:this.tileManager.maxzoom,minzoom:this.tileManager.minzoom,tileSize:512,terrain:this});let o=0;for(const e of i)e.canonical.z>o&&(o=Math.min(e.canonical.z,this.tileManager.maxzoom));return this.getElevationForLngLatZoom(e,o)}getElevation(e,i,o,a=t.a6){return this.getDEMElevation(e,i,o,a)*this.exaggeration}getTerrainData(e){var i,o;if(!this._emptyDemTexture){const e=this.painter.context,i=new t.R({width:1,height:1},new Uint8Array(4));this._emptyDepthTexture=new t.T(e,i,e.gl.RGBA,{premultiply:!1}),this._emptyDemUnpack=[0,0,0,0],this._emptyDemTexture=new t.T(e,new t.R({width:1,height:1}),e.gl.RGBA,{premultiply:!1}),this._emptyDemTexture.bind(e.gl.NEAREST,e.gl.CLAMP_TO_EDGE),this._emptyDemMatrix=t.ap([]);}const a=this.tileManager.getSourceTile(e,!0);if((null==a?void 0:a.dem)&&(!a.demTexture||a.needsTerrainPrepare)){const e=this.painter.context;a.demTexture=this.painter.getTileTexture(a.dem.stride),a.demTexture?a.demTexture.update(a.dem.getPixels(),{premultiply:!1}):a.demTexture=new t.T(e,a.dem.getPixels(),e.gl.RGBA,{premultiply:!1}),a.demTexture.bind(e.gl.NEAREST,e.gl.CLAMP_TO_EDGE),a.needsTerrainPrepare=!1;}const r=a&&a.toString()+a.tileID.key+e.key;if(r&&!this._demMatrixCache[r]){const i=this.tileManager.getSource().maxzoom;let o=e.canonical.z-a.tileID.canonical.z;e.overscaledZ>e.canonical.z&&(e.canonical.z>=i?o=e.canonical.z-i:t.w("cannot calculate elevation if elevation maxzoom > source.maxzoom"));const s=e.canonical.x-(e.canonical.x>>o<>o<>8<<4|e>>8,i[t+3]=0;const o=new t.R({width:this._coordsTextureSize,height:this._coordsTextureSize},new Uint8Array(i.buffer)),a=new t.T(e,o,e.gl.RGBA,{premultiply:!1});return a.bind(e.gl.NEAREST,e.gl.CLAMP_TO_EDGE),this._coordsTexture=a,a}pointCoordinate(e){this.painter.maybeDrawDepth(!0),this.painter.maybeDrawCoords();const i=new Uint8Array(4),o=this.painter.context,a=o.gl,r=Math.round(e.x*this.painter.pixelRatio/devicePixelRatio),s=Math.round(e.y*this.painter.pixelRatio/devicePixelRatio),n=Math.round(this.painter.height/devicePixelRatio);o.bindFramebuffer.set(this.getFramebuffer("coords").framebuffer),a.readPixels(r,n-s-1,1,1,a.RGBA,a.UNSIGNED_BYTE,i),o.bindFramebuffer.set(null);const l=i[0]+(i[2]>>4<<8),c=i[1]+((15&i[2])<<8),h=this.coordsIndex[255-i[3]],u=h&&this.tileManager.getTileByID(h);if(!u)return null;const d=this._coordsTextureSize,_=(1<0,a=o&&0===e.canonical.y,r=o&&e.canonical.y===(1<e.id!==t)),this._recentlyUsed.push(e.id);}stampObject(e){e.stamp=++this._stamp;}getOrCreateFreeObject(){for(const e of this._recentlyUsed)if(!this._objects[e].inUse)return this._objects[e];if(this._objects.length>=this._size)throw new Error("No free RenderPool available, call freeAllObjects() required!");const e=this._createObject(this._objects.length);return this._objects.push(e),e}freeObject(e){e.inUse=!1;}freeAllObjects(){for(const e of this._objects)this.freeObject(e);}isFull(){return !(this._objects.length!e.inUse))}}const ns={background:!0,fill:!0,line:!0,raster:!0,hillshade:!0,"color-relief":!0};class ls{constructor(e,t){this.painter=e,this.terrain=t,this.pool=new ss(e.context,30,t.tileManager.tileSize*t.qualityFactor);}destruct(){this.pool.destruct();}getTexture(e){return this.pool.getObjectForId(e.rtt[this._stacks.length-1].id).texture}prepareForRender(e,t){var i,o,a;this._stacks=[],this._prevType=null,this._rttTiles=[],this._renderableTiles=this.terrain.tileManager.getRenderableTiles(),this._renderableLayerIds=e._order.filter((i=>!e._layers[i].isHidden(t))),this._coordsAscending={};for(const t in e.tileManagers){this._coordsAscending[t]={};const i=e.tileManagers[t].getVisibleCoordinates(),o=e.tileManagers[t].getSource(),r=o instanceof te?o.terrainTileRanges:null;for(const e of i){const i=this.terrain.tileManager.getTerrainCoords(e,r);for(const e in i)(a=this._coordsAscending[t])[e]||(a[e]=[]),this._coordsAscending[t][e].push(i[e]);}}this._rttFingerprints={};for(const t of e._order){const a=e._layers[t],r=a.source;if(ns[a.type]&&!this._rttFingerprints[r]){this._rttFingerprints[r]={};const t=null!==(o=null===(i=e.tileManagers[r])||void 0===i?void 0:i.getState().revision)&&void 0!==o?o:0;for(const e in this._coordsAscending[r])this._rttFingerprints[r][e]=`${this._coordsAscending[r][e].map((e=>e.key)).sort().join()}#${t}`;}}for(const e of this._renderableTiles)for(const t in this._rttFingerprints){const i=this._rttFingerprints[t][e.tileID.key];i&&i!==e.rttFingerprint[t]&&(e.rtt=[]);}}renderLayer(e,i){if(e.isHidden(this.painter.transform.zoom))return !1;const o=Object.assign(Object.assign({},i),{isRenderingToTexture:!0}),a=e.type,r=this.painter,s=this._renderableLayerIds[this._renderableLayerIds.length-1]===e.id;if(ns[a]&&(this._prevType&&ns[this._prevType]||this._stacks.push([]),this._prevType=a,this._stacks[this._stacks.length-1].push(e.id),!s))return !0;if(ns[this._prevType]||ns[a]&&s){this._prevType=a;const e=this._stacks.length-1,i=this._stacks[e]||[];for(const a of this._renderableTiles){if(this.pool.isFull()&&(qa(this.painter,this.terrain,this._rttTiles,o),this._rttTiles=[],this.pool.freeAllObjects()),this._rttTiles.push(a),a.rtt[e]){const t=this.pool.getObjectForId(a.rtt[e].id);if(t.stamp===a.rtt[e].stamp){this.pool.useObject(t);continue}}const s=this.pool.getOrCreateFreeObject();this.pool.useObject(s),this.pool.stampObject(s),a.rtt[e]={id:s.id,stamp:s.stamp},r.context.bindFramebuffer.set(s.fbo.framebuffer),r.context.clear({color:t.bo.transparent,stencil:0}),r.currentStencilSource=void 0;for(const e of i){const t=r.style._layers[e],i=t.source?this._coordsAscending[t.source][a.tileID.key]:[a.tileID];r.context.viewport.set([0,0,s.fbo.width,s.fbo.height]),r._renderTileClippingMasks(t,i,!0),r.renderLayer(r,r.style.tileManagers[t.source],t,i,o),t.source&&(a.rttFingerprint[t.source]=this._rttFingerprints[t.source][a.tileID.key]);}}return qa(this.painter,this.terrain,this._rttTiles,o),this._rttTiles=[],this.pool.freeAllObjects(),ns[a]}return !1}}const cs={"AttributionControl.ToggleAttribution":"Toggle attribution","AttributionControl.MapFeedback":"Map feedback","FullscreenControl.Enter":"Enter fullscreen","FullscreenControl.Exit":"Exit fullscreen","GeolocateControl.FindMyLocation":"Find my location","GeolocateControl.LocationNotAvailable":"Location not available","LogoControl.Title":"MapLibre logo","Map.Title":"Map","Marker.Title":"Map marker","NavigationControl.ResetBearing":"Drag to rotate map, click to reset north","NavigationControl.ZoomIn":"Zoom in","NavigationControl.ZoomOut":"Zoom out","Popup.Close":"Close popup","ScaleControl.Feet":"ft","ScaleControl.Meters":"m","ScaleControl.Kilometers":"km","ScaleControl.Miles":"mi","ScaleControl.NauticalMiles":"nm","GlobeControl.Enable":"Enable globe","GlobeControl.Disable":"Disable globe","TerrainControl.Enable":"Enable terrain","TerrainControl.Disable":"Disable terrain","CooperativeGesturesHandler.WindowsHelpText":"Use Ctrl + scroll to zoom the map","CooperativeGesturesHandler.MacHelpText":"Use ⌘ + scroll to zoom the map","CooperativeGesturesHandler.MobileHelpText":"Use two fingers to move the map"},hs=i,us={hash:!1,interactive:!0,bearingSnap:7,zoomSnap:0,attributionControl:Jr,maplibreLogo:!1,refreshExpiredTiles:!0,canvasContextAttributes:{antialias:!1,preserveDrawingBuffer:!1,powerPreference:"high-performance",failIfMajorPerformanceCaveat:!1,desynchronized:!1,contextType:void 0},scrollZoom:!0,minZoom:-2,maxZoom:22,minPitch:0,maxPitch:60,boxZoom:!0,dragRotate:!0,dragPan:!0,keyboard:!0,doubleClickZoom:!0,touchZoomRotate:!0,touchPitch:!0,cooperativeGestures:!1,trackResize:!0,center:[0,0],elevation:0,zoom:0,bearing:0,pitch:0,roll:0,renderWorldCopies:!0,maxTileCacheSize:null,maxTileCacheZoomLevels:t.c.MAX_TILE_CACHE_ZOOM_LEVELS,transformRequest:null,transformCameraUpdate:null,transformConstrain:null,fadeDuration:300,crossSourceCollisions:!0,clickTolerance:3,localIdeographFontFamily:"sans-serif",pitchWithRotate:!0,rollEnabled:!1,reduceMotion:void 0,validateStyle:!0,maxCanvasSize:[4096,4096],cancelPendingTileRequestsWhileZooming:!0,centerClampedToGround:!0,experimentalZoomLevelsToOverscale:void 0,anisotropicFilterPitch:20};let ds=class extends Qr{get _ownerWindow(){var e,t;return (null===(t=null===(e=this._container)||void 0===e?void 0:e.ownerDocument)||void 0===t?void 0:t.defaultView)||window}constructor(e){var i,o,a;const r=Object.assign(Object.assign(Object.assign({},us),e),{canvasContextAttributes:Object.assign(Object.assign({},us.canvasContextAttributes),e.canvasContextAttributes)});if(null!=r.minZoom&&null!=r.maxZoom&&r.minZoom>r.maxZoom)throw new Error("maxZoom must be greater than or equal to minZoom");if(null!=r.minPitch&&null!=r.maxPitch&&r.minPitch>r.maxPitch)throw new Error("maxPitch must be greater than or equal to minPitch");if(null!=r.minPitch&&r.minPitch<0)throw new Error("minPitch must be greater than or equal to 0");if(null!=r.maxPitch&&r.maxPitch>180)throw new Error("maxPitch must be less than or equal to 180");const s=new Xt,l=new Jt;void 0!==r.minZoom&&s.setMinZoom(r.minZoom),void 0!==r.maxZoom&&s.setMaxZoom(r.maxZoom),void 0!==r.minPitch&&s.setMinPitch(r.minPitch),void 0!==r.maxPitch&&s.setMaxPitch(r.maxPitch),void 0!==r.renderWorldCopies&&s.setRenderWorldCopies(r.renderWorldCopies),null!==r.transformConstrain&&s.setConstrainOverride(r.transformConstrain),super(s,l,{bearingSnap:r.bearingSnap,zoomSnap:r.zoomSnap}),this._idleTriggered=!1,this._crossFadingFactor=1,this._renderTaskQueue=new is,this._controls=[],this._mapId=t.ad(),this._lostContextStyle={style:null,images:null},this._contextLost=e=>{if(e.preventDefault(),this._frameRequest&&(this._frameRequest.abort(),this._frameRequest=null),this.painter.destroy(),this._lostContextStyle=this._getStyleAndImages(),this.style){for(const e of Object.values(this.style._layers))if("custom"===e.type&&console.warn(`Custom layer with id '${e.id}' cannot be restored after WebGL context loss. You will need to re-add it manually after context restoration.`),e._listeners)for(const[t]of Object.entries(e._listeners))console.warn(`Custom layer with id '${e.id}' had event listeners for event '${t}' which cannot be restored after WebGL context loss. You will need to re-add them manually after context restoration.`);this.style.destroy(),this.style=null,this.fire(new t.n("webglcontextlost",{originalEvent:e}));}else this.fire(new t.n("webglcontextlost",{originalEvent:e}));},this._contextRestored=e=>{this._lostContextStyle.style&&this.setStyle(this._lostContextStyle.style,{diff:!1}),this._lostContextStyle.images&&this.style&&(this.style.imageManager.images=this._lostContextStyle.images),this._lostContextStyle={style:null,images:null},this._setupPainter(),this.resize(),this._update(),this._resizeInternal(),this.fire(new t.n("webglcontextrestored",{originalEvent:e}));},this._onMapScroll=e=>{if(e.target===this._container)return this._container.scrollTop=0,this._container.scrollLeft=0,!1},this._onWindowOnline=()=>{this._update();},this._interactive=r.interactive,this._maxTileCacheSize=r.maxTileCacheSize,this._maxTileCacheZoomLevels=r.maxTileCacheZoomLevels,this._canvasContextAttributes=Object.assign({},r.canvasContextAttributes),this._trackResize=!0===r.trackResize,this._bearingSnap=r.bearingSnap,this._zoomSnap=r.zoomSnap,this._centerClampedToGround=r.centerClampedToGround,this._refreshExpiredTiles=!0===r.refreshExpiredTiles,this._fadeDuration=r.fadeDuration,this._crossSourceCollisions=!0===r.crossSourceCollisions,this._collectResourceTiming=!0===r.collectResourceTiming,this._locale=Object.assign(Object.assign({},cs),r.locale),this._clickTolerance=r.clickTolerance,this._overridePixelRatio=r.pixelRatio,this._maxCanvasSize=r.maxCanvasSize,this._zoomLevelsToOverscale=r.experimentalZoomLevelsToOverscale,this.transformCameraUpdate=r.transformCameraUpdate,this.transformConstrain=r.transformConstrain,this.cancelPendingTileRequestsWhileZooming=!0===r.cancelPendingTileRequestsWhileZooming,this.setAnisotropicFilterPitch(r.anisotropicFilterPitch),void 0!==r.reduceMotion&&(n.prefersReducedMotion=r.reduceMotion),this._imageQueueHandle=u.addThrottleControl((()=>this.isMoving())),this._requestManager=new _(r.transformRequest),this._container=this._resolveContainer(r.container),r.maxBounds&&this.setMaxBounds(r.maxBounds),this._setupContainer(),this._setupPainter(),this.on("move",(()=>this._update(!1))),this.on("moveend",(()=>this._update(!1))),this.on("zoom",(()=>this._update(!0))),this.on("terrain",(()=>{this.painter.terrainFacilitator.depthDirty=!0,this._update(!0);})),this.once("idle",(()=>this._idleTriggered=!0)),"undefined"!=typeof window&&(this._ownerWindow.addEventListener("online",this._onWindowOnline,!1),this._setupResizeObserver()),this.handlers=new Yr(this,r),this._hash=r.hash?new Ya("string"==typeof r.hash&&r.hash||void 0).addTo(this):void 0,(null===(i=this._hash)||void 0===i?void 0:i._onHashChange())||(this.jumpTo({center:r.center,elevation:r.elevation,zoom:r.zoom,bearing:r.bearing,pitch:r.pitch,roll:r.roll}),r.bounds&&(this.resize(),this.fitBounds(r.bounds,t.e({},r.fitBoundsOptions,{duration:0}))));const c="string"==typeof r.style||!("globe"===(null===(a=null===(o=r.style)||void 0===o?void 0:o.projection)||void 0===a?void 0:a.type));this.resize(null,c),this._localIdeographFontFamily=r.localIdeographFontFamily,this._validateStyle=r.validateStyle,r.style&&this.setStyle(r.style,{localIdeographFontFamily:r.localIdeographFontFamily}),r.attributionControl&&this.addControl(new es("boolean"==typeof r.attributionControl?void 0:r.attributionControl)),r.maplibreLogo&&this.addControl(new ts,r.logoPosition),this.on("style.load",(()=>{if(c||this._resizeTransform(),this.transform.unmodified){const e=t.V(this.style.stylesheet,["center","zoom","bearing","pitch","roll"]);this.jumpTo(e);}})),this.on("data",(e=>{this._update("style"===e.dataType),this.fire(new t.n(`${e.dataType}data`,e));})),this.on("dataloading",(e=>{this.fire(new t.n(`${e.dataType}dataloading`,e));})),this.on("dataabort",(e=>{this.fire(new t.n("sourcedataabort",e));}));}_getMapId(){return this._mapId}setGlobalStateProperty(e,t){return this.style.setGlobalStateProperty(e,t),this._update(!0)}getGlobalState(){return this.style.getGlobalState()}addControl(e,i){if(void 0===i&&(i=e.getDefaultPosition?e.getDefaultPosition():"top-right"),!(null==e?void 0:e.onAdd))return this.fire(new t.l(new Error("Invalid argument to map.addControl(). Argument must be a control with onAdd and onRemove methods.")));const o=e.onAdd(this);this._controls.push(e);const a=this._controlPositions[i];return i.includes("bottom")?a.insertBefore(o,a.firstChild):a.appendChild(o),this}removeControl(e){if(!(null==e?void 0:e.onRemove))return this.fire(new t.l(new Error("Invalid argument to map.removeControl(). Argument must be a control with onAdd and onRemove methods.")));const i=this._controls.indexOf(e);return i>-1&&this._controls.splice(i,1),e.onRemove(this),this}hasControl(e){return this._controls.includes(e)}coveringTiles(e){return Me(this.transform,e)}calculateCameraOptionsFromTo(e,t,i,o){return null==o&&this.terrain&&(o=this.terrain.getElevationForLngLat(i,this.transform)),super.calculateCameraOptionsFromTo(e,t,i,o)}resize(e,i=!0){if(null!==this._lostContextStyle.style)return this;this._resizeInternal(i);const o=!this._moving;return o&&(this.stop(),this.fire(new t.n("movestart",e)).fire(new t.n("move",e))),this.fire(new t.n("resize",e)),o&&this.fire(new t.n("moveend",e)),this}_resizeInternal(e=!0){const[t,i]=this._containerDimensions(),o=this._getClampedPixelRatio(t,i);if(this._resizeCanvas(t,i,o),this.painter.resize(t,i,o),this.painter.overLimit()){const e=this.painter.context.gl;this._maxCanvasSize=[e.drawingBufferWidth,e.drawingBufferHeight];const o=this._getClampedPixelRatio(t,i);this._resizeCanvas(t,i,o),this.painter.resize(t,i,o);}this._resizeTransform(e);}_resizeTransform(e=!0){var t;const[i,o]=this._containerDimensions();this.transform.resize(i,o,e),null===(t=this._requestedCameraState)||void 0===t||t.resize(i,o,e);}_getClampedPixelRatio(e,t){const{0:i,1:o}=this._maxCanvasSize,a=this.getPixelRatio(),r=e*a,s=t*a;return Math.min(r>i?i/r:1,s>o?o/s:1)*a}getPixelRatio(){var e;return null!==(e=this._overridePixelRatio)&&void 0!==e?e:devicePixelRatio}setPixelRatio(e){this._overridePixelRatio=e,this.resize();}getBounds(){return this.transform.getBounds()}getMaxBounds(){return this.transform.getMaxBounds()}setMaxBounds(e){return this.transform.setMaxBounds(G.convert(e)),this._update()}setMinZoom(e){if((e=null==e?-2:e)>=-2&&e<=this.transform.maxZoom){const i=this.transform.zoom,o=this._getTransformForUpdate();return o.setMinZoom(e),this._applyUpdatedTransform(o),this._update(),i!==this.transform.zoom&&this.fire(new t.n("zoomstart")).fire(new t.n("zoom")).fire(new t.n("zoomend")).fire(new t.n("movestart")).fire(new t.n("move")).fire(new t.n("moveend")),this}throw new Error("minZoom must be between -2 and the current maxZoom, inclusive")}getMinZoom(){return this.transform.minZoom}setMaxZoom(e){if((e=null==e?22:e)>=this.transform.minZoom){const i=this.transform.zoom,o=this._getTransformForUpdate();return o.setMaxZoom(e),this._applyUpdatedTransform(o),this._update(),i!==this.transform.zoom&&this.fire(new t.n("zoomstart")).fire(new t.n("zoom")).fire(new t.n("zoomend")).fire(new t.n("movestart")).fire(new t.n("move")).fire(new t.n("moveend")),this}throw new Error("maxZoom must be greater than the current minZoom")}getMaxZoom(){return this.transform.maxZoom}setMinPitch(e){if((e=null==e?0:e)<0)throw new Error("minPitch must be greater than or equal to 0");if(e>=0&&e<=this.transform.maxPitch){const i=this.transform.pitch,o=this._getTransformForUpdate();return o.setMinPitch(e),this._applyUpdatedTransform(o),this._update(),i!==this.transform.pitch&&this.fire(new t.n("pitchstart")).fire(new t.n("pitch")).fire(new t.n("pitchend")).fire(new t.n("movestart")).fire(new t.n("move")).fire(new t.n("moveend")),this}throw new Error("minPitch must be between 0 and the current maxPitch, inclusive")}getMinPitch(){return this.transform.minPitch}setMaxPitch(e){if((e=null==e?60:e)>180)throw new Error("maxPitch must be less than or equal to 180");if(e>=this.transform.minPitch){const i=this.transform.pitch,o=this._getTransformForUpdate();return o.setMaxPitch(e),this._applyUpdatedTransform(o),this._update(),i!==this.transform.pitch&&this.fire(new t.n("pitchstart")).fire(new t.n("pitch")).fire(new t.n("pitchend")).fire(new t.n("movestart")).fire(new t.n("move")).fire(new t.n("moveend")),this}throw new Error("maxPitch must be greater than the current minPitch")}getMaxPitch(){return this.transform.maxPitch}getAnisotropicFilterPitch(){return this._anisotropicFilterPitch}setAnisotropicFilterPitch(e){if((e=null==e?20:e)>180)throw new Error("anisotropicFilterPitch must be less than or equal to 180");if(e<0)throw new Error("anisotropicFilterPitch must be greater than or equal to 0");return this._anisotropicFilterPitch=e,this._update()}getRenderWorldCopies(){return this.transform.renderWorldCopies}setRenderWorldCopies(e){return this.transform.setRenderWorldCopies(e),this._update()}setTransformConstrain(e){return this.transform.setConstrainOverride(e),this._update()}project(e){return this.transform.locationToScreenPoint(t.W.convert(e),this.style&&this.terrain)}unproject(e){return this.transform.screenPointToLocation(t.P.convert(e),this.terrain)}isMoving(){var e;return this._moving||(null===(e=this.handlers)||void 0===e?void 0:e.isMoving())}isZooming(){var e;return this._zooming||(null===(e=this.handlers)||void 0===e?void 0:e.isZooming())}isRotating(){var e;return this._rotating||(null===(e=this.handlers)||void 0===e?void 0:e.isRotating())}_createDelegatedListener(e,t,i){if("mouseenter"===e||"mouseover"===e){let o=!1;const a=a=>{const r=t.filter((e=>this.getLayer(e))),s=0!==r.length?this.queryRenderedFeatures(a.point,{layers:r}):[];s.length?o||(o=!0,i.call(this,new nr(e,this,a.originalEvent,{features:s}))):o=!1;};return {layers:t,listener:i,delegates:{mousemove:a,mouseout:()=>{o=!1;}}}}if("mouseleave"===e||"mouseout"===e){let o=!1;const a=a=>{const r=t.filter((e=>this.getLayer(e)));(0!==r.length?this.queryRenderedFeatures(a.point,{layers:r}):[]).length?o=!0:o&&(o=!1,i.call(this,new nr(e,this,a.originalEvent)));},r=t=>{o&&(o=!1,i.call(this,new nr(e,this,t.originalEvent)));};return {layers:t,listener:i,delegates:{mousemove:a,mouseout:r}}}{const o=e=>{const o=t.filter((e=>this.getLayer(e))),a=0!==o.length?this.queryRenderedFeatures(e.point,{layers:o}):[];a.length&&(e.features=a,i.call(this,e),delete e.features);};return {layers:t,listener:i,delegates:{[e]:o}}}}_saveDelegatedListener(e,t){var i;this._delegatedListeners||(this._delegatedListeners={}),(i=this._delegatedListeners)[e]||(i[e]=[]),this._delegatedListeners[e].push(t);}_removeDelegatedListener(e,t,i){var o;if(!(null===(o=this._delegatedListeners)||void 0===o?void 0:o[e]))return;const a=this._delegatedListeners[e];for(let e=0;et.includes(e)))){for(const e in o.delegates)this.off(e,o.delegates[e]);return void a.splice(e,1)}}}on(e,t,i){if(void 0===i)return super.on(e,t);const o="string"==typeof t?[t]:t,a=this._createDelegatedListener(e,o,i);this._saveDelegatedListener(e,a);for(const e in a.delegates)this.on(e,a.delegates[e]);return {unsubscribe:()=>{this._removeDelegatedListener(e,o,i);}}}once(e,t,i){if(void 0===i)return super.once(e,t);const o="string"==typeof t?[t]:t,a=this._createDelegatedListener(e,o,i);for(const t in a.delegates){const r=a.delegates[t];a.delegates[t]=(...t)=>{this._removeDelegatedListener(e,o,i),r(...t);};}this._saveDelegatedListener(e,a);for(const e in a.delegates)this.once(e,a.delegates[e]);return this}off(e,t,i){return void 0===i?super.off(e,t):(this._removeDelegatedListener(e,"string"==typeof t?[t]:t,i),this)}queryRenderedFeatures(e,i){if(!this.style)return [];let o;const a=e instanceof t.P||Array.isArray(e),r=a?e:[[0,0],[this.transform.width,this.transform.height]];if(i||(i=(a?{}:e)||{}),r instanceof t.P||"number"==typeof r[0])o=[t.P.convert(r)];else {const e=t.P.convert(r[0]),i=t.P.convert(r[1]);o=[e,new t.P(i.x,e.y),i,new t.P(e.x,i.y),e];}return this.style.queryRenderedFeatures(o,i,this.transform)}querySourceFeatures(e,t){return this.style.querySourceFeatures(e,t)}setStyle(e,i){return !1!==(i=t.e({},{localIdeographFontFamily:this._localIdeographFontFamily,validate:this._validateStyle},i)).diff&&i.localIdeographFontFamily===this._localIdeographFontFamily&&this.style&&e?(this._diffStyle(e,i),this):(this._localIdeographFontFamily=i.localIdeographFontFamily,this._updateStyle(e,i))}setTransformRequest(e){return this._requestManager.setTransformRequest(e),this}_getUIString(e){const t=this._locale[e];if(null==t)throw new Error(`Missing UI string '${e}'`);return t}_updateStyle(e,t){var i,o,a;if(null===(i=this._diffStyleRequest)||void 0===i||i.abort(),this._diffStyleRequest=null,t.transformStyle&&this.style&&!this.style._loaded)return void this.style.once("style.load",(()=>this._updateStyle(e,t)));const r=this.style&&t.transformStyle?this.style.serialize():void 0;return this.style&&(this.style.setEventedParent(null),this.style._remove(!e)),e?(this.style=new ki(this,t||{}),this.style.setEventedParent(this,{style:this.style}),"string"==typeof e?this.style.loadURL(e,t,r):this.style.loadJSON(e,t,r),this):(this._frameRequest&&(this._frameRequest.abort(),this._frameRequest=null),null===(a=null===(o=this.style)||void 0===o?void 0:o.projection)||void 0===a||a.destroy(),delete this.style,this)}_lazyInitEmptyStyle(){this.style||(this.style=new ki(this,{}),this.style.setEventedParent(this,{style:this.style}),this.style.loadEmpty());}_diffStyle(e,i){return t._(this,void 0,void 0,(function*(){var o;if(null===(o=this._diffStyleRequest)||void 0===o||o.abort(),"string"==typeof e){const o=e;this._diffStyleRequest=new AbortController;const a=this._diffStyleRequest;try{const e=yield this._requestManager.transformRequest(o,"Style");if(a.signal.aborted)return void(this._diffStyleRequest=null);const r=yield t.k(e,a);this._diffStyleRequest=null,this._updateDiff(r.data,i);}catch(e){this._diffStyleRequest=null,t.$(e)||this.fire(new t.l(t.d(e)));}}else "object"==typeof e&&(this._diffStyleRequest=null,this._updateDiff(e,i));}))}_updateDiff(e,i){try{this.style.setState(e,i)&&this._update(!0);}catch(o){t.w(`Unable to perform style diff: ${t.d(o).message}. Rebuilding the style from scratch.`),this._updateStyle(e,i);}}getStyle(){if(this.style)return this.style.serialize()}_getStyleAndImages(){return this.style?{style:this.style.serialize(),images:this.style.imageManager.cloneImages()}:{style:null,images:{}}}isStyleLoaded(){if(this.style)return this.style.loaded();t.w("There is no style added to the map.");}addSource(e,t){return this._lazyInitEmptyStyle(),this.style.addSource(e,t),this._update(!0)}isSourceLoaded(e){var i;const o=null===(i=this.style)||void 0===i?void 0:i.tileManagers[e];if(void 0!==o)return o.loaded();this.fire(new t.l(new Error(`There is no tile manager with ID '${e}'`)));}setTerrain(e){if(this.style._checkLoaded(),this._terrainDataCallback&&this.style.off("data",this._terrainDataCallback),e){const i=this.style.tileManagers[e.source];if(!i)throw new Error(`cannot load terrain, because there exists no source with ID: ${e.source}`);null===this.terrain&&i.reload();for(const i in this.style._layers){const o=this.style._layers[i];"hillshade"===o.type&&o.source===e.source&&t.w("You are using the same source for a hillshade layer and for 3D terrain. Please consider using two separate sources to improve rendering quality."),"color-relief"===o.type&&o.source===e.source&&t.w("You are using the same source for a color-relief layer and for 3D terrain. Please consider using two separate sources to improve rendering quality.");}this.terrain=new rs(this.painter,i,e),this.painter.renderToTexture=new ls(this.painter,this.terrain),this.transform.setMinElevationForCurrentTile(this.terrain.getMinTileElevationForLngLatZoom(this.transform.center,this.transform.tileZoom)),this.transform.setElevation(this.terrain.getElevationForLngLatZoom(this.transform.center,this.transform.tileZoom)),this._terrainDataCallback=t=>{var i;"style"===t.dataType?this.terrain.tileManager.freeRtt():"source"===t.dataType&&t.tile&&(t.sourceId!==e.source||this._elevationFreeze||(this.transform.setMinElevationForCurrentTile(this.terrain.getMinTileElevationForLngLatZoom(this.transform.center,this.transform.tileZoom)),this._centerClampedToGround&&this.transform.setElevation(this.terrain.getElevationForLngLatZoom(this.transform.center,this.transform.tileZoom))),"image"===(null===(i=t.source)||void 0===i?void 0:i.type)?this.terrain.tileManager.freeRtt():this.terrain.tileManager.freeRtt(t.tile.tileID));},this.style.on("data",this._terrainDataCallback);}else this.terrain&&this.terrain.destroy(),this.terrain=null,this.painter.renderToTexture&&this.painter.renderToTexture.destruct(),this.painter.renderToTexture=null,this.transform.setMinElevationForCurrentTile(0),this._centerClampedToGround&&this.transform.setElevation(0);return this.fire(new t.n("terrain",{terrain:e})),this}getTerrain(){var e,t;return null!==(t=null===(e=this.terrain)||void 0===e?void 0:e.options)&&void 0!==t?t:null}areTilesLoaded(){var e;const t=null===(e=this.style)||void 0===e?void 0:e.tileManagers;for(const e of Object.values(t))if(!e.areTilesLoaded())return !1;return !0}removeSource(e){return this.style.removeSource(e),this._update(!0)}getSource(e){return this.style.getSource(e)}setSourceTileLodParams(e,t,i){if(i){const o=this.getSource(i);if(!o)throw new Error(`There is no source with ID "${i}", cannot set LOD parameters`);o.calculateTileZoom=Te(Math.max(1,e),Math.max(1,t));}else for(const i in this.style.tileManagers)this.style.tileManagers[i].getSource().calculateTileZoom=Te(Math.max(1,e),Math.max(1,t));return this._update(!0),this}refreshTiles(e,i){const o=this.style.tileManagers[e];if(!o)throw new Error(`There is no tile manager with ID "${e}", cannot refresh tile`);void 0===i?o.reload(!0):o.refreshTiles(i.map((e=>new t.aa(e.z,e.x,e.y))));}addImage(e,i,o={}){const{pixelRatio:a=1,sdf:r=!1,stretchX:s,stretchY:l,content:c,textFitWidth:h,textFitHeight:u}=o;if(this._lazyInitEmptyStyle(),!(i instanceof HTMLImageElement||t.b(i))){if(void 0===i.width||void 0===i.height)return this.fire(new t.l(new Error("Invalid arguments to map.addImage(). The second argument must be an `HTMLImageElement`, `ImageData`, `ImageBitmap`, or object with `width`, `height`, and `data` properties with the same format as `ImageData`")));{const{width:o,height:n,data:d}=i,_=i;return this.style.addImage(e,{data:new t.R({width:o,height:n},new Uint8Array(d)),pixelRatio:a,stretchX:s,stretchY:l,content:c,textFitWidth:h,textFitHeight:u,sdf:r,version:0,userImage:_}),_.onAdd&&_.onAdd(this,e),this}}{const{width:o,height:d,data:_}=n.getImageData(i);this.style.addImage(e,{data:new t.R({width:o,height:d},_),pixelRatio:a,stretchX:s,stretchY:l,content:c,textFitWidth:h,textFitHeight:u,sdf:r,version:0});}}updateImage(e,i){const o=this.style.getImage(e);if(!o)return this.fire(new t.l(new Error("The map has no image with that id. If you are adding a new image use `map.addImage(...)` instead.")));const a=i instanceof HTMLImageElement||t.b(i)?n.getImageData(i):i,{width:r,height:s,data:l}=a;if(void 0===r||void 0===s)return this.fire(new t.l(new Error("Invalid arguments to map.updateImage(). The second argument must be an `HTMLImageElement`, `ImageData`, `ImageBitmap`, or object with `width`, `height`, and `data` properties with the same format as `ImageData`")));if(r!==o.data.width||s!==o.data.height)return this.fire(new t.l(new Error("The width and height of the updated image must be that same as the previous version of the image")));const c=!(i instanceof HTMLImageElement||t.b(i));return o.data.replace(l,c),this.style.updateImage(e,o),this}getImage(e){return this.style.getImage(e)}hasImage(e){return e?!!this.style.getImage(e):(this.fire(new t.l(new Error("Missing required image id"))),!1)}removeImage(e){this.style.removeImage(e);}loadImage(e){return t._(this,void 0,void 0,(function*(){return u.getImage(yield this._requestManager.transformRequest(e,"Image"),new AbortController)}))}listImages(){return this.style.listImages()}addLayer(e,t){return this._lazyInitEmptyStyle(),this.style.addLayer(e,t),this._update(!0)}moveLayer(e,t){return this.style.moveLayer(e,t),this._update(!0)}removeLayer(e){return this.style.removeLayer(e),this._update(!0)}getLayer(e){return this.style.getLayer(e)}getLayersOrder(){return this.style.getLayersOrder()}setLayerZoomRange(e,t,i){return this.style.setLayerZoomRange(e,t,i),this._update(!0)}setFilter(e,t,i={}){return this.style.setFilter(e,t,i),this._update(!0)}getFilter(e){return this.style.getFilter(e)}setPaintProperty(e,t,i,o={}){return this.style.setPaintProperty(e,t,i,o),this._update(!0)}getPaintProperty(e,t){return this.style.getPaintProperty(e,t)}setLayoutProperty(e,t,i,o={}){return this.style.setLayoutProperty(e,t,i,o),this._update(!0)}getLayoutProperty(e,t){return this.style.getLayoutProperty(e,t)}setGlyphs(e,t={}){return this._lazyInitEmptyStyle(),this.style.setGlyphs(e,t),this._update(!0)}getGlyphs(){return this.style.getGlyphsUrl()}addSprite(e,t,i={}){return this._lazyInitEmptyStyle(),this.style.addSprite(e,t,i,(e=>{e||this._update(!0);})),this}removeSprite(e){return this._lazyInitEmptyStyle(),this.style.removeSprite(e),this._update(!0)}getSprite(){return this.style.getSprite()}setSprite(e,t={}){return this._lazyInitEmptyStyle(),this.style.setSprite(e,t,(e=>{e||this._update(!0);})),this}setLight(e,t={}){return this._lazyInitEmptyStyle(),this.style.setLight(e,t),this._update(!0)}getLight(){return this.style.getLight()}setSky(e,t={}){return this._lazyInitEmptyStyle(),this.style.setSky(e,t),this._update(!0)}getSky(){return this.style.getSky()}setFeatureState(e,t){return this.style.setFeatureState(e,t),this._update()}removeFeatureState(e,t){return this.style.removeFeatureState(e,t),this._update()}getFeatureState(e){return this.style.getFeatureState(e)}getContainer(){return this._container}getCanvasContainer(){return this._canvasContainer}getCanvas(){return this._canvas}_containerDimensions(){let e=0,t=0;return this._container&&(e=this._container.clientWidth||400,t=this._container.clientHeight||300),[e,t]}_setupResizeObserver(){var e;let t=!1;const i=Ka((e=>{this._trackResize&&!this._removed&&(this.resize(e),this.redraw());}),50),o=null!==(e=this._ownerWindow.ResizeObserver)&&void 0!==e?e:ResizeObserver;this._resizeObserver=new o((e=>{t?i(e):t=!0;})),this._resizeObserver.observe(this._container);}_resolveContainer(e){if("string"==typeof e){const t=document.getElementById(e);if(!t)throw new Error(`Container '${e}' not found.`);return t}if(e instanceof HTMLElement)return e;if(e&&"object"==typeof e&&1===e.nodeType)return e;throw new Error("Invalid type: 'container' must be a String or HTMLElement.")}_setupContainer(){const e=this._container;e.classList.add("maplibregl-map");const t=this._canvasContainer=d.create("div","maplibregl-canvas-container",e);this._interactive&&t.classList.add("maplibregl-interactive"),this._canvas=d.create("canvas","maplibregl-canvas",t),this._canvas.addEventListener("webglcontextlost",this._contextLost,!1),this._canvas.addEventListener("webglcontextrestored",this._contextRestored,!1),this._canvas.setAttribute("tabindex",this._interactive?"0":"-1"),this._canvas.setAttribute("aria-label",this._getUIString("Map.Title")),this._canvas.setAttribute("role","region");const i=this._containerDimensions(),o=this._getClampedPixelRatio(i[0],i[1]);this._resizeCanvas(i[0],i[1],o);const a=this._controlContainer=d.create("div","maplibregl-control-container",e),r=this._controlPositions={};for(const e of ["top-left","top-right","bottom-left","bottom-right"])r[e]=d.create("div",`maplibregl-ctrl-${e} `,a);this._container.addEventListener("scroll",this._onMapScroll,!1);}_resizeCanvas(e,t,i){this._canvas.width=Math.floor(i*e),this._canvas.height=Math.floor(i*t),this._canvas.style.width=`${e}px`,this._canvas.style.height=`${t}px`;}_setupPainter(){const e=Object.assign(Object.assign({},this._canvasContextAttributes),{alpha:!0,depth:!0,stencil:!0,premultipliedAlpha:!0});let t=null;this._canvas.addEventListener("webglcontextcreationerror",(i=>{t={requestedAttributes:e},i&&(t.statusMessage=i.statusMessage,t.type=i.type);}),{once:!0});let i=null;if(i=this._canvasContextAttributes.contextType?this._canvas.getContext(this._canvasContextAttributes.contextType,e):this._canvas.getContext("webgl2",e)||this._canvas.getContext("webgl",e),!i){const e="Failed to initialize WebGL";throw t?(t.message=e,new Error(JSON.stringify(t))):new Error(e)}this.painter=new Xa(i,this.transform);}migrateProjection(e,i){super.migrateProjection(e,i),this.painter.transform=e,this.fire(new t.n("projectiontransition",{newProjection:this.style.projection.name}));}loaded(){return !this._styleDirty&&!this._sourcesDirty&&!!this.style&&this.style.loaded()}_update(e){var t;return (null===(t=this.style)||void 0===t?void 0:t._loaded)?(this._styleDirty||(this._styleDirty=e),this._sourcesDirty=!0,this.triggerRepaint(),this):this}_requestRenderFrame(e){return this._update(),this._renderTaskQueue.add(e)}_cancelRenderFrame(e){this._renderTaskQueue.remove(e);}_render(e){var i,o,a,r,s,n;const l=this._idleTriggered?this._fadeDuration:0,h=(null===(i=this.style.projection)||void 0===i?void 0:i.transitionState)>0;if(this.painter.context.setDirty(),this.painter.setBaseState(),this._renderTaskQueue.run(e),this._removed)return;let u=!1;if(this.style&&this._styleDirty){this._styleDirty=!1;const e=this.transform.zoom,i=c();this.style.zoomHistory.update(e,i);const o=new t.J(e,{now:i,fadeDuration:l,zoomHistory:this.style.zoomHistory,transition:this.style.getTransition()}),a=o.crossFadingFactor();1===a&&a===this._crossFadingFactor||(u=!0,this._crossFadingFactor=a),this.style.update(o);}const d=(null===(o=this.style.projection)||void 0===o?void 0:o.transitionState)>0!==h;null===(a=this.style.projection)||void 0===a||a.setErrorQueryLatitudeDegrees(this.transform.center.lat),this.transform.setTransitionState(null===(r=this.style.projection)||void 0===r?void 0:r.transitionState,null===(s=this.style.projection)||void 0===s?void 0:s.latitudeErrorCorrectionRadians),this.style&&(this._sourcesDirty||d)&&(this._sourcesDirty=!1,this.style._updateSources(this.transform)),this.terrain?(this.terrain.tileManager.update(this.transform,this.terrain),this.transform.setMinElevationForCurrentTile(this.terrain.getMinTileElevationForLngLatZoom(this.transform.center,this.transform.tileZoom)),!this._elevationFreeze&&this._centerClampedToGround&&this.transform.setElevation(this.terrain.getElevationForLngLatZoom(this.transform.center,this.transform.tileZoom))):(this.transform.setMinElevationForCurrentTile(0),this._centerClampedToGround&&this.transform.setElevation(0)),this._placementDirty=null===(n=this.style)||void 0===n?void 0:n._updatePlacement(this.transform,this.showCollisionBoxes,l,this._crossSourceCollisions,d),this.painter.render(this.style,{showTileBoundaries:this.showTileBoundaries,showOverdrawInspector:this._showOverdrawInspector,rotating:this.isRotating(),zooming:this.isZooming(),moving:this.isMoving(),fadeDuration:l,showPadding:this.showPadding,anisotropicFilterPitch:this.getAnisotropicFilterPitch()}),this.fire(new t.n("render")),this.loaded()&&!this._loaded&&(this._loaded=!0,this.fire(new t.n("load"))),this.style&&(this.style.hasTransitions()||u)&&(this._styleDirty=!0),this.style&&!this._placementDirty&&this.style._releaseSymbolFadeTiles();const _=this._sourcesDirty||this._styleDirty||this._placementDirty;return _||this._repaint?this.triggerRepaint():!this.isMoving()&&this.loaded()&&this.fire(new t.n("idle")),!this._loaded||this._fullyLoaded||_||(this._fullyLoaded=!0),this}redraw(){return this.style&&(this._frameRequest&&(this._frameRequest.abort(),this._frameRequest=null),this._render(0)),this}remove(){var e,i;this._hash&&this._hash.remove();for(const e of this._controls)e.onRemove(this);this._controls=[],this._frameRequest&&(this._frameRequest.abort(),this._frameRequest=null),this._renderTaskQueue.clear(),null===(e=this._diffStyleRequest)||void 0===e||e.abort(),this.painter.destroy(),this.handlers.destroy(),delete this.handlers,this.setStyle(null),"undefined"!=typeof window&&this._ownerWindow.removeEventListener("online",this._onWindowOnline,!1),u.removeThrottleControl(this._imageQueueHandle),null===(i=this._resizeObserver)||void 0===i||i.disconnect();const o=this.painter.context.gl.getExtension("WEBGL_lose_context");(null==o?void 0:o.loseContext)&&o.loseContext(),this._canvas.removeEventListener("webglcontextrestored",this._contextRestored,!1),this._canvas.removeEventListener("webglcontextlost",this._contextLost,!1),this._canvasContainer.remove(),this._controlContainer.remove(),this._container.removeEventListener("scroll",this._onMapScroll,!1),this._container.classList.remove("maplibregl-map"),this._removed=!0,this.fire(new t.n("remove"));}triggerRepaint(){this.style&&!this._frameRequest&&(this._frameRequest=new AbortController,n.frame(this._frameRequest,(e=>{this._frameRequest=null;try{this._render(e);}catch(e){if(!t.$(e)&&!function(e){return e.message===na}(e))throw e}}),(()=>{}),this._ownerWindow));}get showTileBoundaries(){return !!this._showTileBoundaries}set showTileBoundaries(e){this._showTileBoundaries!==e&&(this._showTileBoundaries=e,this._update());}get showPadding(){return !!this._showPadding}set showPadding(e){this._showPadding!==e&&(this._showPadding=e,this._update());}get showCollisionBoxes(){return !!this._showCollisionBoxes}set showCollisionBoxes(e){this._showCollisionBoxes!==e&&(this._showCollisionBoxes=e,e?this.style._generateCollisionBoxes():this._update());}get showOverdrawInspector(){return !!this._showOverdrawInspector}set showOverdrawInspector(e){this._showOverdrawInspector!==e&&(this._showOverdrawInspector=e,this._update());}get repaint(){return !!this._repaint}set repaint(e){this._repaint!==e&&(this._repaint=e,this.triggerRepaint());}get vertices(){return !!this._vertices}set vertices(e){this._vertices=e,this._update();}get version(){return hs}getCameraTargetElevation(){return this.transform.elevation}getProjection(){return this.style.getProjection()}setProjection(e){return this._lazyInitEmptyStyle(),this.style.setProjection(e),this._update(!0)}};const _s={showCompass:!0,showZoom:!0,visualizePitch:!1,visualizeRoll:!0};class ps{constructor(e,i,o=!1){this.mousedown=e=>{this.startMove(e,d.mousePos(this.element,e)),window.addEventListener("mousemove",this.mousemove),window.addEventListener("mouseup",this.mouseup);},this.mousemove=e=>{this.move(e,d.mousePos(this.element,e));},this.mouseup=e=>{this._rotatePitchHandler.dragEnd(e),this.offTemp();},this.touchstart=e=>{1!==e.targetTouches.length?this.reset():(this._startPos=this._lastPos=d.touchPos(this.element,e.targetTouches)[0],this.startMove(e,this._startPos),window.addEventListener("touchmove",this.touchmove,{passive:!1}),window.addEventListener("touchend",this.touchend));},this.touchmove=e=>{1!==e.targetTouches.length?this.reset():(this._lastPos=d.touchPos(this.element,e.targetTouches)[0],this.move(e,this._lastPos));},this.touchend=e=>{0===e.targetTouches.length&&this._startPos&&this._lastPos&&this._startPos.dist(this._lastPos){this._rotatePitchHandler.reset(),delete this._startPos,delete this._lastPos,this.offTemp();},this._clickTolerance=10,this.element=i;const a=new Pr;this._rotatePitchHandler=new vr({clickTolerance:3,move:(e,a)=>{const r=i.getBoundingClientRect(),s=new t.P((r.bottom-r.top)/2,(r.right-r.left)/2);return {bearingDelta:t.cx(new t.P(e.x,a.y),a,s),pitchDelta:o?-.5*(a.y-e.y):void 0}},moveStateManager:a,enable:!0,assignEvents:()=>{}}),this.map=e,i.addEventListener("mousedown",this.mousedown),i.addEventListener("touchstart",this.touchstart,{passive:!1}),i.addEventListener("touchcancel",this.reset);}startMove(e,t){this._rotatePitchHandler.dragStart(e,t),d.disableDrag();}move(e,t){const i=this.map,{bearingDelta:o,pitchDelta:a}=this._rotatePitchHandler.dragMove(e,t)||{};o&&i.setBearing(i.getBearing()+o),a&&i.setPitch(i.getPitch()+a);}off(){const e=this.element;e.removeEventListener("mousedown",this.mousedown),e.removeEventListener("touchstart",this.touchstart),window.removeEventListener("touchmove",this.touchmove),window.removeEventListener("touchend",this.touchend),e.removeEventListener("touchcancel",this.reset),this.offTemp();}offTemp(){d.enableDrag(),window.removeEventListener("mousemove",this.mousemove),window.removeEventListener("mouseup",this.mouseup),window.removeEventListener("touchmove",this.touchmove),window.removeEventListener("touchend",this.touchend);}}let ms;function fs(e,i,o,a=!1){if(a||!o.getCoveringTilesDetailsProvider().allowWorldCopies())return null==e?void 0:e.wrap();const r=new t.W(e.lng,e.lat);if(e=new t.W(e.lng,e.lat),i){const a=new t.W(e.lng-360,e.lat),r=new t.W(e.lng+360,e.lat),s=o.locationToScreenPoint(e).distSqr(i);o.locationToScreenPoint(a).distSqr(i)180;){const t=o.locationToScreenPoint(e);if(t.x>=0&&t.y>=0&&t.x<=o.width&&t.y<=o.height)break;e.lng>o.center.lng?e.lng-=360:e.lng+=360;}return e.lng!==r.lng&&o.isPointOnMapSurface(o.locationToScreenPoint(e))?e:r}const gs={center:"translate(-50%,-50%)",top:"translate(-50%,0)","top-left":"translate(0,0)","top-right":"translate(-100%,0)",bottom:"translate(-50%,-100%)","bottom-left":"translate(0,-100%)","bottom-right":"translate(-100%,-100%)",left:"translate(0,-50%)",right:"translate(-100%,-50%)"};function vs(e,t,i){const o=e.classList;for(const e in gs)o.remove(`maplibregl-${i}-anchor-${e}`);o.add(`maplibregl-${i}-anchor-${t}`);}class xs extends t.E{constructor(e){if(super(),this._onClick=e=>{this.fire(new t.n("click",{originalEvent:e}));},this._onKeyPress=e=>{"Space"!==e.code&&"Enter"!==e.code||this.togglePopup();},this._onMapClick=e=>{const t=e.originalEvent.target,i=this._element;this._popup&&(t===i||i.contains(t))&&this.togglePopup();},this._update=e=>{if(!this._map)return;const t=this._map.loaded()&&!this._map.isMoving();("terrain"===(null==e?void 0:e.type)||"render"===(null==e?void 0:e.type)&&!t)&&this._map.once("render",this._update),this._lngLat=fs(this._lngLat,this._flatPos,this._map.transform),this._flatPos=this._pos=this._map.project(this._lngLat)._add(this._offset),this._map.terrain&&(this._flatPos=this._map.transform.locationToScreenPoint(this._lngLat)._add(this._offset));let i="";"viewport"===this._rotationAlignment||"auto"===this._rotationAlignment?i=`rotateZ(${this._rotation}deg)`:"map"===this._rotationAlignment&&(i=`rotateZ(${this._rotation-this._map.getBearing()}deg)`);let o="";"viewport"===this._pitchAlignment||"auto"===this._pitchAlignment?o="rotateX(0deg)":"map"===this._pitchAlignment&&(o=`rotateX(${this._map.getPitch()}deg)`),this._subpixelPositioning||e&&"moveend"!==e.type||(this._pos=this._pos.round()),this._element.style.transform=`${gs[this._anchor]} translate(${this._pos.x}px, ${this._pos.y}px) ${o} ${i}`,n.frameAsync(new AbortController,this._map._ownerWindow).then((()=>{this._updateOpacity("moveend"===(null==e?void 0:e.type));})).catch((()=>{}));},this._onMove=e=>{if(!this._isDragging){const t=this._clickTolerance||this._map._clickTolerance;this._isDragging=e.point.dist(this._pointerdownPos)>=t;}this._isDragging&&(this._pos=e.point.sub(this._positionDelta),this._lngLat=this._map.unproject(this._pos),this.setLngLat(this._lngLat),this._element.style.pointerEvents="none","pending"===this._state&&(this._state="active",this.fire(new t.n("dragstart"))),this.fire(new t.n("drag")));},this._onUp=()=>{this._element.style.pointerEvents="auto",this._positionDelta=null,this._pointerdownPos=null,this._isDragging=!1,this._map.off("mousemove",this._onMove),this._map.off("touchmove",this._onMove),"active"===this._state&&this.fire(new t.n("dragend")),this._state="inactive";},this._addDragHandler=e=>{this._element.contains(e.originalEvent.target)&&(e.preventDefault(),this._positionDelta=e.point.sub(this._pos).add(this._offset),this._pointerdownPos=e.point,this._state="pending",this._map.on("mousemove",this._onMove),this._map.on("touchmove",this._onMove),this._map.once("mouseup",this._onUp),this._map.once("touchend",this._onUp));},this._anchor=(null==e?void 0:e.anchor)||"center",this._color=(null==e?void 0:e.color)||"#3FB1CE",this._scale=(null==e?void 0:e.scale)||1,this._draggable=(null==e?void 0:e.draggable)||!1,this._clickTolerance=(null==e?void 0:e.clickTolerance)||0,this._subpixelPositioning=(null==e?void 0:e.subpixelPositioning)||!1,this._isDragging=!1,this._state="inactive",this._rotation=(null==e?void 0:e.rotation)||0,this._rotationAlignment=(null==e?void 0:e.rotationAlignment)||"auto",this._pitchAlignment=(null==e?void 0:e.pitchAlignment)&&"auto"!==e.pitchAlignment?e.pitchAlignment:this._rotationAlignment,this.setOpacity(null==e?void 0:e.opacity,null==e?void 0:e.opacityWhenCovered),null==e?void 0:e.element)this._element=e.element,this._offset=t.P.convert((null==e?void 0:e.offset)||[0,0]);else {this._defaultMarker=!0,this._element=d.create("div");const i=d.createNS("http://www.w3.org/2000/svg","svg"),o=41,a=27;i.setAttributeNS(null,"display","block"),i.setAttributeNS(null,"height",`${o}px`),i.setAttributeNS(null,"width",`${a}px`),i.setAttributeNS(null,"viewBox",`0 0 ${a} ${o}`);const r=d.createNS("http://www.w3.org/2000/svg","g");r.setAttributeNS(null,"stroke","none"),r.setAttributeNS(null,"stroke-width","1"),r.setAttributeNS(null,"fill","none"),r.setAttributeNS(null,"fill-rule","evenodd");const s=d.createNS("http://www.w3.org/2000/svg","g");s.setAttributeNS(null,"fill-rule","nonzero");const n=d.createNS("http://www.w3.org/2000/svg","g");n.setAttributeNS(null,"transform","translate(3.0, 29.0)"),n.setAttributeNS(null,"fill","#000000");const l=[{rx:"10.5",ry:"5.25002273"},{rx:"10.5",ry:"5.25002273"},{rx:"9.5",ry:"4.77275007"},{rx:"8.5",ry:"4.29549936"},{rx:"7.5",ry:"3.81822308"},{rx:"6.5",ry:"3.34094679"},{rx:"5.5",ry:"2.86367051"},{rx:"4.5",ry:"2.38636864"}];for(const e of l){const t=d.createNS("http://www.w3.org/2000/svg","ellipse");t.setAttributeNS(null,"opacity","0.04"),t.setAttributeNS(null,"cx","10.5"),t.setAttributeNS(null,"cy","5.80029008"),t.setAttributeNS(null,"rx",e.rx),t.setAttributeNS(null,"ry",e.ry),n.appendChild(t);}const c=d.createNS("http://www.w3.org/2000/svg","g");c.setAttributeNS(null,"fill",this._color);const h=d.createNS("http://www.w3.org/2000/svg","path");h.setAttributeNS(null,"d","M27,13.5 C27,19.074644 20.250001,27.000002 14.75,34.500002 C14.016665,35.500004 12.983335,35.500004 12.25,34.500002 C6.7499993,27.000002 0,19.222562 0,13.5 C0,6.0441559 6.0441559,0 13.5,0 C20.955844,0 27,6.0441559 27,13.5 Z"),c.appendChild(h);const u=d.createNS("http://www.w3.org/2000/svg","g");u.setAttributeNS(null,"opacity","0.25"),u.setAttributeNS(null,"fill","#000000");const _=d.createNS("http://www.w3.org/2000/svg","path");_.setAttributeNS(null,"d","M13.5,0 C6.0441559,0 0,6.0441559 0,13.5 C0,19.222562 6.7499993,27 12.25,34.5 C13,35.522727 14.016664,35.500004 14.75,34.5 C20.250001,27 27,19.074644 27,13.5 C27,6.0441559 20.955844,0 13.5,0 Z M13.5,1 C20.415404,1 26,6.584596 26,13.5 C26,15.898657 24.495584,19.181431 22.220703,22.738281 C19.945823,26.295132 16.705119,30.142167 13.943359,33.908203 C13.743445,34.180814 13.612715,34.322738 13.5,34.441406 C13.387285,34.322738 13.256555,34.180814 13.056641,33.908203 C10.284481,30.127985 7.4148684,26.314159 5.015625,22.773438 C2.6163816,19.232715 1,15.953538 1,13.5 C1,6.584596 6.584596,1 13.5,1 Z"),u.appendChild(_);const p=d.createNS("http://www.w3.org/2000/svg","g");p.setAttributeNS(null,"transform","translate(6.0, 7.0)"),p.setAttributeNS(null,"fill","#FFFFFF");const m=d.createNS("http://www.w3.org/2000/svg","g");m.setAttributeNS(null,"transform","translate(8.0, 8.0)");const f=d.createNS("http://www.w3.org/2000/svg","circle");f.setAttributeNS(null,"fill","#000000"),f.setAttributeNS(null,"opacity","0.25"),f.setAttributeNS(null,"cx","5.5"),f.setAttributeNS(null,"cy","5.5"),f.setAttributeNS(null,"r","5.4999962");const g=d.createNS("http://www.w3.org/2000/svg","circle");g.setAttributeNS(null,"fill","#FFFFFF"),g.setAttributeNS(null,"cx","5.5"),g.setAttributeNS(null,"cy","5.5"),g.setAttributeNS(null,"r","5.4999962"),m.appendChild(f),m.appendChild(g),s.appendChild(n),s.appendChild(c),s.appendChild(u),s.appendChild(p),s.appendChild(m),i.appendChild(s),i.setAttributeNS(null,"height",o*this._scale+"px"),i.setAttributeNS(null,"width",a*this._scale+"px"),this._element.appendChild(i),this._offset=t.P.convert((null==e?void 0:e.offset)||[0,-14]);}if(this._element.classList.add("maplibregl-marker"),this._element.addEventListener("dragstart",(e=>{e.preventDefault();})),this._element.addEventListener("mousedown",(e=>{e.preventDefault();})),vs(this._element,this._anchor,"marker"),null==e?void 0:e.className)for(const t of e.className.split(" "))this._element.classList.add(t);this._popup=null;}addTo(e){return this.remove(),this._map=e,this._element.hasAttribute("aria-label")||this._element.setAttribute("aria-label",e._getUIString("Marker.Title")),this._element.hasAttribute("role")||this._element.setAttribute("role","button"),e.getCanvasContainer().appendChild(this._element),e.on("move",this._update),e.on("moveend",this._update),e.on("terrain",this._update),e.on("projectiontransition",this._update),this._element.addEventListener("click",this._onClick),this.setDraggable(this._draggable),this._update(),this._map.on("click",this._onMapClick),this}remove(){return this._opacityTimeout&&(clearTimeout(this._opacityTimeout),delete this._opacityTimeout),this._map&&(this._map.off("click",this._onMapClick),this._map.off("move",this._update),this._map.off("moveend",this._update),this._map.off("terrain",this._update),this._map.off("projectiontransition",this._update),this._map.off("mousedown",this._addDragHandler),this._map.off("touchstart",this._addDragHandler),this._map.off("mouseup",this._onUp),this._map.off("touchend",this._onUp),this._map.off("mousemove",this._onMove),this._map.off("touchmove",this._onMove),delete this._map),this._element.removeEventListener("click",this._onClick),this._element.remove(),this._popup&&this._popup.remove(),this}getLngLat(){return this._lngLat}setLngLat(e){return this._lngLat=t.W.convert(e),this._pos=null,this._popup&&this._popup.setLngLat(this._lngLat),this._update(),this}getElement(){return this._element}setPopup(e){if(this._popup&&(this._popup.remove(),this._popup=null,this._element.removeEventListener("keypress",this._onKeyPress),this._originalTabIndex||this._element.removeAttribute("tabindex")),e){if(!("offset"in e.options)){const t=38.1,i=13.5,o=Math.abs(i)/Math.SQRT2;e.options.offset=this._defaultMarker?{top:[0,0],"top-left":[0,0],"top-right":[0,0],bottom:[0,-t],"bottom-left":[o,-1*(t-i+o)],"bottom-right":[-o,-1*(t-i+o)],left:[i,-1*(t-i)],right:[-i,-1*(t-i)]}:this._offset;}this._popup=e,this._originalTabIndex=this._element.getAttribute("tabindex"),this._originalTabIndex||this._element.setAttribute("tabindex","0"),this._element.addEventListener("keypress",this._onKeyPress);}return this}setSubpixelPositioning(e){return this._subpixelPositioning=e,this}getPopup(){return this._popup}togglePopup(){const e=this._popup;return this._element.style.opacity===this._opacityWhenCovered?this:e?(e.isOpen()?e.remove():(e.setLngLat(this._lngLat),e.addTo(this._map)),this):this}_updateOpacity(e=!1){var i,o;const a=null===(i=this._map)||void 0===i?void 0:i.terrain,r=this._map.transform.isLocationOccluded(this._lngLat);if(!a||r){const e=r?this._opacityWhenCovered:this._opacity;return void(this._element.style.opacity!==e&&(this._element.style.opacity=e,this._element.classList.toggle("maplibregl-marker-covered",r)))}if(e)this._opacityTimeout=null;else {if(this._opacityTimeout)return;this._opacityTimeout=setTimeout((()=>{this._opacityTimeout=null;}),100);}const s=this._map,n=s.terrain.depthAtPoint(this._pos),l=s.terrain.getElevationForLngLat(this._lngLat,s.transform);if(s.transform.lngLatToCameraDepth(this._lngLat,l)-n<.006)return this._element.style.opacity=this._opacity,void this._element.classList.remove("maplibregl-marker-covered");const c=-this._offset.y/s.transform.pixelsPerMeter,h=Math.sin(s.getPitch()*Math.PI/180)*c,u=s.terrain.depthAtPoint(new t.P(this._pos.x,this._pos.y-this._offset.y)),d=s.transform.lngLatToCameraDepth(this._lngLat,l+h)-u>.006;(null===(o=this._popup)||void 0===o?void 0:o.isOpen())&&d&&this._popup.remove(),this._element.style.opacity=d?this._opacityWhenCovered:this._opacity,this._element.classList.toggle("maplibregl-marker-covered",d);}getOffset(){return this._offset}setOffset(e){return this._offset=t.P.convert(e),this._update(),this}addClassName(e){this._element.classList.add(e);}removeClassName(e){this._element.classList.remove(e);}toggleClassName(e){return this._element.classList.toggle(e)}setDraggable(e){return this._draggable=!!e,this._map&&(e?(this._map.on("mousedown",this._addDragHandler),this._map.on("touchstart",this._addDragHandler)):(this._map.off("mousedown",this._addDragHandler),this._map.off("touchstart",this._addDragHandler))),this}isDraggable(){return this._draggable}setRotation(e){return this._rotation=e||0,this._update(),this}getRotation(){return this._rotation}setRotationAlignment(e){return this._rotationAlignment=e||"auto",this._update(),this}getRotationAlignment(){return this._rotationAlignment}setPitchAlignment(e){return this._pitchAlignment=e&&"auto"!==e?e:this._rotationAlignment,this._update(),this}getPitchAlignment(){return this._pitchAlignment}setOpacity(e,t){return (void 0===this._opacity||void 0===e&&void 0===t)&&(this._opacity="1",this._opacityWhenCovered="0.2"),void 0!==e&&(this._opacity=String(e)),void 0!==t&&(this._opacityWhenCovered=String(t)),this._map&&this._updateOpacity(!0),this}}const bs={positionOptions:{enableHighAccuracy:!1,maximumAge:0,timeout:6e3},fitBoundsOptions:{maxZoom:15},trackUserLocation:!1,showAccuracyCircle:!0,showUserLocation:!0};let ys=0,ws=!1;const Ts={maxWidth:100,unit:"metric"};function Ps(e,t,i){const o=(null==i?void 0:i.maxWidth)||100,a=e._container.clientHeight/2,r=e._container.clientWidth/2,s=e.unproject([r-o/2,a]),n=e.unproject([r+o/2,a]),l=Math.round(e.project(n).x-e.project(s).x),c=Math.min(o,l,e._container.clientWidth),h=s.distanceTo(n);if("imperial"===(null==i?void 0:i.unit)){const i=3.2808*h;i>5280?Cs(t,c,i/5280,e._getUIString("ScaleControl.Miles")):Cs(t,c,i,e._getUIString("ScaleControl.Feet"));}else "nautical"===(null==i?void 0:i.unit)?Cs(t,c,h/1852,e._getUIString("ScaleControl.NauticalMiles")):h>=1e3?Cs(t,c,h/1e3,e._getUIString("ScaleControl.Kilometers")):Cs(t,c,h,e._getUIString("ScaleControl.Meters"));}function Cs(e,t,i,o){const a=function(e){const t=Math.pow(10,`${Math.floor(e)}`.length-1);let i=e/t;return i=i>=10?10:i>=5?5:i>=3?3:i>=2?2:i>=1?1:function(e){const t=Math.pow(10,Math.ceil(-Math.log(e)/Math.LN10));return Math.round(e*t)/t}(i),t*i}(i);e.style.width=t*(a/i)+"px",e.innerHTML=`${a} ${o}`;}const Ms={closeButton:!0,closeOnClick:!0,focusAfterOpen:!0,className:"",maxWidth:"240px",subpixelPositioning:!1,locationOccludedOpacity:void 0,padding:void 0},Is=["a[href]","[tabindex]:not([tabindex='-1'])","[contenteditable]:not([contenteditable='false'])","button:not([disabled])","input:not([disabled])","select:not([disabled])","textarea:not([disabled])"].join(", ");function Es(e){if(e){if("number"==typeof e){const i=Math.round(Math.abs(e)/Math.SQRT2);return {center:new t.P(0,0),top:new t.P(0,e),"top-left":new t.P(i,i),"top-right":new t.P(-i,i),bottom:new t.P(0,-e),"bottom-left":new t.P(i,-i),"bottom-right":new t.P(-i,-i),left:new t.P(e,0),right:new t.P(-e,0)}}if(e instanceof t.P||Array.isArray(e)){const i=t.P.convert(e);return {center:i,top:i,"top-left":i,"top-right":i,bottom:i,"bottom-left":i,"bottom-right":i,left:i,right:i}}return {center:t.P.convert(e.center||[0,0]),top:t.P.convert(e.top||[0,0]),"top-left":t.P.convert(e["top-left"]||[0,0]),"top-right":t.P.convert(e["top-right"]||[0,0]),bottom:t.P.convert(e.bottom||[0,0]),"bottom-left":t.P.convert(e["bottom-left"]||[0,0]),"bottom-right":t.P.convert(e["bottom-right"]||[0,0]),left:t.P.convert(e.left||[0,0]),right:t.P.convert(e.right||[0,0])}}return Es(new t.P(0,0))}const Ss=i;e.AJAXError=t.cG,e.EXTENT=t.a6,e.Event=t.n,e.Evented=t.E,e.LngLat=t.W,e.MercatorCoordinate=t.a7,e.Point=t.P,e.addProtocol=t.cH,e.config=t.c,e.removeProtocol=t.cI,e.AttributionControl=es,e.BoxZoomHandler=_r,e.CanvasSource=oe,e.CooperativeGesturesHandler=$r,e.DoubleClickZoomHandler=Zr,e.DragPanHandler=Vr,e.DragRotateHandler=Wr,e.EdgeInsets=Ut,e.FullscreenControl=class extends t.E{constructor(e={}){var i;super(),this._onFullscreenChange=()=>{var e;let t=window.document.fullscreenElement||window.document.webkitFullscreenElement;for(;null===(e=null==t?void 0:t.shadowRoot)||void 0===e?void 0:e.fullscreenElement;)t=t.shadowRoot.fullscreenElement;t===this._container!==this._fullscreen&&this._handleFullscreenChange();},this._onClickFullscreen=()=>{this._isFullscreen()?this._exitFullscreen():this._requestFullscreen();},this._fullscreen=!1,this._pseudo=null!==(i=e.pseudo)&&void 0!==i&&i,(null==e?void 0:e.container)&&(e.container instanceof HTMLElement?this._container=e.container:t.w("Full screen control 'container' must be a DOM element.")),"onfullscreenchange"in document?this._fullscreenchange="fullscreenchange":"onmozfullscreenchange"in document?this._fullscreenchange="mozfullscreenchange":"onwebkitfullscreenchange"in document?this._fullscreenchange="webkitfullscreenchange":"onmsfullscreenchange"in document&&(this._fullscreenchange="MSFullscreenChange");}onAdd(e){return this._map=e,this._container||(this._container=this._map.getContainer()),this._controlContainer=d.create("div","maplibregl-ctrl maplibregl-ctrl-group"),this._setupUI(),this._controlContainer}onRemove(){this._controlContainer.remove(),this._map=null,window.document.removeEventListener(this._fullscreenchange,this._onFullscreenChange);}_setupUI(){const e=this._fullscreenButton=d.create("button","maplibregl-ctrl-fullscreen",this._controlContainer);d.create("span","maplibregl-ctrl-icon",e).setAttribute("aria-hidden","true"),e.type="button",this._updateTitle(),this._fullscreenButton.addEventListener("click",this._onClickFullscreen),window.document.addEventListener(this._fullscreenchange,this._onFullscreenChange);}_updateTitle(){const e=this._getTitle();this._fullscreenButton.setAttribute("aria-label",e),this._fullscreenButton.title=e;}_getTitle(){return this._map._getUIString(this._isFullscreen()?"FullscreenControl.Exit":"FullscreenControl.Enter")}_isFullscreen(){return this._fullscreen}_handleFullscreenChange(){this._fullscreen=!this._fullscreen,this._fullscreenButton.classList.toggle("maplibregl-ctrl-shrink"),this._fullscreenButton.classList.toggle("maplibregl-ctrl-fullscreen"),this._updateTitle(),this._fullscreen?(this.fire(new t.n("fullscreenstart")),this._prevCooperativeGesturesEnabled=this._map.cooperativeGestures.isEnabled(),this._map.cooperativeGestures.disable()):(this.fire(new t.n("fullscreenend")),this._prevCooperativeGesturesEnabled&&this._map.cooperativeGestures.enable());}_exitFullscreen(){this._pseudo?this._togglePseudoFullScreen():window.document.exitFullscreen?window.document.exitFullscreen():window.document.webkitCancelFullScreen?window.document.webkitCancelFullScreen():this._togglePseudoFullScreen();}_requestFullscreen(){this._pseudo?this._togglePseudoFullScreen():this._container.requestFullscreen?this._container.requestFullscreen():this._container.webkitRequestFullscreen?this._container.webkitRequestFullscreen():this._togglePseudoFullScreen();}_togglePseudoFullScreen(){this._container.classList.toggle("maplibregl-pseudo-fullscreen"),this._handleFullscreenChange(),this._map.resize();}},e.GeoJSONSource=ee,e.GeolocateControl=class extends t.E{constructor(e){super(),this._onSuccess=e=>{if(this._map){if(this._isOutOfMapMaxBounds(e))return this._setErrorState(),this.fire(new t.n("outofmaxbounds",e)),this._updateMarker(),void this._finish();if(this.options.trackUserLocation)switch(this._lastKnownPosition=e,this._watchState){case "WAITING_ACTIVE":case "ACTIVE_LOCK":case "ACTIVE_ERROR":this._watchState="ACTIVE_LOCK",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active-error"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-active");break;case "BACKGROUND":case "BACKGROUND_ERROR":this._watchState="BACKGROUND",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background-error"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-background");break;default:throw new Error(`Unexpected watchState ${this._watchState}`)}this.options.showUserLocation&&"OFF"!==this._watchState&&this._updateMarker(e),this.options.trackUserLocation&&"ACTIVE_LOCK"!==this._watchState||this._updateCamera(e),this.options.showUserLocation&&this._dotElement.classList.remove("maplibregl-user-location-dot-stale"),this.fire(new t.n("geolocate",e)),this._finish();}},this._updateCamera=e=>{const i=new t.W(e.coords.longitude,e.coords.latitude),o=e.coords.accuracy,a=this._map.getBearing(),r=t.e({bearing:a},this.options.fitBoundsOptions),s=G.fromLngLat(i,o);this._map.fitBounds(s,r,{geolocateSource:!0});},this._updateMarker=e=>{if(e){const i=new t.W(e.coords.longitude,e.coords.latitude);this._accuracyCircleMarker.setLngLat(i).addTo(this._map),this._userLocationDotMarker.setLngLat(i).addTo(this._map),this._accuracy=e.coords.accuracy,this._updateCircleRadiusIfNeeded();}else this._userLocationDotMarker.remove(),this._accuracyCircleMarker.remove();},this._onUpdate=()=>{this._updateCircleRadiusIfNeeded();},this._onError=e=>{if(this._map){if(1===e.code){this._watchState="OFF",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active-error"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background-error"),this._geolocateButton.disabled=!0;const e=this._map._getUIString("GeolocateControl.LocationNotAvailable");this._geolocateButton.title=e,this._geolocateButton.setAttribute("aria-label",e),void 0!==this._geolocationWatchID&&this._clearWatch();}else {if(3===e.code&&ws)return;this._setErrorState();}"OFF"!==this._watchState&&this.options.showUserLocation&&this._dotElement.classList.add("maplibregl-user-location-dot-stale"),this.fire(new t.n("error",e)),this._finish();}},this._finish=()=>{this._timeoutId&&clearTimeout(this._timeoutId),this._timeoutId=void 0;},this._onMoveStart=e=>{if(!this._map)return;const i=(null==e?void 0:e[0])instanceof ResizeObserverEntry;e.geolocateSource||"ACTIVE_LOCK"!==this._watchState||i||this._map.isZooming()||(this._watchState="BACKGROUND",this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-background"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active"),this.fire(new t.n("trackuserlocationend")),this.fire(new t.n("userlocationlostfocus")));},this._setupUI=()=>{this._map&&(this._container.addEventListener("contextmenu",(e=>{e.preventDefault();})),this._geolocateButton=d.create("button","maplibregl-ctrl-geolocate",this._container),d.create("span","maplibregl-ctrl-icon",this._geolocateButton).setAttribute("aria-hidden","true"),this._geolocateButton.type="button",this._geolocateButton.disabled=!0);},this._finishSetupUI=e=>{if(this._map){if(!1===e){t.w("Geolocation support is not available so the GeolocateControl will be disabled.");const e=this._map._getUIString("GeolocateControl.LocationNotAvailable");this._geolocateButton.disabled=!0,this._geolocateButton.title=e,this._geolocateButton.setAttribute("aria-label",e);}else {const e=this._map._getUIString("GeolocateControl.FindMyLocation");this._geolocateButton.disabled=!1,this._geolocateButton.title=e,this._geolocateButton.setAttribute("aria-label",e);}this.options.trackUserLocation&&(this._geolocateButton.setAttribute("aria-pressed","false"),this._watchState="OFF"),this.options.showUserLocation&&(this._dotElement=d.create("div","maplibregl-user-location-dot"),this._userLocationDotMarker=new xs({element:this._dotElement}),this._circleElement=d.create("div","maplibregl-user-location-accuracy-circle"),this._accuracyCircleMarker=new xs({element:this._circleElement,pitchAlignment:"map"}),this.options.trackUserLocation&&(this._watchState="OFF"),this._map.on("zoom",this._onUpdate),this._map.on("move",this._onUpdate),this._map.on("rotate",this._onUpdate),this._map.on("pitch",this._onUpdate)),this._geolocateButton.addEventListener("click",(()=>this.trigger())),this._setup=!0,this.options.trackUserLocation&&this._map.on("movestart",this._onMoveStart);}},this.options=t.e({},bs,e);}onAdd(e){return this._map=e,this._container=d.create("div","maplibregl-ctrl maplibregl-ctrl-group"),this._setupUI(),function(){return t._(this,arguments,void 0,(function*(e=!1){if(void 0!==ms&&!e)return ms;if(void 0===window.navigator.permissions)return ms=!!window.navigator.geolocation,ms;try{const e=yield window.navigator.permissions.query({name:"geolocation"});ms="denied"!==e.state;}catch(e){ms=!!window.navigator.geolocation;}return ms}))}().then((e=>this._finishSetupUI(e))),this._container}onRemove(){void 0!==this._geolocationWatchID&&(window.navigator.geolocation.clearWatch(this._geolocationWatchID),this._geolocationWatchID=void 0),this.options.showUserLocation&&this._userLocationDotMarker&&this._userLocationDotMarker.remove(),this.options.showAccuracyCircle&&this._accuracyCircleMarker&&this._accuracyCircleMarker.remove(),this._container.remove(),this._map.off("movestart",this._onMoveStart),this._map.off("zoom",this._onUpdate),this._map.off("move",this._onUpdate),this._map.off("rotate",this._onUpdate),this._map.off("pitch",this._onUpdate),this._map=void 0,ys=0,ws=!1;}_isOutOfMapMaxBounds(e){const t=this._map.getMaxBounds(),i=e.coords;return t&&(i.longitudet.getEast()||i.latitudet.getNorth())}_setErrorState(){switch(this._watchState){case "WAITING_ACTIVE":this._watchState="ACTIVE_ERROR",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-active-error");break;case "ACTIVE_LOCK":this._watchState="ACTIVE_ERROR",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-active-error"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-waiting");break;case "BACKGROUND":this._watchState="BACKGROUND_ERROR",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-background-error"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-waiting");break;case "ACTIVE_ERROR":case "BACKGROUND_ERROR":case "OFF":case void 0:break;default:throw new Error(`Unexpected watchState ${this._watchState}`)}}_updateCircleRadiusIfNeeded(){const e=this._userLocationDotMarker.getLngLat();if(!(this.options.showUserLocation&&this.options.showAccuracyCircle&&this._accuracy&&e))return;const t=this._map.project(e),i=this._map.unproject([t.x+100,t.y]),o=e.distanceTo(i)/100,a=2*this._accuracy/o;this._circleElement.style.width=`${a.toFixed(2)}px`,this._circleElement.style.height=`${a.toFixed(2)}px`;}trigger(){if(!this._setup)return t.w("Geolocate control triggered before added to a map"),!1;if(this.options.trackUserLocation){switch(this._watchState){case "OFF":this._watchState="WAITING_ACTIVE",this.fire(new t.n("trackuserlocationstart"));break;case "WAITING_ACTIVE":case "ACTIVE_LOCK":case "ACTIVE_ERROR":case "BACKGROUND_ERROR":ys--,ws=!1,this._watchState="OFF",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-active-error"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background"),this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background-error"),this.fire(new t.n("trackuserlocationend"));break;case "BACKGROUND":this._watchState="ACTIVE_LOCK",this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-background"),this._lastKnownPosition&&this._updateCamera(this._lastKnownPosition),this.fire(new t.n("trackuserlocationstart")),this.fire(new t.n("userlocationfocus"));break;default:throw new Error(`Unexpected watchState ${this._watchState}`)}switch(this._watchState){case "WAITING_ACTIVE":this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-active");break;case "ACTIVE_LOCK":this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-active");break;case "OFF":break;default:throw new Error(`Unexpected watchState ${this._watchState}`)}if("OFF"===this._watchState&&void 0!==this._geolocationWatchID)this._clearWatch();else if(void 0===this._geolocationWatchID){let e;this._geolocateButton.classList.add("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.setAttribute("aria-pressed","true"),ys++,ys>1?(e={maximumAge:6e5,timeout:0},ws=!0):(e=this.options.positionOptions,ws=!1),this._geolocationWatchID=window.navigator.geolocation.watchPosition(this._onSuccess,this._onError,e);}}else window.navigator.geolocation.getCurrentPosition(this._onSuccess,this._onError,this.options.positionOptions),this._timeoutId=setTimeout(this._finish,1e4);return !0}_clearWatch(){window.navigator.geolocation.clearWatch(this._geolocationWatchID),this._geolocationWatchID=void 0,this._geolocateButton.classList.remove("maplibregl-ctrl-geolocate-waiting"),this._geolocateButton.setAttribute("aria-pressed","false"),this.options.showUserLocation&&this._updateMarker(null);}},e.GlobeControl=class{constructor(){this._toggleProjection=()=>{var e;const t=null===(e=this._map.getProjection())||void 0===e?void 0:e.type;this._map.setProjection("mercator"!==t&&t?{type:"mercator"}:{type:"globe"}),this._updateGlobeIcon();},this._updateGlobeIcon=()=>{var e;this._globeButton.classList.remove("maplibregl-ctrl-globe"),this._globeButton.classList.remove("maplibregl-ctrl-globe-enabled"),"globe"===(null===(e=this._map.getProjection())||void 0===e?void 0:e.type)?(this._globeButton.classList.add("maplibregl-ctrl-globe-enabled"),this._globeButton.title=this._map._getUIString("GlobeControl.Disable")):(this._globeButton.classList.add("maplibregl-ctrl-globe"),this._globeButton.title=this._map._getUIString("GlobeControl.Enable"));};}onAdd(e){return this._map=e,this._container=d.create("div","maplibregl-ctrl maplibregl-ctrl-group"),this._globeButton=d.create("button","maplibregl-ctrl-globe",this._container),d.create("span","maplibregl-ctrl-icon",this._globeButton).setAttribute("aria-hidden","true"),this._globeButton.type="button",this._globeButton.addEventListener("click",this._toggleProjection),this._updateGlobeIcon(),this._map.on("styledata",this._updateGlobeIcon),this._map.on("projectiontransition",this._updateGlobeIcon),this._container}onRemove(){this._container.remove(),this._map.off("styledata",this._updateGlobeIcon),this._map.off("projectiontransition",this._updateGlobeIcon),this._globeButton.removeEventListener("click",this._toggleProjection),this._map=void 0;}},e.Hash=Ya,e.ImageSource=te,e.KeyboardHandler=kr,e.LngLatBounds=G,e.LogoControl=ts,e.Map=ds,e.MapLibreMap=ds,e.MapMouseEvent=nr,e.MapTouchEvent=lr,e.MapWheelEvent=cr,e.Marker=xs,e.NavigationControl=class{constructor(e){this._updateZoomButtons=()=>{const e=this._map.getZoom(),t=e===this._map.getMaxZoom(),i=e===this._map.getMinZoom();this._zoomInButton.disabled=t,this._zoomOutButton.disabled=i,this._zoomInButton.setAttribute("aria-disabled",t.toString()),this._zoomOutButton.setAttribute("aria-disabled",i.toString());},this._rotateCompassArrow=()=>{this._compassIcon.style.transform=this.options.visualizePitch&&this.options.visualizeRoll?`scale(${1/Math.pow(Math.cos(this._map.transform.pitchInRadians),.5)}) rotateZ(${-this._map.transform.roll}deg) rotateX(${this._map.transform.pitch}deg) rotateZ(${-this._map.transform.bearing}deg)`:this.options.visualizePitch?`scale(${1/Math.pow(Math.cos(this._map.transform.pitchInRadians),.5)}) rotateX(${this._map.transform.pitch}deg) rotateZ(${-this._map.transform.bearing}deg)`:this.options.visualizeRoll?`rotate(${-this._map.transform.bearing-this._map.transform.roll}deg)`:`rotate(${-this._map.transform.bearing}deg)`;},this._setButtonTitle=(e,t)=>{const i=this._map._getUIString(`NavigationControl.${t}`);e.title=i,e.setAttribute("aria-label",i);},this.options=t.e({},_s,e),this._container=d.create("div","maplibregl-ctrl maplibregl-ctrl-group"),this._container.addEventListener("contextmenu",(e=>e.preventDefault())),this.options.showZoom&&(this._zoomInButton=this._createButton("maplibregl-ctrl-zoom-in",(e=>this._map.zoomIn({},{originalEvent:e}))),d.create("span","maplibregl-ctrl-icon",this._zoomInButton).setAttribute("aria-hidden","true"),this._zoomOutButton=this._createButton("maplibregl-ctrl-zoom-out",(e=>this._map.zoomOut({},{originalEvent:e}))),d.create("span","maplibregl-ctrl-icon",this._zoomOutButton).setAttribute("aria-hidden","true")),this.options.showCompass&&(this._compass=this._createButton("maplibregl-ctrl-compass",(e=>{this.options.visualizePitch?this._map.resetNorthPitch({},{originalEvent:e}):this._map.resetNorth({},{originalEvent:e});})),this._compassIcon=d.create("span","maplibregl-ctrl-icon",this._compass),this._compassIcon.setAttribute("aria-hidden","true"));}onAdd(e){return this._map=e,this.options.showZoom&&(this._setButtonTitle(this._zoomInButton,"ZoomIn"),this._setButtonTitle(this._zoomOutButton,"ZoomOut"),this._map.on("zoom",this._updateZoomButtons),this._updateZoomButtons()),this.options.showCompass&&(this._setButtonTitle(this._compass,"ResetBearing"),this.options.visualizePitch&&this._map.on("pitch",this._rotateCompassArrow),this.options.visualizeRoll&&this._map.on("roll",this._rotateCompassArrow),this._map.on("rotate",this._rotateCompassArrow),this._rotateCompassArrow(),this._handler=new ps(this._map,this._compass,this.options.visualizePitch)),this._container}onRemove(){this._container.remove(),this.options.showZoom&&this._map.off("zoom",this._updateZoomButtons),this.options.showCompass&&(this.options.visualizePitch&&this._map.off("pitch",this._rotateCompassArrow),this.options.visualizeRoll&&this._map.off("roll",this._rotateCompassArrow),this._map.off("rotate",this._rotateCompassArrow),this._handler.off(),delete this._handler),delete this._map;}_createButton(e,t){const i=d.create("button",e,this._container);return i.type="button",i.addEventListener("click",t),i}},e.Popup=class extends t.E{constructor(e){super(),this._updateOpacity=()=>{void 0!==this.options.locationOccludedOpacity&&(this._container.style.opacity=this._map.transform.isLocationOccluded(this.getLngLat())?`${this.options.locationOccludedOpacity}`:"");},this.remove=()=>(this._content&&this._content.remove(),this._container&&(this._container.remove(),delete this._container),this._map&&(this._map.off("move",this._update),this._map.off("move",this._onClose),this._map.off("click",this._onClose),this._map.off("remove",this.remove),this._map.off("terrain",this._update),this._map.off("projectiontransition",this._update),this._map.off("mousemove",this._update),this._map.off("mouseup",this._update),this._map.off("drag",this._update),this._map._canvasContainer.classList.remove("maplibregl-track-pointer"),delete this._map,this.fire(new t.n("close"))),this),this._update=e=>{if(!this._map||!this._lngLat&&!this._trackPointer||!this._content)return;if(!this._container){if(this._container=d.create("div","maplibregl-popup",this._map.getContainer()),this._tip=d.create("div","maplibregl-popup-tip",this._container),this._container.appendChild(this._content),this.options.className)for(const e of this.options.className.split(" "))this._container.classList.add(e);this._closeButton&&this._closeButton.setAttribute("aria-label",this._map._getUIString("Popup.Close")),this._trackPointer&&this._container.classList.add("maplibregl-popup-track-pointer");}let t;if(this.options.maxWidth&&this._container.style.maxWidth!==this.options.maxWidth&&(this._container.style.maxWidth=this.options.maxWidth),this._lngLat=fs(this._lngLat,this._flatPos,this._map.transform,this._trackPointer),e&&"point"in e&&e.point&&(t=e.point),this._trackPointer&&!t)return;const i=this._flatPos=this._pos=this._trackPointer&&t?t:this._map.project(this._lngLat);this._map.terrain&&(this._flatPos=this._trackPointer&&t?t:this._map.transform.locationToScreenPoint(this._lngLat));let o=this.options.anchor;const a=Es(this.options.offset);if(!o){const e=this._container.offsetWidth,t=this._container.offsetHeight,r=function(e){var t,i,o,a;return e?{top:null!==(t=e.top)&&void 0!==t?t:0,right:null!==(i=e.right)&&void 0!==i?i:0,bottom:null!==(o=e.bottom)&&void 0!==o?o:0,left:null!==(a=e.left)&&void 0!==a?a:0}:{top:0,right:0,bottom:0,left:0}}(this.options.padding);let s;s=i.y+a.bottom.ythis._map.transform.height-t-r.bottom?["bottom"]:[],i.xthis._map.transform.width-e/2-r.right&&s.push("right"),o=0===s.length?"bottom":s.join("-");}let r=i.add(a[o]);this.options.subpixelPositioning||(r=r.round()),this._container.style.transform=`${gs[o]} translate(${r.x}px,${r.y}px)`,vs(this._container,o,"popup"),this._updateOpacity();},this._onClose=()=>{this.remove();},this.options=t.e(Object.create(Ms),e);}addTo(e){return this._map&&this.remove(),this._map=e,this.options.closeOnClick&&this._map.on("click",this._onClose),this.options.closeOnMove&&this._map.on("move",this._onClose),this._map.on("remove",this.remove),this._map.on("terrain",this._update),this._map.on("projectiontransition",this._update),this._update(),this._focusFirstElement(),this._trackPointer?(this._map.on("mousemove",this._update),this._map.on("mouseup",this._update),this._container&&this._container.classList.add("maplibregl-popup-track-pointer"),this._map._canvasContainer.classList.add("maplibregl-track-pointer")):this._map.on("move",this._update),this.fire(new t.n("open")),this}isOpen(){return !!this._map}getLngLat(){return this._lngLat}setLngLat(e){return this._lngLat=t.W.convert(e),this._pos=null,this._flatPos=null,this._trackPointer=!1,this._update(),this._map&&(this._map.on("move",this._update),this._map.off("mousemove",this._update),this._container&&this._container.classList.remove("maplibregl-popup-track-pointer"),this._map._canvasContainer.classList.remove("maplibregl-track-pointer")),this}trackPointer(){return this._trackPointer=!0,this._pos=null,this._flatPos=null,this._update(),this._map&&(this._map.off("move",this._update),this._map.on("mousemove",this._update),this._map.on("drag",this._update),this._container&&this._container.classList.add("maplibregl-popup-track-pointer"),this._map._canvasContainer.classList.add("maplibregl-track-pointer")),this}getElement(){return this._container}setText(e){return this.setDOMContent(document.createTextNode(e))}setHTML(e){const t=document.createDocumentFragment(),i=document.createElement("body");let o;for(i.innerHTML=e;o=i.firstChild,o;)t.appendChild(o);return this.setDOMContent(t)}getMaxWidth(){var e;return null===(e=this._container)||void 0===e?void 0:e.style.maxWidth}setMaxWidth(e){return this.options.maxWidth=e,this._update(),this}setDOMContent(e){if(this._content)for(;this._content.hasChildNodes();)this._content.firstChild&&this._content.removeChild(this._content.firstChild);else this._content=d.create("div","maplibregl-popup-content",this._container);return this._content.appendChild(e),this._createCloseButton(),this._update(),this._focusFirstElement(),this}addClassName(e){return this._container&&this._container.classList.add(e),this}removeClassName(e){return this._container&&this._container.classList.remove(e),this}setOffset(e){return this.options.offset=e,this._update(),this}toggleClassName(e){if(this._container)return this._container.classList.toggle(e)}setSubpixelPositioning(e){this.options.subpixelPositioning=e;}setPadding(e){this.options.padding=e,this._update();}_createCloseButton(){this.options.closeButton&&(this._closeButton=d.create("button","maplibregl-popup-close-button",this._content),this._closeButton.type="button",this._closeButton.innerHTML="×",this._closeButton.addEventListener("click",this._onClose));}_focusFirstElement(){if(!this.options.focusAfterOpen||!this._container)return;const e=this._container.querySelector(Is);e&&e.focus();}},e.RasterDEMTileSource=$,e.RasterTileSource=q,e.ScaleControl=class{constructor(e){this._onMove=()=>{Ps(this._map,this._container,this.options);},this.setUnit=e=>{this.options.unit=e,Ps(this._map,this._container,this.options);},this.options=Object.assign(Object.assign({},Ts),e);}getDefaultPosition(){return "bottom-left"}onAdd(e){return this._map=e,this._container=d.create("div","maplibregl-ctrl maplibregl-ctrl-scale",e.getContainer()),this._map.on("move",this._onMove),this._onMove(),this._container}onRemove(){this._container.remove(),this._map.off("move",this._onMove),this._map=void 0;}},e.ScrollZoomHandler=Nr,e.Style=ki,e.TerrainControl=class{constructor(e){this._toggleTerrain=()=>{this._map.getTerrain()?this._map.setTerrain(null):this._map.setTerrain(this.options),this._updateTerrainIcon();},this._updateTerrainIcon=()=>{this._terrainButton.classList.remove("maplibregl-ctrl-terrain"),this._terrainButton.classList.remove("maplibregl-ctrl-terrain-enabled"),this._map.terrain?(this._terrainButton.classList.add("maplibregl-ctrl-terrain-enabled"),this._terrainButton.title=this._map._getUIString("TerrainControl.Disable")):(this._terrainButton.classList.add("maplibregl-ctrl-terrain"),this._terrainButton.title=this._map._getUIString("TerrainControl.Enable"));},this.options=e;}onAdd(e){return this._map=e,this._container=d.create("div","maplibregl-ctrl maplibregl-ctrl-group"),this._terrainButton=d.create("button","maplibregl-ctrl-terrain",this._container),d.create("span","maplibregl-ctrl-icon",this._terrainButton).setAttribute("aria-hidden","true"),this._terrainButton.type="button",this._terrainButton.addEventListener("click",this._toggleTerrain),this._updateTerrainIcon(),this._map.on("terrain",this._updateTerrainIcon),this._container}onRemove(){this._container.remove(),this._map.off("terrain",this._updateTerrainIcon),this._map=void 0;}},e.TwoFingersTouchPitchHandler=Lr,e.TwoFingersTouchRotateHandler=Dr,e.TwoFingersTouchZoomHandler=zr,e.TwoFingersTouchZoomRotateHandler=qr,e.VectorTileSource=W,e.VideoSource=ie,e.addSourceType=(e,i)=>t._(void 0,void 0,void 0,(function*(){if(re(e))throw new Error(`A source type called "${e}" already exists.`);((e,t)=>{ae[e]=t;})(e,i);})),e.clearPrewarmedResources=function(){const e=A;e&&(e.isPreloaded()&&1===e.numActive()?(e.release(z),A=null):console.warn("Could not clear WebWorkers since there are active Map instances that still reference it. The pre-warmed WebWorker pool can only be cleared when all map instances have been removed with map.remove()"));},e.createTileMesh=ci,e.getGlobalDispatcher=B,e.getMaxParallelImageRequests=function(){return t.c.MAX_PARALLEL_IMAGE_REQUESTS},e.getRTLTextPluginStatus=function(){return ce().getRTLTextPluginStatus()},e.getVersion=function(){return Ss},e.getWorkerCount=function(){return R.workerCount},e.getWorkerUrl=function(){return t.c.WORKER_URL},e.importScriptInWorkers=function(e){return B().broadcast("IS",e)},e.isTimeFrozen=function(){return l.isFrozen()},e.now=c,e.prewarm=function(){F().acquire(z);},e.restoreNow=function(){l.restoreNow();},e.setMaxParallelImageRequests=function(e){t.c.MAX_PARALLEL_IMAGE_REQUESTS=e;},e.setNow=function(e){l.setNow(e);},e.setRTLTextPlugin=function(e,t){return ce().setRTLTextPlugin(e,t)},e.setWorkerCount=function(e){R.workerCount=e;},e.setWorkerUrl=function(e){t.c.WORKER_URL=e;};})); + +// +// Our custom intro provides a specialized "define()" function, called by the +// AMD modules below, that sets up the worker blob URL and then executes the +// main module, storing its exported value as 'maplibregl' + + +var maplibregl$1 = maplibregl; + +return maplibregl$1; + +})); +//# sourceMappingURL=maplibre-gl.js.map diff --git a/static/js/vendors/pmtiles.js b/static/js/vendors/pmtiles.js new file mode 100644 index 00000000..fb67dadd --- /dev/null +++ b/static/js/vendors/pmtiles.js @@ -0,0 +1,2 @@ +"use strict";var pmtiles=(()=>{var k=Object.defineProperty;var Je=Object.getOwnPropertyDescriptor;var Ye=Object.getOwnPropertyNames;var Qe=Object.prototype.hasOwnProperty;var Ce=Math.pow;var f=(r,e)=>k(r,"name",{value:e,configurable:!0});var Xe=(r,e)=>{for(var t in e)k(r,t,{get:e[t],enumerable:!0})},_e=(r,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of Ye(e))!Qe.call(r,i)&&i!==t&&k(r,i,{get:()=>e[i],enumerable:!(n=Je(e,i))||n.enumerable});return r};var et=r=>_e(k({},"__esModule",{value:!0}),r);var m=(r,e,t)=>new Promise((n,i)=>{var a=l=>{try{s(t.next(l))}catch(c){i(c)}},o=l=>{try{s(t.throw(l))}catch(c){i(c)}},s=l=>l.done?n(l.value):Promise.resolve(l.value).then(a,o);s((t=t.apply(r,e)).next())});var Dt={};Xe(Dt,{Compression:()=>Oe,EtagMismatch:()=>B,FetchSource:()=>V,FileSource:()=>se,PMTiles:()=>S,Protocol:()=>ne,ResolvedValueCache:()=>oe,SharedPromiseCache:()=>K,TileType:()=>ae,bytesToHeader:()=>$e,findTile:()=>Fe,getUint64:()=>b,leafletRasterLayer:()=>wt,readVarint:()=>R,tileIdToZxy:()=>At,tileTypeExt:()=>Ze,zxyToTileId:()=>Ie});var w=Uint8Array,E=Uint16Array,tt=Int32Array,Me=new w([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),Ue=new w([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),rt=new w([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Ee=f(function(r,e){for(var t=new E(31),n=0;n<31;++n)t[n]=e+=1<>1|(g&21845)<<1,T=(T&52428)>>2|(T&13107)<<2,T=(T&61680)>>4|(T&3855)<<4,te[g]=((T&65280)>>8|(T&255)<<8)>>1;var T,g,Z=f(function(r,e,t){for(var n=r.length,i=0,a=new E(e);i>l]=c}else for(s=new E(n),i=0;i>15-r[i]);return s},"hMap"),F=new w(288);for(g=0;g<144;++g)F[g]=8;var g;for(g=144;g<256;++g)F[g]=9;var g;for(g=256;g<280;++g)F[g]=7;var g;for(g=280;g<288;++g)F[g]=8;var g,Re=new w(32);for(g=0;g<32;++g)Re[g]=5;var g;var at=Z(F,9,1);var st=Z(Re,5,1),_=f(function(r){for(var e=r[0],t=1;te&&(e=r[t]);return e},"max"),z=f(function(r,e,t){var n=e/8|0;return(r[n]|r[n+1]<<8)>>(e&7)&t},"bits"),ee=f(function(r,e){var t=e/8|0;return(r[t]|r[t+1]<<8|r[t+2]<<16)>>(e&7)},"bits16"),ot=f(function(r){return(r+7)/8|0},"shft"),ut=f(function(r,e,t){return(e==null||e<0)&&(e=0),(t==null||t>r.length)&&(t=r.length),new w(r.subarray(e,t))},"slc");var ft=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],y=f(function(r,e,t){var n=new Error(e||ft[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,y),!t)throw n;return n},"err"),re=f(function(r,e,t,n){var i=r.length,a=n?n.length:0;if(!i||e.f&&!e.l)return t||new w(0);var o=!t,s=o||e.i!=2,l=e.i;o&&(t=new w(i*3));var c=f(function(Ae){var Te=t.length;if(Ae>Te){var De=new w(Math.max(Te*2,Ae));De.set(t),t=De}},"cbuf"),h=e.f||0,u=e.p||0,v=e.b||0,d=e.l,p=e.d,C=e.m,H=e.n,G=i*8;do{if(!d){h=z(r,u,1);var q=z(r,u+1,3);if(u+=3,q)if(q==1)d=at,p=st,C=9,H=5;else if(q==2){var N=z(r,u,31)+257,pe=z(r,u+10,15)+4,me=N+z(r,u+5,31)+1;u+=14;for(var I=new w(me),J=new w(19),x=0;x>4;if(A<16)I[x++]=A;else{var M=0,$=0;for(A==16?($=3+z(r,u,3),u+=2,M=I[x-1]):A==17?($=3+z(r,u,7),u+=3):A==18&&($=11+z(r,u,127),u+=7);$--;)I[x++]=M}}var we=I.subarray(0,N),D=I.subarray(N);C=_(we),H=_(D),d=Z(we,C,1),p=Z(D,H,1)}else y(1);else{var A=ot(u)+4,j=r[A-4]|r[A-3]<<8,W=A+j;if(W>i){l&&y(0);break}s&&c(v+j),t.set(r.subarray(A,W),v),e.b=v+=j,e.p=u=W*8,e.f=h;continue}if(u>G){l&&y(0);break}}s&&c(v+131072);for(var je=(1<>4;if(u+=M&15,u>G){l&&y(0);break}if(M||y(2),U<256)t[v++]=U;else if(U==256){Y=u,d=null;break}else{var xe=U-254;if(U>264){var x=U-257,O=Me[x];xe=z(r,u,(1<>4;Q||y(3),u+=Q&15;var D=it[X];if(X>3){var O=Ue[X];D+=ee(r,u)&(1<G){l&&y(0);break}s&&c(v+131072);var be=v+xe;if(v>3&1)+(e>>4&1);n>0;n-=!r[t++]);return t+(e&2)},"gzs"),ct=f(function(r){var e=r.length;return(r[e-4]|r[e-3]<<8|r[e-2]<<16|r[e-1]<<24)>>>0},"gzl");var vt=f(function(r,e){return((r[0]&15)!=8||r[0]>>4>7||(r[0]<<8|r[1])%31)&&y(6,"invalid zlib data"),(r[1]>>5&1)==+!e&&y(6,"invalid zlib data: "+(r[1]&32?"need":"unexpected")+" dictionary"),(r[1]>>3&4)+2},"zls");function gt(r,e){return re(r,{i:2},e&&e.out,e&&e.dictionary)}f(gt,"inflateSync");function pt(r,e){var t=ht(r);return t+8>r.length&&y(6,"invalid gzip data"),re(r.subarray(t,-8),{i:2},e&&e.out||new w(ct(r)),e&&e.dictionary)}f(pt,"gunzipSync");function mt(r,e){return re(r.subarray(vt(r,e&&e.dictionary),-4),{i:2},e&&e.out,e&&e.dictionary)}f(mt,"unzlibSync");function Be(r,e){return r[0]==31&&r[1]==139&&r[2]==8?pt(r,e):(r[0]&15)!=8||r[0]>>4>7||(r[0]<<8|r[1])%31?gt(r,e):mt(r,e)}f(Be,"decompressSync");var dt=typeof TextDecoder!="undefined"&&new TextDecoder,yt=0;try{dt.decode(lt,{stream:!0}),yt=1}catch(r){}var wt=f((r,e)=>{let t=!1,n="",i=L.GridLayer.extend({createTile:f((a,o)=>{let s=document.createElement("img"),l=new AbortController,c=l.signal;return s.cancel=()=>{l.abort()},t||(r.getHeader().then(h=>{h.tileType===1||h.tileType===6?console.error("Error: archive contains vector tiles, but leafletRasterLayer is for displaying raster tiles. See https://github.com/protomaps/PMTiles/tree/main/js for details."):h.tileType===2?n="image/png":h.tileType===3?n="image/jpeg":h.tileType===4?n="image/webp":h.tileType===5&&(n="image/avif")}),t=!0),r.getZxy(a.z,a.x,a.y,c).then(h=>{if(h){let u=new Blob([h.data],{type:n}),v=window.URL.createObjectURL(u);s.src=v}else s.style.display="none";s.cancel=void 0,o(void 0,s)}).catch(h=>{if(h.name!=="AbortError")throw h}),s},"createTile"),_removeTile:f(function(a){let o=this._tiles[a];o&&(o.el.cancel&&o.el.cancel(),o.el.src&&window.URL.revokeObjectURL(o.el.src),o.el.width=0,o.el.height=0,o.el.deleted=!0,L.DomUtil.remove(o.el),delete this._tiles[a],this.fire("tileunload",{tile:o.el,coords:this._keyToTileCoords(a)}))},"_removeTile")});return new i(e)},"leafletRasterLayer"),xt=f(r=>(e,t)=>{if(t instanceof AbortController)return r(e,t);let n=new AbortController;return r(e,n).then(i=>t(void 0,i.data,i.cacheControl||"",i.expires||""),i=>t(i)).catch(i=>t(i)),{cancel:f(()=>n.abort(),"cancel")}},"v3compat"),ie=class ie{constructor(e){this.tilev4=f((e,t)=>m(this,null,function*(){if(e.type==="json"){let v=e.url.substr(10),d=this.tiles.get(v);if(d||(d=new S(v),this.tiles.set(v,d)),this.metadata){let C=yield d.getTileJson(e.url);return t.signal.throwIfAborted(),{data:C}}let p=yield d.getHeader();return t.signal.throwIfAborted(),(p.minLon>=p.maxLon||p.minLat>=p.maxLat)&&console.error(`Bounds of PMTiles archive ${p.minLon},${p.minLat},${p.maxLon},${p.maxLat} are not valid.`),{data:{tiles:[`${e.url}/{z}/{x}/{y}`],minzoom:p.minZoom,maxzoom:p.maxZoom,bounds:[p.minLon,p.minLat,p.maxLon,p.maxLat]}}}let n=new RegExp(/pmtiles:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/),i=e.url.match(n);if(!i)throw new Error("Invalid PMTiles protocol URL");let a=i[1],o=this.tiles.get(a);o||(o=new S(a),this.tiles.set(a,o));let s=i[2],l=i[3],c=i[4],h=yield o==null?void 0:o.getZxy(+s,+l,+c,t.signal);if(t.signal.throwIfAborted(),h)return{data:new Uint8Array(h.data),cacheControl:h.cacheControl,expires:h.expires};let u=yield o.getHeader();if(u.tileType===1||u.tileType===6){if(this.errorOnMissingTile)throw new Error("Tile not found.");return{data:new Uint8Array}}return{data:null}}),"tilev4");this.tile=xt(this.tilev4);this.tiles=new Map,this.metadata=(e==null?void 0:e.metadata)||!1,this.errorOnMissingTile=(e==null?void 0:e.errorOnMissingTile)||!1}add(e){this.tiles.set(e.source.getKey(),e)}get(e){return this.tiles.get(e)}};f(ie,"Protocol");var ne=ie;function P(r,e){return(e>>>0)*4294967296+(r>>>0)}f(P,"toNum");function bt(r,e){let t=e.buf,n=t[e.pos++],i=(n&112)>>4;if(n<128||(n=t[e.pos++],i|=(n&127)<<3,n<128)||(n=t[e.pos++],i|=(n&127)<<10,n<128)||(n=t[e.pos++],i|=(n&127)<<17,n<128)||(n=t[e.pos++],i|=(n&127)<<24,n<128)||(n=t[e.pos++],i|=(n&1)<<31,n<128))return P(r,i);throw new Error("Expected varint not more than 10 bytes")}f(bt,"readVarintRemainder");function R(r){let e=r.buf,t=e[r.pos++],n=t&127;return t<128||(t=e[r.pos++],n|=(t&127)<<7,t<128)||(t=e[r.pos++],n|=(t&127)<<14,t<128)||(t=e[r.pos++],n|=(t&127)<<21,t<128)?n:(t=e[r.pos],n|=(t&15)<<28,bt(n,r))}f(R,"readVarint");function He(r,e,t,n,i){return i===0?n!==0?[r-1-t,r-1-e]:[t,e]:[e,t]}f(He,"rotate");function Ie(r,e,t){if(r>26)throw new Error("Tile zoom level exceeds max safe number limit (26)");if(e>=1<=1<0;s>>=1){let l=a&s,c=o&s;n+=(3*l^c)*(1<>1;if(e>26)throw new Error("Tile zoom level exceeds max safe number limit (26)");let t=((1<(a[a.Unknown=0]="Unknown",a[a.None=1]="None",a[a.Gzip=2]="Gzip",a[a.Brotli=3]="Brotli",a[a.Zstd=4]="Zstd",a))(Oe||{});function ue(r,e){return m(this,null,function*(){if(e===1||e===0)return r;if(e===2){if(typeof globalThis.DecompressionStream=="undefined")return Be(new Uint8Array(r));let t=new Response(r).body;if(!t)throw new Error("Failed to read response stream");let n=t.pipeThrough(new globalThis.DecompressionStream("gzip"));return new Response(n).arrayBuffer()}throw new Error("Compression method not supported")})}f(ue,"defaultDecompress");var ae=(s=>(s[s.Unknown=0]="Unknown",s[s.Mvt=1]="Mvt",s[s.Png=2]="Png",s[s.Jpeg=3]="Jpeg",s[s.Webp=4]="Webp",s[s.Avif=5]="Avif",s[s.Mlt=6]="Mlt",s))(ae||{});function Ze(r){return r===1?".mvt":r===2?".png":r===3?".jpg":r===4?".webp":r===5?".avif":r===6?".mlt":""}f(Ze,"tileTypeExt");var Tt=127;function Fe(r,e){let t=0,n=r.length-1;for(;t<=n;){let i=n+t>>1,a=e-r[i].tileId;if(a>0)t=i+1;else if(a<0)n=i-1;else return r[i]}return n>=0&&(r[n].runLength===0||e-r[n].tileId-1,o=/Chrome|Chromium|Edg|OPR|Brave/.test(i);this.chromeWindowsNoCache=!1,a&&o&&(this.chromeWindowsNoCache=!0)}getKey(){return this.url}setHeaders(e){this.customHeaders=e}getBytes(e,t,n,i){return m(this,null,function*(){let a,o;n?o=n:(a=new AbortController,o=a.signal);let s=new Headers(this.customHeaders);s.set("range",`bytes=${e}-${e+t-1}`);let l;this.mustReload?l="reload":this.chromeWindowsNoCache&&(l="no-store");let c=yield fetch(this.url,{signal:o,cache:l,headers:s,credentials:this.credentials});if(e===0&&c.status===416){let d=c.headers.get("Content-Range");if(!d||!d.startsWith("bytes */"))throw new Error("Missing content-length on 416 response");let p=+d.substr(8);s.set("range",`bytes=0-${p-1}`),c=yield fetch(this.url,{signal:o,cache:"reload",headers:s,credentials:this.credentials})}let h=c.headers.get("Etag");if(h!=null&&h.startsWith("W/")&&(h=null),c.status===416||i&&h&&h!==i)throw this.mustReload=!0,new B(`Server returned non-matching ETag ${i} after one retry. Check browser extensions and servers for issues that may affect correct ETag headers.`);if(c.status>=300)throw new Error(`Bad response code: ${c.status}`);let u=c.headers.get("Content-Length");if(c.status===200&&(!u||+u>t))throw a&&a.abort(),new Error("Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.");return{data:yield c.arrayBuffer(),etag:h||void 0,cacheControl:c.headers.get("Cache-Control")||void 0,expires:c.headers.get("Expires")||void 0}})}};f(le,"FetchSource");var V=le;function b(r,e){let t=r.getUint32(e+4,!0),n=r.getUint32(e+0,!0);return t*Ce(2,32)+n}f(b,"getUint64");function $e(r,e){let t=new DataView(r),n=t.getUint8(7);if(n>3)throw new Error(`Archive is spec version ${n} but this library supports up to spec version 3`);return{specVersion:n,rootDirectoryOffset:b(t,8),rootDirectoryLength:b(t,16),jsonMetadataOffset:b(t,24),jsonMetadataLength:b(t,32),leafDirectoryOffset:b(t,40),leafDirectoryLength:b(t,48),tileDataOffset:b(t,56),tileDataLength:b(t,64),numAddressedTiles:b(t,72),numTileEntries:b(t,80),numTileContents:b(t,88),clustered:t.getUint8(96)===1,internalCompression:t.getUint8(97),tileCompression:t.getUint8(98),tileType:t.getUint8(99),minZoom:t.getUint8(100),maxZoom:t.getUint8(101),minLon:t.getInt32(102,!0)/1e7,minLat:t.getInt32(106,!0)/1e7,maxLon:t.getInt32(110,!0)/1e7,maxLat:t.getInt32(114,!0)/1e7,centerZoom:t.getUint8(118),centerLon:t.getInt32(119,!0)/1e7,centerLat:t.getInt32(123,!0)/1e7,etag:e}}f($e,"bytesToHeader");function ke(r){let e={buf:new Uint8Array(r),pos:0},t=R(e),n=[],i=0;for(let a=0;a0?n[a].offset=n[a-1].offset+n[a-1].length:n[a].offset=o-1}return n}f(ke,"deserializeIndex");var he=class he extends Error{};f(he,"EtagMismatch");var B=he;function Ve(r,e){return m(this,null,function*(){let t=yield r.getBytes(0,16384);if(new DataView(t.data).getUint16(0,!0)!==19792)throw new Error("Wrong magic number for PMTiles archive");let i=t.data.slice(0,Tt),a=$e(i,t.etag),o=t.data.slice(a.rootDirectoryOffset,a.rootDirectoryOffset+a.rootDirectoryLength),s=`${r.getKey()}|${a.etag||""}|${a.rootDirectoryOffset}|${a.rootDirectoryLength}`,l=ke(yield e(o,a.internalCompression));return[a,[s,l.length,l]]})}f(Ve,"getHeaderAndRoot");function Ke(r,e,t,n,i){return m(this,null,function*(){let a=yield r.getBytes(t,n,void 0,i.etag),o=yield e(a.data,i.internalCompression),s=ke(o);if(s.length===0)throw new Error("Empty directory is invalid");return s})}f(Ke,"getDirectory");var ce=class ce{constructor(e=100,t=!0,n=ue){this.cache=new Map,this.maxCacheEntries=e,this.counter=1,this.decompress=n}getHeader(e){return m(this,null,function*(){let t=e.getKey(),n=this.cache.get(t);if(n)return n.lastUsed=this.counter++,n.data;let i=yield Ve(e,this.decompress);return i[1]&&this.cache.set(i[1][0],{lastUsed:this.counter++,data:i[1][2]}),this.cache.set(t,{lastUsed:this.counter++,data:i[0]}),this.prune(),i[0]})}getDirectory(e,t,n,i){return m(this,null,function*(){let a=`${e.getKey()}|${i.etag||""}|${t}|${n}`,o=this.cache.get(a);if(o)return o.lastUsed=this.counter++,o.data;let s=yield Ke(e,this.decompress,t,n,i);return this.cache.set(a,{lastUsed:this.counter++,data:s}),this.prune(),s})}prune(){if(this.cache.size>this.maxCacheEntries){let e=1/0,t;this.cache.forEach((n,i)=>{n.lastUsed{Ve(e,this.decompress).then(s=>{s[1]&&this.cache.set(s[1][0],{lastUsed:this.counter++,data:Promise.resolve(s[1][2])}),a(s[0]),this.prune()}).catch(s=>{o(s)})});return this.cache.set(t,{lastUsed:this.counter++,data:i}),i})}getDirectory(e,t,n,i){return m(this,null,function*(){let a=`${e.getKey()}|${i.etag||""}|${t}|${n}`,o=this.cache.get(a);if(o)return o.lastUsed=this.counter++,yield o.data;let s=new Promise((l,c)=>{Ke(e,this.decompress,t,n,i).then(h=>{l(h),this.prune()}).catch(h=>{c(h)})});return this.cache.set(a,{lastUsed:this.counter++,data:s}),s})}prune(){if(this.cache.size>=this.maxCacheEntries){let e=1/0,t;this.cache.forEach((n,i)=>{n.lastUsed{this.getHeader(e).then(o=>{i(),this.invalidations.delete(t)}).catch(o=>{a(o)})});this.invalidations.set(t,n)})}};f(ve,"SharedPromiseCache");var K=ve,ge=class ge{constructor(e,t,n){typeof e=="string"?this.source=new V(e):this.source=e,n?this.decompress=n:this.decompress=ue,t?this.cache=t:this.cache=new K}getHeader(){return m(this,null,function*(){return yield this.cache.getHeader(this.source)})}getZxyAttempt(e,t,n,i){return m(this,null,function*(){let a=Ie(e,t,n),o=yield this.cache.getHeader(this.source);if(eo.maxZoom)return;let s=o.rootDirectoryOffset,l=o.rootDirectoryLength;for(let c=0;c<=3;c++){let h=yield this.cache.getDirectory(this.source,s,l,o),u=Fe(h,a);if(u){if(u.runLength>0){let v=yield this.source.getBytes(o.tileDataOffset+u.offset,u.length,i,o.etag);return{data:yield this.decompress(v.data,o.tileCompression),cacheControl:v.cacheControl,expires:v.expires}}s=o.leafDirectoryOffset+u.offset,l=u.length}else return}throw new Error("Maximum directory depth exceeded")})}getZxy(e,t,n,i){return m(this,null,function*(){try{return yield this.getZxyAttempt(e,t,n,i)}catch(a){if(a instanceof B)return this.cache.invalidate(this.source),yield this.getZxyAttempt(e,t,n,i);throw a}})}getMetadataAttempt(){return m(this,null,function*(){let e=yield this.cache.getHeader(this.source),t=yield this.source.getBytes(e.jsonMetadataOffset,e.jsonMetadataLength,void 0,e.etag),n=yield this.decompress(t.data,e.internalCompression),i=new TextDecoder("utf-8");return JSON.parse(i.decode(n))})}getMetadata(){return m(this,null,function*(){try{return yield this.getMetadataAttempt()}catch(e){if(e instanceof B)return this.cache.invalidate(this.source),yield this.getMetadataAttempt();throw e}})}getTileJson(e){return m(this,null,function*(){let t=yield this.getHeader(),n=yield this.getMetadata(),i=Ze(t.tileType);return{tilejson:"3.0.0",scheme:"xyz",tiles:[`${e}/{z}/{x}/{y}${i}`],vector_layers:n.vector_layers,attribution:n.attribution,description:n.description,name:n.name,version:n.version,bounds:[t.minLon,t.minLat,t.maxLon,t.maxLat],center:[t.centerLon,t.centerLat,t.centerZoom],minzoom:t.minZoom,maxzoom:t.maxZoom}})}};f(ge,"PMTiles");var S=ge;return et(Dt);})(); +//# sourceMappingURL=pmtiles.js.map \ No newline at end of file From bdbb7ae1964c27ce4e035b8b8ea058622a02c39e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:13:49 +0000 Subject: [PATCH 26/39] fix(auth): revoke JWT access on deactivation, deletion and role change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT access tokens freeze role/identity at login (access 1h, refresh 7d) and validated tokens are cached for 30s. The Bearer and cookie auth paths trusted claims.role and never re-checked the account, so demoting an admin, or disabling/deleting an account, did not revoke access until the token expired. The app-password path already re-read role/active from the DB; only the JWT/cookie path had the gap. Re-validate the caller against the live user record on the token path via the already-cached get_user_flags (role / is_external / active), bounded by USER_FLAGS_CACHE_TTL and invalidated eagerly on set_user_active / change_user_role / delete_user_admin: - middleware/user.rs: new resolve_live_role helper (+ pure decide_live_role core) — returns the *current* role, rejects deleted (NotFound) and deactivated accounts, and fails open on transient lookup errors (mirrors require_internal_user). Login/refresh remain the canonical active gate. - middleware/auth.rs: auth_middleware (Bearer + cookie) now populates CurrentUser with the live role and rejects revoked accounts (Bearer -> 401 AccountInactive; cookie -> fall through to 401/login redirect). require_admin emits an audit line on denial. - middleware/admin.rs: require_admin / require_authenticated re-check the live record, return the live role, and audit admin denials. Downstream admin gates (dedup_handler, subject_group_handler, OCS) inherit the live role automatically via CurrentUser / require_authenticated. Tests: decide_live_role policy (active / demoted / deactivated / deleted / transient fail-open) and AccountInactive -> 401. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TAzLEQDaLak3dnrEN3YT35 --- src/interfaces/middleware/admin.rs | 63 +++++++++---- src/interfaces/middleware/auth.rs | 96 ++++++++++++++++---- src/interfaces/middleware/user.rs | 137 ++++++++++++++++++++++++++++- 3 files changed, 260 insertions(+), 36 deletions(-) diff --git a/src/interfaces/middleware/admin.rs b/src/interfaces/middleware/admin.rs index 8ddd13c2..54a86c68 100644 --- a/src/interfaces/middleware/admin.rs +++ b/src/interfaces/middleware/admin.rs @@ -14,6 +14,7 @@ use crate::application::ports::auth_ports::TokenServicePort; use crate::common::di::AppState; use crate::interfaces::api::cookie_auth::{ACCESS_COOKIE, extract_cookie_value}; use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::user::{LiveRole, resolve_live_role}; /// Validate the request's JWT (from the `Authorization: Bearer …` header /// or the access-token cookie) and require `claims.role == "admin"`. @@ -43,19 +44,37 @@ pub async fn require_admin( .validate_token(&token) .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - if claims.role != "admin" { - return Err(AppError::new( - StatusCode::FORBIDDEN, - "Admin access required", - "Forbidden", - )); - } + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|_| AppError::internal_error("Invalid user ID in token"))?; - Ok(( - Uuid::parse_str(&claims.sub) - .map_err(|_| AppError::internal_error("Invalid user ID in token"))?, - claims.role.clone(), - )) + // Gate on the *live* role, not the JWT claim: a demotion or deactivation + // must take effect within the flags-cache TTL rather than surviving until + // the token expires. + match resolve_live_role( + auth.auth_application_service.as_ref(), + user_id, + &claims.role, + ) + .await + { + LiveRole::Active(role) if role == "admin" => Ok((user_id, role)), + LiveRole::Active(role) => { + tracing::info!( + target: "audit", + event = "authz.admin_denied", + reason = "not_admin", + caller_id = %user_id, + role = %role, + "👮🏻‍♂️ admin-only endpoint denied for non-admin caller" + ); + Err(AppError::new( + StatusCode::FORBIDDEN, + "Admin access required", + "Forbidden", + )) + } + LiveRole::Revoked => Err(AppError::unauthorized("Account is no longer active")), + } } /// Validate the request's JWT (any role) and return `(user_id, role)`. @@ -84,9 +103,19 @@ pub async fn require_authenticated( .validate_token(&token) .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - Ok(( - Uuid::parse_str(&claims.sub) - .map_err(|_| AppError::internal_error("Invalid user ID in token"))?, - claims.role.clone(), - )) + let user_id = Uuid::parse_str(&claims.sub) + .map_err(|_| AppError::internal_error("Invalid user ID in token"))?; + + // Reject tokens whose account was deactivated/deleted, and return the + // caller's live role rather than the (possibly stale) JWT claim. + match resolve_live_role( + auth.auth_application_service.as_ref(), + user_id, + &claims.role, + ) + .await + { + LiveRole::Active(role) => Ok((user_id, role)), + LiveRole::Revoked => Err(AppError::unauthorized("Account is no longer active")), + } } diff --git a/src/interfaces/middleware/auth.rs b/src/interfaces/middleware/auth.rs index 547523c7..2843fa1b 100644 --- a/src/interfaces/middleware/auth.rs +++ b/src/interfaces/middleware/auth.rs @@ -13,6 +13,7 @@ use crate::common::di::AppState; // Re-export CurrentUser from application layer for use in handlers pub use crate::application::dtos::user_dto::CurrentUser; use crate::application::ports::auth_ports::TokenServicePort; +use crate::interfaces::middleware::user::{LiveRole, resolve_live_role}; /// Marker inserted into request extensions when the user was authenticated /// via the `oxicloud_access` HttpOnly cookie rather than a Bearer/Basic header. @@ -111,6 +112,9 @@ pub enum AuthError { #[error("User not found")] UserNotFound, + #[error("Account is no longer active")] + AccountInactive, + #[error("Access denied: {0}")] AccessDenied(String), @@ -127,6 +131,10 @@ impl IntoResponse for AuthError { AuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, msg), AuthError::TokenExpired => (StatusCode::UNAUTHORIZED, "Token expired".to_string()), AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "User not found".to_string()), + AuthError::AccountInactive => ( + StatusCode::UNAUTHORIZED, + "Account is no longer active".to_string(), + ), AuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, msg), AuthError::AuthServiceUnavailable => ( StatusCode::INTERNAL_SERVER_ERROR, @@ -181,11 +189,26 @@ pub async fn auth_middleware( let user_id = Uuid::parse_str(&claims.sub).map_err(|_| { AuthError::InvalidToken("Invalid user ID in token".to_string()) })?; + // A cryptographically valid token must not outlive the + // account: re-check the live record so deactivation, + // deletion and demotion take effect within the flags-cache + // TTL instead of waiting for token expiry. The returned + // role is authoritative — never the frozen JWT claim. + let role = match resolve_live_role( + auth_service.auth_application_service.as_ref(), + user_id, + &claims.role, + ) + .await + { + LiveRole::Active(role) => role, + LiveRole::Revoked => return Err(AuthError::AccountInactive), + }; let current_user = Arc::new(CurrentUser { id: user_id, username: claims.username.clone(), email: claims.email.clone(), - role: claims.role.clone(), + role, }); request.extensions_mut().insert(current_user); tracing::Span::current().record("user_id", user_id.to_string()); @@ -280,16 +303,33 @@ pub async fn auth_middleware( let user_id = Uuid::parse_str(&claims.sub).map_err(|_| { AuthError::InvalidToken("Invalid user ID in token".to_string()) })?; - let current_user = Arc::new(CurrentUser { - id: user_id, - username: claims.username.clone(), - email: claims.email.clone(), - role: claims.role.clone(), - }); - request.extensions_mut().insert(current_user); - request.extensions_mut().insert(CookieAuthenticated); - tracing::Span::current().record("user_id", user_id.to_string()); - return Ok(next.run(request).await); + // Same live-account re-check as the Bearer path. On + // revocation we fall through (rather than erroring) so the + // browser receives the standard 401 and redirects to + // /login, exactly like an invalid or expired cookie. + match resolve_live_role( + auth_service.auth_application_service.as_ref(), + user_id, + &claims.role, + ) + .await + { + LiveRole::Active(role) => { + let current_user = Arc::new(CurrentUser { + id: user_id, + username: claims.username.clone(), + email: claims.email.clone(), + role, + }); + request.extensions_mut().insert(current_user); + request.extensions_mut().insert(CookieAuthenticated); + tracing::Span::current().record("user_id", user_id.to_string()); + return Ok(next.run(request).await); + } + LiveRole::Revoked => { + // Fall through to the unauthenticated 401 / login redirect. + } + } } Err(e) => { tracing::debug!("Cookie token validation failed: {}", e); @@ -344,8 +384,11 @@ fn dav_basic_auth_challenge(message: &'static str) -> Response { /// Middleware to verify that the authenticated user has an admin role. /// -/// Must be applied AFTER auth_middleware, as it depends on -/// `CurrentUser` being present in the request extensions. +/// Must be applied AFTER auth_middleware, as it depends on `CurrentUser` +/// being present in the request extensions. The role carried by +/// `CurrentUser` is the *live* role resolved by `auth_middleware` (see +/// [`resolve_live_role`]), not the JWT claim, so a demotion is honoured +/// here within the flags-cache TTL. pub async fn require_admin(request: Request, next: Next) -> Response { // Get the CurrentUser inserted by auth_middleware if let Some(current_user) = request.extensions().get::>() { @@ -353,13 +396,21 @@ pub async fn require_admin(request: Request, next: Next) -> Response { tracing::debug!("Admin access granted for user: {}", current_user.username); return next.run(request).await; } - tracing::warn!( - "Admin access denied for user: {} (role: {})", - current_user.username, - current_user.role + tracing::info!( + target: "audit", + event = "authz.admin_denied", + reason = "not_admin", + caller_id = %current_user.id, + role = %current_user.role, + "👮🏻‍♂️ admin-only route denied for non-admin caller" ); } else { - tracing::warn!("Admin check failed: no authenticated user in request"); + tracing::info!( + target: "audit", + event = "authz.admin_denied", + reason = "unauthenticated", + "👮🏻‍♂️ admin-only route reached with no authenticated user" + ); } // Access denied @@ -415,4 +466,13 @@ mod tests { Some(r#"Basic realm="OxiCloud""#), ); } + + #[test] + fn account_inactive_maps_to_401() { + // A token that is still cryptographically valid but whose account was + // deactivated/deleted must be rejected with 401 (credentials no longer + // valid), so browsers redirect to /login rather than seeing a 403. + let resp = AuthError::AccountInactive.into_response(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } } diff --git a/src/interfaces/middleware/user.rs b/src/interfaces/middleware/user.rs index 4aa4f6c7..64bf2f44 100644 --- a/src/interfaces/middleware/user.rs +++ b/src/interfaces/middleware/user.rs @@ -32,7 +32,8 @@ use uuid::Uuid; use crate::application::services::auth_application_service::AuthApplicationService; use crate::common::di::AppState; -use crate::domain::entities::user::UserRole; +use crate::domain::entities::user::{UserFlags, UserRole}; +use crate::domain::errors::{DomainError, ErrorKind}; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::CurrentUser; @@ -102,6 +103,91 @@ pub async fn require_admin_user( Ok(()) } +/// Outcome of re-checking a token-authenticated caller against the live +/// user record (see [`resolve_live_role`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LiveRole { + /// The account exists and is active. Carries the caller's *current* + /// role string (`"admin"` / `"user"`), which is authoritative and + /// supersedes the — possibly stale — JWT `role` claim. + Active(String), + /// The account is deactivated or deleted: the request must be rejected + /// even though its token is still cryptographically valid. + Revoked, +} + +/// Re-validate a caller carried by a still-valid token against the live +/// user record, so deactivation, deletion and role changes take effect +/// within [`USER_FLAGS_CACHE_TTL`](crate::application::services::auth_application_service) +/// instead of waiting for the token to expire (access 1 h / refresh 7 d by +/// default). +/// +/// JWT claims — `role` included — are frozen at login. Without this check a +/// demoted admin keeps admin power, and a disabled or deleted account keeps +/// full access, until its token expires. Returning the *current* role lets +/// every caller stop trusting `claims.role`. +/// +/// Cost: the short-TTL-cached `get_user_flags` (no `image` column), so ~one +/// tiny indexed query per user per cache-TTL window; admin role/active +/// changes invalidate the entry eagerly for immediate effect. +/// +/// Availability stance mirrors [`require_internal_user`]: a *transient* +/// lookup failure fails OPEN with the claim role (a DB blip must not lock +/// every authenticated user out, and login/refresh already enforce `active` +/// at the canonical layer). A *missing* row (`NotFound`) is a definitive +/// revocation and fails CLOSED. +pub async fn resolve_live_role( + auth: &AuthApplicationService, + user_id: Uuid, + claim_role: &str, +) -> LiveRole { + decide_live_role(auth.get_user_flags(user_id).await, user_id, claim_role) +} + +/// Pure decision core of [`resolve_live_role`], split out so the +/// allow/revoke/fail-open policy is unit-testable without a service or DB. +fn decide_live_role( + flags: Result, + user_id: Uuid, + claim_role: &str, +) -> LiveRole { + match flags { + Ok(flags) if flags.active => LiveRole::Active(flags.role.to_string()), + Ok(_) => { + audit_token_revoked(user_id, "deactivated"); + LiveRole::Revoked + } + // The user row is gone — a definitive revocation; fail closed. + Err(e) if matches!(e.kind, ErrorKind::NotFound) => { + audit_token_revoked(user_id, "deleted"); + LiveRole::Revoked + } + // Transient lookup failure (DB blip): fail open on the claim role so + // a momentary outage doesn't 401 every authenticated user at once. + Err(e) => { + tracing::warn!( + user_id = %user_id, + error = %e, + "live-user re-check failed transiently; allowing request on the JWT claim role (fail-open)" + ); + LiveRole::Active(claim_role.to_string()) + } + } +} + +/// Audit a request rejected because the token outlived the account's access +/// (deactivation or deletion). Anti-enumeration is not a concern — the +/// subject is the caller's own account. +fn audit_token_revoked(user_id: Uuid, reason: &'static str) { + tracing::info!( + target: "audit", + event = "auth.token_revoked", + reason = reason, + caller_id = %user_id, + "👮🏻‍♂️ valid token presented for an account that is no longer active — rejected" + ); +} + /// Axum middleware layer that blocks external users from a whole route /// subtree. Apply via `.layer(from_fn_with_state(state, require_internal_user_layer))` /// on the protocol nests (CalDAV / CardDAV / WebDAV) that have no @@ -153,3 +239,52 @@ pub async fn require_internal_user_layer( next.run(request).await } + +#[cfg(test)] +mod tests { + use super::*; + + fn flags(role: UserRole, active: bool) -> UserFlags { + UserFlags { + role, + is_external: false, + active, + } + } + + #[test] + fn active_admin_yields_current_admin_role() { + let live = decide_live_role(Ok(flags(UserRole::Admin, true)), Uuid::nil(), "user"); + // The live record wins over the (stale) claim — a freshly promoted + // user is admin even though their token still says "user". + assert_eq!(live, LiveRole::Active("admin".to_string())); + } + + #[test] + fn active_user_yields_current_user_role() { + // A demoted admin: token claim still "admin", live record "user". + let live = decide_live_role(Ok(flags(UserRole::User, true)), Uuid::nil(), "admin"); + assert_eq!(live, LiveRole::Active("user".to_string())); + } + + #[test] + fn deactivated_account_is_revoked() { + let live = decide_live_role(Ok(flags(UserRole::Admin, false)), Uuid::nil(), "admin"); + assert_eq!(live, LiveRole::Revoked); + } + + #[test] + fn deleted_account_not_found_is_revoked() { + let err = DomainError::new(ErrorKind::NotFound, "User", "no such user"); + let live = decide_live_role(Err(err), Uuid::nil(), "admin"); + assert_eq!(live, LiveRole::Revoked); + } + + #[test] + fn transient_error_fails_open_on_claim_role() { + // A DB blip must not lock everyone out: allow on the claim role. + let err = DomainError::new(ErrorKind::InternalError, "User", "connection reset"); + let live = decide_live_role(Err(err), Uuid::nil(), "admin"); + assert_eq!(live, LiveRole::Active("admin".to_string())); + } +} From 513b6224b4e4d76b7e4babdc8965bb1b9c2ae001 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:17:56 +0000 Subject: [PATCH 27/39] feat(places): photo map view (MapLibre + PMTiles, Approach A) Adds a Places tab to the Photos section: a self-hosted MapLibre GL map of the user's geotagged photos. - places.js: lazy-loads vendored MapLibre + pmtiles.js on first open; draws the server-aggregated clusters (GET /api/photos/geo) as HTML thumbnail markers (no glyphs/sprites, no client-side clustering); pan/zoom refetches for the new viewport; click a cluster to zoom in, or a single photo to open it in the lightbox. - Optional vector basemap read directly from /basemaps/basemap.pmtiles over HTTP Range (label-light Protomaps style, light/dark aware); falls back to a themed background when no basemap is present. ODbL attribution shown. - "Moments | Places" sub-nav wired into the Photos section. - enable_places now defaults on, since the route + UI are ready. No new backend serving code: tower-http ServeDir already serves static/ with Range, so the .pmtiles basemap is just a static file. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- src/common/config.rs | 2 +- static/css/views/places.css | 90 +++++++ static/index.html | 2 + static/js/app/navigation.js | 5 +- static/js/features/library/places.js | 377 +++++++++++++++++++++++++++ static/locales/en.json | 6 +- 6 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 static/css/views/places.css create mode 100644 static/js/features/library/places.js diff --git a/src/common/config.rs b/src/common/config.rs index 0a1147c9..57414c63 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -895,7 +895,7 @@ impl Default for FeaturesConfig { enable_trash: true, // Enable trash feature enable_search: true, // Enable search feature enable_music: true, // Enable music feature - enable_places: false, // Photo map; off until the map UI ships + enable_places: true, // Photo map (GET /api/photos/geo + Places tab) expose_system_users: true, // Expose OxiCloud users as address book by default } } diff --git a/static/css/views/places.css b/static/css/views/places.css new file mode 100644 index 00000000..83d6502f --- /dev/null +++ b/static/css/views/places.css @@ -0,0 +1,90 @@ +/* Photos sub-navigation (Moments | Places) */ +.photos-subnav { + display: flex; + gap: var(--space-1); + padding: var(--space-2) var(--space-2) 0; +} + +.photos-subnav.hidden { + display: none; +} + +.photos-subnav-tab { + background: none; + border: none; + padding: var(--space-2) var(--space-3); + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--color-text-faint); + cursor: pointer; + border-radius: var(--radius-md); + border-bottom: 2px solid transparent; +} + +.photos-subnav-tab:hover { + color: var(--color-text); +} + +.photos-subnav-tab.active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +/* Map view */ +.places-container { + display: none; +} + +.places-container.active { + display: flex; + flex-direction: column; + height: calc(100vh - 150px); + min-height: 360px; + padding: var(--space-2); +} + +.places-map { + flex: 1 1 auto; + width: 100%; + border-radius: var(--radius-2xl); + overflow: hidden; +} + +.places-loading, +.places-error { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + color: var(--color-text-faint); +} + +.places-loading i { + animation: spin 1s linear infinite; +} + +/* Cluster markers — a circular photo thumbnail with a count badge */ +.places-cluster { + background-size: cover; + background-position: center; + background-color: var(--color-bg-muted); + border-radius: 50%; + border: 2px solid var(--color-bg-surface); + box-shadow: 0 2px 8px var(--color-shadow-sm); + cursor: pointer; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.places-cluster-count { + background: var(--color-accent); + color: var(--color-danger-text); + font-size: var(--text-2xs); + font-weight: var(--weight-bold); + line-height: 1; + padding: var(--space-0-5) var(--space-1-5); + border-radius: var(--radius-full); + transform: translateY(35%); +} diff --git a/static/index.html b/static/index.html index da1a4295..b2a37ff8 100644 --- a/static/index.html +++ b/static/index.html @@ -36,6 +36,7 @@ + @@ -57,6 +58,7 @@ + diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index ecf5304e..0f8ac042 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -10,6 +10,7 @@ import { batchToolbar } from '../features/files/batchToolbar.js'; import { favorites } from '../features/library/favorites.js'; import { musicView } from '../features/library/music.js'; import { photosView } from '../features/library/photos.js'; +import { placesView } from '../features/library/places.js'; import { grants } from '../model/grants.js'; import { favoritesView } from '../views/favorites/favoritesView.js'; import { mySharesView } from '../views/myShares/mySharesView.js'; @@ -225,9 +226,10 @@ function setCurrentSection(section) { // Reset owner column — sections that need it re-enable it explicitly below. ui.setOwnerColumnVisible(false); - // Hide photosView when switching to any other section + // Hide photosView (+ the Places sub-view) when switching to any other section if (section !== 'photos' && photosView) { photosView.hide(); + placesView.unmountTabs(); } // Hide musicView when switching to any other section @@ -451,6 +453,7 @@ function switchToPhotosSection() { if (photosView) { photosView.show(); } + placesView.mountTabs(); if (batchToolbar) batchToolbar.clear(); } diff --git a/static/js/features/library/places.js b/static/js/features/library/places.js new file mode 100644 index 00000000..6e11d5b1 --- /dev/null +++ b/static/js/features/library/places.js @@ -0,0 +1,377 @@ +/** + * OxiCloud - Places (photo map) + * + * Renders the user's geotagged photos on a self-hosted MapLibre GL map. + * Photos are clustered *server-side* (GET /api/photos/geo, grid aggregation), + * so we draw one lightweight HTML marker per cluster — no glyph/sprite assets + * and no client-side clustering needed. The vector basemap is optional: if a + * `static/basemaps/basemap.pmtiles` is present it is read directly by the + * browser over HTTP Range (pmtiles.js); otherwise the map falls back to a + * plain themed background and still shows the photo clusters. + * + * MapLibre + pmtiles.js are heavy, so they are vendored and lazy-loaded only + * when the Places tab is first opened. + */ + +import { getCsrfHeaders } from '../../core/csrf.js'; +import { i18n } from '../../core/i18n.js'; +import { photosView } from './photos.js'; +import { photosLightbox } from './photosLightbox.js'; + +/** @import {FileItem} from '../../core/types.js' */ +/** @typedef {{lng: number, lat: number, count: number, sample_file_id: string}} GeoClusterItem */ + +const BASEMAP_URL = '/basemaps/basemap.pmtiles'; + +export const placesView = { + /** @type {HTMLElement|null} */ + _container: null, + /** @type {HTMLElement|null} */ + _subnav: null, + /** @type {any} MapLibre Map instance */ + _map: null, + /** @type {any[]} current cluster markers */ + _markers: [], + /** @type {{maplibregl: any, pmtiles: any}|null} */ + _libs: null, + /** @type {number} debounce timer for moveend refresh */ + _moveTimer: 0, + /** @type {'moments'|'places'} */ + _activeTab: 'moments', + /** @type {boolean|null} cached basemap availability */ + _hasBasemap: null, + + /** Auth headers (HttpOnly cookies + CSRF) */ + _headers() { + return getCsrfHeaders(); + }, + + // ── Sub-navigation (Moments | Places) ─────────────────────────── + // Lives at the top of `.content-area`; mounted while the Photos section + // is active and torn down (hidden) when the user leaves it. + + /** Create/show the Moments|Places tab bar and the map container. */ + mountTabs() { + const contentArea = document.querySelector('.content-area'); + if (!contentArea) return; + + if (!this._subnav) { + const bar = document.createElement('div'); + bar.className = 'photos-subnav'; + bar.innerHTML = + `` + + ``; + bar.addEventListener('click', (e) => { + const btn = /** @type {HTMLElement} */ (e.target).closest('[data-ptab]'); + if (btn) this._switchTab(/** @type {'moments'|'places'} */ (btn.getAttribute('data-ptab'))); + }); + contentArea.insertBefore(bar, contentArea.firstChild); + this._subnav = bar; + } + this._subnav.classList.remove('hidden'); + + if (!this._container) { + const el = document.createElement('div'); + el.id = 'places-container'; + el.className = 'places-container'; + contentArea.appendChild(el); + this._container = el; + } + + // Always (re)enter the Photos section on the Moments tab. + this._activeTab = 'moments'; + this._setActiveTab('moments'); + this.hide(); + }, + + /** Hide the tab bar and the map (called when leaving the Photos section). */ + unmountTabs() { + this._subnav?.classList.add('hidden'); + this.hide(); + }, + + /** Hide the map container (without destroying the map). */ + hide() { + this._container?.classList.remove('active'); + }, + + /** + * @param {'moments'|'places'} tab + */ + _switchTab(tab) { + if (tab === this._activeTab) return; + this._activeTab = tab; + this._setActiveTab(tab); + if (tab === 'places') { + photosView.hide(); + this._showMap(); + } else { + this.hide(); + photosView.show(); + } + }, + + /** @param {string} tab */ + _setActiveTab(tab) { + this._subnav?.querySelectorAll('[data-ptab]').forEach((b) => { + b.classList.toggle('active', b.getAttribute('data-ptab') === tab); + }); + }, + + // ── Map ───────────────────────────────────────────────────────── + + /** Reveal the map container and (lazily) build the map. */ + async _showMap() { + if (!this._container) return; + this._container.classList.add('active'); + + if (this._map) { + this._map.resize(); + this._refreshClusters(false); + return; + } + + this._container.innerHTML = '
' + '
'; + try { + const libs = await this._loadLibs(); + await this._initMap(libs); + } catch (err) { + console.error('Places map failed to load:', err); + if (this._container) { + this._container.innerHTML = `
${this._esc(i18n.t('photos.map_error'))}
`; + } + } + }, + + /** Inject a vendored script once, resolving when it has loaded. + * @param {string} src + * @returns {Promise} + */ + _loadScript(src) { + return new Promise((resolve, reject) => { + if (document.querySelector(`script[data-vendor="${src}"]`)) { + resolve(); + return; + } + const s = document.createElement('script'); + s.src = src; + s.async = true; + s.dataset.vendor = src; + s.addEventListener('load', () => resolve()); + s.addEventListener('error', () => reject(new Error(`Failed to load ${src}`))); + document.head.appendChild(s); + }); + }, + + /** Lazy-load MapLibre GL + pmtiles.js (+ MapLibre CSS) and read their globals. */ + async _loadLibs() { + if (this._libs) return this._libs; + if (!document.querySelector('link[data-vendor="maplibre-css"]')) { + const l = document.createElement('link'); + l.rel = 'stylesheet'; + l.href = '/js/vendors/maplibre-gl.css'; + l.dataset.vendor = 'maplibre-css'; + document.head.appendChild(l); + } + await this._loadScript('/js/vendors/maplibre-gl.js'); + await this._loadScript('/js/vendors/pmtiles.js'); + const w = /** @type {any} */ (window); + this._libs = { maplibregl: w.maplibregl, pmtiles: w.pmtiles }; + return this._libs; + }, + + /** Whether a basemap .pmtiles is available (cached after first probe). */ + async _checkBasemap() { + if (this._hasBasemap !== null) return this._hasBasemap; + try { + const res = await fetch(BASEMAP_URL, { headers: { Range: 'bytes=0-0' } }); + this._hasBasemap = res.ok; // 200/206 = present, 404 = absent + } catch { + this._hasBasemap = false; + } + return this._hasBasemap; + }, + + /** + * @param {{maplibregl: any, pmtiles: any}} libs + */ + async _initMap({ maplibregl, pmtiles }) { + const hasBasemap = await this._checkBasemap(); + if (hasBasemap) { + try { + const protocol = new pmtiles.Protocol(); + maplibregl.addProtocol('pmtiles', protocol.tile); + } catch (e) { + console.error('pmtiles protocol registration failed:', e); + } + } + + this._map = new maplibregl.Map({ + container: 'places-map', + style: hasBasemap ? this._basemapStyle() : this._blankStyle(), + center: [0, 25], + zoom: 1.3, + attributionControl: false + }); + this._map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); + if (hasBasemap) { + this._map.addControl( + new maplibregl.AttributionControl({ + customAttribution: 'Protomaps ©
OpenStreetMap' + }) + ); + } + + this._map.on('load', () => { + this._removeLoading(); + this._refreshClusters(true); + }); + this._map.on('moveend', () => { + clearTimeout(this._moveTimer); + this._moveTimer = window.setTimeout(() => this._refreshClusters(false), 250); + }); + }, + + _removeLoading() { + this._container?.querySelector('.places-loading')?.remove(); + }, + + /** Fetch clusters for the current viewport and render them. + * @param {boolean} fit Fit the map to the returned clusters (first load). + */ + async _refreshClusters(fit) { + if (!this._map) return; + const b = this._map.getBounds(); + const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`; + const zoom = Math.round(this._map.getZoom()); + try { + const res = await fetch(`/api/photos/geo?bbox=${bbox}&zoom=${zoom}`, { + credentials: 'include', + headers: this._headers() + }); + if (!res.ok) return; + /** @type {GeoClusterItem[]} */ + const clusters = await res.json(); + this._renderMarkers(clusters); + if (fit && clusters.length) this._fitTo(clusters); + } catch (err) { + console.error('Places geo fetch failed:', err); + } + }, + + /** @param {GeoClusterItem[]} clusters */ + _renderMarkers(clusters) { + for (const m of this._markers) m.remove(); + this._markers = []; + if (!this._libs) return; + const { maplibregl } = this._libs; + + for (const c of clusters) { + const size = Math.round(Math.min(64, 30 + Math.log2(c.count + 1) * 6)); + const el = document.createElement('div'); + el.className = 'places-cluster'; + el.style.width = `${size}px`; + el.style.height = `${size}px`; + el.style.backgroundImage = `url(/api/files/${c.sample_file_id}/thumbnail/icon)`; + if (c.count > 1) { + el.innerHTML = `${c.count}`; + } + el.addEventListener('click', () => this._onClusterClick(c)); + const marker = new maplibregl.Marker({ element: el }).setLngLat([c.lng, c.lat]).addTo(this._map); + this._markers.push(marker); + } + }, + + /** @param {GeoClusterItem} c */ + _onClusterClick(c) { + const zoom = this._map.getZoom(); + if (c.count === 1 || zoom >= 16) { + // Drill down to the representative photo. We only know its id, so + // build a minimal item and let the lightbox load the rest. + const item = /** @type {FileItem} */ ( + /** @type {any} */ ({ + id: c.sample_file_id, + name: '', + mime_type: 'image/jpeg', + created_at: 0, + sort_date: 0, + size_formatted: '' + }) + ); + photosLightbox.open([item], 0); + } else { + this._map.easeTo({ center: [c.lng, c.lat], zoom: Math.min(zoom + 2.5, 17) }); + } + }, + + /** @param {GeoClusterItem[]} clusters */ + _fitTo(clusters) { + if (!this._libs) return; + const { maplibregl } = this._libs; + const bounds = new maplibregl.LngLatBounds(); + for (const c of clusters) bounds.extend([c.lng, c.lat]); + if (!bounds.isEmpty()) { + this._map.fitBounds(bounds, { padding: 64, maxZoom: 14, duration: 0 }); + } + }, + + /** @returns {boolean} */ + _isDark() { + return document.documentElement.getAttribute('data-color-scheme') === 'dark'; + }, + + /** Minimal MapLibre style: themed background only (no basemap). */ + _blankStyle() { + return { + version: 8, + sources: {}, + layers: [ + { + id: 'bg', + type: 'background', + paint: { 'background-color': this._isDark() ? '#0f172a' : '#e8eef3' } + } + ] + }; + }, + + /** Label-light Protomaps vector style (no glyphs/sprites required). */ + _basemapStyle() { + const dark = this._isDark(); + const c = dark + ? { earth: '#1b2433', land: '#222d3d', water: '#0d1b2a', roads: '#3a4860', buildings: '#2a3547', boundary: '#475569' } + : { earth: '#f3efe9', land: '#e9e4da', water: '#a8c8e8', roads: '#ffffff', buildings: '#e0dccf', boundary: '#c9c2b6' }; + return { + version: 8, + sources: { + protomaps: { + type: 'vector', + url: `pmtiles://${BASEMAP_URL}`, + attribution: 'Protomaps © OpenStreetMap' + } + }, + layers: [ + { id: 'bg', type: 'background', paint: { 'background-color': c.earth } }, + { id: 'earth', type: 'fill', source: 'protomaps', 'source-layer': 'earth', paint: { 'fill-color': c.earth } }, + { id: 'landuse', type: 'fill', source: 'protomaps', 'source-layer': 'landuse', paint: { 'fill-color': c.land, 'fill-opacity': 0.6 } }, + { id: 'water', type: 'fill', source: 'protomaps', 'source-layer': 'water', paint: { 'fill-color': c.water } }, + { id: 'roads', type: 'line', source: 'protomaps', 'source-layer': 'roads', minzoom: 7, paint: { 'line-color': c.roads, 'line-width': 0.8 } }, + { id: 'buildings', type: 'fill', source: 'protomaps', 'source-layer': 'buildings', minzoom: 13, paint: { 'fill-color': c.buildings } }, + { + id: 'boundaries', + type: 'line', + source: 'protomaps', + 'source-layer': 'boundaries', + paint: { 'line-color': c.boundary, 'line-width': 0.6, 'line-dasharray': [2, 2] } + } + ] + }; + }, + + /** @param {any} s */ + _esc(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } +}; diff --git a/static/locales/en.json b/static/locales/en.json index 32a0efca..ce783528 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -73,7 +73,11 @@ "delete_selected_confirm": "Move the selected items to Trash?", "delete_one_confirm": "Move \"{{name}}\" to Trash?", "layout_square": "Grid", - "layout_justified": "Justified" + "layout_justified": "Justified", + "tab_moments": "Moments", + "tab_places": "Places", + "map_loading": "Loading map…", + "map_error": "Could not load the map" }, "music": { "create_playlist": "Create Playlist", From 5fe645972272187577c42be2ddec8a2bd5149242 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:18:33 +0000 Subject: [PATCH 28/39] docs(plan): mark Phase 1 (Places) complete via Approach A Records the as-built choices: static-file basemap via ServeDir Range (not the pmtiles crate), HTML thumbnail markers (not deck.gl), default-on flag, and the pending browser smoke-test + operator basemap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- docs/plan/Photos-People-Places.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/plan/Photos-People-Places.md b/docs/plan/Photos-People-Places.md index 70e0f38e..62471e22 100644 --- a/docs/plan/Photos-People-Places.md +++ b/docs/plan/Photos-People-Places.md @@ -44,7 +44,13 @@ migrations, and a **vanilla-JS / vanilla-CSS** frontend (design tokens from | 0.6 HEIC | ⬜ pending | open decision (native `libheif` dep). | | 0.7 Sub-nav tabs | ⬜ pending | deferred to Phase 1 (tabs need the Places/People views). | -**Phase 1 — Places: backend in progress** (data API: index, repo aggregation, service, flag, endpoints). The map frontend (1.5 PMTiles serving, 1.7 MapLibre module, 1.8 thumbnails) is gated on the MapLibre-vendoring decision + a basemap asset. **Phase 2 — People: not started.** No new vendored JS/assets added yet. +**Phase 1 — Places: complete (Approach A).** +- Backend (`f4b431b`): migration `…_places_geo_index.sql`, `FileBlobReadRepository::list_geo_clusters` (plain-SQL grid aggregation, no PostGIS), `PlacesService` (caller_id-scoped), `OXICLOUD_ENABLE_PLACES` (now **default on**, `513b622`), `GET /api/photos/geo`. +- Frontend: vendored MapLibre 5.24.0 + pmtiles 4.4.1 (`bb3d739`); `places.js` (`513b622`) renders the server-aggregated clusters as **HTML thumbnail markers** (no glyphs/sprites, no client-side clustering), refetches on pan/zoom, and drills into the lightbox. Optional Protomaps `.pmtiles` basemap read over HTTP **Range via the existing `ServeDir`** (label-light style, light/dark) with graceful fallback to a themed background; ODbL attribution. "Moments | Places" sub-nav. +- **Deviations from the original plan:** 1.5 serves the basemap as a *static file* (ServeDir Range) instead of the `pmtiles` Rust crate; 1.8 uses MapLibre HTML markers instead of a deck.gl `IconLayer`. Both keep the footprint minimal and need zero new backend code. +- **Pending:** browser smoke-test, and an operator-supplied `static/basemaps/basemap.pmtiles` for the street backdrop (works without it). + +**Phase 2 — People: not started.** --- From 05d51ededd78728d33653b215a429d807c39a0b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:27:11 +0000 Subject: [PATCH 29/39] perf(grants,favorites): batch resource resolution to kill N+1 and pool fan-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three list endpoints resolved each resource with one query per id: - GET /api/grants/incoming and /api/grants/outgoing used join_all(ids.map(get_file)) + join_all(ids.map(get_folder)), so a single page (limit ≤ 200) could demand ~200 concurrent connections from the 20-connection primary pool, causing acquire-timeouts and head-of-line blocking under load. - The NextCloud favorites REPORT (oc:filter-files) fetched get_file/ get_folder once per favorite — up to N serial round-trips per sync. Add by-ids batch reads that mirror the existing get_file/get_folder column mapping and NOT is_trashed filter: - FileBlobReadRepository::get_files_by_ids / FolderDbRepository::get_folders_by_ids (one SELECT ... WHERE id = ANY($1)), exposed as FileRetrievalService:: get_files_by_ids / FolderService::get_folders_by_ids returning DTOs. - Both grant handlers and the favorites REPORT now issue two batch queries total and look results up by id, preserving original order. Missing ids (stale grants whose resource was deleted, or trashed/removed favorites) drop out exactly as before. No auth-semantics change: these paths already resolved ids vetted by the authorization engine / favorites table. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TAzLEQDaLak3dnrEN3YT35 --- .../services/file_retrieval_service.rs | 11 + src/application/services/folder_service.rs | 11 + .../pg/file_blob_read_repository.rs | 46 ++++ .../repositories/pg/folder_db_repository.rs | 31 +++ src/interfaces/api/handlers/grant_handler.rs | 226 +++++++----------- src/interfaces/nextcloud/report_handler.rs | 42 +++- 6 files changed, 226 insertions(+), 141 deletions(-) diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 41f83bd8..27ae48e7 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -250,6 +250,17 @@ impl FileRetrievalService { let stream = self.file_read.get_file_stream(id).await?; Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream)))) } + + /// Batch counterpart of [`FileRetrievalUseCase::get_file`]: resolve many + /// file ids in ONE query instead of one per id. Like `get_file` it + /// performs no per-file authorization — both current callers (ACL grant + /// listing, NextCloud favorites REPORT) resolve ids already vetted by the + /// authorization engine or the favorites table. Missing or trashed ids are + /// absent from the result; callers re-associate by `id`. + pub async fn get_files_by_ids(&self, ids: &[String]) -> Result, DomainError> { + let files = self.file_read.get_files_by_ids(ids).await?; + Ok(files.into_iter().map(FileDto::from).collect()) + } } impl FileRetrievalUseCase for FileRetrievalService { diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 8fd096e8..66f3893f 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -29,6 +29,17 @@ impl FolderService { } } + /// Batch counterpart of `get_folder`: resolve many folder ids in ONE + /// query instead of one per id. Like `get_folder` it performs no + /// per-folder authorization — both current callers (ACL grant listing, + /// NextCloud favorites REPORT) resolve ids already vetted by the + /// authorization engine or the favorites table. Missing or trashed ids + /// are absent from the result; callers re-associate by `id`. + pub async fn get_folders_by_ids(&self, ids: &[String]) -> Result, DomainError> { + let folders = self.folder_storage.get_folders_by_ids(ids).await?; + Ok(folders.into_iter().map(FolderDto::from).collect()) + } + /// Helper: parse a folder id string into a `Resource::Folder`. Returns /// `DomainError::not_found` on parse error (anti-enumeration — the same /// error as "folder does not exist"). diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index f2fefdfe..4999f101 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -255,6 +255,52 @@ impl FileBlobReadRepository { }) } + /// Batch-fetch files by id — the by-ids counterpart of [`get_file`], + /// used to resolve a page of ACL grants or favorites in ONE round-trip + /// instead of one query per id (the previous `join_all(ids.map(get_file))` + /// could fan out to ~200 concurrent pooled connections per page). Applies + /// the same `NOT is_trashed` filter and identical column mapping as + /// `get_file`. Ids that are missing or trashed simply drop out, so callers + /// must re-associate results by id; ordering is not guaranteed. + pub async fn get_files_by_ids(&self, ids: &[String]) -> Result, DomainError> { + let uuid_ids: Vec = ids.iter().filter_map(|id| id.parse().ok()).collect(); + if uuid_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, FileRow>( + "SELECT fi.id::text, fi.name, fi.folder_id::text, fo.path, \ + fi.size, fi.mime_type, \ + EXTRACT(EPOCH FROM fi.created_at)::bigint, \ + EXTRACT(EPOCH FROM fi.updated_at)::bigint, \ + fi.blob_hash, \ + fi.user_id \ + FROM storage.files fi \ + LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \ + WHERE fi.id = ANY($1) AND NOT fi.is_trashed", + ) + .bind(&uuid_ids) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("FileBlobRead", format!("get_files_by_ids: {e}")) + })?; + + rows.into_iter() + .map( + |(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid)| { + Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, uid) + }, + ) + .collect::, _>>() + .map_err(|e| { + DomainError::internal_error( + "FileBlobRead", + format!("get_files_by_ids mapping: {e}"), + ) + }) + } + /// Returns the user_id (owner) for a given file ID. /// Mirrors `FolderDbRepository::get_folder_user_id`. /// Used by the AuthorizationEngine for owner short-circuit. diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index 97852fae..5d38ce56 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -109,6 +109,37 @@ impl FolderDbRepository { ) .map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}"))) } + + /// Batch-fetch folders by id — the by-ids counterpart of `get_folder`, + /// resolving a page of ACL grants or favorites in ONE query instead of + /// one per id. Same `NOT is_trashed` filter and column mapping as + /// `get_folder`; missing or trashed ids drop out and callers re-associate + /// by id; ordering is not guaranteed. + pub async fn get_folders_by_ids(&self, ids: &[String]) -> Result, DomainError> { + let uuid_ids: Vec = ids.iter().filter_map(|id| id.parse().ok()).collect(); + if uuid_ids.is_empty() { + return Ok(Vec::new()); + } + + let rows = sqlx::query_as::<_, FolderRow>( + r#" + SELECT id::text, name, path, parent_id::text, user_id, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint, + EXTRACT(EPOCH FROM tree_modified_at)::bigint + FROM storage.folders + WHERE id = ANY($1) AND NOT is_trashed + "#, + ) + .bind(&uuid_ids) + .fetch_all(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("get_folders_by_ids: {e}")))?; + + rows.into_iter() + .map(|r| Self::row_to_folder(r.0, r.1, r.2, r.3, Some(r.4), r.5, r.6, r.7)) + .collect() + } } impl FolderRepository for FolderDbRepository { diff --git a/src/interfaces/api/handlers/grant_handler.rs b/src/interfaces/api/handlers/grant_handler.rs index 7fc39ab3..63674364 100644 --- a/src/interfaces/api/handlers/grant_handler.rs +++ b/src/interfaces/api/handlers/grant_handler.rs @@ -11,8 +11,8 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use futures::future::join_all; use serde::Deserialize; +use std::collections::HashMap; use std::sync::Arc; use tracing::{error, warn}; use utoipa::IntoParams; @@ -26,13 +26,10 @@ use crate::application::dtos::grant_dto::{ SubjectInputDto, UpdateRoleDto, role_from_permissions, }; use crate::application::ports::authorization_ports::AuthorizationEngine; -use crate::application::ports::file_ports::FileRetrievalUseCase; -use crate::application::ports::folder_ports::FolderUseCase; use crate::application::services::recipient_notification_service::NotifyTrigger; use crate::common::di::AppState; #[allow(unused_imports)] use crate::common::errors::DomainError; -use crate::domain::errors::ErrorKind; use crate::domain::services::authorization::{ GrantCursor, IncomingGrantSummary, OutgoingResourceSummary, Permission, Resource, ResourceKind, Role, Subject, @@ -664,83 +661,64 @@ pub async fn list_shared_with_me( .map(|s| s.resource_id.to_string()) .collect(); - // Resolve resource details concurrently (files and folders in parallel). - let (file_results, folder_results) = tokio::join!( - join_all(file_ids.iter().map(|id| file_service.get_file(id))), - join_all(folder_ids.iter().map(|id| folder_service.get_folder(id))) + // Resolve resource details in two batch queries (was one per id via + // join_all, which could fan out to ~limit concurrent pooled connections + // and starve the primary pool). Missing ids — stale grants whose resource + // was deleted before the cascade trigger fired — drop out of the maps. + let (file_list, folder_list) = tokio::join!( + file_service.get_files_by_ids(&file_ids), + folder_service.get_folders_by_ids(&folder_ids) ); + let file_map: HashMap = match file_list { + Ok(files) => files.into_iter().map(|f| (f.id.clone(), f)).collect(), + Err(e) => return AppError::from(e).into_response(), + }; + let folder_map: HashMap = match folder_list { + Ok(folders) => folders.into_iter().map(|f| (f.id.clone(), f)).collect(), + Err(e) => return AppError::from(e).into_response(), + }; - // Build the unified item list in original grant order (newest first). - // We iterate summaries in order and pick the resolved result from the - // appropriate typed bucket. - let mut file_idx = 0usize; - let mut folder_idx = 0usize; - + // Build the unified item list in original grant order (newest first), + // looking each resolved resource up by id. let mut items: Vec = Vec::with_capacity(summaries.len()); for summary in &summaries { + let rid = summary.resource_id.to_string(); match summary.resource_type { - ResourceKind::File => { - let result = &file_results[file_idx]; - file_idx += 1; - match result { - Ok(file_dto) => { - items.push(SharedWithMeItemDto { - resource_type: ResourceTypeDto::File, - permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), - granted_at: summary.granted_at, - granted_by: summary.granted_by, - resource: ResourceContentDto::File( - file_dto.clone().without_hierarchy_info(), - ), - }); - } - Err(e) if e.kind == ErrorKind::NotFound => { - // Stale grant (file deleted, trigger not yet fired) — skip silently. - warn!( - "Skipping stale file grant for resource_id={}: not found", - summary.resource_id - ); - } - Err(e) => { - return AppError::internal_error(format!( - "Failed to fetch file {}: {e}", - summary.resource_id - )) - .into_response(); - } + ResourceKind::File => match file_map.get(&rid) { + Some(file_dto) => { + items.push(SharedWithMeItemDto { + resource_type: ResourceTypeDto::File, + permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), + granted_at: summary.granted_at, + granted_by: summary.granted_by, + resource: ResourceContentDto::File( + file_dto.clone().without_hierarchy_info(), + ), + }); } - } - ResourceKind::Folder => { - let result = &folder_results[folder_idx]; - folder_idx += 1; - match result { - Ok(folder_dto) => { - items.push(SharedWithMeItemDto { - resource_type: ResourceTypeDto::Folder, - permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), - granted_at: summary.granted_at, - granted_by: summary.granted_by, - resource: ResourceContentDto::Folder( - folder_dto.clone().without_hierarchy_info(), - ), - }); - } - Err(e) if e.kind == ErrorKind::NotFound => { - warn!( - "Skipping stale folder grant for resource_id={}: not found", - summary.resource_id - ); - } - Err(e) => { - return AppError::internal_error(format!( - "Failed to fetch folder {}: {e}", - summary.resource_id - )) - .into_response(); - } + None => warn!( + "Skipping stale file grant for resource_id={}: not found", + summary.resource_id + ), + }, + ResourceKind::Folder => match folder_map.get(&rid) { + Some(folder_dto) => { + items.push(SharedWithMeItemDto { + resource_type: ResourceTypeDto::Folder, + permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), + granted_at: summary.granted_at, + granted_by: summary.granted_by, + resource: ResourceContentDto::Folder( + folder_dto.clone().without_hierarchy_info(), + ), + }); } - } + None => warn!( + "Skipping stale folder grant for resource_id={}: not found", + summary.resource_id + ), + }, } } @@ -909,13 +887,20 @@ pub async fn list_my_shares( .map(|s| s.resource_id.to_string()) .collect(); - let (file_results, folder_results) = tokio::join!( - join_all(file_ids.iter().map(|id| file_service.get_file(id))), - join_all(folder_ids.iter().map(|id| folder_service.get_folder(id))) + // Two batch queries instead of one get_* per id (see list_shared_with_me). + let (file_list, folder_list) = tokio::join!( + file_service.get_files_by_ids(&file_ids), + folder_service.get_folders_by_ids(&folder_ids) ); + let file_map: HashMap = match file_list { + Ok(files) => files.into_iter().map(|f| (f.id.clone(), f)).collect(), + Err(e) => return AppError::from(e).into_response(), + }; + let folder_map: HashMap = match folder_list { + Ok(folders) => folders.into_iter().map(|f| (f.id.clone(), f)).collect(), + Err(e) => return AppError::from(e).into_response(), + }; - let mut file_idx = 0usize; - let mut folder_idx = 0usize; let mut items: Vec = Vec::with_capacity(summaries.len()); for summary in &summaries { @@ -935,64 +920,39 @@ pub async fn list_my_shares( }) .collect(); + let rid = summary.resource_id.to_string(); match summary.resource_type { - ResourceKind::File => { - let result = &file_results[file_idx]; - file_idx += 1; - match result { - Ok(file_dto) => { - // Caller is the granter — they had share-access to the - // resource, so the containing hierarchy is already known - // to them. Keep `path` (unlike list_shared_with_me). - items.push(OutgoingResourceItemDto { - resource_type: ResourceTypeDto::File, - first_shared_at: summary.first_shared_at, - resource: ResourceContentDto::File(file_dto.clone()), - grants, - }); - } - Err(e) if e.kind == ErrorKind::NotFound => { - warn!( - "Skipping stale outgoing file grant for resource_id={}: not found", - summary.resource_id - ); - } - Err(e) => { - return AppError::internal_error(format!( - "Failed to fetch file {}: {e}", - summary.resource_id - )) - .into_response(); - } + ResourceKind::File => match file_map.get(&rid) { + Some(file_dto) => { + // Caller is the granter — they had share-access to the + // resource, so the containing hierarchy is already known + // to them. Keep `path` (unlike list_shared_with_me). + items.push(OutgoingResourceItemDto { + resource_type: ResourceTypeDto::File, + first_shared_at: summary.first_shared_at, + resource: ResourceContentDto::File(file_dto.clone()), + grants, + }); } - } - ResourceKind::Folder => { - let result = &folder_results[folder_idx]; - folder_idx += 1; - match result { - Ok(folder_dto) => { - items.push(OutgoingResourceItemDto { - resource_type: ResourceTypeDto::Folder, - first_shared_at: summary.first_shared_at, - resource: ResourceContentDto::Folder(folder_dto.clone()), - grants, - }); - } - Err(e) if e.kind == ErrorKind::NotFound => { - warn!( - "Skipping stale outgoing folder grant for resource_id={}: not found", - summary.resource_id - ); - } - Err(e) => { - return AppError::internal_error(format!( - "Failed to fetch folder {}: {e}", - summary.resource_id - )) - .into_response(); - } + None => warn!( + "Skipping stale outgoing file grant for resource_id={}: not found", + summary.resource_id + ), + }, + ResourceKind::Folder => match folder_map.get(&rid) { + Some(folder_dto) => { + items.push(OutgoingResourceItemDto { + resource_type: ResourceTypeDto::Folder, + first_shared_at: summary.first_shared_at, + resource: ResourceContentDto::Folder(folder_dto.clone()), + grants, + }); } - } + None => warn!( + "Skipping stale outgoing folder grant for resource_id={}: not found", + summary.resource_id + ), + }, } } diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index d5e81b38..9e056d0b 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -7,7 +7,7 @@ use quick_xml::{ Reader, Writer, events::{BytesEnd, BytesStart, Event}, }; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use crate::application::dtos::display_helpers::{ @@ -17,7 +17,6 @@ use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::application::ports::favorites_ports::FavoritesUseCase; -use crate::application::ports::file_ports::FileRetrievalUseCase; use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; @@ -86,20 +85,47 @@ async fn handle_filter_files( let home_prefix = format!("My Folder - {}/", user.username); - // Pass 1: fetch the favorited DTOs (the per-item fetch is a separate - // concern from the oc:fileid resolution batched below). + // Pass 1: resolve the favorited DTOs in two batch queries (was one + // get_* per favorite — up to N serial round-trips on a sync client's + // REPORT). Results are looked up by id so the response keeps favorites + // order; missing/trashed favorites simply drop out (as before). + let mut file_ids: Vec = Vec::new(); + let mut folder_ids: Vec = Vec::new(); + for fav in &favorites { + match fav.item_type.as_str() { + "file" => file_ids.push(fav.item_id.clone()), + "folder" => folder_ids.push(fav.item_id.clone()), + _ => {} + } + } + + let file_map: HashMap = file_service + .get_files_by_ids(&file_ids) + .await + .map_err(|e| AppError::internal_error(format!("Failed to resolve favorite files: {e}")))? + .into_iter() + .map(|f| (f.id.clone(), f)) + .collect(); + let folder_map: HashMap = folder_service + .get_folders_by_ids(&folder_ids) + .await + .map_err(|e| AppError::internal_error(format!("Failed to resolve favorite folders: {e}")))? + .into_iter() + .map(|f| (f.id.clone(), f)) + .collect(); + let mut files: Vec = Vec::new(); let mut folders: Vec = Vec::new(); for fav in &favorites { match fav.item_type.as_str() { "file" => { - if let Ok(f) = file_service.get_file(&fav.item_id).await { - files.push(f); + if let Some(f) = file_map.get(&fav.item_id) { + files.push(f.clone()); } } "folder" => { - if let Ok(f) = folder_service.get_folder(&fav.item_id).await { - folders.push(f); + if let Some(f) = folder_map.get(&fav.item_id) { + folders.push(f.clone()); } } _ => {} From 7cb79d41b9624c4a4f7db524b03c3b784f57f445 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:28:39 +0000 Subject: [PATCH 30/39] feat(faces): schema + feature flag for People (off by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of Phase 2 (People / faces): - migration: `faces` schema with `faces.persons` and `faces.faces`. Embeddings are stored as BYTEA (512 x f32) — no pgvector extension dependency; similarity is computed in-app (pgvector/VectorChord is the documented scale-up). Cascade deletes (by user and by source file) satisfy the right to erasure. - OXICLOUD_ENABLE_FACES feature flag, OFF by default (biometric data, opt-in per deployment). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- migrations/20260801000004_faces.sql | 47 +++++++++++++++++++++++++++++ src/common/config.rs | 10 ++++++ 2 files changed, 57 insertions(+) create mode 100644 migrations/20260801000004_faces.sql diff --git a/migrations/20260801000004_faces.sql b/migrations/20260801000004_faces.sql new file mode 100644 index 00000000..2d2dfd43 --- /dev/null +++ b/migrations/20260801000004_faces.sql @@ -0,0 +1,47 @@ +-- ════════════════════════════════════════════════════════════════════════ +-- People / Faces: per-user face detections and identity clusters. +-- +-- Embeddings are stored as BYTEA (512 × float32, L2-normalized = 2048 bytes) +-- rather than a pgvector column, so the feature adds NO new PostgreSQL +-- extension dependency. Similarity is computed in-app (brute-force cosine +-- scales comfortably to ~100k faces); pgvector / VectorChord with an HNSW +-- index is the documented upgrade path for larger libraries. +-- +-- Biometric data — the feature is OFF by default (OXICLOUD_ENABLE_FACES) and +-- opt-in per user. All rows cascade-delete with their owning user, and face +-- rows cascade-delete with their source file, satisfying the right to erasure. +-- ════════════════════════════════════════════════════════════════════════ + +CREATE SCHEMA IF NOT EXISTS faces; + +-- An identity cluster ("person"). display_name is NULL until the user names it. +CREATE TABLE IF NOT EXISTS faces.persons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + display_name TEXT, + cover_face_id UUID, -- representative face (set by the app) + is_hidden BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_persons_user ON faces.persons (user_id); + +-- A single detected face with its embedding and (optional) person assignment. +CREATE TABLE IF NOT EXISTS faces.faces ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + person_id UUID REFERENCES faces.persons(id) ON DELETE SET NULL, + bbox REAL[] NOT NULL, -- [x, y, w, h], normalized 0..1 + det_score REAL NOT NULL, -- detector confidence + quality REAL, -- blur/size gate score (nullable) + embedding BYTEA NOT NULL, -- 512 × float32, L2-normalized + blob_hash VARCHAR(64), -- dedup-aware reuse across identical files + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_faces_user ON faces.faces (user_id); +CREATE INDEX IF NOT EXISTS idx_faces_person ON faces.faces (person_id); +CREATE INDEX IF NOT EXISTS idx_faces_file ON faces.faces (file_id); +CREATE INDEX IF NOT EXISTS idx_faces_blob ON faces.faces (blob_hash); diff --git a/src/common/config.rs b/src/common/config.rs index 57414c63..3c28d46f 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -881,6 +881,9 @@ pub struct FeaturesConfig { pub enable_music: bool, /// Lists the user's geotagged photos on a map (GET /api/photos/geo). pub enable_places: bool, + /// Face detection + identity clustering for the photo library ("People"). + /// Biometric data — OFF by default; opt-in per deployment/user. + pub enable_faces: bool, /// Expose other OxiCloud users as a read-only "system" address book /// at GET /api/address-books. Set to false to hide the user directory. pub expose_system_users: bool, @@ -896,6 +899,7 @@ impl Default for FeaturesConfig { enable_search: true, // Enable search feature enable_music: true, // Enable music feature enable_places: true, // Photo map (GET /api/photos/geo + Places tab) + enable_faces: false, // People/faces (biometric) — opt-in, off by default expose_system_users: true, // Expose OxiCloud users as address book by default } } @@ -1387,6 +1391,12 @@ impl AppConfig { config.features.enable_places = val; } + if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::()) + && let Ok(val) = enable_faces + { + config.features.enable_faces = val; + } + // Content search (embedded Tantivy index) if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::()) && let Ok(val) = v From 524e57375605542ea6c49d7f608e6857f96affb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:34:46 +0000 Subject: [PATCH 31/39] feat(faces): domain entities, ports & no-op analyzer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 increment 2: - domain: Face, Person, BoundingBox, DetectedFace (512-d embeddings). - ports: FaceAnalyzerPort (detect + embed from raw bytes; decodes internally so the application layer stays image/ML-crate agnostic) and FaceRepository (user-scoped face/person persistence). - DTOs: PersonDto, FaceBoxDto. - NoopFaceAnalyzer — reports is_ready()==false and returns no faces, so the whole People pipeline compiles and runs inert until the operator wires a real ONNX-backed analyzer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- src/application/dtos/mod.rs | 1 + src/application/dtos/people_dto.rs | 30 +++++++ src/application/ports/face_ports.rs | 78 +++++++++++++++++++ src/application/ports/mod.rs | 1 + src/domain/entities/face.rs | 72 +++++++++++++++++ src/domain/entities/mod.rs | 1 + src/infrastructure/services/mod.rs | 1 + .../services/noop_face_analyzer.rs | 26 +++++++ 8 files changed, 210 insertions(+) create mode 100644 src/application/dtos/people_dto.rs create mode 100644 src/application/ports/face_ports.rs create mode 100644 src/domain/entities/face.rs create mode 100644 src/infrastructure/services/noop_face_analyzer.rs diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index d86edb67..d3a5ea09 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -14,6 +14,7 @@ pub mod geo_dto; pub mod grant_dto; pub mod i18n_dto; pub mod pagination; +pub mod people_dto; pub mod playlist_dto; pub mod plugin_dto; pub mod recent_dto; diff --git a/src/application/dtos/people_dto.rs b/src/application/dtos/people_dto.rs new file mode 100644 index 00000000..8de09a8d --- /dev/null +++ b/src/application/dtos/people_dto.rs @@ -0,0 +1,30 @@ +//! DTOs for the People (faces) API. + +use serde::Serialize; +use utoipa::ToSchema; + +/// A named (or unnamed) identity cluster, with a cover photo for its tile. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct PersonDto { + pub id: String, + /// `None` until the user names the person. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// File id of the cover face's photo, for the tile thumbnail. + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_file_id: Option, + pub face_count: i64, + pub is_hidden: bool, +} + +/// One face box within a photo (for tagging overlays in the lightbox). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct FaceBoxDto { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub person_id: Option, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} diff --git a/src/application/ports/face_ports.rs b/src/application/ports/face_ports.rs new file mode 100644 index 00000000..95e00eb8 --- /dev/null +++ b/src/application/ports/face_ports.rs @@ -0,0 +1,78 @@ +//! Ports for the People (faces) feature. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::common::errors::DomainError; +use crate::domain::entities::face::{DetectedFace, Face, Person}; + +/// Detects faces in an image and produces an aligned, L2-normalized embedding +/// for each. Takes raw encoded bytes (it decodes internally) so the +/// application layer stays decoupled from any image/ML crate. +/// +/// The default implementation ([`NoopFaceAnalyzer`](crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer)) +/// is a no-op that reports `is_ready() == false`; a real ONNX-backed +/// implementation is wired in when the operator provides models at runtime. +#[async_trait] +pub trait FaceAnalyzerPort: Send + Sync + 'static { + /// Whether a usable model is loaded. When false, indexing is skipped. + fn is_ready(&self) -> bool; + + /// Detect and embed every face in `image_bytes` (an encoded JPEG/PNG/…). + async fn analyze(&self, image_bytes: &[u8]) -> Result, DomainError>; +} + +/// Persistence for faces and persons. Every method is user-scoped; the +/// repository enforces `WHERE user_id = …` so callers only ever touch their +/// own biometric data. +#[async_trait] +pub trait FaceRepository: Send + Sync + 'static { + // ── faces ────────────────────────────────────────────────────── + async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError>; + async fn faces_for_file(&self, file_id: Uuid) -> Result, DomainError>; + async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError>; + async fn faces_for_user(&self, user_id: Uuid) -> Result, DomainError>; + /// Faces previously computed for any file sharing this content hash — + /// lets indexing reuse results for deduplicated (identical) uploads. + async fn faces_for_blob( + &self, + user_id: Uuid, + blob_hash: &str, + ) -> Result, DomainError>; + async fn assign_person( + &self, + face_id: Uuid, + person_id: Option, + ) -> Result<(), DomainError>; + + // ── persons ──────────────────────────────────────────────────── + async fn create_person(&self, person: &Person) -> Result<(), DomainError>; + async fn persons_for_user(&self, user_id: Uuid) -> Result, DomainError>; + async fn rename_person( + &self, + user_id: Uuid, + person_id: Uuid, + name: Option, + ) -> Result<(), DomainError>; + async fn set_person_cover( + &self, + person_id: Uuid, + cover_face_id: Uuid, + ) -> Result<(), DomainError>; + async fn set_person_hidden( + &self, + user_id: Uuid, + person_id: Uuid, + hidden: bool, + ) -> Result<(), DomainError>; + /// File ids that contain a face assigned to this person (most recent first). + async fn files_for_person( + &self, + user_id: Uuid, + person_id: Uuid, + ) -> Result, DomainError>; + + /// Hard-delete every face and person for a user (right to erasure / + /// disabling the feature). + async fn delete_all_for_user(&self, user_id: Uuid) -> Result<(), DomainError>; +} diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 5e6c39be..352ed8f4 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -10,6 +10,7 @@ pub mod compression_ports; pub mod content_index_ports; pub mod dedup_ports; pub mod email_sender; +pub mod face_ports; pub mod favorites_ports; pub mod file_lifecycle; pub mod file_ports; diff --git a/src/domain/entities/face.rs b/src/domain/entities/face.rs new file mode 100644 index 00000000..46380833 --- /dev/null +++ b/src/domain/entities/face.rs @@ -0,0 +1,72 @@ +//! Domain entities for the People (faces) feature. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Length of a face embedding vector (ArcFace-style). +pub const EMBEDDING_DIM: usize = 512; + +/// A face bounding box in normalized image coordinates (each component 0..1). +#[derive(Debug, Clone, Copy)] +pub struct BoundingBox { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl BoundingBox { + /// `[x, y, w, h]` — the storage representation (Postgres `REAL[]`). + pub fn to_array(self) -> Vec { + vec![self.x, self.y, self.w, self.h] + } + + /// Build from a stored `[x, y, w, h]` array; missing components default to 0. + pub fn from_slice(a: &[f32]) -> Self { + Self { + x: a.first().copied().unwrap_or(0.0), + y: a.get(1).copied().unwrap_or(0.0), + w: a.get(2).copied().unwrap_or(0.0), + h: a.get(3).copied().unwrap_or(0.0), + } + } +} + +/// A face produced by the analyzer but not yet persisted: where it is, how +/// confident the detector was, an optional quality score, and a 512-d, +/// L2-normalized embedding. +#[derive(Debug, Clone)] +pub struct DetectedFace { + pub bbox: BoundingBox, + pub det_score: f32, + pub quality: Option, + pub embedding: Vec, +} + +/// A persisted face detection. +#[derive(Debug, Clone)] +pub struct Face { + pub id: Uuid, + pub file_id: Uuid, + pub user_id: Uuid, + /// Identity cluster this face belongs to, if any. + pub person_id: Option, + pub bbox: BoundingBox, + pub det_score: f32, + pub quality: Option, + pub embedding: Vec, + pub blob_hash: Option, + pub created_at: DateTime, +} + +/// An identity cluster ("person"). `display_name` is `None` until the user +/// names it. +#[derive(Debug, Clone)] +pub struct Person { + pub id: Uuid, + pub user_id: Uuid, + pub display_name: Option, + pub cover_face_id: Option, + pub is_hidden: bool, + pub created_at: DateTime, +} diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index f020f3d8..75539dff 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -4,6 +4,7 @@ pub mod calendar_event; pub mod contact; pub mod device_code; pub mod entity_errors; +pub mod face; pub mod file; pub mod folder; pub mod magic_link_token; diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 788ab933..8c4decce 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -17,6 +17,7 @@ pub mod migration_blob_backend; pub mod migration_job; pub mod mock_email_sender; pub mod nextcloud_chunked_upload_service; +pub mod noop_face_analyzer; pub mod oidc_service; pub mod password_hasher; pub mod path_resolver_service; diff --git a/src/infrastructure/services/noop_face_analyzer.rs b/src/infrastructure/services/noop_face_analyzer.rs new file mode 100644 index 00000000..e7779bd3 --- /dev/null +++ b/src/infrastructure/services/noop_face_analyzer.rs @@ -0,0 +1,26 @@ +//! Default no-op face analyzer. +//! +//! Used when no ML model is configured: it reports `is_ready() == false` and +//! returns no faces, so the whole People pipeline compiles and runs inert +//! until a real ONNX-backed analyzer (provided by the operator) replaces it. + +use async_trait::async_trait; + +use crate::application::ports::face_ports::FaceAnalyzerPort; +use crate::common::errors::DomainError; +use crate::domain::entities::face::DetectedFace; + +/// Analyzer that never detects anything. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopFaceAnalyzer; + +#[async_trait] +impl FaceAnalyzerPort for NoopFaceAnalyzer { + fn is_ready(&self) -> bool { + false + } + + async fn analyze(&self, _image_bytes: &[u8]) -> Result, DomainError> { + Ok(Vec::new()) + } +} From 248ad70f015582e3f9bb56c6b18024545703dc3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:37:17 +0000 Subject: [PATCH 32/39] feat(faces): bytea-backed PostgreSQL repository Phase 2 increment 3: FacePgRepository implements FaceRepository. Embeddings stored/read as BYTEA (512 little-endian f32), bbox as REAL[]. Every query is user-scoped. Covers face CRUD, person CRUD (create / rename / cover / hide), files-for-person, and a transactional delete-all-for-user (right to erasure). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- .../repositories/pg/face_pg_repository.rs | 322 ++++++++++++++++++ src/infrastructure/repositories/pg/mod.rs | 2 + 2 files changed, 324 insertions(+) create mode 100644 src/infrastructure/repositories/pg/face_pg_repository.rs diff --git a/src/infrastructure/repositories/pg/face_pg_repository.rs b/src/infrastructure/repositories/pg/face_pg_repository.rs new file mode 100644 index 00000000..a417e382 --- /dev/null +++ b/src/infrastructure/repositories/pg/face_pg_repository.rs @@ -0,0 +1,322 @@ +//! PostgreSQL repository for the People (faces) feature. +//! +//! Embeddings are stored as `BYTEA` (512 × little-endian `f32`); there is no +//! pgvector dependency. Similarity search / clustering is done in-app over the +//! decoded vectors (see `PeopleService`). + +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::application::ports::face_ports::FaceRepository; +use crate::common::errors::DomainError; +use crate::domain::entities::face::{BoundingBox, Face, Person}; + +/// Row shape for `faces.faces` selects (avoids `clippy::type_complexity`). +type FaceRow = ( + Uuid, // id + Uuid, // file_id + Uuid, // user_id + Option, // person_id + Vec, // bbox (REAL[]) + f32, // det_score + Option, // quality + Vec, // embedding (BYTEA) + Option, // blob_hash + DateTime, // created_at +); + +type PersonRow = ( + Uuid, // id + Uuid, // user_id + Option, // display_name + Option, // cover_face_id + bool, // is_hidden + DateTime, // created_at +); + +fn embedding_to_bytes(e: &[f32]) -> Vec { + let mut out = Vec::with_capacity(e.len() * 4); + for v in e { + out.extend_from_slice(&v.to_le_bytes()); + } + out +} + +fn bytes_to_embedding(b: &[u8]) -> Vec { + b.chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} + +fn row_to_face(r: FaceRow) -> Face { + let ( + id, + file_id, + user_id, + person_id, + bbox, + det_score, + quality, + embedding, + blob_hash, + created_at, + ) = r; + Face { + id, + file_id, + user_id, + person_id, + bbox: BoundingBox::from_slice(&bbox), + det_score, + quality, + embedding: bytes_to_embedding(&embedding), + blob_hash, + created_at, + } +} + +fn row_to_person(r: PersonRow) -> Person { + let (id, user_id, display_name, cover_face_id, is_hidden, created_at) = r; + Person { + id, + user_id, + display_name, + cover_face_id, + is_hidden, + created_at, + } +} + +fn db_err(ctx: &'static str, e: sqlx::Error) -> DomainError { + DomainError::internal_error("FacePg", format!("{ctx}: {e}")) +} + +const FACE_COLS: &str = + "id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash, created_at"; +const PERSON_COLS: &str = "id, user_id, display_name, cover_face_id, is_hidden, created_at"; + +pub struct FacePgRepository { + pool: Arc, +} + +impl FacePgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl FaceRepository for FacePgRepository { + async fn save_faces(&self, faces: &[Face]) -> Result<(), DomainError> { + if faces.is_empty() { + return Ok(()); + } + let mut tx = self.pool.begin().await.map_err(|e| db_err("begin", e))?; + for f in faces { + sqlx::query( + r#" + INSERT INTO faces.faces + (id, file_id, user_id, person_id, bbox, det_score, quality, embedding, blob_hash) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + "#, + ) + .bind(f.id) + .bind(f.file_id) + .bind(f.user_id) + .bind(f.person_id) + .bind(f.bbox.to_array()) + .bind(f.det_score) + .bind(f.quality) + .bind(embedding_to_bytes(&f.embedding)) + .bind(f.blob_hash.as_deref()) + .execute(&mut *tx) + .await + .map_err(|e| db_err("save_faces", e))?; + } + tx.commit().await.map_err(|e| db_err("commit", e))?; + Ok(()) + } + + async fn faces_for_file(&self, file_id: Uuid) -> Result, DomainError> { + let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE file_id = $1"); + let rows: Vec = sqlx::query_as(&sql) + .bind(file_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| db_err("faces_for_file", e))?; + Ok(rows.into_iter().map(row_to_face).collect()) + } + + async fn delete_faces_for_file(&self, file_id: Uuid) -> Result<(), DomainError> { + sqlx::query("DELETE FROM faces.faces WHERE file_id = $1") + .bind(file_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| db_err("delete_faces_for_file", e))?; + Ok(()) + } + + async fn faces_for_user(&self, user_id: Uuid) -> Result, DomainError> { + let sql = format!("SELECT {FACE_COLS} FROM faces.faces WHERE user_id = $1"); + let rows: Vec = sqlx::query_as(&sql) + .bind(user_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| db_err("faces_for_user", e))?; + Ok(rows.into_iter().map(row_to_face).collect()) + } + + async fn faces_for_blob( + &self, + user_id: Uuid, + blob_hash: &str, + ) -> Result, DomainError> { + let sql = + format!("SELECT {FACE_COLS} FROM faces.faces WHERE user_id = $1 AND blob_hash = $2"); + let rows: Vec = sqlx::query_as(&sql) + .bind(user_id) + .bind(blob_hash) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| db_err("faces_for_blob", e))?; + Ok(rows.into_iter().map(row_to_face).collect()) + } + + async fn assign_person( + &self, + face_id: Uuid, + person_id: Option, + ) -> Result<(), DomainError> { + sqlx::query("UPDATE faces.faces SET person_id = $2 WHERE id = $1") + .bind(face_id) + .bind(person_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| db_err("assign_person", e))?; + Ok(()) + } + + async fn create_person(&self, person: &Person) -> Result<(), DomainError> { + sqlx::query( + r#" + INSERT INTO faces.persons (id, user_id, display_name, cover_face_id, is_hidden) + VALUES ($1, $2, $3, $4, $5) + "#, + ) + .bind(person.id) + .bind(person.user_id) + .bind(person.display_name.as_deref()) + .bind(person.cover_face_id) + .bind(person.is_hidden) + .execute(self.pool.as_ref()) + .await + .map_err(|e| db_err("create_person", e))?; + Ok(()) + } + + async fn persons_for_user(&self, user_id: Uuid) -> Result, DomainError> { + let sql = format!( + "SELECT {PERSON_COLS} FROM faces.persons WHERE user_id = $1 ORDER BY created_at" + ); + let rows: Vec = sqlx::query_as(&sql) + .bind(user_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| db_err("persons_for_user", e))?; + Ok(rows.into_iter().map(row_to_person).collect()) + } + + async fn rename_person( + &self, + user_id: Uuid, + person_id: Uuid, + name: Option, + ) -> Result<(), DomainError> { + sqlx::query( + "UPDATE faces.persons SET display_name = $3, updated_at = now() WHERE id = $2 AND user_id = $1", + ) + .bind(user_id) + .bind(person_id) + .bind(name) + .execute(self.pool.as_ref()) + .await + .map_err(|e| db_err("rename_person", e))?; + Ok(()) + } + + async fn set_person_cover( + &self, + person_id: Uuid, + cover_face_id: Uuid, + ) -> Result<(), DomainError> { + sqlx::query( + "UPDATE faces.persons SET cover_face_id = $2, updated_at = now() WHERE id = $1", + ) + .bind(person_id) + .bind(cover_face_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| db_err("set_person_cover", e))?; + Ok(()) + } + + async fn set_person_hidden( + &self, + user_id: Uuid, + person_id: Uuid, + hidden: bool, + ) -> Result<(), DomainError> { + sqlx::query( + "UPDATE faces.persons SET is_hidden = $3, updated_at = now() WHERE id = $2 AND user_id = $1", + ) + .bind(user_id) + .bind(person_id) + .bind(hidden) + .execute(self.pool.as_ref()) + .await + .map_err(|e| db_err("set_person_hidden", e))?; + Ok(()) + } + + async fn files_for_person( + &self, + user_id: Uuid, + person_id: Uuid, + ) -> Result, DomainError> { + let rows: Vec<(Uuid,)> = sqlx::query_as( + r#" + SELECT file_id + FROM faces.faces + WHERE user_id = $1 AND person_id = $2 + GROUP BY file_id + ORDER BY max(created_at) DESC + "#, + ) + .bind(user_id) + .bind(person_id) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| db_err("files_for_person", e))?; + Ok(rows.into_iter().map(|(id,)| id).collect()) + } + + async fn delete_all_for_user(&self, user_id: Uuid) -> Result<(), DomainError> { + let mut tx = self.pool.begin().await.map_err(|e| db_err("begin", e))?; + sqlx::query("DELETE FROM faces.faces WHERE user_id = $1") + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|e| db_err("delete_all_faces", e))?; + sqlx::query("DELETE FROM faces.persons WHERE user_id = $1") + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|e| db_err("delete_all_persons", e))?; + tx.commit().await.map_err(|e| db_err("commit", e))?; + Ok(()) + } +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 98f49aee..a9d6f5bf 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -6,6 +6,7 @@ mod contact_group_pg_repository; mod contact_persistence_dto; mod contact_pg_repository; mod device_code_pg_repository; +mod face_pg_repository; mod favorites_pg_repository; pub mod file_metadata_repository; mod magic_link_token_pg_repository; @@ -33,6 +34,7 @@ pub use contact_group_pg_repository::ContactGroupPgRepository; pub use contact_persistence_dto::*; pub use contact_pg_repository::ContactPgRepository; pub use device_code_pg_repository::DeviceCodePgRepository; +pub use face_pg_repository::FacePgRepository; pub use favorites_pg_repository::FavoritesPgRepository; pub use file_blob_read_repository::FileBlobReadRepository; pub use file_blob_write_repository::FileBlobWriteRepository; From de95de013d8a3ac10dbf9abc8e81399cdacdeec3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:40:33 +0000 Subject: [PATCH 33/39] =?UTF-8?q?feat(faces):=20PeopleService=20=E2=80=94?= =?UTF-8?q?=20identity=20clustering=20+=20use=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 increment 4: - recluster(user): union-find connected-components over the user's face embeddings (cosine threshold); groups of >= min_faces become persons, and an existing person's name is preserved across reclusters. O(n^2) — fine for moderate libraries; an ANN index is the documented scale-up. - caller_id-scoped use cases for the HTTP layer: list_people (non-empty clusters, cover thumbnail, most-photographed first), person_photos, faces_for_file, rename, hide, merge, and delete_all (right to erasure). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- src/application/services/mod.rs | 1 + src/application/services/people_service.rs | 271 +++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 src/application/services/people_service.rs diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 299b723d..e4089ec3 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -20,6 +20,7 @@ pub mod magic_link_invite_service; pub mod music_service; pub mod nextcloud_file_id_service; pub mod nextcloud_login_flow_service; +pub mod people_service; pub mod places_service; pub mod recent_service; pub mod recipient_notification_service; diff --git a/src/application/services/people_service.rs b/src/application/services/people_service.rs new file mode 100644 index 00000000..f03d9ad0 --- /dev/null +++ b/src/application/services/people_service.rs @@ -0,0 +1,271 @@ +//! People (faces) use cases: identity clustering + the read/mutation methods +//! the HTTP layer calls. +//! +//! Clustering is a full re-cluster over the user's faces: a union-find groups +//! faces whose embeddings are within a cosine threshold (connected +//! components), and groups of at least `min_faces` become a "person". This is +//! O(n²) in the user's face count — fine for moderate libraries; an ANN index +//! (pgvector/VectorChord) is the documented scale-up. +//! +//! Strictly user-scoped (the repository filters by user), so — like +//! `RecentService` / `PlacesService` — no `AuthorizationEngine` check is +//! needed: the `caller_id` parameter is the access scope. + +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::Utc; +use uuid::Uuid; + +use crate::application::dtos::people_dto::{FaceBoxDto, PersonDto}; +use crate::application::ports::face_ports::FaceRepository; +use crate::common::errors::DomainError; +use crate::domain::entities::face::Person; +use crate::infrastructure::repositories::pg::FacePgRepository; + +/// Cosine similarity of two equal-length vectors. Embeddings are produced +/// L2-normalized, so this is ~a dot product; we normalize anyway for safety. +fn cosine(a: &[f32], b: &[f32]) -> f32 { + if a.len() != b.len() || a.is_empty() { + return 0.0; + } + let (mut dot, mut na, mut nb) = (0.0f32, 0.0f32, 0.0f32); + for (&x, &y) in a.iter().zip(b.iter()) { + dot += x * y; + na += x * x; + nb += y * y; + } + if na == 0.0 || nb == 0.0 { + return 0.0; + } + dot / (na.sqrt() * nb.sqrt()) +} + +/// Disjoint-set with path-halving + union by rank. +struct UnionFind { + parent: Vec, + rank: Vec, +} + +impl UnionFind { + fn new(n: usize) -> Self { + Self { + parent: (0..n).collect(), + rank: vec![0; n], + } + } + fn find(&mut self, mut x: usize) -> usize { + while self.parent[x] != x { + self.parent[x] = self.parent[self.parent[x]]; + x = self.parent[x]; + } + x + } + fn union(&mut self, a: usize, b: usize) { + let (ra, rb) = (self.find(a), self.find(b)); + if ra == rb { + return; + } + match self.rank[ra].cmp(&self.rank[rb]) { + std::cmp::Ordering::Less => self.parent[ra] = rb, + std::cmp::Ordering::Greater => self.parent[rb] = ra, + std::cmp::Ordering::Equal => { + self.parent[rb] = ra; + self.rank[ra] += 1; + } + } + } +} + +pub struct PeopleService { + repo: Arc, + /// Min cosine similarity to link two faces into the same identity. + cluster_threshold: f32, + /// Min faces in a cluster before it becomes a named-able "person". + min_faces: usize, +} + +impl PeopleService { + pub fn new(repo: Arc) -> Self { + Self { + repo, + cluster_threshold: 0.5, + min_faces: 3, + } + } + + /// Re-cluster a user's faces. Returns the number of new persons created. + pub async fn recluster(&self, user_id: Uuid) -> Result { + let faces = self.repo.faces_for_user(user_id).await?; + let n = faces.len(); + if n == 0 { + return Ok(0); + } + + let mut uf = UnionFind::new(n); + for i in 0..n { + for j in (i + 1)..n { + if cosine(&faces[i].embedding, &faces[j].embedding) >= self.cluster_threshold { + uf.union(i, j); + } + } + } + + let mut groups: HashMap> = HashMap::new(); + for i in 0..n { + let root = uf.find(i); + groups.entry(root).or_default().push(i); + } + + let mut created = 0usize; + for idxs in groups.into_values() { + if idxs.len() < self.min_faces { + // Too small to be a person — leave/reset these faces unassigned. + for &i in &idxs { + if faces[i].person_id.is_some() { + self.repo.assign_person(faces[i].id, None).await?; + } + } + continue; + } + + // Reuse an existing person on this cluster (preserves a user's name) + // or mint a new one. + let existing = idxs.iter().find_map(|&i| faces[i].person_id); + let person_id = match existing { + Some(pid) => pid, + None => { + let pid = Uuid::new_v4(); + let person = Person { + id: pid, + user_id, + display_name: None, + cover_face_id: Some(faces[idxs[0]].id), + is_hidden: false, + created_at: Utc::now(), + }; + self.repo.create_person(&person).await?; + created += 1; + pid + } + }; + for &i in &idxs { + if faces[i].person_id != Some(person_id) { + self.repo + .assign_person(faces[i].id, Some(person_id)) + .await?; + } + } + let _ = self + .repo + .set_person_cover(person_id, faces[idxs[0]].id) + .await; + } + + Ok(created) + } + + /// People (non-empty clusters), most-photographed first. + pub async fn list_people(&self, caller_id: Uuid) -> Result, DomainError> { + let persons = self.repo.persons_for_user(caller_id).await?; + let faces = self.repo.faces_for_user(caller_id).await?; + + let mut count: HashMap = HashMap::new(); + let mut face_file: HashMap = HashMap::new(); + for f in &faces { + if let Some(pid) = f.person_id { + *count.entry(pid).or_default() += 1; + } + face_file.insert(f.id, f.file_id); + } + + let mut out: Vec = persons + .into_iter() + .filter_map(|p| { + let c = count.get(&p.id).copied().unwrap_or(0); + if c == 0 { + return None; // hide empty clusters (e.g. after a merge) + } + let cover_file_id = p + .cover_face_id + .and_then(|fid| face_file.get(&fid).copied()) + .map(|u| u.to_string()); + Some(PersonDto { + id: p.id.to_string(), + name: p.display_name, + cover_file_id, + face_count: c, + is_hidden: p.is_hidden, + }) + }) + .collect(); + out.sort_by(|a, b| b.face_count.cmp(&a.face_count)); + Ok(out) + } + + /// File ids of a person's photos (most recent first). + pub async fn person_photos( + &self, + caller_id: Uuid, + person_id: Uuid, + ) -> Result, DomainError> { + let files = self.repo.files_for_person(caller_id, person_id).await?; + Ok(files.into_iter().map(|u| u.to_string()).collect()) + } + + /// Face boxes within a photo (for lightbox tagging), caller-scoped. + pub async fn faces_for_file( + &self, + caller_id: Uuid, + file_id: Uuid, + ) -> Result, DomainError> { + let faces = self.repo.faces_for_file(file_id).await?; + Ok(faces + .into_iter() + .filter(|f| f.user_id == caller_id) + .map(|f| FaceBoxDto { + id: f.id.to_string(), + person_id: f.person_id.map(|u| u.to_string()), + x: f.bbox.x, + y: f.bbox.y, + w: f.bbox.w, + h: f.bbox.h, + }) + .collect()) + } + + pub async fn rename_person( + &self, + caller_id: Uuid, + person_id: Uuid, + name: Option, + ) -> Result<(), DomainError> { + self.repo.rename_person(caller_id, person_id, name).await + } + + pub async fn set_hidden( + &self, + caller_id: Uuid, + person_id: Uuid, + hidden: bool, + ) -> Result<(), DomainError> { + self.repo + .set_person_hidden(caller_id, person_id, hidden) + .await + } + + /// Merge `from` into `into` by reassigning all of `from`'s faces. The + /// now-empty `from` person is hidden by `list_people`. + pub async fn merge(&self, caller_id: Uuid, into: Uuid, from: Uuid) -> Result<(), DomainError> { + let faces = self.repo.faces_for_user(caller_id).await?; + for f in faces.into_iter().filter(|f| f.person_id == Some(from)) { + self.repo.assign_person(f.id, Some(into)).await?; + } + Ok(()) + } + + /// Erase all of the caller's face data (right to erasure / opt-out). + pub async fn delete_all(&self, caller_id: Uuid) -> Result<(), DomainError> { + self.repo.delete_all_for_user(caller_id).await + } +} From 1dca9d7d055bd9023335c2672f19d77d01e61a0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:46:57 +0000 Subject: [PATCH 34/39] feat(faces): indexing pipeline + DI wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 increment 5: - FaceIndexingService: a FileLifecycleHook that, on image upload, detects + embeds faces in a background task and stores them. Dedup-aware (clones an identical blob's faces instead of re-running inference), reindexes on overwrite, and relies on the DB cascade for deletes. Completely inert when no model is ready. - DI: registers the hook in the FileLifecycleService chain and exposes PeopleService in AppState — both gated on OXICLOUD_ENABLE_FACES, both using the default no-op analyzer until the operator wires a real ONNX model. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- src/common/di.rs | 41 ++++ .../services/face_indexing_service.rs | 191 ++++++++++++++++++ src/infrastructure/services/mod.rs | 1 + 3 files changed, 233 insertions(+) create mode 100644 src/infrastructure/services/face_indexing_service.rs diff --git a/src/common/di.rs b/src/common/di.rs index 37cf04fe..d75cbea7 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -17,6 +17,7 @@ use crate::application::services::folder_service::FolderService; use crate::application::services::i18n_application_service::I18nApplicationService; use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService; use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService; +use crate::application::services::people_service::PeopleService; use crate::application::services::places_service::PlacesService; use crate::application::services::recent_service::RecentService; use crate::application::services::search_service::SearchService; @@ -360,6 +361,9 @@ impl AppServiceFactory { fls = fls.with_hook(audio.clone()); } fls = fls.with_hook(media_metadata_service.clone()); + if self.config.features.enable_faces { + fls = fls.with_hook(self.create_face_indexing_service(db_pool)); + } let file_lifecycle = Arc::new(fls); Ok(CoreServices { @@ -810,6 +814,34 @@ impl AppServiceFactory { service } + /// Creates the face-indexing lifecycle hook (People feature). Uses the + /// default no-op analyzer until the operator wires a real ONNX model. + pub fn create_face_indexing_service( + &self, + db_pool: &Arc, + ) -> Arc { + let blob_root = self.storage_path.join(".blobs"); + let analyzer: Arc = + Arc::new(crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer); + Arc::new( + crate::infrastructure::services::face_indexing_service::FaceIndexingService::new( + db_pool.clone(), + blob_root, + analyzer, + ), + ) + } + + /// Creates the People (faces) read/clustering service. + pub fn create_people_service(&self, db_pool: &Arc) -> Arc { + let repo = Arc::new( + crate::infrastructure::repositories::pg::FacePgRepository::new(db_pool.clone()), + ); + let service = Arc::new(PeopleService::new(repo)); + tracing::info!("People service initialized"); + service + } + /// Preloads translations for every locale in the registry. Build /// the registry at startup via `LocaleRegistry::discover` and pass /// the resulting list here. @@ -1018,6 +1050,7 @@ impl AppServiceFactory { let favorites_service: Option>; let recent_service: Option>; let places_service: Option>; + let people_service: Option>; let storage_usage_service: Option>; let mut auth_services: Option = None; let mut nextcloud_services: Option = None; @@ -1046,6 +1079,12 @@ impl AppServiceFactory { None }; + people_service = if core.config.features.enable_faces { + Some(self.create_people_service(&pool)) + } else { + None + }; + storage_usage_service = Some(storage_usage.clone()); self.start_tree_etag_flush_job(&maintenance_pool); @@ -1273,6 +1312,7 @@ impl AppServiceFactory { favorites_service, recent_service, places_service, + people_service, storage_usage_service, calendar_service: None, contact_service: None, @@ -1720,6 +1760,7 @@ pub struct AppState { pub favorites_service: Option>, pub recent_service: Option>, pub places_service: Option>, + pub people_service: Option>, pub storage_usage_service: Option>, pub calendar_service: Option>, pub contact_service: Option>, diff --git a/src/infrastructure/services/face_indexing_service.rs b/src/infrastructure/services/face_indexing_service.rs new file mode 100644 index 00000000..1ec0705b --- /dev/null +++ b/src/infrastructure/services/face_indexing_service.rs @@ -0,0 +1,191 @@ +//! Face indexing as a `FileLifecycleHook`. +//! +//! On image upload it detects + embeds faces (off the request path, in a +//! background task) and stores them. It mirrors `MediaMetadataService`: reads +//! the blob from the local `.blobs` tree, is dedup-aware (identical uploads +//! clone an existing file's faces instead of re-running inference), and is +//! completely inert when no model is configured (`FaceAnalyzerPort::is_ready() +//! == false`) — so the feature compiles and runs with the default no-op +//! analyzer until the operator wires a real ONNX model. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use chrono::Utc; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::application::ports::face_ports::{FaceAnalyzerPort, FaceRepository}; +use crate::application::ports::file_lifecycle::FileLifecycleHook; +use crate::common::errors::DomainError; +use crate::domain::entities::face::Face; +use crate::infrastructure::repositories::pg::FacePgRepository; + +/// Minimum detector confidence for a face to be stored. +const MIN_DET_SCORE: f32 = 0.6; + +fn is_image(content_type: &str) -> bool { + content_type.starts_with("image/") +} + +pub struct FaceIndexingService { + pool: Arc, + repo: Arc, + analyzer: Arc, + blob_root: PathBuf, +} + +impl FaceIndexingService { + pub fn new(pool: Arc, blob_root: PathBuf, analyzer: Arc) -> Self { + let repo = Arc::new(FacePgRepository::new(pool.clone())); + Self { + pool, + repo, + analyzer, + blob_root, + } + } + + /// Local path of a blob: `.blobs/{prefix}/{hash}.blob`. + fn blob_path(&self, hash: &str) -> PathBuf { + let prefix = if hash.len() >= 2 { &hash[0..2] } else { hash }; + self.blob_root.join(prefix).join(format!("{hash}.blob")) + } + + /// Spawn a background indexing task. `reuse_dedup` clones faces from an + /// existing file with the same blob hash instead of re-running inference; + /// `delete_first` clears prior faces (used on overwrite). + fn spawn_index(&self, file_id: Uuid, blob_hash: String, reuse_dedup: bool, delete_first: bool) { + let pool = self.pool.clone(); + let repo = self.repo.clone(); + let analyzer = self.analyzer.clone(); + let blob_path = self.blob_path(&blob_hash); + tokio::spawn(async move { + if delete_first { + let _ = repo.delete_faces_for_file(file_id).await; + } + if let Err(e) = index_file( + &pool, + &repo, + analyzer.as_ref(), + file_id, + &blob_path, + &blob_hash, + reuse_dedup, + ) + .await + { + tracing::warn!(target: "oxicloud::faces", "face indexing failed for {file_id}: {e}"); + } + }); + } +} + +impl FileLifecycleHook for FaceIndexingService { + fn on_file_created( + &self, + file_id: &str, + blob_hash: &str, + content_type: &str, + is_new_blob: bool, + ) { + if !is_image(content_type) || !self.analyzer.is_ready() { + return; + } + if let Ok(fid) = file_id.parse::() { + // Dedup hit (blob already existed) → clone an existing file's faces. + self.spawn_index(fid, blob_hash.to_string(), !is_new_blob, false); + } + } + + fn on_file_copied( + &self, + file_id: &str, + blob_hash: &str, + content_type: &str, + _source_file_id: &str, + ) { + if !is_image(content_type) || !self.analyzer.is_ready() { + return; + } + if let Ok(fid) = file_id.parse::() { + self.spawn_index(fid, blob_hash.to_string(), true, false); + } + } + + fn on_file_updated(&self, file_id: &str, blob_hash: &str, content_type: &str) { + if !is_image(content_type) || !self.analyzer.is_ready() { + return; + } + if let Ok(fid) = file_id.parse::() { + self.spawn_index(fid, blob_hash.to_string(), false, true); + } + } + + fn on_file_deleted(&self, _file_id: &str) { + // faces.faces.file_id has ON DELETE CASCADE — the DB cleans up. + } +} + +async fn lookup_user(pool: &PgPool, file_id: Uuid) -> Result { + let row: (Uuid,) = sqlx::query_as("SELECT user_id FROM storage.files WHERE id = $1") + .bind(file_id) + .fetch_one(pool) + .await + .map_err(|e| DomainError::internal_error("Faces", format!("lookup user: {e}")))?; + Ok(row.0) +} + +async fn index_file( + pool: &PgPool, + repo: &FacePgRepository, + analyzer: &dyn FaceAnalyzerPort, + file_id: Uuid, + blob_path: &Path, + blob_hash: &str, + reuse_dedup: bool, +) -> Result<(), DomainError> { + let user_id = lookup_user(pool, file_id).await?; + + // Dedup-aware fast path: reuse faces already computed for an identical blob. + if reuse_dedup { + let peers = repo.faces_for_blob(user_id, blob_hash).await?; + let cloned: Vec = peers + .into_iter() + .filter(|f| f.file_id != file_id) + .map(|f| Face { + id: Uuid::new_v4(), + file_id, + ..f + }) + .collect(); + if !cloned.is_empty() { + repo.save_faces(&cloned).await?; + return Ok(()); + } + // No peer found — fall through and analyze. + } + + let bytes = tokio::fs::read(blob_path) + .await + .map_err(|e| DomainError::internal_error("Faces", format!("read blob: {e}")))?; + let detected = analyzer.analyze(&bytes).await?; + + let faces: Vec = detected + .into_iter() + .filter(|d| d.det_score >= MIN_DET_SCORE) + .map(|d| Face { + id: Uuid::new_v4(), + file_id, + user_id, + person_id: None, + bbox: d.bbox, + det_score: d.det_score, + quality: d.quality, + embedding: d.embedding, + blob_hash: Some(blob_hash.to_string()), + created_at: Utc::now(), + }) + .collect(); + repo.save_faces(&faces).await +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 8c4decce..b41b6edc 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -6,6 +6,7 @@ pub mod compression_service; pub mod dedup_service; pub mod encrypted_blob_backend; pub mod exif_service; +pub mod face_indexing_service; pub mod file_content_cache; pub mod file_system_i18n_service; pub mod image_transcode_service; From 5c42b4d2b19fbfd4435e1ab0eec700011e54dfdb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:51:01 +0000 Subject: [PATCH 35/39] feat(faces): /api/people endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 increment 6 — the People HTTP API (mounted only when OXICLOUD_ENABLE_FACES is on; every handler is caller-scoped): - GET /api/people list identity clusters - GET /api/people/{id}/photos a person's photo file ids - PATCH /api/people/{id} name / rename a person - POST /api/people/{id}/hide hide / unhide - POST /api/people/merge merge two clusters - POST /api/people/recluster re-run clustering - DELETE /api/people/data erase all face data (opt-out) - GET /api/people/faces/{file_id} face boxes for lightbox tagging Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/handlers/people_handler.rs | 177 ++++++++++++++++++ src/interfaces/api/routes.rs | 20 +- 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/interfaces/api/handlers/people_handler.rs diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index aebeb707..4859923e 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -16,6 +16,7 @@ pub mod grant_handler; pub mod i18n_handler; pub mod magic_link_handler; pub mod music_handler; +pub mod people_handler; pub mod photos_handler; pub mod recent_handler; pub mod search_handler; diff --git a/src/interfaces/api/handlers/people_handler.rs b/src/interfaces/api/handlers/people_handler.rs new file mode 100644 index 00000000..cb217313 --- /dev/null +++ b/src/interfaces/api/handlers/people_handler.rs @@ -0,0 +1,177 @@ +//! HTTP handlers for the People (faces) feature. +//! +//! Every route is mounted only when `OXICLOUD_ENABLE_FACES` is on (the service +//! is present in `AppState`); each handler is also defensive. All work is +//! strictly caller-scoped by `PeopleService` (the repository filters by user). + +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::common::di::AppState; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::AuthUser; + +fn disabled() -> Response { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "People feature is disabled" })), + ) + .into_response() +} + +fn bad_id() -> Response { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "invalid id" })), + ) + .into_response() +} + +/// GET /api/people — identity clusters for the caller. +pub async fn list_people(State(state): State>, auth_user: AuthUser) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + match svc.list_people(auth_user.id).await { + Ok(people) => Json(people).into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +/// GET /api/people/{id}/photos — file ids of a person's photos. +pub async fn person_photos( + State(state): State>, + auth_user: AuthUser, + Path(id): Path, +) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + let Ok(person_id) = Uuid::parse_str(&id) else { + return bad_id(); + }; + match svc.person_photos(auth_user.id, person_id).await { + Ok(files) => Json(files).into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +#[derive(Deserialize)] +pub struct RenameBody { + pub name: Option, +} + +/// PATCH /api/people/{id} — name (or clear the name of) a person. +pub async fn rename_person( + State(state): State>, + auth_user: AuthUser, + Path(id): Path, + Json(body): Json, +) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + let Ok(person_id) = Uuid::parse_str(&id) else { + return bad_id(); + }; + match svc.rename_person(auth_user.id, person_id, body.name).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +#[derive(Deserialize)] +pub struct HideBody { + pub hidden: bool, +} + +/// POST /api/people/{id}/hide — hide/unhide a person from the grid. +pub async fn hide_person( + State(state): State>, + auth_user: AuthUser, + Path(id): Path, + Json(body): Json, +) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + let Ok(person_id) = Uuid::parse_str(&id) else { + return bad_id(); + }; + match svc.set_hidden(auth_user.id, person_id, body.hidden).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +#[derive(Deserialize)] +pub struct MergeBody { + pub into: String, + pub from: String, +} + +/// POST /api/people/merge — merge `from` into `into`. +pub async fn merge_people( + State(state): State>, + auth_user: AuthUser, + Json(body): Json, +) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + let (Ok(into), Ok(from)) = (Uuid::parse_str(&body.into), Uuid::parse_str(&body.from)) else { + return bad_id(); + }; + match svc.merge(auth_user.id, into, from).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +/// POST /api/people/recluster — re-run identity clustering for the caller. +pub async fn recluster(State(state): State>, auth_user: AuthUser) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + match svc.recluster(auth_user.id).await { + Ok(n) => Json(serde_json::json!({ "persons_created": n })).into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +/// DELETE /api/people/data — erase all of the caller's face data. +pub async fn delete_all(State(state): State>, auth_user: AuthUser) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + match svc.delete_all(auth_user.id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +/// GET /api/people/faces/{file_id} — face boxes within a photo (lightbox tags). +pub async fn faces_for_file( + State(state): State>, + auth_user: AuthUser, + Path(file_id): Path, +) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + let Ok(fid) = Uuid::parse_str(&file_id) else { + return bad_id(); + }; + match svc.faces_for_file(auth_user.id, fid).await { + Ok(boxes) => Json(boxes).into_response(), + Err(e) => AppError::from(e).into_response(), + } +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index af2ff022..feb06d64 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -6,7 +6,7 @@ use axum::{ extract::{DefaultBodyLimit, State}, http::StatusCode, response::{IntoResponse, Json as AxumJson, Response}, - routing::{any, delete, get, post, put}, + routing::{any, delete, get, patch, post, put}, }; use serde_json::json; use std::sync::Arc; @@ -440,6 +440,24 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { router = router.nest("/photos", photos_router); } + // People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on. + if app_state.people_service.is_some() { + use crate::interfaces::api::handlers::people_handler; + + let people_router = Router::new() + .route("/", get(people_handler::list_people)) + .route("/merge", post(people_handler::merge_people)) + .route("/recluster", post(people_handler::recluster)) + .route("/data", delete(people_handler::delete_all)) + .route("/faces/{file_id}", get(people_handler::faces_for_file)) + .route("/{id}", patch(people_handler::rename_person)) + .route("/{id}/photos", get(people_handler::person_photos)) + .route("/{id}/hide", post(people_handler::hide_person)) + .with_state(app_state.clone()); + + router = router.nest("/people", people_router); + } + // Re-enable trash routes to make the trash view work if let Some(_trash_service_ref) = trash_service.clone() { tracing::info!("Setting up trash routes for trash view"); From 6314fa6b1cb2b0e8c7265eeaa22c02ba6c286971 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:58:23 +0000 Subject: [PATCH 36/39] feat(people): add People tab frontend for face clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the client side of Phase 2 (People). A new "People" tab in the Photos sub-navigation lists identity clusters from GET /api/people and drills into a person's photos using the existing photos lightbox. - people.js: peopleView with list/drill-in/rename, reusing .photos-grid tiles and photosLightbox; rename via Modal.prompt + PATCH /api/people/{id} - people.css: person grid, circular avatars, single-person header, loading/empty states — all design tokens, no raw colors - places.js: People tab wired into the Moments|Places sub-nav, revealed only when GET /api/people is reachable (capability probe); _switchTab now toggles three views - index.html: load people.css + people.js - en.json: photos.tab_people + people.* labels (other locales fall back to English via i18n) The tab stays hidden unless OXICLOUD_ENABLE_FACES is on (the API 404s otherwise), so this is inert by default. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- static/css/views/people.css | 112 +++++++++++++++++ static/index.html | 2 + static/js/features/library/people.js | 177 +++++++++++++++++++++++++++ static/js/features/library/places.js | 39 ++++-- static/locales/en.json | 9 ++ 5 files changed, 328 insertions(+), 11 deletions(-) create mode 100644 static/css/views/people.css create mode 100644 static/js/features/library/people.js diff --git a/static/css/views/people.css b/static/css/views/people.css new file mode 100644 index 00000000..4b1d435c --- /dev/null +++ b/static/css/views/people.css @@ -0,0 +1,112 @@ +/* People (faces) view */ +.people-container { + display: none; +} + +.people-container.active { + display: block; + padding: var(--space-2); +} + +/* Grid of person tiles */ +.people-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--space-4); + padding: var(--space-2); +} + +.person-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + background: none; + border: none; + cursor: pointer; + border-radius: var(--radius-lg); +} + +.person-tile:hover { + background: var(--color-bg-muted); +} + +.person-avatar { + width: 96px; + height: 96px; + border-radius: 50%; + background-size: cover; + background-position: center; + background-color: var(--color-bg-muted); + border: 2px solid var(--color-border); +} + +.person-name { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--color-text); +} + +.person-count { + font-size: var(--text-xs); + color: var(--color-text-faint); +} + +/* Single-person header */ +.people-toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2); +} + +.people-toolbar .people-title { + flex: 1; + margin: 0; + font-size: var(--text-lg); + font-weight: var(--weight-semibold); + color: var(--color-text); +} + +.people-back, +.people-rename { + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: none; + color: var(--color-text-subtle); + font-size: var(--text-base); + cursor: pointer; +} + +.people-back:hover, +.people-rename:hover { + background: var(--color-bg-muted); +} + +/* Loading / empty states */ +.people-loading, +.people-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-20) var(--space-5); + color: var(--color-text-faint); +} + +.people-empty i { + font-size: 48px; + color: var(--color-border-medium); +} + +.people-loading i { + animation: spin 1s linear infinite; +} diff --git a/static/index.html b/static/index.html index b2a37ff8..e1ed6fbf 100644 --- a/static/index.html +++ b/static/index.html @@ -37,6 +37,7 @@ + @@ -59,6 +60,7 @@ + diff --git a/static/js/features/library/people.js b/static/js/features/library/people.js new file mode 100644 index 00000000..8024c42a --- /dev/null +++ b/static/js/features/library/people.js @@ -0,0 +1,177 @@ +/** + * OxiCloud - People (faces) + * + * A grid of identity clusters from GET /api/people; clicking a person shows + * their photos (reusing the photos lightbox). Faces are detected + clustered + * server-side; this view is read-mostly (list, drill-in, rename). + * + * The feature is gated on OXICLOUD_ENABLE_FACES — when it is off the API 404s + * and the view shows a short "disabled" hint (and the Places/People sub-nav + * hides the People tab via a capability probe). + */ + +import { Modal } from '../../components/modal.js'; +import { getCsrfHeaders } from '../../core/csrf.js'; +import { i18n } from '../../core/i18n.js'; +import { photosLightbox } from './photosLightbox.js'; + +/** @import {FileItem} from '../../core/types.js' */ +/** @typedef {{id: string, name?: string, cover_file_id?: string, face_count: number, is_hidden: boolean}} PersonItem */ + +export const peopleView = { + /** @type {HTMLElement|null} */ + _container: null, + + _headers() { + return getCsrfHeaders(); + }, + + /** Ensure the container exists (sibling in .content-area). */ + _mount() { + const ca = document.querySelector('.content-area'); + if (!ca) return; + if (!this._container) { + const el = document.createElement('div'); + el.id = 'people-container'; + el.className = 'people-container'; + ca.appendChild(el); + this._container = el; + } + }, + + async show() { + this._mount(); + if (!this._container) return; + this._container.classList.add('active'); + await this._renderList(); + }, + + hide() { + this._container?.classList.remove('active'); + }, + + async _renderList() { + if (!this._container) return; + this._container.innerHTML = '
'; + try { + const res = await fetch('/api/people', { credentials: 'include', headers: this._headers() }); + if (!res.ok) { + this._renderHint(i18n.t('people.disabled')); + return; + } + /** @type {PersonItem[]} */ + const people = await res.json(); + if (!people.length) { + this._renderHint(i18n.t('people.empty')); + return; + } + let html = '
'; + for (const p of people) { + const cover = p.cover_file_id ? `/api/files/${p.cover_file_id}/thumbnail/icon` : ''; + const name = p.name || i18n.t('people.unnamed'); + html += `'; + } + html += '
'; + this._container.innerHTML = html; + this._container.querySelectorAll('.person-tile').forEach((t) => { + const el = /** @type {HTMLElement} */ (t); + el.addEventListener('click', () => this._openPerson(el.dataset.id || '', el.dataset.name || '')); + }); + } catch (err) { + console.error('People load failed:', err); + this._renderHint(i18n.t('people.disabled')); + } + }, + + /** + * @param {string} personId + * @param {string} name + */ + async _openPerson(personId, name) { + if (!this._container) return; + this._container.innerHTML = + '
' + + `` + + `

${this._escHtml(name)}

` + + `` + + '
' + + '
'; + /** @type {HTMLButtonElement} */ (this._container.querySelector('.people-back')).onclick = () => this._renderList(); + /** @type {HTMLButtonElement} */ (this._container.querySelector('.people-rename')).onclick = () => this._rename(personId, name); + + try { + const res = await fetch(`/api/people/${personId}/photos`, { credentials: 'include', headers: this._headers() }); + if (!res.ok) return; + /** @type {string[]} */ + const fileIds = await res.json(); + // Minimal FileItems so the lightbox can open them by id. + const items = fileIds.map( + (id) => + /** @type {FileItem} */ (/** @type {any} */ ({ id, name: '', mime_type: 'image/jpeg', created_at: 0, sort_date: 0, size_formatted: '' })) + ); + const grid = this._container.querySelector('#person-photos'); + if (!grid) return; + let html = ''; + fileIds.forEach((id, i) => { + html += `
`; + }); + grid.innerHTML = html; + grid.querySelectorAll('.photo-tile').forEach((t) => { + const el = /** @type {HTMLElement} */ (t); + el.addEventListener('click', () => photosLightbox.open(items, Number(el.dataset.idx))); + }); + } catch (err) { + console.error('Person photos failed:', err); + } + }, + + /** + * @param {string} personId + * @param {string} current + */ + async _rename(personId, current) { + const placeholder = i18n.t('people.unnamed'); + const value = current === placeholder ? '' : current; + const name = await Modal.prompt({ + title: i18n.t('people.rename_title'), + label: i18n.t('people.name_label'), + value + }); + if (name === null) return; + try { + await fetch(`/api/people/${personId}`, { + method: 'PATCH', + credentials: 'include', + headers: { ...this._headers(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name || null }) + }); + } catch (err) { + console.error('Rename failed:', err); + } + this._openPerson(personId, name || placeholder); + }, + + /** @param {string} text */ + _renderHint(text) { + if (!this._container) return; + this._container.innerHTML = `

${this._escHtml(text)}

`; + }, + + /** @param {any} s */ + _escHtml(s) { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + }, + + /** @param {any} s */ + _escAttr(s) { + return String(s || '') + .replace(/"/g, '"') + .replace(/${this._esc(i18n.t('photos.tab_moments'))}` + - ``; + `` + + ``; bar.addEventListener('click', (e) => { const btn = /** @type {HTMLElement} */ (e.target).closest('[data-ptab]'); - if (btn) this._switchTab(/** @type {'moments'|'places'} */ (btn.getAttribute('data-ptab'))); + if (btn) this._switchTab(/** @type {'moments'|'places'|'people'} */ (btn.getAttribute('data-ptab'))); }); contentArea.insertBefore(bar, contentArea.firstChild); this._subnav = bar; + this._probePeople(); } this._subnav.classList.remove('hidden'); @@ -82,12 +85,26 @@ export const placesView = { this._activeTab = 'moments'; this._setActiveTab('moments'); this.hide(); + peopleView.hide(); + }, + + /** Reveal the People tab only if GET /api/people is available (faces on). */ + async _probePeople() { + try { + const res = await fetch('/api/people', { credentials: 'include', headers: getCsrfHeaders() }); + if (res.ok) { + this._subnav?.querySelector('[data-ptab="people"]')?.classList.remove('hidden'); + } + } catch { + /* leave the People tab hidden */ + } }, /** Hide the tab bar and the map (called when leaving the Photos section). */ unmountTabs() { this._subnav?.classList.add('hidden'); this.hide(); + peopleView.hide(); }, /** Hide the map container (without destroying the map). */ @@ -96,19 +113,19 @@ export const placesView = { }, /** - * @param {'moments'|'places'} tab + * @param {'moments'|'places'|'people'} tab */ _switchTab(tab) { if (tab === this._activeTab) return; this._activeTab = tab; this._setActiveTab(tab); - if (tab === 'places') { - photosView.hide(); - this._showMap(); - } else { - this.hide(); - photosView.show(); - } + // Hide all three views, then show the selected one. + photosView.hide(); + this.hide(); + peopleView.hide(); + if (tab === 'places') this._showMap(); + else if (tab === 'people') peopleView.show(); + else photosView.show(); }, /** @param {string} tab */ diff --git a/static/locales/en.json b/static/locales/en.json index ce783528..572a88a8 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -76,9 +76,18 @@ "layout_justified": "Justified", "tab_moments": "Moments", "tab_places": "Places", + "tab_people": "People", "map_loading": "Loading map…", "map_error": "Could not load the map" }, + "people": { + "unnamed": "Unnamed", + "empty": "No people yet", + "disabled": "Face recognition is disabled", + "rename_title": "Name this person", + "name_label": "Name", + "back": "Back" + }, "music": { "create_playlist": "Create Playlist", "playlists": "Playlists", From eacb91337504b4e029f8a70fe6e6e8382ce8f24e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 11:59:04 +0000 Subject: [PATCH 37/39] docs(plan): record Phase 2 (People) backend + frontend status Updates the implementation status from "not started" to reflect the landed face-recognition stack and notes the deviations from the original plan (BYTEA embeddings + in-Rust cosine instead of pgvector; a single FaceAnalyzerPort instead of split detector/embedder ports; union-find connected-components clustering). Flags the remaining piece: the real ONNX analyzer (ort + operator-supplied models), which adds a crate and can't be exercised in this environment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- docs/plan/Photos-People-Places.md | 39 ++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/plan/Photos-People-Places.md b/docs/plan/Photos-People-Places.md index 62471e22..bf8f2381 100644 --- a/docs/plan/Photos-People-Places.md +++ b/docs/plan/Photos-People-Places.md @@ -50,7 +50,44 @@ migrations, and a **vanilla-JS / vanilla-CSS** frontend (design tokens from - **Deviations from the original plan:** 1.5 serves the basemap as a *static file* (ServeDir Range) instead of the `pmtiles` Rust crate; 1.8 uses MapLibre HTML markers instead of a deck.gl `IconLayer`. Both keep the footprint minimal and need zero new backend code. - **Pending:** browser smoke-test, and an operator-supplied `static/basemaps/basemap.pmtiles` for the street backdrop (works without it). -**Phase 2 — People: not started.** +**Phase 2 — People: backend + frontend landed; real face model pending.** +- **Migration** (`…_faces.sql`): `faces` schema with `faces.persons` + `faces.faces`. + **Deviation from 2.2:** embeddings stored as **`BYTEA`** (512×`f32` little-endian), **no + `pgvector`** — cosine similarity runs in Rust. This keeps the extension footprint at + today's `pg_trgm`/`ltree`/`citext` and is fine at personal-library scale; the HNSW/ANN + path is the documented growth step if it's ever needed. +- **Config:** `OXICLOUD_ENABLE_FACES` (`FeaturesConfig::enable_faces`, **default off** — + biometric/opt-in). Everything below is inert when off. +- **Domain/ports:** `Face`, `Person`, `BoundingBox`, `DetectedFace` (`domain/entities/face.rs`); + `FaceAnalyzerPort` (single `analyze(&[u8]) -> Vec` + `is_ready()`) and + `FaceRepository` (`face_ports.rs`). **Deviation from 2.3:** detector+embedder collapsed + into one `FaceAnalyzerPort` (the analyzer owns detect→align→embed) instead of split + `FaceDetectorPort`/`FaceEmbedderPort` — simpler seam for a single ONNX session. +- **Repository:** `FacePgRepository` (`infrastructure/repositories/pg/`) — bytea + encode/decode, person CRUD, `faces_for_*`, `assign_person`, `delete_all_for_user`. +- **Service:** `PeopleService` (`application/services/people_service.rs`) — `recluster()` + via **union-find connected-components** (cosine ≥ 0.5, `min_faces` 3, immich-style), + plus list/photos/rename/hide/merge/delete. "List my own people" needs no `authz.require` + (user-scoped, like `RecentService`/`PlacesService`). +- **Indexing:** `FaceIndexingService` implements `FileLifecycleHook` — background + detect+embed on image create/copy/update, **dedup by blob hash**. Driven by the + analyzer port; with the no-op analyzer it does nothing. +- **Analyzer (placeholder):** `NoopFaceAnalyzer` (`is_ready()=false`, returns no faces) so + the whole stack compiles and runs **without any ML model**. The real ONNX analyzer is + the one remaining backend piece (see below). +- **HTTP:** `people_handler.rs` + routes (gated on `people_service.is_some()`): + `GET /api/people`, `/api/people/{id}/photos`, `PATCH /api/people/{id}`, + `POST /api/people/merge`, `/api/people/recluster`, `GET /api/people/data`, + `GET /api/people/faces/{file_id}`, `POST /api/people/{id}/hide`. +- **Frontend (`6314fa6`):** `people.js` + `people.css` — person grid (circular cover, + name, count), drill into a person's photos via the existing lightbox, rename via + `Modal.prompt` + `PATCH`. Wired into the Photos sub-nav as a third **People** tab that a + capability probe (`GET /api/people`) reveals only when faces are on; otherwise hidden. + i18n keys in `en.json` (others fall back to English). +- **Pending:** **2.4 real ONNX analyzer** — add `ort` (load-dynamic) + an operator-supplied + SCRFD/RetinaFace detector and ArcFace/EdgeFace embedder, implementing `FaceAnalyzerPort`. + Adds the `ort` crate and can't be exercised in this environment (no models). Per-user + opt-in consent gate (2.1) and lightbox face-box tagging (2.8) also still open. --- From 12ede47b2cf4457c5da6b88e3327519de6ac08d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 12:28:49 +0000 Subject: [PATCH 38/39] feat(faces): real ONNX face analyzer (SCRFD + ArcFace), opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the last Phase 2 piece: a working face detector/embedder behind the new `faces-onnx` cargo feature (mirrors how `plugins` gates wasmtime). Inert by default — the default build is unchanged and ships the no-op analyzer. Pipeline (InsightFace/immich pattern): SCRFD detection with 5-point landmarks → least-squares similarity alignment to the canonical 112×112 template → ArcFace embedding → L2-normalized 512-d vector. - face_geometry.rs (always compiled, unit-tested): SCRFD anchor/distance decode, NMS, the closed-form (complex-number) similarity transform, bilinear affine warp, NCHW normalization, L2-norm, Laplacian sharpness. 11 unit tests cover the error-prone math with no model needed. - onnx_face_analyzer.rs (feature `faces-onnx`): wires the geometry to ONNX Runtime via `ort` (load-dynamic, so libonnxruntime is dlopen'd at runtime and the crate builds without it). Inference runs on spawn_blocking; each session is serialized behind a Mutex. Loads via `ort::init_from` (fallible) not ORT's lazy loader, which would panic under `panic = "abort"`. - config: FacesConfig + OXICLOUD_FACES_{ORT_DYLIB,DETECTOR_MODEL, EMBEDDER_MODEL,DET_SIZE,DET_THRESHOLD,NMS_THRESHOLD,INTRA_THREADS}. - di: build_face_analyzer() loads the real analyzer when the feature is compiled in and runtime+models are configured; any missing piece or load failure degrades to the no-op analyzer (logged) so startup never fails. - ort/ndarray added as optional deps; example.env documents the setup. Models and the ONNX Runtime dylib are operator-provided at runtime and are never committed. Cannot be exercised in CI (no models/dylib); the geometry is unit-tested and the ONNX seam is isolated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- Cargo.lock | 80 +++ Cargo.toml | 8 + example.env | 37 ++ src/common/config.rs | 87 ++++ src/common/di.rs | 58 ++- src/infrastructure/services/face_geometry.rs | 473 ++++++++++++++++++ src/infrastructure/services/mod.rs | 3 + .../services/onnx_face_analyzer.rs | 340 +++++++++++++ 8 files changed, 1082 insertions(+), 4 deletions(-) create mode 100644 src/infrastructure/services/face_geometry.rs create mode 100644 src/infrastructure/services/onnx_face_analyzer.rs diff --git a/Cargo.lock b/Cargo.lock index f837cab2..90b2d8e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3654,6 +3654,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -3866,6 +3876,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-owned" version = "0.3.4" @@ -4071,6 +4091,21 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "nom" version = "7.1.3" @@ -4157,6 +4192,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -4238,6 +4282,25 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "libloading", + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + [[package]] name = "outref" version = "0.1.0" @@ -4676,7 +4739,9 @@ dependencies = [ "mockall", "moka", "mp3-duration", + "ndarray", "nom-exif", + "ort", "oxc_allocator", "oxc_codegen", "oxc_minifier", @@ -5074,6 +5139,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postcard" version = "1.1.3" @@ -5496,6 +5570,12 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.12.0" diff --git a/Cargo.toml b/Cargo.toml index 10b9e41a..18b4e207 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,8 @@ nom-exif = "3.6.1" extism = { version = "1.30.0", optional = true } toml = { version = "1.1.2", optional = true } file-rotate = { version = "0.7.6", optional = true } +ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "tracing", "api-24"], optional = true } +ndarray = { version = "0.17.2", optional = true } [features] default = [] @@ -95,6 +97,12 @@ plugins = ["dep:extism", "dep:toml", "dep:file-rotate"] # this lets one `cargo build` produce both `oxicloud` and `load-seed` # without recompiling oxicloud with mockall in scope. load_seed_bin = [] +# Real ONNX-backed face analyzer (detector + embedder) for the People feature. +# Opt-in: pulls `ort` (ONNX Runtime, load-dynamic — dlopen's libonnxruntime at +# runtime) + `ndarray`, a heavy stack most deployments won't use. Activation also +# requires OXICLOUD_ENABLE_FACES=true *and* operator-provided ONNX models; without +# this feature the People pipeline falls back to the inert NoopFaceAnalyzer. +faces-onnx = ["dep:ort", "dep:ndarray"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(integration_tests)'] } diff --git a/example.env b/example.env index e1bbc0ae..a4913e46 100644 --- a/example.env +++ b/example.env @@ -224,6 +224,43 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud # Set to false to prevent users from browsing the user directory. #OXICLOUD_EXPOSE_SYSTEM_USERS=true +# ── People (face recognition) ──────────────────────────────────────────── +# Biometric data (GDPR Art. 9) — OFF by default, opt-in per deployment. +# Detects faces and clusters them into people in the photo library. +# +# Requires ALL of: +# 1. a binary built with the `faces-onnx` cargo feature +# (`cargo build --release --features faces-onnx`), +# 2. OXICLOUD_ENABLE_FACES=true, +# 3. the ONNX Runtime shared library + two operator-provided ONNX models +# (a SCRFD/RetinaFace detector with 5-point landmarks, and an ArcFace +# 512-d embedder — e.g. InsightFace `buffalo_l`). Models are NOT shipped. +# Without all three, the People pipeline stays inert (no-op analyzer) and the +# server still boots; the People tab stays hidden in the UI. +#OXICLOUD_ENABLE_FACES=false + +# Path to libonnxruntime.{so,dylib,dll}. Falls back to ORT_DYLIB_PATH. +# Use the ONNX Runtime build matching this app's `ort` crate (>= 1.24). +#OXICLOUD_FACES_ORT_DYLIB=/opt/onnxruntime/lib/libonnxruntime.so + +# Face detector model (SCRFD/RetinaFace, 5-point landmarks). +#OXICLOUD_FACES_DETECTOR_MODEL=/var/lib/oxicloud/models/scrfd_10g_bnkps.onnx + +# Face embedder model (ArcFace, 112x112 input -> 512-d output). +#OXICLOUD_FACES_EMBEDDER_MODEL=/var/lib/oxicloud/models/w600k_r50.onnx + +# Detector square input size in px (default: 640) +#OXICLOUD_FACES_DET_SIZE=640 + +# Minimum detector confidence to keep a face, 0..1 (default: 0.5) +#OXICLOUD_FACES_DET_THRESHOLD=0.5 + +# IoU threshold for non-maximum suppression, 0..1 (default: 0.4) +#OXICLOUD_FACES_NMS_THRESHOLD=0.4 + +# ONNX Runtime intra-op threads; 0 = let ONNX Runtime decide (default: 0) +#OXICLOUD_FACES_INTRA_THREADS=0 + # WASM plugin runtime (Extism). Requires a binary built with the `plugins` # cargo feature (`cargo run --features plugins`); without that feature these # vars are inert. Untrusted plugins run sandboxed: no filesystem, no network, diff --git a/src/common/config.rs b/src/common/config.rs index 3c28d46f..84bad869 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -905,6 +905,53 @@ impl Default for FeaturesConfig { } } +/// Face-recognition (People) model configuration. +/// +/// Only consulted when the `faces-onnx` cargo feature is compiled in *and* +/// [`FeaturesConfig::enable_faces`] is true; otherwise the inert +/// `NoopFaceAnalyzer` is used regardless of these values. The ONNX Runtime +/// dylib and both model files are operator-provided at runtime (never +/// committed) — when any is unset or fails to load, the People pipeline +/// silently falls back to the no-op analyzer and the server still boots. +#[derive(Debug, Clone)] +pub struct FacesConfig { + /// `libonnxruntime.{so,dylib,dll}`. Falls back to the `ORT_DYLIB_PATH` + /// environment variable when unset. Env: `OXICLOUD_FACES_ORT_DYLIB`. + pub ort_dylib: Option, + /// SCRFD/RetinaFace detector model with 5-point landmarks. + /// Env: `OXICLOUD_FACES_DETECTOR_MODEL`. + pub detector_model: Option, + /// ArcFace embedder model (112×112 → 512-d). + /// Env: `OXICLOUD_FACES_EMBEDDER_MODEL`. + pub embedder_model: Option, + /// Detector square input size in pixels (default 640). + /// Env: `OXICLOUD_FACES_DET_SIZE`. + pub det_size: u32, + /// Minimum detector confidence to keep a face (default 0.5). + /// Env: `OXICLOUD_FACES_DET_THRESHOLD`. + pub det_threshold: f32, + /// IoU threshold for non-max suppression (default 0.4). + /// Env: `OXICLOUD_FACES_NMS_THRESHOLD`. + pub nms_threshold: f32, + /// ONNX Runtime intra-op threads (0 = let ORT decide). + /// Env: `OXICLOUD_FACES_INTRA_THREADS`. + pub intra_threads: usize, +} + +impl Default for FacesConfig { + fn default() -> Self { + Self { + ort_dylib: None, + detector_model: None, + embedder_model: None, + det_size: 640, + det_threshold: 0.5, + nms_threshold: 0.4, + intra_threads: 0, + } + } +} + /// Content-search configuration (embedded Tantivy index over file names and /// extracted file content). /// @@ -1070,6 +1117,8 @@ pub struct AppConfig { pub content_search: ContentSearchConfig, /// WASM plugin runtime configuration pub plugins: PluginConfig, + /// Face-recognition (People) model configuration + pub faces: FacesConfig, } /// Server-side i18n knobs. @@ -1123,6 +1172,7 @@ impl Default for AppConfig { i18n: I18nConfig::default(), content_search: ContentSearchConfig::default(), plugins: PluginConfig::default(), + faces: FacesConfig::default(), } } } @@ -1397,6 +1447,43 @@ impl AppConfig { config.features.enable_faces = val; } + // Faces (People) ONNX runtime + models — operator-provided at runtime. + if let Ok(v) = env::var("OXICLOUD_FACES_ORT_DYLIB").or_else(|_| env::var("ORT_DYLIB_PATH")) + && !v.is_empty() + { + config.faces.ort_dylib = Some(PathBuf::from(v)); + } + if let Ok(v) = env::var("OXICLOUD_FACES_DETECTOR_MODEL") + && !v.is_empty() + { + config.faces.detector_model = Some(PathBuf::from(v)); + } + if let Ok(v) = env::var("OXICLOUD_FACES_EMBEDDER_MODEL") + && !v.is_empty() + { + config.faces.embedder_model = Some(PathBuf::from(v)); + } + if let Ok(v) = env::var("OXICLOUD_FACES_DET_SIZE").map(|v| v.parse::()) + && let Ok(val) = v + { + config.faces.det_size = val; + } + if let Ok(v) = env::var("OXICLOUD_FACES_DET_THRESHOLD").map(|v| v.parse::()) + && let Ok(val) = v + { + config.faces.det_threshold = val; + } + if let Ok(v) = env::var("OXICLOUD_FACES_NMS_THRESHOLD").map(|v| v.parse::()) + && let Ok(val) = v + { + config.faces.nms_threshold = val; + } + if let Ok(v) = env::var("OXICLOUD_FACES_INTRA_THREADS").map(|v| v.parse::()) + && let Ok(val) = v + { + config.faces.intra_threads = val; + } + // Content search (embedded Tantivy index) if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::()) && let Ok(val) = v diff --git a/src/common/di.rs b/src/common/di.rs index d75cbea7..0ca12004 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -814,15 +814,16 @@ impl AppServiceFactory { service } - /// Creates the face-indexing lifecycle hook (People feature). Uses the - /// default no-op analyzer until the operator wires a real ONNX model. + /// Creates the face-indexing lifecycle hook (People feature). Picks the + /// real ONNX analyzer when the `faces-onnx` feature is compiled in and the + /// operator has configured the runtime + models; otherwise the inert no-op + /// analyzer (see [`Self::build_face_analyzer`]). pub fn create_face_indexing_service( &self, db_pool: &Arc, ) -> Arc { let blob_root = self.storage_path.join(".blobs"); - let analyzer: Arc = - Arc::new(crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer); + let analyzer = self.build_face_analyzer(); Arc::new( crate::infrastructure::services::face_indexing_service::FaceIndexingService::new( db_pool.clone(), @@ -832,6 +833,55 @@ impl AppServiceFactory { ) } + /// Selects the face analyzer. With the `faces-onnx` feature and a fully + /// configured runtime + models, loads the real ONNX analyzer; any missing + /// piece or load failure degrades gracefully to the no-op analyzer (logged) + /// so startup never fails on biometric configuration. + fn build_face_analyzer( + &self, + ) -> Arc { + #[cfg(feature = "faces-onnx")] + { + let f = &self.config.faces; + if let (Some(dylib), Some(detector), Some(embedder)) = ( + f.ort_dylib.as_ref(), + f.detector_model.as_ref(), + f.embedder_model.as_ref(), + ) { + use crate::infrastructure::services::onnx_face_analyzer::{ + OnnxFaceAnalyzer, OnnxLoadConfig, + }; + let cfg = OnnxLoadConfig { + dylib, + detector, + embedder, + det_size: f.det_size, + det_threshold: f.det_threshold, + nms_threshold: f.nms_threshold, + intra_threads: f.intra_threads, + }; + match OnnxFaceAnalyzer::load(&cfg) { + Ok(analyzer) => { + tracing::info!("Face analyzer: ONNX models loaded"); + return Arc::new(analyzer); + } + Err(e) => { + tracing::warn!( + "Face analyzer: failed to load ONNX models ({e}); \ + falling back to no-op analyzer" + ); + } + } + } else { + tracing::info!( + "Face analyzer: faces-onnx compiled but runtime/models not fully \ + configured; using no-op analyzer" + ); + } + } + Arc::new(crate::infrastructure::services::noop_face_analyzer::NoopFaceAnalyzer) + } + /// Creates the People (faces) read/clustering service. pub fn create_people_service(&self, db_pool: &Arc) -> Arc { let repo = Arc::new( diff --git a/src/infrastructure/services/face_geometry.rs b/src/infrastructure/services/face_geometry.rs new file mode 100644 index 00000000..f607cbad --- /dev/null +++ b/src/infrastructure/services/face_geometry.rs @@ -0,0 +1,473 @@ +//! Pure geometry + post-processing for the ONNX face pipeline. +//! +//! Everything here is plain Rust (no `ort`, no `ndarray`) so it compiles in the +//! default build and is exercised by `cargo test` — the error-prone numerical +//! parts (SCRFD anchor decode, NMS, 5-point similarity alignment, the affine +//! warp, normalization) are unit-tested in isolation, while the untestable ONNX +//! session calls live behind the `faces-onnx` feature in `onnx_face_analyzer`. +//! +//! The pipeline mirrors InsightFace's reference implementation: +//! SCRFD detector (distance-to-box anchors over strides 8/16/32) → 5-point +//! similarity transform onto the canonical 112×112 ArcFace template → ArcFace +//! embedder → L2-normalized 512-d vector. + +use image::RgbImage; + +/// One detected face in **detector-input pixel** coordinates (before scaling +/// back to the original image): an axis-aligned box `[x1, y1, x2, y2]`, the +/// five facial landmarks, and the detector confidence. +#[derive(Debug, Clone, Copy)] +pub struct Detection { + pub bbox: [f32; 4], + pub kps: [[f32; 2]; 5], + pub score: f32, +} + +/// A 2×3 affine transform mapping an output/template coordinate to a source +/// coordinate: `src = (a·ox + b·oy + tx, c·ox + d·oy + ty)`. Used to sample the +/// source image when warping an aligned face crop. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Affine { + pub a: f32, + pub b: f32, + pub c: f32, + pub d: f32, + pub tx: f32, + pub ty: f32, +} + +/// Canonical ArcFace 5-point template for a 112×112 crop +/// (left eye, right eye, nose, left mouth, right mouth). +pub const ARCFACE_TEMPLATE: [[f32; 2]; 5] = [ + [38.2946, 51.6963], + [73.5318, 51.5014], + [56.0252, 71.7366], + [41.5493, 92.3655], + [70.7299, 92.2041], +]; + +/// Aligned-crop side length expected by the ArcFace embedder. +pub const ALIGN_SIZE: u32 = 112; + +/// Letterbox geometry for the detector: the largest scale that fits a +/// `w0 × h0` image into a `det × det` square without distortion, plus the +/// resulting (possibly smaller) dimensions placed at the top-left. +/// +/// Returns `(new_w, new_h, scale)` where `scale = min(det/w0, det/h0)` and +/// detector-space coordinates map back to the original by dividing by `scale`. +pub fn letterbox(w0: u32, h0: u32, det: u32) -> (u32, u32, f32) { + if w0 == 0 || h0 == 0 { + return (0, 0, 1.0); + } + let scale = (det as f32 / w0 as f32).min(det as f32 / h0 as f32); + let new_w = ((w0 as f32 * scale).round() as u32).clamp(1, det); + let new_h = ((h0 as f32 * scale).round() as u32).clamp(1, det); + (new_w, new_h, scale) +} + +/// `NCHW`, RGB, float input tensor for an ONNX model: `(px − mean) · scale`, +/// channel-major (all R, then all G, then all B). Length is `3 · w · h`. +pub fn chw_normalized(img: &RgbImage, mean: f32, scale: f32) -> Vec { + let (w, h) = (img.width() as usize, img.height() as usize); + let mut out = vec![0.0f32; 3 * w * h]; + let plane = w * h; + for (i, px) in img.pixels().enumerate() { + out[i] = (px[0] as f32 - mean) * scale; + out[plane + i] = (px[1] as f32 - mean) * scale; + out[2 * plane + i] = (px[2] as f32 - mean) * scale; + } + out +} + +/// Decode one SCRFD feature-map stride into detections, appending those above +/// `threshold` to `out`. All coordinates are in detector-input pixels. +/// +/// `scores` is `[n]`, `bbox` is `[n·4]` (left, top, right, bottom *distances*, +/// already multiplied by `stride`), `kps` (when present) is `[n·10]` +/// (5 × (dx, dy) distances, already multiplied by `stride`), where +/// `n = feat_h · feat_w · num_anchors`. Anchor centers follow InsightFace's +/// row-major `mgrid` order with `num_anchors` consecutive duplicates. +#[allow(clippy::too_many_arguments)] +pub fn decode_stride( + scores: &[f32], + bbox: &[f32], + kps: Option<&[f32]>, + stride: u32, + feat_h: u32, + feat_w: u32, + num_anchors: u32, + threshold: f32, + out: &mut Vec, +) { + let stride_f = stride as f32; + let mut idx = 0usize; + for y in 0..feat_h { + for x in 0..feat_w { + let cx = x as f32 * stride_f; + let cy = y as f32 * stride_f; + for _ in 0..num_anchors { + if idx >= scores.len() { + return; + } + let score = scores[idx]; + if score >= threshold { + let b = idx * 4; + if b + 3 < bbox.len() { + let det_bbox = [ + cx - bbox[b], + cy - bbox[b + 1], + cx + bbox[b + 2], + cy + bbox[b + 3], + ]; + let mut det_kps = [[0.0f32; 2]; 5]; + if let Some(kps) = kps { + let k = idx * 10; + if k + 9 < kps.len() { + for (p, slot) in det_kps.iter_mut().enumerate() { + *slot = [cx + kps[k + p * 2], cy + kps[k + p * 2 + 1]]; + } + } + } + out.push(Detection { + bbox: det_bbox, + kps: det_kps, + score, + }); + } + } + idx += 1; + } + } + } +} + +/// Intersection-over-union of two `[x1, y1, x2, y2]` boxes. +pub fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 { + let x1 = a[0].max(b[0]); + let y1 = a[1].max(b[1]); + let x2 = a[2].min(b[2]); + let y2 = a[3].min(b[3]); + let iw = (x2 - x1).max(0.0); + let ih = (y2 - y1).max(0.0); + let inter = iw * ih; + let area_a = (a[2] - a[0]).max(0.0) * (a[3] - a[1]).max(0.0); + let area_b = (b[2] - b[0]).max(0.0) * (b[3] - b[1]).max(0.0); + let union = area_a + area_b - inter; + if union <= 0.0 { 0.0 } else { inter / union } +} + +/// Greedy non-maximum suppression: keep highest-scoring boxes, drop any whose +/// IoU with an already-kept box exceeds `iou_thresh`. Returns the kept +/// detections, highest score first. +pub fn nms(mut dets: Vec, iou_thresh: f32) -> Vec { + dets.sort_by(|a, b| b.score.total_cmp(&a.score)); + let mut keep: Vec = Vec::with_capacity(dets.len()); + for d in dets { + if keep.iter().all(|k| iou(&k.bbox, &d.bbox) <= iou_thresh) { + keep.push(d); + } + } + keep +} + +/// Least-squares similarity transform (scale + rotation + translation, no +/// shear, no reflection) mapping `src` landmarks onto `dst`, returned as its +/// **inverse** affine (output/template coordinate → source coordinate) ready +/// for backward-warp sampling. +/// +/// Solved in closed form via the complex-number formulation: with points as +/// complex numbers, `w = Σ (b'ᵢ · conj(a'ᵢ)) / Σ |a'ᵢ|²` and `t = mean_b − +/// w·mean_a`, which is equivalent to the Umeyama solution InsightFace obtains +/// from `skimage.SimilarityTransform`. +pub fn similarity_transform_inverse(src: &[[f32; 2]; 5], dst: &[[f32; 2]; 5]) -> Affine { + let n = 5.0f32; + let (mut max, mut may, mut mbx, mut mby) = (0.0f32, 0.0f32, 0.0f32, 0.0f32); + for i in 0..5 { + max += src[i][0]; + may += src[i][1]; + mbx += dst[i][0]; + mby += dst[i][1]; + } + max /= n; + may /= n; + mbx /= n; + mby /= n; + + // num = Σ b'·conj(a') (complex), den = Σ |a'|² (real) + let (mut num_re, mut num_im, mut den) = (0.0f32, 0.0f32, 0.0f32); + for i in 0..5 { + let ax = src[i][0] - max; + let ay = src[i][1] - may; + let bx = dst[i][0] - mbx; + let by = dst[i][1] - mby; + // b' · conj(a') = (bx + i·by)(ax − i·ay) + num_re += bx * ax + by * ay; + num_im += by * ax - bx * ay; + den += ax * ax + ay * ay; + } + let den = if den.abs() < 1e-12 { 1e-12 } else { den }; + // w = num/den (forward scale·rotation) + let wr = num_re / den; + let wi = num_im / den; + // t = mean_b − w·mean_a + let tr = mbx - (wr * max - wi * may); + let ti = mby - (wi * max + wr * may); + + // Inverse of the similarity: src = Ainv·(out − t), Ainv = [[wr,wi],[−wi,wr]]/|w|² + let det = wr * wr + wi * wi; + let g = if det.abs() < 1e-12 { 0.0 } else { 1.0 / det }; + Affine { + a: g * wr, + b: g * wi, + c: -g * wi, + d: g * wr, + tx: -g * (wr * tr + wi * ti), + ty: g * (wi * tr - wr * ti), + } +} + +/// Warp `img` into an `ALIGN_SIZE × ALIGN_SIZE` aligned face crop using the +/// inverse affine from [`similarity_transform_inverse`], sampling bilinearly +/// and clamping to the image edge. +pub fn warp_to_aligned(img: &RgbImage, inv: &Affine) -> RgbImage { + let (w, h) = (img.width(), img.height()); + let mut out = RgbImage::new(ALIGN_SIZE, ALIGN_SIZE); + for oy in 0..ALIGN_SIZE { + for ox in 0..ALIGN_SIZE { + let sx = inv.a * ox as f32 + inv.b * oy as f32 + inv.tx; + let sy = inv.c * ox as f32 + inv.d * oy as f32 + inv.ty; + let px = bilinear_sample(img, sx, sy, w, h); + out.put_pixel(ox, oy, px); + } + } + out +} + +/// Bilinear RGB sample at floating `(x, y)`, clamping out-of-bounds reads to +/// the nearest edge. +fn bilinear_sample(img: &RgbImage, x: f32, y: f32, w: u32, h: u32) -> image::Rgb { + let x = x.clamp(0.0, (w - 1) as f32); + let y = y.clamp(0.0, (h - 1) as f32); + let x0 = x.floor() as u32; + let y0 = y.floor() as u32; + let x1 = (x0 + 1).min(w - 1); + let y1 = (y0 + 1).min(h - 1); + let dx = x - x0 as f32; + let dy = y - y0 as f32; + let p00 = img.get_pixel(x0, y0); + let p10 = img.get_pixel(x1, y0); + let p01 = img.get_pixel(x0, y1); + let p11 = img.get_pixel(x1, y1); + let mut out = [0u8; 3]; + for (ch, slot) in out.iter_mut().enumerate() { + let top = p00[ch] as f32 * (1.0 - dx) + p10[ch] as f32 * dx; + let bot = p01[ch] as f32 * (1.0 - dx) + p11[ch] as f32 * dx; + *slot = (top * (1.0 - dy) + bot * dy).round().clamp(0.0, 255.0) as u8; + } + image::Rgb(out) +} + +/// In-place L2 normalization. A zero vector is left unchanged. +pub fn l2_normalize(v: &mut [f32]) { + let norm = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 1e-12 { + for x in v.iter_mut() { + *x /= norm; + } + } +} + +/// Variance of the discrete Laplacian over the luminance of an RGB crop — a +/// cheap focus/sharpness proxy (higher = sharper). Used as a face quality +/// score for cover selection and gating. +pub fn laplacian_variance(img: &RgbImage) -> f32 { + let (w, h) = (img.width() as i64, img.height() as i64); + if w < 3 || h < 3 { + return 0.0; + } + let lum = |x: i64, y: i64| -> f32 { + let p = img.get_pixel(x as u32, y as u32); + 0.299 * p[0] as f32 + 0.587 * p[1] as f32 + 0.114 * p[2] as f32 + }; + let mut vals = Vec::with_capacity(((w - 2) * (h - 2)) as usize); + for y in 1..h - 1 { + for x in 1..w - 1 { + let l = 4.0 * lum(x, y) - lum(x - 1, y) - lum(x + 1, y) - lum(x, y - 1) - lum(x, y + 1); + vals.push(l); + } + } + let n = vals.len() as f32; + if n == 0.0 { + return 0.0; + } + let mean = vals.iter().sum::() / n; + vals.iter().map(|v| (v - mean) * (v - mean)).sum::() / n +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn letterbox_fits_and_preserves_aspect() { + // Landscape 1000×500 into 640 → width-bound, scale 0.64. + let (nw, nh, s) = letterbox(1000, 500, 640); + assert_eq!(nw, 640); + assert_eq!(nh, 320); + assert!((s - 0.64).abs() < 1e-6); + // Square fills exactly. + let (nw, nh, s) = letterbox(800, 800, 640); + assert_eq!((nw, nh), (640, 640)); + assert!((s - 0.8).abs() < 1e-6); + } + + #[test] + fn letterbox_degenerate_is_safe() { + assert_eq!(letterbox(0, 10, 640), (0, 0, 1.0)); + } + + #[test] + fn chw_layout_and_normalization() { + let mut img = RgbImage::new(2, 1); + img.put_pixel(0, 0, image::Rgb([127, 0, 255])); + img.put_pixel(1, 0, image::Rgb([128, 255, 0])); + let t = chw_normalized(&img, 127.5, 1.0 / 128.0); + // Length = 3 channels × 2 px. + assert_eq!(t.len(), 6); + // R plane first, then G, then B (NCHW). + assert!((t[0] - (127.0 - 127.5) / 128.0).abs() < 1e-6); + assert!((t[1] - (128.0 - 127.5) / 128.0).abs() < 1e-6); + assert!((t[2] - (0.0 - 127.5) / 128.0).abs() < 1e-6); // G of px0 + assert!((t[4] - (255.0 - 127.5) / 128.0).abs() < 1e-6); // B of px0 + } + + #[test] + fn distance_decode_recovers_box_and_kps() { + // 1×2 grid, stride 8, 1 anchor → cell centers (0,0) then (8,0). + let scores = [0.9f32, 0.9]; + // distances left/top/right/bottom (already × stride), identical per cell. + let bbox = [2.0, 1.0, 3.0, 4.0, 2.0, 1.0, 3.0, 4.0]; + let kps: Vec = vec![ + 1.0, 1.0, 2.0, 2.0, 0.0, 0.0, -1.0, 1.0, 1.0, -1.0, // cell 0 + 1.0, 1.0, 2.0, 2.0, 0.0, 0.0, -1.0, 1.0, 1.0, -1.0, // cell 1 + ]; + let mut out = Vec::new(); + decode_stride(&scores, &bbox, Some(&kps), 8, 1, 2, 1, 0.5, &mut out); + assert_eq!(out.len(), 2); + // Cell 0, center (0,0): box = center ± distances, kps = center + offset. + assert_eq!(out[0].bbox, [-2.0, -1.0, 3.0, 4.0]); + assert_eq!(out[0].kps[0], [1.0, 1.0]); + assert_eq!(out[0].kps[1], [2.0, 2.0]); + // Cell 1, center (8,0): anchor center advanced by one stride in x. + assert_eq!(out[1].bbox, [8.0 - 2.0, -1.0, 8.0 + 3.0, 4.0]); + assert_eq!(out[1].kps[0], [9.0, 1.0]); + } + + #[test] + fn decode_thresholds_out_low_scores() { + let scores = [0.2f32, 0.8]; + let bbox = [0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0]; + let mut out = Vec::new(); + // 1×2 grid, 1 anchor → two cells. + decode_stride(&scores, &bbox, None, 8, 1, 2, 1, 0.5, &mut out); + assert_eq!(out.len(), 1); + assert!((out[0].score - 0.8).abs() < 1e-6); + } + + #[test] + fn iou_and_nms() { + let a = [0.0, 0.0, 10.0, 10.0]; + let b = [0.0, 0.0, 10.0, 10.0]; + assert!((iou(&a, &b) - 1.0).abs() < 1e-6); + let c = [100.0, 100.0, 110.0, 110.0]; + assert_eq!(iou(&a, &c), 0.0); + + let dets = vec![ + Detection { + bbox: a, + kps: [[0.0; 2]; 5], + score: 0.9, + }, + Detection { + bbox: b, + kps: [[0.0; 2]; 5], + score: 0.8, + }, // dup of a + Detection { + bbox: c, + kps: [[0.0; 2]; 5], + score: 0.7, + }, // separate + ]; + let kept = nms(dets, 0.4); + assert_eq!(kept.len(), 2); + assert!((kept[0].score - 0.9).abs() < 1e-6); + } + + #[test] + fn similarity_identity() { + let inv = similarity_transform_inverse(&ARCFACE_TEMPLATE, &ARCFACE_TEMPLATE); + assert!((inv.a - 1.0).abs() < 1e-4); + assert!(inv.b.abs() < 1e-4); + assert!(inv.c.abs() < 1e-4); + assert!((inv.d - 1.0).abs() < 1e-4); + assert!(inv.tx.abs() < 1e-3); + assert!(inv.ty.abs() < 1e-3); + } + + #[test] + fn similarity_pure_translation() { + // src = dst shifted by (+10, +5); inverse must map out→src by the same shift. + let mut src = ARCFACE_TEMPLATE; + for p in &mut src { + p[0] += 10.0; + p[1] += 5.0; + } + let inv = similarity_transform_inverse(&src, &ARCFACE_TEMPLATE); + assert!((inv.a - 1.0).abs() < 1e-4); + assert!(inv.b.abs() < 1e-4); + assert!((inv.tx - 10.0).abs() < 1e-3); + assert!((inv.ty - 5.0).abs() < 1e-3); + } + + #[test] + fn warp_identity_preserves_template_region() { + // A 112×112 gradient warped by identity returns (close to) itself. + let mut img = RgbImage::new(ALIGN_SIZE, ALIGN_SIZE); + for y in 0..ALIGN_SIZE { + for x in 0..ALIGN_SIZE { + img.put_pixel(x, y, image::Rgb([x as u8, y as u8, 128])); + } + } + let inv = similarity_transform_inverse(&ARCFACE_TEMPLATE, &ARCFACE_TEMPLATE); + let out = warp_to_aligned(&img, &inv); + let a = out.get_pixel(40, 60); + assert!((a[0] as i32 - 40).abs() <= 1); + assert!((a[1] as i32 - 60).abs() <= 1); + } + + #[test] + fn l2_normalize_unit_length() { + let mut v = vec![3.0f32, 4.0]; + l2_normalize(&mut v); + assert!((v[0] - 0.6).abs() < 1e-6); + assert!((v[1] - 0.8).abs() < 1e-6); + let mut z = vec![0.0f32, 0.0]; + l2_normalize(&mut z); // unchanged, no NaN + assert_eq!(z, vec![0.0, 0.0]); + } + + #[test] + fn laplacian_variance_sharp_vs_flat() { + let flat = RgbImage::from_pixel(8, 8, image::Rgb([100, 100, 100])); + assert!(laplacian_variance(&flat) < 1e-3); + let mut checker = RgbImage::new(8, 8); + for y in 0..8 { + for x in 0..8 { + let v = if (x + y) % 2 == 0 { 0 } else { 255 }; + checker.put_pixel(x, y, image::Rgb([v, v, v])); + } + } + assert!(laplacian_variance(&checker) > 1000.0); + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index b41b6edc..67c52e70 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -6,6 +6,7 @@ pub mod compression_service; pub mod dedup_service; pub mod encrypted_blob_backend; pub mod exif_service; +pub mod face_geometry; pub mod face_indexing_service; pub mod file_content_cache; pub mod file_system_i18n_service; @@ -20,6 +21,8 @@ pub mod mock_email_sender; pub mod nextcloud_chunked_upload_service; pub mod noop_face_analyzer; pub mod oidc_service; +#[cfg(feature = "faces-onnx")] +pub mod onnx_face_analyzer; pub mod password_hasher; pub mod path_resolver_service; pub mod path_service; diff --git a/src/infrastructure/services/onnx_face_analyzer.rs b/src/infrastructure/services/onnx_face_analyzer.rs new file mode 100644 index 00000000..f58d73c5 --- /dev/null +++ b/src/infrastructure/services/onnx_face_analyzer.rs @@ -0,0 +1,340 @@ +//! ONNX-backed face analyzer (SCRFD detector + ArcFace embedder). +//! +//! Compiled only with the `faces-onnx` cargo feature. Mirrors the +//! immich/InsightFace pipeline: detect faces + 5-point landmarks (SCRFD), +//! similarity-align each face to the canonical 112×112 template, then embed +//! (ArcFace) into an L2-normalized 512-d vector. All inference runs on a +//! blocking thread (`spawn_blocking`) so it never stalls a Tokio worker, and +//! each ONNX session is serialized behind a `Mutex` (ORT's `run` needs `&mut`). +//! +//! The heavy numerical post-processing lives in [`super::face_geometry`] (plain +//! Rust, unit-tested); this module only wires it to ONNX Runtime. +//! +//! **Models are operator-provided at runtime, never committed.** `load` returns +//! an error (→ caller falls back to the no-op analyzer) if the ONNX Runtime +//! dylib or either model file is missing or incompatible — the server still +//! boots. The dylib is loaded via [`ort::init_from`] (a fallible path) rather +//! than ORT's lazy loader, which would `panic` on a missing library (fatal +//! under `panic = "abort"`). + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use image::RgbImage; +use ort::session::Session; +use ort::value::Tensor; + +use super::face_geometry as geom; +use crate::application::ports::face_ports::FaceAnalyzerPort; +use crate::common::errors::DomainError; +use crate::domain::entities::face::{BoundingBox, DetectedFace, EMBEDDING_DIM}; + +/// SCRFD pyramid strides for the 3- and 5-level model variants. +const STRIDES_3: [u32; 3] = [8, 16, 32]; +const STRIDES_5: [u32; 5] = [8, 16, 32, 64, 128]; + +/// Discard faces smaller than this (original-image pixels) — embeddings of tiny +/// faces are unreliable. +const MIN_FACE_PX: f32 = 24.0; +/// Hard cap on faces processed per image (bounds work on crowd shots). +const MAX_FACES: usize = 64; + +/// Output layout of an InsightFace SCRFD model, inferred from its output count. +#[derive(Clone, Copy)] +struct ScrfdLayout { + /// Feature-map count per output kind (3 for strides 8/16/32, 5 with 64/128). + fmc: usize, + num_anchors: u32, + use_kps: bool, +} + +impl ScrfdLayout { + fn from_num_outputs(n: usize) -> Option { + match n { + 6 => Some(Self { + fmc: 3, + num_anchors: 2, + use_kps: false, + }), + 9 => Some(Self { + fmc: 3, + num_anchors: 2, + use_kps: true, + }), + 10 => Some(Self { + fmc: 5, + num_anchors: 1, + use_kps: false, + }), + 15 => Some(Self { + fmc: 5, + num_anchors: 1, + use_kps: true, + }), + _ => None, + } + } + + fn strides(&self) -> &'static [u32] { + if self.fmc == 3 { + &STRIDES_3 + } else { + &STRIDES_5 + } + } +} + +/// Where to find the runtime + models, plus detector knobs. Borrowed paths; +/// nothing is retained after [`OnnxFaceAnalyzer::load`]. +pub struct OnnxLoadConfig<'a> { + /// Path to `libonnxruntime.{so,dylib,dll}`. + pub dylib: &'a Path, + /// SCRFD detector `.onnx`. + pub detector: &'a Path, + /// ArcFace embedder `.onnx`. + pub embedder: &'a Path, + pub det_size: u32, + pub det_threshold: f32, + pub nms_threshold: f32, + /// ORT intra-op threads (0 = let ONNX Runtime decide). + pub intra_threads: usize, +} + +struct Inner { + detector: Mutex, + embedder: Mutex, + layout: ScrfdLayout, + det_size: u32, + det_threshold: f32, + nms_threshold: f32, +} + +/// Real face analyzer. Cheap to clone (`Arc` inside). +#[derive(Clone)] +pub struct OnnxFaceAnalyzer { + inner: Arc, +} + +fn dom(e: impl std::fmt::Display) -> DomainError { + DomainError::internal_error("Faces", e.to_string()) +} + +fn build_session(path: &Path, intra_threads: usize) -> Result { + let mut builder = Session::builder().map_err(dom)?; + if intra_threads > 0 { + builder = builder.with_intra_threads(intra_threads).map_err(dom)?; + } + builder.commit_from_file(path).map_err(dom) +} + +impl OnnxFaceAnalyzer { + /// Load the ONNX Runtime dylib and both models. Returns an error (caller + /// falls back to the no-op analyzer) on any missing/incompatible artifact. + pub fn load(cfg: &OnnxLoadConfig<'_>) -> Result { + // Fallible dylib load — populates ORT's global handle so later calls + // never hit the panicking lazy loader. + ort::init_from(cfg.dylib) + .map_err(|e| dom(format!("ONNX Runtime dylib: {e}")))? + .commit(); + + let detector = build_session(cfg.detector, cfg.intra_threads)?; + let embedder = build_session(cfg.embedder, cfg.intra_threads)?; + + let n_out = detector.outputs().len(); + let layout = ScrfdLayout::from_num_outputs(n_out).ok_or_else(|| { + dom(format!( + "detector has {n_out} outputs; expected an SCRFD model (6/9/10/15)" + )) + })?; + if !layout.use_kps { + tracing::warn!( + target: "oxicloud::faces", + "SCRFD model has no landmark outputs; face alignment will be approximate" + ); + } + + tracing::info!( + target: "oxicloud::faces", + "ONNX face analyzer ready (detector {} outputs, embedder loaded, det_size={})", + n_out, cfg.det_size + ); + + Ok(Self { + inner: Arc::new(Inner { + detector: Mutex::new(detector), + embedder: Mutex::new(embedder), + layout, + det_size: cfg.det_size, + det_threshold: cfg.det_threshold, + nms_threshold: cfg.nms_threshold, + }), + }) + } +} + +impl Inner { + /// Full synchronous pipeline for one encoded image. + fn analyze_blocking(&self, image_bytes: &[u8]) -> Result, DomainError> { + let orig = image::load_from_memory(image_bytes) + .map_err(|e| dom(format!("decode image: {e}")))? + .to_rgb8(); + let (w0, h0) = (orig.width(), orig.height()); + if w0 == 0 || h0 == 0 { + return Ok(Vec::new()); + } + + let dets = self.detect(&orig)?; + + let mut faces = Vec::new(); + for det in dets.into_iter().take(MAX_FACES) { + let fw = det.bbox[2] - det.bbox[0]; + let fh = det.bbox[3] - det.bbox[1]; + if fw < MIN_FACE_PX || fh < MIN_FACE_PX { + continue; + } + let Some(embedding) = self.embed(&orig, &det)? else { + continue; + }; + let aligned_quality = { + let inv = geom::similarity_transform_inverse(&det.kps, &geom::ARCFACE_TEMPLATE); + let aligned = geom::warp_to_aligned(&orig, &inv); + geom::laplacian_variance(&aligned) + }; + let x = (det.bbox[0] / w0 as f32).clamp(0.0, 1.0); + let y = (det.bbox[1] / h0 as f32).clamp(0.0, 1.0); + let bw = (fw / w0 as f32).clamp(0.0, 1.0); + let bh = (fh / h0 as f32).clamp(0.0, 1.0); + faces.push(DetectedFace { + bbox: BoundingBox { x, y, w: bw, h: bh }, + det_score: det.score, + quality: Some(aligned_quality), + embedding, + }); + } + Ok(faces) + } + + /// Run SCRFD and return detections in **original-image pixels**. + fn detect(&self, orig: &RgbImage) -> Result, DomainError> { + let det = self.det_size; + let (nw, nh, scale) = geom::letterbox(orig.width(), orig.height(), det); + let resized = image::imageops::resize(orig, nw, nh, image::imageops::FilterType::Triangle); + let mut canvas = RgbImage::new(det, det); + image::imageops::overlay(&mut canvas, &resized, 0, 0); + let input = geom::chw_normalized(&canvas, 127.5, 1.0 / 128.0); + let tensor = + Tensor::from_array(([1_i64, 3, det as i64, det as i64], input)).map_err(dom)?; + + let layout = self.layout; + let total = layout.fmc * if layout.use_kps { 3 } else { 2 }; + let raw: Vec> = { + let mut sess = self + .detector + .lock() + .map_err(|_| dom("detector mutex poisoned"))?; + let outputs = sess.run(ort::inputs![tensor]).map_err(dom)?; + (0..total) + .map(|i| { + outputs[i] + .try_extract_tensor::() + .map(|(_, data)| data.to_vec()) + .map_err(dom) + }) + .collect::>()? + }; + + let mut dets = Vec::new(); + for (si, &stride) in layout.strides().iter().enumerate() { + let scores = &raw[si]; + let bbox: Vec = raw[layout.fmc + si] + .iter() + .map(|v| v * stride as f32) + .collect(); + let kps: Option> = if layout.use_kps { + Some( + raw[2 * layout.fmc + si] + .iter() + .map(|v| v * stride as f32) + .collect(), + ) + } else { + None + }; + let feat = det / stride; + geom::decode_stride( + scores, + &bbox, + kps.as_deref(), + stride, + feat, + feat, + layout.num_anchors, + self.det_threshold, + &mut dets, + ); + } + + // Scale detector-space coordinates back to the original image. + let inv_scale = if scale.abs() < 1e-9 { 1.0 } else { 1.0 / scale }; + for d in &mut dets { + for v in &mut d.bbox { + *v *= inv_scale; + } + for k in &mut d.kps { + k[0] *= inv_scale; + k[1] *= inv_scale; + } + } + Ok(geom::nms(dets, self.nms_threshold)) + } + + /// Align one detection and run the ArcFace embedder. Returns `None` if the + /// embedder produces an unexpected output length. + fn embed( + &self, + orig: &RgbImage, + det: &geom::Detection, + ) -> Result>, DomainError> { + let inv = geom::similarity_transform_inverse(&det.kps, &geom::ARCFACE_TEMPLATE); + let aligned = geom::warp_to_aligned(orig, &inv); + let input = geom::chw_normalized(&aligned, 127.5, 1.0 / 127.5); + let size = geom::ALIGN_SIZE as i64; + let tensor = Tensor::from_array(([1_i64, 3, size, size], input)).map_err(dom)?; + + let mut embedding: Vec = { + let mut sess = self + .embedder + .lock() + .map_err(|_| dom("embedder mutex poisoned"))?; + let outputs = sess.run(ort::inputs![tensor]).map_err(dom)?; + let (_, data) = outputs[0].try_extract_tensor::().map_err(dom)?; + data.to_vec() + }; + if embedding.len() != EMBEDDING_DIM { + tracing::warn!( + target: "oxicloud::faces", + "embedder returned {} dims, expected {EMBEDDING_DIM}; skipping face", + embedding.len() + ); + return Ok(None); + } + geom::l2_normalize(&mut embedding); + Ok(Some(embedding)) + } +} + +#[async_trait] +impl FaceAnalyzerPort for OnnxFaceAnalyzer { + fn is_ready(&self) -> bool { + true + } + + async fn analyze(&self, image_bytes: &[u8]) -> Result, DomainError> { + let inner = self.inner.clone(); + let bytes = image_bytes.to_vec(); + tokio::task::spawn_blocking(move || inner.analyze_blocking(&bytes)) + .await + .map_err(|e| dom(format!("inference task join: {e}")))? + } +} From f4e387bad0d7194938402b84b541fccaeedd950f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 12:29:33 +0000 Subject: [PATCH 39/39] docs(plan): mark Phase 2 (People) complete with ONNX analyzer Records the real SCRFD+ArcFace analyzer behind the faces-onnx feature, the FacesConfig env vars, and the deviation (unit-tested geometry split from the feature-gated ONNX seam). Notes what stays optional: per-user consent gate, lightbox face boxes, and a periodic re-cluster scheduler. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- docs/plan/Photos-People-Places.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/plan/Photos-People-Places.md b/docs/plan/Photos-People-Places.md index bf8f2381..e4d3ef66 100644 --- a/docs/plan/Photos-People-Places.md +++ b/docs/plan/Photos-People-Places.md @@ -50,7 +50,7 @@ migrations, and a **vanilla-JS / vanilla-CSS** frontend (design tokens from - **Deviations from the original plan:** 1.5 serves the basemap as a *static file* (ServeDir Range) instead of the `pmtiles` Rust crate; 1.8 uses MapLibre HTML markers instead of a deck.gl `IconLayer`. Both keep the footprint minimal and need zero new backend code. - **Pending:** browser smoke-test, and an operator-supplied `static/basemaps/basemap.pmtiles` for the street backdrop (works without it). -**Phase 2 — People: backend + frontend landed; real face model pending.** +**Phase 2 — People: complete (detector/embedder shipped, opt-in).** - **Migration** (`…_faces.sql`): `faces` schema with `faces.persons` + `faces.faces`. **Deviation from 2.2:** embeddings stored as **`BYTEA`** (512×`f32` little-endian), **no `pgvector`** — cosine similarity runs in Rust. This keeps the extension footprint at @@ -72,9 +72,18 @@ migrations, and a **vanilla-JS / vanilla-CSS** frontend (design tokens from - **Indexing:** `FaceIndexingService` implements `FileLifecycleHook` — background detect+embed on image create/copy/update, **dedup by blob hash**. Driven by the analyzer port; with the no-op analyzer it does nothing. -- **Analyzer (placeholder):** `NoopFaceAnalyzer` (`is_ready()=false`, returns no faces) so - the whole stack compiles and runs **without any ML model**. The real ONNX analyzer is - the one remaining backend piece (see below). +- **Analyzer:** two implementations behind `FaceAnalyzerPort`. `NoopFaceAnalyzer` + (`is_ready()=false`) is the default so the stack compiles/runs **without any ML model**. + `OnnxFaceAnalyzer` (`12ede47`, behind the **`faces-onnx`** cargo feature) is the real + SCRFD+ArcFace pipeline; `di::build_face_analyzer` picks it when the feature is compiled in + and runtime+models are configured, else degrades to the no-op (logged) so startup never + fails. **Deviation from 2.4:** the error-prone math (SCRFD anchor decode, NMS, the + closed-form similarity alignment, affine warp, normalization) lives in `face_geometry.rs`, + compiled in **every** build and covered by 11 unit tests; only the ONNX session calls are + feature-gated (and untestable here, no models). `ort` uses **load-dynamic** so + `libonnxruntime` is dlopen'd at runtime and the crate builds without it; loading goes + through `ort::init_from` (fallible) not ORT's lazy loader, which would `panic` under + `panic = "abort"`. - **HTTP:** `people_handler.rs` + routes (gated on `people_service.is_some()`): `GET /api/people`, `/api/people/{id}/photos`, `PATCH /api/people/{id}`, `POST /api/people/merge`, `/api/people/recluster`, `GET /api/people/data`, @@ -84,10 +93,14 @@ migrations, and a **vanilla-JS / vanilla-CSS** frontend (design tokens from `Modal.prompt` + `PATCH`. Wired into the Photos sub-nav as a third **People** tab that a capability probe (`GET /api/people`) reveals only when faces are on; otherwise hidden. i18n keys in `en.json` (others fall back to English). -- **Pending:** **2.4 real ONNX analyzer** — add `ort` (load-dynamic) + an operator-supplied - SCRFD/RetinaFace detector and ArcFace/EdgeFace embedder, implementing `FaceAnalyzerPort`. - Adds the `ort` crate and can't be exercised in this environment (no models). Per-user - opt-in consent gate (2.1) and lightbox face-box tagging (2.8) also still open. +- **Config (2.4):** `FacesConfig` + `OXICLOUD_FACES_{ORT_DYLIB,DETECTOR_MODEL, + EMBEDDER_MODEL,DET_SIZE,DET_THRESHOLD,NMS_THRESHOLD,INTRA_THREADS}` (documented in + `example.env`). To run faces: build `--features faces-onnx`, set `OXICLOUD_ENABLE_FACES=true`, + and point the three model/runtime paths at an operator-supplied ONNX Runtime + + SCRFD detector + ArcFace embedder (e.g. InsightFace `buffalo_l`). Nothing is committed. +- **Still open (optional):** per-user opt-in consent gate (2.1), lightbox face-box tagging + (2.8), and the periodic full re-cluster job (2.6 has on-demand `recluster`; no scheduler + yet). End-to-end smoke-test needs real models + a browser, which only you can run. ---