diff --git a/.dockerignore b/.dockerignore index f6d86cf..d340b4f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,3 @@ -data/ -data_sources/ .venv **/node_modules **/dist @@ -8,6 +6,5 @@ server-rs/target .task .claude __pycache__ -*.parquet analyses/ *.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d33a35..908f976 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,3 +80,49 @@ jobs: - name: Build run: npm run build + + lint-rust: + name: Lint Rust + runs-on: ubuntu-latest + defaults: + run: + working-directory: server-rs + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: server-rs + + - name: Run clippy + run: cargo clippy -- -D warnings + + - name: Check formatting + run: cargo fmt --check + + test-rust: + name: Test Rust + runs-on: ubuntu-latest + needs: [lint-rust] + defaults: + run: + working-directory: server-rs + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: server-rs + + - name: Run tests + run: cargo test diff --git a/.gitignore b/.gitignore index 172268f..39735f3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ tfl_journey_client server-rs/target .task data +frontend/public/assets diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c9d529..702822d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,8 +3,10 @@ "*.venv": true, "**/__pycache__": true, "**/node_modules": true, - "**/.ruff_cache":true, - "**/.pytest_cache":true, - "**/target":true + "**/.ruff_cache": true, + "**/.pytest_cache": true, + "**/target": true, + "frontend/dist": true, + "**/.task": true } -} +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index cd0a5df..1b20795 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,11 +15,14 @@ All commands use [Task](https://taskfile.dev) runner. Python uses `uv run`. Fron ```bash # Development servers task dev:server # Rust backend on :8001 (cargo run --release) -task dev:frontend # Webpack dev server on :3030 (proxies /api to :8001) +task dev:frontend # Webpack dev server on :3001 (proxies /api to :8001) # Data pipeline task prepare # Build wide.parquet from all pre-downloaded sources +# Assets +task download:map-assets # Download font glyphs + twemoji PNGs into frontend/public/assets/ + # Quality task lint # Lint all: Python (ruff) + TypeScript (ESLint+Prettier) + Rust (clippy+fmt) task format # Auto-fix formatting for all languages @@ -83,41 +86,89 @@ The server and frontend must handle these human-readable names. See the full ren Rust + Axum. Loads parquet into memory at startup. -**Structure:** -- `data/property.rs` — Loads `wide.parquet`, auto-discovers numeric + enum features, computes histograms, sorts rows by spatial locality, precomputes H3 cells (resolutions 4–12) -- `data/poi.rs` — Loads `filtered_uk_pois.parquet` -- `index.rs` — `GridIndex`: 0.01° spatial grid for O(1) cell lookup -- `filter.rs` — Parses filter strings and checks rows. Format: `name:min:max` (numeric), `name:val1|val2` (enum) -- `routes/` — One file per endpoint +**Structure** (uses Rust 2018 module style — `foo.rs` + `foo/` directory, not `foo/mod.rs`): +- `data.rs` + `data/` — Property and POI data loading +- `parsing.rs` + `parsing/` — Filter parsing and bounds parsing +- `routes.rs` + `routes/` — One file per endpoint +- `utils.rs` + `utils/` — GridIndex, hashing, interned columns - `consts.rs` — Key constants (histogram bins, H3 range, max enum cardinality, excluded columns) **API endpoints:** - `GET /api/features` — Feature metadata with histograms and 2nd/98th percentiles -- `GET /api/hexagons?resolution=&bounds=&filters=` — H3 aggregates (min/max per feature per hex) +- `GET /api/hexagons?resolution=&bounds=&filters=&fields=` — H3 aggregates (min/max per feature per hex), AABB-filtered to bounds +- `GET /api/postcodes?bounds=&filters=&fields=` — Postcode polygon aggregates, AABB-filtered to bounds +- `GET /api/postcode/:postcode` — Single postcode lookup (centroid + polygon) - `GET /api/hexagon-properties?h3=&resolution=&filters=&limit=&offset=` — Paginated properties within a hexagon - `GET /api/pois?bounds=&categories=` — POIs by bounds (max 5000) - `GET /api/poi-categories` — Available POI category names Serves `frontend/dist/` as static fallback in production. -**Data representation:** -- Numeric features: row-major flat `Vec`, NaN = null -- Enum features: `Vec` indices into value list, 255 = null -- String fields (address, postcode): `Vec`, empty = null +**Data representation (unified model):** +- All features (numeric and enum): row-major flat `Vec`, NaN = null +- Enum features: stored as f32 indices (0.0, 1.0, 2.0...) with `enum_values: FxHashMap>` mapping feature index → string values +- String fields (address, postcode): interned/packed for memory efficiency - The server accepts the parquet path as a CLI argument (defaults to `data_sources/processed/wide.parquet`) ### Frontend (`frontend/`) React 18 + TypeScript. deck.gl `H3HexagonLayer` over MapLibre GL. TailwindCSS. No state management library — pure React hooks. +**Architecture:** +- `App.tsx` — Minimal router: loads features/POI categories, handles page navigation (home/dashboard/data-sources/faq) +- `MapPage.tsx` — Dashboard layout: composes map + left/right panes, uses custom hooks for all logic +- Custom hooks in `hooks/` encapsulate stateful logic: + - `useMapData` — Hexagon/postcode fetching, bounds, loading state, color range calculation + - `useFilters` — Filter state and handlers (add/remove/change/drag/pin) + - `useHexagonSelection` — Selection state, area stats, properties fetching + - `usePOIData` — POI fetching with debounce + - `usePaneResize` — Reusable pane resize handlers + - `useTheme` — Theme state with localStorage persistence + - `useUrlSync` — URL state synchronization + **Key patterns:** -- `App.tsx` manages all state, API fetching (150ms debounce), and URL state sync (300ms debounce) - URL encodes view/filters/POI categories/active tab as query params for shareable links -- AbortControllers cancel in-flight requests on new queries -- Zoom → H3 resolution: `<7→7, <9.5→8, <11→9, <13→10, ≥13→11` -- Bounds quantized to 0.01° to match backend caching +- AbortControllers cancel in-flight requests on new queries (150ms debounce) +- Zoom → H3 resolution defined in `consts.ts` `ZOOM_TO_RESOLUTION_THRESHOLDS`: `<7.5→5, <9.5→6, <10.5→8, <12→9, ≥12→10` +- `POSTCODE_ZOOM_THRESHOLD = 15`: below 15 shows H3 hexagons, at/above 15 shows postcode polygons +- Viewport bounds computed via `getBoundsFromViewState()` in `map-utils.ts` — uses Web Mercator math with **TILE_SIZE=512** (MapLibre/deck.gl convention, NOT 256) - Properties pane uses feature names from API response (human-readable), not hardcoded field names -- Proxy: dev server on :3030 proxies `/api` to :8001; also handles VS Code `/proxy/PORT` patterns +- Proxy: dev server on :3001 proxies `/api` to :8001; also handles VS Code `/proxy/PORT` patterns + +**Shared UI Components (`frontend/src/components/ui/`):** +- `icons/` — One file per icon (CloseIcon, InfoIcon, EyeIcon, PlusIcon, ChevronIcon, FilterIcon, LightbulbIcon, DownloadIcon, MapPinIcon, CheckIcon, ClipboardIcon, SunIcon, MoonIcon, SpinnerIcon). All accept `className` prop. **Never inline SVGs** — always extract to this folder. +- `IconButton.tsx` — Reusable icon button wrapper with consistent hover states. Accepts `active` prop for teal highlight. +- `SearchInput.tsx` — Styled search input with dark mode support. Used in Filters, POIPane, PropertiesPane. +- `PaneHeader.tsx` — Reusable pane header with title, optional subtitle, info button, and close button. +- `SelectionButtons.tsx` — "All" / "None" selection buttons for checkbox lists. +- `TabButton.tsx` — Tab button with active state styling. Used in right pane tabs. +- `EmptyState.tsx` — Empty state display with icon, title, description. Also exports `PaneEmptyState` for centered pane messages. +- `CheckboxList.tsx` — Checkbox list with toggle logic. Variants for array and Set-based selection. + +**Shared Components (`frontend/src/components/`):** +- `FeatureInfoPopup.tsx` — Popup showing feature name, description, detail, and "View data source" link. +- `FeatureIcons.tsx` — `FeatureActions` component combining eye/info/add/remove icons for feature rows. + +**Shared Utilities (`frontend/src/lib/`):** +- `api.ts` — `apiUrl(endpoint, params?)` builds API URLs. `logNonAbortError(label, err)` and `isAbortError(err)` for error handling. +- `features.ts` — `groupFeaturesByCategory(features)` groups FeatureMeta[] by their `group` field. +- `format.ts` — `formatNumber(value, decimals)` for number formatting. `calculateHistogramMean(histogram)` for weighted mean calculation. +- `property-fields.ts` — `getNum(property, ...keys)` for getting numeric property values with fallback field names. + +When adding new UI, prefer using these shared components over inline implementations to maintain consistency. + +**When to extract vs inline:** +- Extract to `hooks/`: Stateful logic with useState/useEffect/useCallback that can be named as a cohesive unit (e.g., `useFilters`, `useMapData`). If a component has 5+ related state variables and handlers, extract them to a hook. +- Extract to page component: Layout + hook composition for a major view (e.g., `MapPage` composes `useMapData` + `useFilters` + child components). Keep App.tsx focused on routing. +- Extract to `ui/` component: Repeated 3+ times with same styling (buttons, inputs, icons) +- Extract to `lib/`: Pure functions used across components (formatting, calculations, lookups) +- Keep inline: One-off UI specific to a single component + +**Component size guideline:** If a component exceeds ~300 lines, look for extraction opportunities. Large components are usually doing too much — split into hooks (for logic) and child components (for UI sections). + +**Naming conventions:** +- UI components: PascalCase, noun-based (`TabButton`, `EmptyState`) +- Utilities: camelCase verb-based (`formatNumber`, `calculateHistogramMean`) ## Frontend Design Guide (STRICT — must be followed for all UI changes) @@ -173,9 +224,11 @@ Every UI element must use the correct token from this table. Do not invent new p - Deck.gl postcode labels (RGB arrays): `[220,220,220,220]` text / `[30,30,30,200]` outline in dark; inverse in light. **Map basemaps:** -- Light: `https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json` -- Dark: `https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json` -- `handleMapLoad` must only apply label/water tweaks in light mode. Dark Matter has good defaults. +- Self-hosted Protomaps tiles served from PMTiles via `/api/tiles/{z}/{x}/{y}` +- Style built by `@protomaps/basemaps` library with `namedFlavor(theme)` for light/dark +- Font glyphs and twemoji PNGs served locally from `frontend/public/assets/` (no external CDN deps at runtime) +- `CopyWebpackPlugin` copies `frontend/public/` → `dist/` on build; Rust `ServeDir` fallback serves them in prod +- Download assets with `task download:map-assets` (script: `pipeline/download/map_assets.py`) **HomePage (landing page):** - Page bg: `bg-warm-50 dark:bg-warm-900` @@ -216,14 +269,77 @@ Every UI element must use the correct token from this table. Do not invent new p - [ ] Sidebars, dropdowns, and popups are readable in both modes - [ ] HomePage and DataSourcesPage adapt correctly +## Coding Preferences + +- **Unified data models over special-casing**: Prefer storing different data types uniformly (e.g., enums as f32 indices alongside numeric features) rather than maintaining separate code paths +- **Terse tests**: Test what matters in as few tests as possible — don't overcomplicate with excessive setup or edge cases that don't add value +- **Extract and organize**: Group related utilities into proper modules (e.g., `utils/`, `parsing/`) rather than leaving helpers scattered +- **Inline module tests**: Place `#[cfg(test)] mod tests { }` at the bottom of each module file rather than in separate test files +- **Decompose large React components**: Extract stateful logic into custom hooks (`useXxx`), extract page layouts into page components. App.tsx should only handle routing and initial data loading. Each hook should encapsulate one cohesive concern (e.g., `useFilters` owns filter state + all filter handlers). + +## Rust Code Style (server-rs) + +Follow these conventions in all Rust code: + +1. **Module style**: Use Rust 2018 module naming — `foo.rs` + `foo/` directory, NOT `foo/mod.rs` +2. **Imports over inline paths**: Import items at the top of the file, don't use `crate::` inline in code + ```rust + // Good + use crate::utils::generate_priorities; + let p = generate_priorities(n); + + // Bad + let p = crate::utils::generate_priorities(n); + ``` +3. **Tracing macros**: Import and use short form, not fully qualified + ```rust + // Good + use tracing::{info, warn}; + info!("message"); + + // Bad + tracing::info!("message"); + ``` +4. **JSON serialization**: Use `serde_json` with `#[derive(Serialize)]` structs, not manual string building +5. **Precompute at startup**: For static/rarely-changing responses, compute once at startup and store in `AppState` +6. **Unique placeholders**: When injecting content into HTML, use distinctive markers like `__PERFECT_POSTCODES_OG_TAGS__` that won't accidentally match other content + ## Key Implementation Details - **Spatial sort**: Rows sorted by 0.01° grid cell at load time for cache-friendly sequential access -- **Row-major layout**: `feature_data[row * num_features + feat_idx]` — all features for one property are contiguous +- **Row-major layout**: `feature_data[row * num_features + feat_idx]` — all features (numeric and enum) for one property are contiguous - **H3 precomputation**: Resolutions 4–12 computed in parallel (rayon) at startup - **Histogram percentiles without sorting**: O(n) two-pass algorithm — build histogram, interpolate percentiles -- **Direct JSON writing**: Hexagon endpoint writes JSON via string buffer, avoids serde_json::Value allocations +- **Startup precomputation**: Static responses (like `/api/features`) are computed once at startup and cached in `AppState` - **POI transform validation**: Fails if any OSM category is unmapped — guarantees exhaustive coverage - **Fuzzy join**: Groups by postcode, uses `thefuzz.token_sort_ratio` with numeric token compatibility, greedy assignment from highest score - **Filter bounds format**: `south,west,north,east` (not standard bbox order) +- **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`. +- **GridIndex returns slightly more than requested**: The 0.01° grid cells mean properties up to ~1km outside the viewport may be returned. The AABB filter in the route handlers catches these extras. - **POI proximity**: Uses 0.05° grid (~5km cells) to reduce candidates before haversine distance check +- **OG tag injection**: Uses `` placeholder in HTML, replaced at runtime by middleware + +## Rust Performance Patterns (server-rs) + +**Lookup optimization:** +- `AppState.feature_name_to_index: FxHashMap` for O(1) feature lookups (used in filter parsing, field selection) +- Never use `.position()` on feature_names in hot paths — always use the prebuilt HashMap +- Enum filters use `FxHashSet` (f32 bits) for O(1) contains checks instead of `Vec::contains` + +**Hot loop patterns:** +- Hoist conditional branches outside loops when possible (e.g., `if has_selective` check moved outside aggregation loop in hexagons.rs) +- Use `into_par_iter()` for file I/O (postcode GeoJSON loading) and CPU-bound startup work (H3 precomputation) + +**Cardinality counting:** +- Use `FxHashSet` with `f32::to_bits()` for O(n) unique value counting instead of collect→sort→dedup O(n log n) +- For enum ordering, convert order slice to `FxHashSet` before filtering to get O(1) contains + +**Data structure choices:** +- CSR (Compressed Sparse Row) for GridIndex — single flat `values` array + `offsets` array eliminates per-cell Vec overhead +- `Box<[f32]>` for fixed-size aggregation arrays — avoids Vec capacity field (8 bytes saved per cell) +- Bit-packed booleans for flags like `is_approx_build_date` — 8x memory savings vs `Vec` + +**What NOT to optimize:** +- String cloning in JSON responses (~10-20 small strings) — negligible vs serialization overhead +- GridIndex 3-pass build (min/max → count → fill) — necessary for CSR without O(n) extra memory +- Arc for enum values — complexity not worth modest benefit diff --git a/Dockerfile b/Dockerfile index c690687..71839f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app/frontend COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend/ ./ -RUN npm run build +RUN npm run build:no-prerender # Stage 2: Build Rust server FROM rust:1.83-bookworm AS server @@ -20,6 +20,11 @@ WORKDIR /app COPY --from=server /app/server-rs/target/release/property-map-server ./ COPY --from=frontend /app/frontend/dist ./dist/ +COPY property-data/wide.parquet ./data/ +COPY property-data/filtered_uk_pois.parquet ./data/ +COPY property-data/uk.pmtiles ./data/ +COPY manual-data/postcode_boundaries ./data/postcode_boundaries/ + EXPOSE 8001 ENTRYPOINT ["./property-map-server"] -CMD ["--data", "/data/wide.parquet", "--pois", "/data/filtered_uk_pois.parquet"] +CMD ["--data", "/app/data/wide.parquet", "--pois", "/app/data/filtered_uk_pois.parquet", "--tiles", "/app/data/uk.pmtiles", "--postcodes", "/app/data/postcode_boundaries"] diff --git a/Makefile.data b/Makefile.data new file mode 100644 index 0000000..a1cf3e4 --- /dev/null +++ b/Makefile.data @@ -0,0 +1,250 @@ +# Data pipeline — download sources and build wide.parquet +# +# Usage: +# make -f Makefile.data prepare # Build wide.parquet (+ all deps) +# make -f Makefile.data tiles # Download UK map tiles +# +# Or include from the main Makefile and use targets directly. + +SHELL := /bin/bash +.DELETE_ON_ERROR: + +DATA_DIR := ./property-data +MANUAL_DATA := ./manual-data + +# ── Output files ────────────────────────────────────────────────────────────── + +TILES := $(DATA_DIR)/uk.pmtiles +ARCGIS := $(DATA_DIR)/arcgis_data.parquet +PRICE_PAID := $(DATA_DIR)/price-paid-complete.parquet +IOD := $(DATA_DIR)/IoD2025_Scores.parquet +POIS_RAW := $(DATA_DIR)/uk_pois.parquet +POIS_FILTERED := $(DATA_DIR)/filtered_uk_pois.parquet +POI_PROXIMITY := $(DATA_DIR)/poi_proximity.parquet +EPC_PP := $(DATA_DIR)/epc_pp.parquet +WIDE := $(DATA_DIR)/wide.parquet +PRICE_INDEX := $(DATA_DIR)/price_index.parquet +PRICES_STAMP := $(DATA_DIR)/.prices_done +EPC := $(MANUAL_DATA)/certificates.csv +JT_BANK := $(MANUAL_DATA)/journey_times_bank.parquet +JT_FITZROVIA := $(MANUAL_DATA)/journey_times_fitzrovia.parquet +ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet +CRIME_DIR := $(MANUAL_DATA)/crime +CRIME := $(DATA_DIR)/crime_by_lsoa.parquet +NOISE := $(DATA_DIR)/road_noise.parquet +OFSTED := $(DATA_DIR)/ofsted.parquet +NAPTAN := $(DATA_DIR)/naptan.parquet +BROADBAND := $(DATA_DIR)/broadband.parquet +SCHOOL_PROX := $(DATA_DIR)/school_proximity.parquet +GEOSURE_DIR := $(DATA_DIR)/geosure +GEOSURE := $(DATA_DIR)/geosure.parquet +INSPIRE_DIR := $(DATA_DIR)/inspire +OA_BOUNDARIES := $(DATA_DIR)/oa_boundaries.gpkg +UPRN_LOOKUP := $(DATA_DIR)/uprn_lookup.parquet +PC_BOUNDARIES := $(MANUAL_DATA)/postcode_boundaries + +# Sentinel files for directory targets (Make doesn't track directories well) +GEOSURE_STAMP := $(GEOSURE_DIR)/.done +INSPIRE_STAMP := $(INSPIRE_DIR)/.done + +PMTILES_VERSION := 1.22.3 + +# ── Phony aliases ───────────────────────────────────────────────────────────── + +.PHONY: prepare wide tiles \ + download-arcgis download-price-paid download-deprivation download-ethnicity \ + download-naptan download-pois download-ofsted download-broadband \ + download-postcodes download-geosure download-noise download-inspire \ + download-oa-boundaries download-uprn-lookup \ + transform-pois transform-epc-pp transform-crime transform-poi-proximity \ + transform-school-proximity transform-geosure transform-postcode-boundaries \ + generate-postcode-boundaries \ + journey-times + +prepare: $(DATA_DIR)/.prices_done +wide: $(WIDE) +tiles: $(TILES) +download-arcgis: $(ARCGIS) +download-price-paid: $(PRICE_PAID) +download-deprivation: $(IOD) +download-ethnicity: $(ETHNICITY) +download-naptan: $(NAPTAN) +download-pois: $(POIS_RAW) +download-ofsted: $(OFSTED) +download-broadband: $(BROADBAND) +download-postcodes: $(POSTCODES) +download-geosure: $(GEOSURE_STAMP) +download-noise: $(NOISE) +download-inspire: $(INSPIRE_STAMP) +download-oa-boundaries: $(OA_BOUNDARIES) +download-uprn-lookup: $(UPRN_LOOKUP) +transform-pois: $(POIS_FILTERED) +transform-epc-pp: $(EPC_PP) +transform-crime: $(CRIME) +transform-poi-proximity: $(POI_PROXIMITY) +transform-school-proximity: $(SCHOOL_PROX) +transform-geosure: $(GEOSURE) +transform-postcode-boundaries: $(PC_BOUNDARIES) +generate-postcode-boundaries: $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP) + uv run python -m pipeline.transform.postcode_boundaries \ + --uprn $(UPRN_LOOKUP) \ + --oa-boundaries $(OA_BOUNDARIES) \ + --inspire $(INSPIRE_DIR) \ + --output $(PC_BOUNDARIES) + +# ── Downloads ───────────────────────────────────────────────────────────────── + +$(TILES): + uv run -m pipeline.download.tiles --output $@ --pmtiles-version $(PMTILES_VERSION) + +# EPC requires manual registration — fail with instructions +$(EPC): + @echo "" + @echo "=== EPC dataset not found ===" + @echo "The EPC certificates file is required: $@" + @echo "" + @echo "To obtain it, register at https://epc.opendatacommunities.org/login" + @echo "and place certificates.csv in manual-data/" + @echo "" + @exit 1 + +$(ARCGIS): + uv run python -m pipeline.download.arcgis --output $@ + +$(PRICE_PAID): + uv run python -m pipeline.download.price_paid --output $@ + +$(IOD): + uv run python -m pipeline.download.deprivation_data --output $@ + +$(ETHNICITY): + uv run python -m pipeline.download.ethnicity --output $@ + +$(NAPTAN): + uv run python -m pipeline.download.naptan --output $@ + +$(POIS_RAW): + uv run python -m pipeline.download.pois --output $@ + +$(OFSTED): + uv run python -m pipeline.download.ofsted --output $@ + +$(BROADBAND): + uv run python -m pipeline.download.broadband --output $@ + +$(POSTCODES): + uv run python -m pipeline.download.postcodes --output $@ + +$(GEOSURE_STAMP): + uv run python -m pipeline.download.geosure --output $(GEOSURE_DIR) + @touch $@ + +$(NOISE): $(ARCGIS) + uv run python -m pipeline.download.noise --arcgis $(ARCGIS) --output $@ + +$(INSPIRE_STAMP): + uv run python -m pipeline.download.inspire --output $(INSPIRE_DIR) + @touch $@ + +$(OA_BOUNDARIES): + uv run python -m pipeline.download.oa_boundaries --output $@ + +$(UPRN_LOOKUP): + uv run python -m pipeline.download.uprn_lookup --output $@ + +# ── Journey times (requires TFL_API_KEY) ────────────────────────────────────── + +$(JT_BANK): + @echo "" + @echo "=== TFL journey times (bank) not found ===" + @echo "Place journey_times_bank.parquet in $(MANUAL_DATA)/" + @echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin" + @echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=bank" + @echo "" + @exit 1 + +$(JT_FITZROVIA): + @echo "" + @echo "=== TFL journey times (fitzrovia) not found ===" + @echo "Place journey_times_fitzrovia.parquet in $(MANUAL_DATA)/" + @echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin" + @echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=fitzrovia" + @echo "" + @exit 1 + +journey-times: $(ARCGIS) +ifndef DEST + $(error DEST required — e.g. make journey-times DEST=bank) +endif + uv run python -m pipeline.journey_times --destination $(DEST) --output-dir $(DATA_DIR) --postcodes $(ARCGIS) + +# ── Transforms ──────────────────────────────────────────────────────────────── + +$(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) + uv run python -m pipeline.transform.transform_poi --input $(POIS_RAW) --naptan $(NAPTAN) --output $@ + +$(EPC_PP): $(PRICE_PAID) $(EPC) + uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@ + +$(CRIME): + @if [ ! -d "$(CRIME_DIR)" ]; then \ + echo ""; \ + echo "=== Crime dataset not found ==="; \ + echo "Place police.uk crime CSVs in $(CRIME_DIR)/"; \ + echo "Download from https://data.police.uk/data/"; \ + echo ""; \ + exit 1; \ + fi + uv run python -m pipeline.transform.crime --input $(CRIME_DIR) --output $@ + +$(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) + uv run python -m pipeline.transform.poi_proximity --arcgis $(ARCGIS) --pois $(POIS_FILTERED) --output $@ + +$(SCHOOL_PROX): $(OFSTED) $(ARCGIS) + uv run python -m pipeline.transform.school_proximity --ofsted $(OFSTED) --arcgis $(ARCGIS) --output $@ + +$(GEOSURE): $(GEOSURE_STAMP) $(ARCGIS) + uv run python -m pipeline.transform.transform_geosure --geosure $(GEOSURE_DIR) --arcgis $(ARCGIS) --output $@ + +# Postcode boundaries require manual generation — fail with instructions +$(PC_BOUNDARIES): + @echo "" + @echo "=== Postcode boundaries not found ===" + @echo "The postcode boundaries directory is required: $@" + @echo "" + @echo "Generate it with:" + @echo " uv run python -m pipeline.transform.postcode_boundaries \\" + @echo " --uprn $(UPRN_LOOKUP) \\" + @echo " --oa-boundaries $(OA_BOUNDARIES) \\" + @echo " --inspire $(INSPIRE_DIR) \\" + @echo " --output $@" + @echo "" + @exit 1 + +# ── Final merge ─────────────────────────────────────────────────────────────── + +$(WIDE): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \ + $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) + uv run python -m pipeline.transform.merge \ + --epc-pp $(EPC_PP) \ + --arcgis $(ARCGIS) \ + --iod $(IOD) \ + --poi-proximity $(POI_PROXIMITY) \ + --journey-times-bank $(JT_BANK) \ + --journey-times-fitzrovia $(JT_FITZROVIA) \ + --ethnicity $(ETHNICITY) \ + --crime $(CRIME) \ + --noise $(NOISE) \ + --school-proximity $(SCHOOL_PROX) \ + --broadband $(BROADBAND) \ + --geosure $(GEOSURE) \ + --output $@ + +# ── Price estimation (post-merge) ──────────────────────────────────────────── + +$(PRICE_INDEX): $(WIDE) + uv run python -m pipeline.transform.price_index --input $(WIDE) --output $@ + +$(PRICES_STAMP): $(WIDE) $(PRICE_INDEX) + uv run python -m pipeline.transform.price_estimate --input $(WIDE) --index $(PRICE_INDEX) + @touch $@ diff --git a/README.md b/README.md index 61e6bc8..0a9f908 100644 --- a/README.md +++ b/README.md @@ -4,74 +4,17 @@ ```sh curl -1sLf 'https://dl.cloudsmith.io/public/task/task/setup.deb.sh' | sudo -E bash +apt install task task prepare ``` ## Area -1. 45 min commute (perhaps near train station) - - elizabeth line - - train frequency - - train strikes - -2. affluency scores - - crime rates - violent, antisocial - - good rated schools? - - employment rates / medium income - - health - - ethnic group - - -3. services - - driving distance within several schools - - city/town centre within 10-15 min drive - - gp - - bigger town/city centre than bath/york/oxford 4. ambiance - nature / greenery within 5 mins walk - - not noisy (e.g. right next to A/B roads or highstreet) - -5. fibre optic availability - -6. between london and bournemouth ish - -7. [Y] historical prices - 8. current listings -## Action plan - -1. use openstreetmap api to get the map - -## Data Sources - -- [Price Paid](https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads) - -- [English Indices of Deprevation 2025](https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025) - - The English Indices of Deprivation (IoD25) measure relative levels of deprivation in 33,755 small areas or neighbourhoods, called Lower-layer Super Output Areas (LSOAs), in England. - -- [Population by Ethnicity and Region 2021](https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data) - -- [Crime](https://data.police.uk/data/) - -- [Postcode -> GPS](https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data) - -Nice to haves? - -- - file 8! - -## Backend Data Sources - -- [UK Regions](https://www.ons.gov.uk/methodology/geography/ukgeographies/statisticalgeographies) -![alt text](image.png) - - [Lower Level Super Output Area (LSOAs)](https://communitiesopendata-communities.hub.arcgis.com/datasets/4da63019f25546aa92a922a5ea682950_0/explore?location=51.506508%2C-0.041229%2C13.82) - - [Local Authority (Lower Tier)](https://communitiesopendata-communities.hub.arcgis.com/datasets/f3954cc3ded54a08b6fffbb361f5ee76_0/explore?location=52.522271%2C-2.489913%2C7.17) - - [Local Autheority (Upper Tier)](https://communitiesopendata-communities.hub.arcgis.com/datasets/6e8edb2974da4834bbafa09644a5b02d_0/explore?location=52.684195%2C-2.489482%2C7.17) -- [Open Geography](https://geoportal.statistics.gov.uk/) -- [CommunitiesOpenData](https://communitiesopendata-communities.hub.arcgis.com/) -- [PlanetOSM](https://planet.openstreetmap.org/) for open street map POI -- [TFL api](https://api-portal.tfl.gov.uk/signin) -- [EPC](https://epc.opendatacommunities.org/login) - rightmove: curl '' @@ -81,3 +24,70 @@ curl ' + bash -c " + cargo install cargo-watch && + cargo watch -x 'run -- --data /app/data/wide.parquet --pois /app/data/filtered_uk_pois.parquet --tiles /app/data/uk.pmtiles --postcodes /app/data/postcode_boundaries' + " + ports: + - "8001:8001" + networks: + - dev-network + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - .:/app + - cargo-registry:/usr/local/cargo/registry + - cargo-target:/app/server-rs/target + - ./property-data:/app/data:ro + + environment: + POCKETBASE_URL: http://pocketbase:8090 + SCREENSHOT_URL: http://screenshot:8002 + OLLAMA_URL: http://host.docker.internal:11434 + depends_on: + pocketbase: + condition: service_healthy + + screenshot: + build: /volumes/syncthing/Projects/property-map/screenshot + environment: + APP_URL: http://frontend:3001 + CACHE_DIR: /cache + volumes: + - screenshot-cache:/cache + networks: + - dev-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8002/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s + deploy: + resources: + reservations: + devices: + - driver: nvidia + capabilities: [ gpu ] + count: 1 + + frontend: + image: node:22-slim + working_dir: /app/frontend + command: > + bash -c " + npm install && + npm run dev + " + ports: + - "3001:3001" + networks: + - dev-network + volumes: + - .:/app + - frontend-node-modules:/app/frontend/node_modules + environment: + API_PROXY_TARGET: http://server:8001 + PB_PROXY_TARGET: http://pocketbase:8090 + + pocketbase: + image: ghcr.io/muchobien/pocketbase:latest + ports: + - "8090:8090" + volumes: + - pb-data:/pb/pb_data + networks: + - dev-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + +volumes: + pb-data: + cargo-registry: + cargo-target: + frontend-node-modules: + screenshot-cache: + +networks: + dev-network: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3a0f5f4..b051336 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,31 +13,42 @@ "@deck.gl/layers": "^9.0.0", "@deck.gl/mapbox": "^9.2.6", "@deck.gl/react": "^9.0.0", + "@plausible-analytics/tracker": "^0.4.4", + "@protomaps/basemaps": "^5.7.0", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slider": "^1.1.0", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", "maplibre-gl": "^4.0.0", + "pocketbase": "^0.26.8", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-map-gl": "^7.1.0", - "tailwind-merge": "^2.2.0", - "tailwindcss-animate": "^1.0.7" + "react-map-gl": "^7.1.0" }, "devDependencies": { + "@babel/core": "^7.29.0", + "@babel/preset-env": "^7.29.0", + "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", + "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "autoprefixer": "^10.4.0", + "babel-loader": "^10.0.0", + "copy-webpack-plugin": "^13.0.1", "css-loader": "^7.0.0", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^4.6.0", + "favicons": "^7.2.0", + "favicons-webpack-plugin": "^6.0.1", "html-webpack-plugin": "^5.6.0", + "mini-css-extract-plugin": "^2.9.0", "postcss": "^8.4.0", "postcss-loader": "^8.0.0", "prettier": "^3.2.0", + "puppeteer": "^24.0.0", + "react-refresh": "^0.18.0", "style-loader": "^4.0.0", "tailwindcss": "^3.4.0", "ts-loader": "^9.5.0", @@ -51,6 +62,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -60,11 +72,10 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -74,6 +85,317 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.6.tgz", + "integrity": "sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", @@ -84,6 +406,1304 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz", + "integrity": "sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", + "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@deck.gl/core": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.6.tgz", @@ -254,6 +1874,16 @@ "node": ">=10.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -465,20 +2095,393 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -499,12 +2502,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1501,6 +3506,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -1514,6 +3520,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1523,6 +3530,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -1691,6 +3699,67 @@ "node": ">=20.0.0" } }, + "node_modules/@plausible-analytics/tracker": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.4.tgz", + "integrity": "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA==" + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.6.2.tgz", + "integrity": "sha512-IhIAD5n4XvGHuL9nAgWfsBR0TdxtjrUWETYKCBHxauYXEv+b+ctEbs9neEgPC7Ecgzv4bpZTBwesAoGDeFymzA==", + "dev": true, + "dependencies": { + "anser": "^2.1.1", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "html-entities": "^2.1.0", + "schema-utils": "^4.2.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "@types/webpack": "5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <6.0.0", + "webpack": "^5.0.0", + "webpack-dev-server": "^4.8.0 || 5.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/@probe.gl/env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz", @@ -1712,6 +3781,35 @@ "integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==", "license": "MIT" }, + "node_modules/@protomaps/basemaps": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.0.tgz", + "integrity": "sha512-vIInnzVSxHuOcvj1BFGkCjlFxG/9a1GV23t98kGEVcPUM7aEqTnf6loUHTRJYX5eCz+WCO16N0aibr1SLg830Q==", + "bin": { + "generate_style": "src/cli.ts" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.2.tgz", + "integrity": "sha512-GBY0+2lI9fDrjgb5dFL9+enKXqyOPok9PXg/69NVkjW3bikbK9RQrNrI3qccQXmDNN7ln4j/yL89Qgvj/tfqrw==", + "dev": true, + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2241,6 +4339,12 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true + }, "node_modules/@turf/boolean-clockwise": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", @@ -2636,6 +4740,16 @@ "@types/node": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -3127,6 +5241,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -3175,6 +5298,12 @@ "ajv": "^8.8.2" } }, + "node_modules/anser": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz", + "integrity": "sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==", + "dev": true + }, "node_modules/ansi-html-community": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", @@ -3218,12 +5347,14 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3237,6 +5368,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -3446,6 +5578,18 @@ "node": ">=0.10.0" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3456,6 +5600,15 @@ "node": ">= 0.4" } }, + "node_modules/author-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/author-regex/-/author-regex-1.0.0.tgz", + "integrity": "sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, "node_modules/autoprefixer": { "version": "10.4.23", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", @@ -3509,6 +5662,70 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/babel-loader": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-10.0.0.tgz", + "integrity": "sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA==", + "dev": true, + "dependencies": { + "find-up": "^5.0.0" + }, + "engines": { + "node": "^18.20.0 || ^20.10.0 || >=22.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5.61.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", + "integrity": "sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.6", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz", + "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.6", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.6.tgz", + "integrity": "sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.6" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3516,6 +5733,97 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.3.tgz", + "integrity": "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==", + "dev": true, + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dev": true, + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3547,6 +5855,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/basic-ftp": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -3558,6 +5875,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3640,6 +5958,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3701,6 +6020,15 @@ "node": ">=0.10.0" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3838,6 +6166,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -3907,6 +6236,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -3931,6 +6261,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -3949,16 +6280,17 @@ "node": ">=6.0" } }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", + "node_modules/chromium-bidi": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.0.1.tgz", + "integrity": "sha512-c+RLxH0Vg2x2syS9wPw378oJgiJNXtYXUvnVAldUlt5uaHekn0CCU7gPksNgHjrH1qFhmjVXQj4esvuthuC7OQ==", + "dev": true, "dependencies": { - "clsx": "^2.1.1" + "mitt": "^3.0.1", + "zod": "^3.24.1" }, - "funding": { - "url": "https://polar.sh/cva" + "peerDependencies": { + "devtools-protocol": "*" } }, "node_modules/clean-css": { @@ -3974,6 +6306,20 @@ "node": ">= 10.0" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -3989,13 +6335,17 @@ "node": ">=6" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, "engines": { - "node": ">=6" + "node": ">=12.5.0" } }, "node_modules/color-convert": { @@ -4018,6 +6368,16 @@ "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -4166,6 +6526,12 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -4183,6 +6549,29 @@ "dev": true, "license": "MIT" }, + "node_modules/copy-webpack-plugin": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz", + "integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==", + "dev": true, + "dependencies": { + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2", + "tinyglobby": "^0.2.12" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, "node_modules/core-assert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", @@ -4196,6 +6585,30 @@ "node": ">=0.10.0" } }, + "node_modules/core-js-compat": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", + "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", + "dev": true, + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-pure": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.48.0.tgz", + "integrity": "sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==", + "dev": true, + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4346,6 +6759,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -4361,6 +6775,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4531,6 +6954,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4552,6 +6989,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -4565,10 +7011,17 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devtools-protocol": { + "version": "0.0.1551306", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz", + "integrity": "sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==", + "dev": true + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/dir-glob": { @@ -4588,6 +7041,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, "license": "MIT" }, "node_modules/dns-packet": { @@ -4737,6 +7191,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -4747,6 +7207,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -4804,6 +7273,15 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -5018,6 +7496,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -5316,6 +7824,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -5409,6 +7930,15 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -5506,6 +8036,41 @@ "node": ">=0.10.0" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5513,10 +8078,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -5533,6 +8105,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -5604,11 +8177,47 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" } }, + "node_modules/favicons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/favicons/-/favicons-7.2.0.tgz", + "integrity": "sha512-k/2rVBRIRzOeom3wI9jBPaSEvoTSQEW4iM0EveBmBBKFxO8mSyyRWtDlfC3VnEfu0avmjrMzy8/ZFPSe6F71Hw==", + "dev": true, + "dependencies": { + "escape-html": "^1.0.3", + "sharp": "^0.33.1", + "xml2js": "^0.6.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/favicons-webpack-plugin": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/favicons-webpack-plugin/-/favicons-webpack-plugin-6.0.1.tgz", + "integrity": "sha512-Gl0Co4zIZq74EKXdpfe8FaoJqbuf0undV4UgpsL34vqICRAYUDwQdp3D+z+uxEOV0i9o+vHDn7Q6jaSxRiDJUA==", + "dev": true, + "dependencies": { + "find-root": "^1.1.0", + "parse-author": "^2.0.0", + "parse5": "^7.1.1" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "html-webpack-plugin": "^5.5.0" + }, + "peerDependencies": { + "favicons": "^7.0.1", + "webpack": "^5.0.0" + } + }, "node_modules/faye-websocket": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", @@ -5622,6 +8231,15 @@ "node": ">=0.8.0" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fflate": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", @@ -5645,6 +8263,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -5689,6 +8308,12 @@ "dev": true, "license": "MIT" }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "dev": true + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5820,6 +8445,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5834,6 +8460,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5880,12 +8507,30 @@ "node": ">= 0.4" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/geojson-vt": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", "license": "ISC" }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5964,6 +8609,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/get-value": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", @@ -6005,6 +8664,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -6259,6 +8919,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6290,6 +8951,22 @@ "wbuf": "^1.1.0" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ] + }, "node_modules/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -6415,6 +9092,19 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-proxy-middleware": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", @@ -6440,6 +9130,19 @@ } } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/hyperdyperid": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", @@ -6692,6 +9395,15 @@ "node": ">=10.13.0" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -6767,6 +9479,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -6815,6 +9528,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -6896,6 +9610,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6917,6 +9632,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6941,6 +9665,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7011,6 +9736,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -7290,7 +10016,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7315,6 +10041,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -7349,6 +10087,18 @@ "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -7446,6 +10196,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -7458,6 +10209,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, "license": "MIT" }, "node_modules/loader-runner": { @@ -7497,6 +10249,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7535,6 +10293,15 @@ "tslib": "^2.0.3" } }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/lz4js": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/lz4js/-/lz4js-0.2.0.tgz", @@ -7677,6 +10444,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -7696,6 +10464,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -7741,6 +10510,26 @@ "node": ">= 0.6" } }, + "node_modules/mini-css-extract-plugin": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz", + "integrity": "sha512-540P2c5dYnJlyJxTaSloliZexv8rji6rY8FhQN+WF/82iHQfA23j/xtJx97L+mXOML27EqksSek/g4eK7jaL3g==", + "dev": true, + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -7773,6 +10562,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true + }, "node_modules/mjolnir.js": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.0.0.tgz", @@ -7810,6 +10605,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -7821,6 +10617,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -7859,6 +10656,15 @@ "dev": true, "license": "MIT" }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -7881,6 +10687,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7903,6 +10710,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7912,6 +10720,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -8170,6 +10979,38 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -8200,6 +11041,18 @@ "node": ">=6" } }, + "node_modules/parse-author": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-author/-/parse-author-2.0.0.tgz", + "integrity": "sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw==", + "dev": true, + "dependencies": { + "author-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -8219,6 +11072,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -8274,6 +11151,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "node_modules/path-to-regexp": { @@ -8306,16 +11184,24 @@ "pbf": "bin/pbf" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -8328,6 +11214,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8337,6 +11224,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -8360,6 +11248,11 @@ "node": ">=16.0.0" } }, + "node_modules/pocketbase": { + "version": "0.26.8", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.8.tgz", + "integrity": "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -8374,6 +11267,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8402,6 +11296,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -8419,6 +11314,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8444,6 +11340,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8581,6 +11478,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -8606,6 +11504,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -8633,6 +11532,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, "license": "MIT" }, "node_modules/potpack": { @@ -8695,6 +11595,15 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8737,6 +11646,41 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8747,6 +11691,45 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "24.36.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.36.1.tgz", + "integrity": "sha512-uPiDUyf7gd7Il1KnqfNUtHqntL0w1LapEw5Zsuh8oCK8GsqdxySX1PzdIHKB2Dw273gWY4MW0zC5gy3Re9XlqQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@puppeteer/browsers": "2.11.2", + "chromium-bidi": "13.0.1", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1551306", + "puppeteer-core": "24.36.1", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.36.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.36.1.tgz", + "integrity": "sha512-L7ykMWc3lQf3HS7ME3PSjp7wMIjJeW6+bKfH/RSTz5l6VUDGubnrC2BKj3UvM28Y5PMDFW0xniJOZHBZPpW1dQ==", + "dev": true, + "dependencies": { + "@puppeteer/browsers": "2.11.2", + "chromium-bidi": "13.0.1", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1551306", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.4.0", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -8787,6 +11770,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -8926,6 +11910,15 @@ "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", "license": "MIT" }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -8999,6 +11992,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -9023,6 +12017,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -9074,6 +12069,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -9095,6 +12108,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", @@ -9119,6 +12167,15 @@ "strip-ansi": "^6.0.1" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -9140,6 +12197,7 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -9212,6 +12270,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -9252,6 +12311,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -9359,6 +12419,15 @@ "dev": true, "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "dev": true, + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -9657,6 +12726,45 @@ "node": ">=8" } }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9769,6 +12877,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -9779,6 +12902,16 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/snappyjs": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", @@ -9797,6 +12930,34 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/sort-asc": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", @@ -9846,6 +13007,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -9952,6 +13114,12 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9976,6 +13144,17 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -9985,6 +13164,20 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -10142,6 +13335,7 @@ "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -10164,6 +13358,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -10198,6 +13393,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10206,20 +13402,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tailwind-merge": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", - "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -10253,19 +13440,11 @@ "node": ">=14.0.0" } }, - "node_modules/tailwindcss-animate": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", - "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", - "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, "node_modules/tailwindcss/node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -10275,6 +13454,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -10298,6 +13478,45 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/tar-stream/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", @@ -10359,6 +13578,29 @@ "dev": true, "license": "MIT" }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10392,6 +13634,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -10401,6 +13644,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -10437,6 +13681,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -10453,6 +13698,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -10470,6 +13716,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -10488,6 +13735,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -10540,6 +13788,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/ts-loader": { @@ -10717,6 +13966,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -10771,6 +14026,46 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", @@ -10958,6 +14253,12 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.0.tgz", + "integrity": "sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA==", + "dev": true + }, "node_modules/webpack": { "version": "5.104.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", @@ -11362,6 +14663,23 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -11407,6 +14725,80 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -11420,6 +14812,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zstd-codec": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5f527eb..e83aab9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,8 +2,10 @@ "name": "property-map-frontend", "version": "1.0.0", "scripts": { - "dev": "webpack serve --mode development --port 3030", - "build": "webpack --mode production", + "dev": "webpack serve --mode development --port 3001", + "build": "webpack --mode production && node scripts/prerender.mjs", + "build:no-prerender": "webpack --mode production", + "prerender": "node scripts/prerender.mjs", "typecheck": "tsc --noEmit", "lint": "eslint src --ext .ts,.tsx", "lint:fix": "eslint src --ext .ts,.tsx --fix", @@ -16,31 +18,42 @@ "@deck.gl/layers": "^9.0.0", "@deck.gl/mapbox": "^9.2.6", "@deck.gl/react": "^9.0.0", + "@plausible-analytics/tracker": "^0.4.4", + "@protomaps/basemaps": "^5.7.0", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slider": "^1.1.0", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", "maplibre-gl": "^4.0.0", + "pocketbase": "^0.26.8", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-map-gl": "^7.1.0", - "tailwind-merge": "^2.2.0", - "tailwindcss-animate": "^1.0.7" + "react-map-gl": "^7.1.0" }, "devDependencies": { + "@babel/core": "^7.29.0", + "@babel/preset-env": "^7.29.0", + "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", + "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "autoprefixer": "^10.4.0", + "babel-loader": "^10.0.0", + "copy-webpack-plugin": "^13.0.1", "css-loader": "^7.0.0", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^4.6.0", + "favicons": "^7.2.0", + "favicons-webpack-plugin": "^6.0.1", "html-webpack-plugin": "^5.6.0", + "mini-css-extract-plugin": "^2.9.0", "postcss": "^8.4.0", "postcss-loader": "^8.0.0", "prettier": "^3.2.0", + "puppeteer": "^24.0.0", + "react-refresh": "^0.18.0", "style-loader": "^4.0.0", "tailwindcss": "^3.4.0", "ts-loader": "^9.5.0", diff --git a/frontend/public/cereal.png b/frontend/public/cereal.png new file mode 100644 index 0000000..715d31e Binary files /dev/null and b/frontend/public/cereal.png differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..d7b72c8 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/house.png b/frontend/public/house.png new file mode 100644 index 0000000..d012170 Binary files /dev/null and b/frontend/public/house.png differ diff --git a/frontend/scripts/prerender.mjs b/frontend/scripts/prerender.mjs new file mode 100644 index 0000000..098130c --- /dev/null +++ b/frontend/scripts/prerender.mjs @@ -0,0 +1,140 @@ +import { createServer } from 'http'; +import { readFileSync, writeFileSync, existsSync, statSync } from 'fs'; +import { join, extname } from 'path'; +import { launch } from 'puppeteer'; + +const DIST_DIR = join(import.meta.dirname, '..', 'dist'); +const INDEX_PATH = join(DIST_DIR, 'index.html'); + +const MIME_TYPES = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', +}; + +function startServer() { + return new Promise((resolve) => { + const server = createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + let filePath = join(DIST_DIR, url.pathname === '/' ? 'index.html' : url.pathname); + + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + // SPA fallback + filePath = INDEX_PATH; + } + + const ext = extname(filePath); + const mime = MIME_TYPES[ext] || 'application/octet-stream'; + const content = readFileSync(filePath); + res.writeHead(200, { 'Content-Type': mime }); + res.end(content); + }); + + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + resolve({ server, port }); + }); + }); +} + +async function prerender() { + console.log('Starting prerender...'); + + const { server, port } = await startServer(); + console.log(`Static server on port ${port}`); + + const browser = await launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + try { + const page = await browser.newPage(); + + // Intercept API requests to prevent real fetches and retry loops + await page.setRequestInterception(true); + page.on('request', (req) => { + const url = req.url(); + if (url.includes('/api/features')) { + req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ groups: [] }), + }); + } else if (url.includes('/api/poi-categories')) { + req.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ groups: [] }), + }); + } else if (url.includes('/api/')) { + req.respond({ + status: 200, + contentType: 'application/json', + body: '{}', + }); + } else { + req.continue(); + } + }); + + await page.goto(`http://127.0.0.1:${port}/`, { + waitUntil: 'networkidle0', + timeout: 30000, + }); + + // Wait for the home page heading to render + await page.waitForSelector('h1', { timeout: 10000 }); + + // Extract and clean the rendered HTML + const html = await page.evaluate(() => { + const root = document.getElementById('root'); + if (!root) return ''; + + // Strip fade-in-visible classes (added by IntersectionObserver effects) + root.querySelectorAll('.fade-in-visible').forEach((el) => { + el.classList.remove('fade-in-visible'); + }); + + // Clean canvas elements (dimensions set by ResizeObserver effect) + root.querySelectorAll('canvas').forEach((canvas) => { + canvas.removeAttribute('width'); + canvas.removeAttribute('height'); + canvas.style.removeProperty('width'); + canvas.style.removeProperty('height'); + }); + + return root.innerHTML; + }); + + if (!html || html.length < 100) { + throw new Error('Prerender produced too little HTML — something went wrong'); + } + + // Inject into dist/index.html + const indexHtml = readFileSync(INDEX_PATH, 'utf-8'); + const updated = indexHtml.replace( + '
', + `
${html}
` + ); + + if (updated === indexHtml) { + throw new Error('Could not find
in index.html'); + } + + writeFileSync(INDEX_PATH, updated); + console.log(`Prerendered ${html.length} chars into dist/index.html`); + } finally { + await browser.close(); + server.close(); + } +} + +prerender().catch((err) => { + console.error('Prerender failed:', err); + process.exit(1); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0de2505..c74acc1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,518 +1,118 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import Map from './components/Map'; -import Filters from './components/Filters'; -import POIPane from './components/POIPane'; -import { PropertiesPane } from './components/PropertiesPane'; -import AreaPane from './components/AreaPane'; -import DataSources from './components/DataSources'; -import DataSourcesPage from './components/DataSourcesPage'; -import FAQPage from './components/FAQPage'; -import HomePage from './components/HomePage'; -import type { - FeatureMeta, - FeatureGroup, - FeatureFilters, - Bounds, - HexagonData, - ViewChangeParams, - ApiResponse, - POI, - POIResponse, - POICategoriesResponse, - POICategoryGroup, - ViewState, - Property, - HexagonPropertiesResponse, - HexagonStatsResponse, -} from './types'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import MapPage, { type ExportState } from './components/map/MapPage'; +import DataSourcesPage from './components/data-sources/DataSourcesPage'; +import FAQPage from './components/faq/FAQPage'; +import PricingPage from './components/pricing/PricingPage'; +import HomePage from './components/home/HomePage'; +import SavedSearchesPage from './components/saved-searches/SavedSearchesPage'; +import Header, { type Page } from './components/ui/Header'; +import AuthModal from './components/ui/AuthModal'; +import SaveSearchModal from './components/ui/SaveSearchModal'; +import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; +import { fetchWithRetry, apiUrl } from './lib/api'; +import { parseUrlState } from './lib/url-state'; +import { INITIAL_VIEW_STATE } from './lib/consts'; +import { useTheme } from './hooks/useTheme'; +import { useIsMobile } from './hooks/useIsMobile'; +import { useAuth } from './hooks/useAuth'; +import { useSavedSearches } from './hooks/useSavedSearches'; -type Theme = 'light' | 'dark'; - -const DEBOUNCE_MS = 150; -const URL_DEBOUNCE_MS = 300; -const INITIAL_RETRY_MS = 1000; -const MAX_RETRY_MS = 10000; - -async function fetchWithRetry( - url: string, - onSuccess: (data: T) => void, - signal: AbortSignal -): Promise { - let delay = INITIAL_RETRY_MS; - while (!signal.aborted) { - try { - const res = await fetch(url, { signal }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const json = await res.json(); - onSuccess(json); - return; - } catch (err) { - if (signal.aborted) return; - console.error(`Failed to fetch ${url}, retrying in ${delay}ms:`, err); - await new Promise((resolve) => setTimeout(resolve, delay)); - delay = Math.min(delay * 2, MAX_RETRY_MS); - } +declare global { + interface Window { + __screenshot_ready?: boolean; } } -// Detect if running through VS Code web proxy and construct API base URL -function getApiBaseUrl(): string { - const { pathname, href } = window.location; - - // Check pathname for /proxy/PORT pattern (VS Code web proxy) - const pathMatch = pathname.match(/^(\/proxy\/)(\d+)/); - if (pathMatch) { - return `${pathMatch[1]}8001`; +function pageToPath(page: Page): string { + switch (page) { + case 'dashboard': + return '/dashboard'; + case 'data-sources': + return '/data-sources'; + case 'faq': + return '/faq'; + case 'saved-searches': + return '/saved'; + case 'pricing': + return '/pricing'; + default: + return '/'; } - - // Check full href in case proxy rewrites pathname - const hrefMatch = href.match(/(\/proxy\/)\d+/); - if (hrefMatch) { - return `${hrefMatch[1]}8001`; - } - - // Default: same origin (works for both local dev with webpack proxy and production) - return ''; } -const DEFAULT_VIEW: ViewState = { - longitude: -1.5, - latitude: 53.5, - zoom: 6, - pitch: 0, -}; - -// --- URL State helpers --- - -function parseUrlState(): { - viewState?: ViewState; - filters?: FeatureFilters; - poiCategories?: Set; - tab?: 'pois' | 'properties' | 'area'; -} { - const params = new URLSearchParams(window.location.search); - const result: ReturnType = {}; - - // Parse view: v=lat,lng,zoom - const v = params.get('v'); - if (v) { - const parts = v.split(',').map(Number); - if (parts.length === 3 && parts.every((n) => !isNaN(n))) { - result.viewState = { - latitude: parts[0], - longitude: parts[1], - zoom: parts[2], - pitch: 0, - }; - } - } - - // Parse filters: f=name:min:max,name:val1|val2 - const f = params.get('f'); - if (f) { - const filters: FeatureFilters = {}; - for (const segment of f.split(',')) { - const colonIdx = segment.indexOf(':'); - if (colonIdx === -1) continue; - const name = segment.substring(0, colonIdx); - const rest = segment.substring(colonIdx + 1); - if (rest.includes(':')) { - // Numeric: name:min:max - const [minStr, maxStr] = rest.split(':'); - const min = Number(minStr); - const max = Number(maxStr); - if (!isNaN(min) && !isNaN(max)) { - filters[name] = [min, max]; - } - } else if (rest.includes('|')) { - // Enum: name:val1|val2 - filters[name] = rest.split('|'); - } else { - // Single enum value - filters[name] = [rest]; - } - } - if (Object.keys(filters).length > 0) { - result.filters = filters; - } - } - - // Parse POI categories: poi=Cafe,Pub,School - const poi = params.get('poi'); - if (poi) { - result.poiCategories = new Set(poi.split(',').filter(Boolean)); - } - - // Parse tab: tab=p or tab=o or tab=a - const tab = params.get('tab'); - if (tab === 'p') result.tab = 'properties'; - else if (tab === 'o') result.tab = 'pois'; - else if (tab === 'a') result.tab = 'area'; - - return result; +function pathToPage(pathname: string): Page | null { + if (pathname === '/dashboard') return 'dashboard'; + if (pathname === '/data-sources') return 'data-sources'; + if (pathname === '/faq') return 'faq'; + if (pathname === '/saved') return 'saved-searches'; + if (pathname === '/pricing') return 'pricing'; + if (pathname === '/') return 'home'; + return null; } -function stateToParams( - viewState: { latitude: number; longitude: number; zoom: number } | null, - filters: FeatureFilters, - features: FeatureMeta[], - selectedPOICategories: Set, - rightPaneTab: 'pois' | 'properties' | 'area' -): URLSearchParams { - const params = new URLSearchParams(); - - // View - if (viewState) { - params.set( - 'v', - `${viewState.latitude.toFixed(4)},${viewState.longitude.toFixed(4)},${viewState.zoom.toFixed(1)}` - ); - } - - // Filters - const filterEntries = Object.entries(filters); - if (filterEntries.length > 0) { - const filtersStr = filterEntries - .map(([name, value]) => { - const meta = features.find((f) => f.name === name); - if (meta?.type === 'enum') { - return `${name}:${(value as string[]).join('|')}`; - } - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - params.set('f', filtersStr); - } - - // POI categories - if (selectedPOICategories.size > 0) { - params.set('poi', Array.from(selectedPOICategories).join(',')); - } - - // Tab (only if non-default) - if (rightPaneTab === 'properties') { - params.set('tab', 'p'); - } else if (rightPaneTab === 'area') { - params.set('tab', 'a'); - } - - return params; -} - -// --- Header --- - -type Page = 'home' | 'dashboard' | 'data-sources' | 'faq'; - -function Header({ - activePage, - onPageChange, - theme, - onToggleTheme, -}: { - activePage: Page; - onPageChange: (page: Page) => void; - theme: Theme; - onToggleTheme: () => void; -}) { - const [copied, setCopied] = useState(false); - - const handleShare = useCallback(() => { - navigator.clipboard.writeText(window.location.href).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - }, []); - - const tabClass = (page: Page) => - `px-3 py-1.5 rounded text-sm font-medium transition-colors ${ - activePage === page - ? 'bg-navy-700 text-white' - : 'text-warm-300 hover:bg-navy-800 hover:text-white' - }`; - - return ( -
-
- - -
-
- - {activePage === 'dashboard' && ( - - )} -
-
- ); -} - -// --- App --- - export default function App() { - // Parse URL state once on mount const urlState = useMemo(() => parseUrlState(), []); - - const [features, setFeatures] = useState([]); - const [filters, setFilters] = useState(urlState.filters || {}); - const [activeFeature, setActiveFeature] = useState(null); - const [dragValue, setDragValue] = useState<[number, number] | null>(null); - const [pinnedFeature, setPinnedFeature] = useState(null); - const [rawData, setRawData] = useState([]); - const [dragData, setDragData] = useState(null); - const [resolution, setResolution] = useState(8); - const [bounds, setBounds] = useState(null); - const [loading, setLoading] = useState(false); - const [zoom, setZoom] = useState(urlState.viewState?.zoom || DEFAULT_VIEW.zoom); - const debounceRef = useRef | null>(null); - const abortControllerRef = useRef(null); - const dragAbortRef = useRef(null); - - // View state for URL serialization - const [currentView, setCurrentView] = useState<{ - latitude: number; - longitude: number; - zoom: number; - } | null>( - urlState.viewState - ? { - latitude: urlState.viewState.latitude, - longitude: urlState.viewState.longitude, - zoom: urlState.viewState.zoom, - } - : null + const initialViewState = useMemo( + () => urlState.viewState || INITIAL_VIEW_STATE, + [urlState.viewState] ); - // Initial view state for Map - const initialViewState = useMemo(() => urlState.viewState || DEFAULT_VIEW, []); - - // POI state - const [pois, setPois] = useState([]); - const [poiCategoryGroups, setPOICategoryGroups] = useState([]); - const [selectedPOICategories, setSelectedPOICategories] = useState>( - urlState.poiCategories || new Set() - ); - const poiDebounceRef = useRef | null>(null); - const poiAbortControllerRef = useRef(null); - - // Hexagon properties state - const [selectedHexagon, setSelectedHexagon] = useState<{ h3: string; resolution: number } | null>( - null - ); - const [properties, setProperties] = useState([]); - const [propertiesTotal, setPropertiesTotal] = useState(0); - const [propertiesOffset, setPropertiesOffset] = useState(0); - const [loadingProperties, setLoadingProperties] = useState(false); - const [rightPaneTab, setRightPaneTab] = useState<'pois' | 'properties' | 'area'>(urlState.tab || 'pois'); - - // Area stats state - const [areaStats, setAreaStats] = useState(null); - const [loadingAreaStats, setLoadingAreaStats] = useState(false); - - // Hover state - const [hoveredHexagon, setHoveredHexagon] = useState(null); - const [hoveredAreaStats, setHoveredAreaStats] = useState(null); - const [hoveredProperties, setHoveredProperties] = useState(null); - const [hoveredPropertiesTotal, setHoveredPropertiesTotal] = useState(0); - const [loadingHoveredAreaStats, setLoadingHoveredAreaStats] = useState(false); - const [hoverMode, setHoverMode] = useState(true); - const hoverAbortRef = useRef(null); - const hoverDebounceRef = useRef | null>(null); - const [initialLoading, setInitialLoading] = useState(true); - const [activePage, setActivePage] = useState(() => { - // Restore from history state if available (e.g. back/forward navigation) - if (window.history.state?.page) return window.history.state.page; + const isScreenshotMode = useMemo(() => { const params = new URLSearchParams(window.location.search); - return params.has('v') || params.has('f') || params.has('poi') || params.has('tab') - ? 'dashboard' - : 'home'; - }); + return params.get('screenshot') === '1'; + }, []); + const isOgMode = useMemo(() => { + const params = new URLSearchParams(window.location.search); + return params.get('og') === '1'; + }, []); - // Feature name to auto-open in the info popup after back navigation + // Core data + const [features, setFeatures] = useState([]); + const [poiCategoryGroups, setPOICategoryGroups] = useState([]); + const [initialLoading, setInitialLoading] = useState(true); + + // UI state const [pendingInfoFeature, setPendingInfoFeature] = useState(null); + const [activePage, setActivePage] = useState(() => { + if (isScreenshotMode) return 'dashboard'; - // Navigate between pages with history support - const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => { - // Before pushing, tag the current state with the info feature so back restores it - if (infoFeature) { - window.history.replaceState({ ...window.history.state, infoFeature }, ''); + // Derive page from URL pathname + const fromPath = pathToPage(window.location.pathname); + if (fromPath) return fromPath; + + // Restore from history state (e.g. popstate) + if (window.history.state?.page) return window.history.state.page; + + // Backward compat: dashboard params on unknown path + const params = new URLSearchParams(window.location.search); + if (params.has('v') || params.has('f') || params.has('poi') || params.has('tab')) { + // Rewrite URL to /dashboard keeping query params + window.history.replaceState({ page: 'dashboard' }, '', `/dashboard${window.location.search}`); + return 'dashboard'; } - const url = hash ? `${window.location.pathname}${window.location.search}#${hash}` : `${window.location.pathname}${window.location.search}`; - window.history.pushState({ page }, '', url); - setActivePage(page); - }, []); - // Handle browser back/forward - useEffect(() => { - // Tag the initial state so popstate can restore it - if (!window.history.state?.page) { - window.history.replaceState({ page: activePage }, ''); - } - const handlePopState = (e: PopStateEvent) => { - if (e.state?.page) { - setActivePage(e.state.page); - if (e.state.infoFeature) { - setPendingInfoFeature(e.state.infoFeature); - } - } - }; - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Theme state — defaults to system preference on first visit - const [theme, setTheme] = useState(() => { - const stored = localStorage.getItem('theme'); - if (stored === 'light' || stored === 'dark') return stored; - return 'light'; + return 'home'; }); - // Sync dark class on and persist to localStorage - useEffect(() => { - const root = document.documentElement; - if (theme === 'dark') { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } - localStorage.setItem('theme', theme); - }, [theme]); + const { theme, toggleTheme } = useTheme(); + const isMobile = useIsMobile(); + const { + user, + loading: authLoading, + error: authError, + login, + register, + logout, + requestPasswordReset, + clearError, + } = useAuth(); + const [showAuthModal, setShowAuthModal] = useState(false); + const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login'); - const toggleTheme = useCallback(() => { - setTheme((prev) => (prev === 'light' ? 'dark' : 'light')); - }, []); + const savedSearches = useSavedSearches(user?.id ?? null); + const [showSaveModal, setShowSaveModal] = useState(false); - // Derive enabled features from filter keys - const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); - - // Derive view feature: active drag takes priority over pinned - const viewFeature = activeFeature || pinnedFeature; - const viewSource: 'drag' | 'eye' | null = activeFeature ? 'drag' : pinnedFeature ? 'eye' : null; - // Color range: always the feature's full slider range from metadata - // For enum features, use ordinal index range [0, values.length - 1] - const colorRange = useMemo((): [number, number] | null => { - if (!viewFeature) return null; - const meta = features.find((f) => f.name === viewFeature); - if (!meta) return null; - if (meta.type === 'enum' && meta.values && meta.values.length > 0) { - return [0, meta.values.length - 1]; - } - if (meta.min != null && meta.max != null) return [meta.min, meta.max]; - return null; - }, [viewFeature, features]); - - // Filter range: current drag or committed filter values, used for gray-out - const filterRange = useMemo((): [number, number] | null => { - if (!viewFeature) return null; - if (activeFeature && dragValue) return dragValue; - const filterVal = filters[viewFeature]; - if (filterVal && typeof filterVal[0] === 'number') return filterVal as [number, number]; - return null; - }, [viewFeature, activeFeature, dragValue, filters]); - - // --- URL sync --- - const urlDebounceRef = useRef | null>(null); - - useEffect(() => { - if (urlDebounceRef.current) { - clearTimeout(urlDebounceRef.current); - } - urlDebounceRef.current = setTimeout(() => { - const params = stateToParams( - currentView, - filters, - features, - selectedPOICategories, - rightPaneTab - ); - const search = params.toString(); - const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname; - window.history.replaceState({ ...window.history.state }, '', newUrl); - }, URL_DEBOUNCE_MS); - - return () => { - if (urlDebounceRef.current) clearTimeout(urlDebounceRef.current); - }; - }, [currentView, filters, features, selectedPOICategories, rightPaneTab]); - - // Fetch feature metadata + POI categories on mount with exponential backoff + // Load features and POI categories on mount useEffect(() => { const controller = new AbortController(); let featuresLoaded = false; @@ -523,7 +123,7 @@ export default function App() { }; fetchWithRetry<{ groups: FeatureGroup[] }>( - `${getApiBaseUrl()}/api/features`, + apiUrl('features'), (json) => { const flat: FeatureMeta[] = json.groups.flatMap((g) => g.features.map((f) => ({ ...f, group: g.name })) @@ -536,7 +136,7 @@ export default function App() { ); fetchWithRetry( - `${getApiBaseUrl()}/api/poi-categories`, + apiUrl('poi-categories'), (json) => { setPOICategoryGroups(json.groups); poisLoaded = true; @@ -548,571 +148,149 @@ export default function App() { return () => controller.abort(); }, []); - // Build filter query string helper - const buildFilterParam = useCallback((): string => { - const filterEntries = Object.entries(filters); - if (filterEntries.length === 0) return ''; - return filterEntries - .map(([name, value]) => { - const meta = features.find((f) => f.name === name); - if (meta?.type === 'enum') { - return `${name}:${(value as string[]).join('|')}`; - } - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - }, [filters, features]); + // Screenshot mode ready signal — MapPage sets __screenshot_ready once map data loads + + // Navigation + const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => { + if (infoFeature) { + window.history.replaceState({ ...window.history.state, infoFeature }, ''); + } + const path = pageToPath(page); + const url = hash ? `${path}#${hash}` : path; + window.history.pushState({ page }, '', url); + setActivePage(page); + }, []); - // Debounced fetch when resolution/bounds/filters change — always fetch hexagons useEffect(() => { - if (!bounds) return; - - if (debounceRef.current) { - clearTimeout(debounceRef.current); + if (!window.history.state?.page) { + window.history.replaceState( + { page: activePage }, + '', + pageToPath(activePage) + window.location.search + window.location.hash + ); } - - debounceRef.current = setTimeout(async () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - abortControllerRef.current = new AbortController(); - - setLoading(true); - try { - const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; - const filtersStr = buildFilterParam(); - - const params = new URLSearchParams({ - resolution: resolution.toString(), - bounds: boundsStr, - }); - if (filtersStr) params.set('filters', filtersStr); - const res = await fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, { - signal: abortControllerRef.current.signal, - }); - const json: ApiResponse = await res.json(); - setRawData(json.features || []); - } catch (err) { - if (err instanceof Error && err.name !== 'AbortError') { - console.error('Failed to fetch data:', err); + const handlePopState = (e: PopStateEvent) => { + if (e.state?.page) { + setActivePage(e.state.page); + if (e.state.infoFeature) { + setPendingInfoFeature(e.state.infoFeature); } - } finally { - setLoading(false); - } - }, DEBOUNCE_MS); - - return () => { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - }; - }, [resolution, bounds, filters, buildFilterParam]); - - // During slider drag, use the expanded dataset (without active feature filter) - // so both narrowing and expanding are visible. Otherwise use server-filtered data. - const data = dragData ?? rawData; - - // Fetch POIs when bounds or selected categories change - useEffect(() => { - if (!bounds || selectedPOICategories.size === 0) { - setPois([]); - return; - } - - if (poiDebounceRef.current) { - clearTimeout(poiDebounceRef.current); - } - - poiDebounceRef.current = setTimeout(async () => { - if (poiAbortControllerRef.current) { - poiAbortControllerRef.current.abort(); - } - poiAbortControllerRef.current = new AbortController(); - - try { - const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; - const categoriesStr = Array.from(selectedPOICategories).join(','); - const params = new URLSearchParams({ - categories: categoriesStr, - bounds: boundsStr, - }); - const res = await fetch(`${getApiBaseUrl()}/api/pois?${params}`, { - signal: poiAbortControllerRef.current.signal, - }); - const json: POIResponse = await res.json(); - setPois(json.pois || []); - } catch (err) { - if (err instanceof Error && err.name !== 'AbortError') { - console.error('Failed to fetch POIs:', err); - } - } - }, DEBOUNCE_MS); - - return () => { - if (poiDebounceRef.current) { - clearTimeout(poiDebounceRef.current); - } - }; - }, [bounds, selectedPOICategories]); - - const prevBoundsRef = useRef(''); - const handleViewChange = useCallback( - ({ - resolution: newRes, - bounds: newBounds, - zoom: newZoom, - latitude, - longitude, - }: ViewChangeParams) => { - // Only update bounds/resolution when quantized values actually change - const boundsKey = `${newBounds.south},${newBounds.west},${newBounds.north},${newBounds.east},${newRes}`; - if (boundsKey !== prevBoundsRef.current) { - prevBoundsRef.current = boundsKey; - setResolution(newRes); - setBounds(newBounds); - } - setZoom(newZoom); - setCurrentView({ latitude, longitude, zoom: newZoom }); - }, - [] - ); - - const handleAddFilter = useCallback( - (name: string) => { - const meta = features.find((f) => f.name === name); - if (!meta) return; - if (meta.type === 'enum' && meta.values) { - setFilters((prev) => ({ ...prev, [name]: [...meta.values!] })); - } else if (meta.min != null && meta.max != null) { - setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] })); - } - }, - [features] - ); - - const handleFilterChange = useCallback((name: string, value: [number, number] | string[]) => { - setFilters((prev) => ({ ...prev, [name]: value })); - }, []); - - const handleRemoveFilter = useCallback((name: string) => { - setFilters((prev) => { - const next = { ...prev }; - delete next[name]; - return next; - }); - setPinnedFeature((prev) => (prev === name ? null : prev)); - }, []); - - const handleDragStart = useCallback( - (name: string) => { - const meta = features.find((f) => f.name === name); - if (meta?.type === 'enum') return; // No drag interaction for enum features - setActiveFeature(name); - const fval = filters[name]; - setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); - - // Fetch hexagons without this feature's filter so we can expand the range - if (!bounds) return; - if (dragAbortRef.current) dragAbortRef.current.abort(); - dragAbortRef.current = new AbortController(); - - const otherFilters = Object.entries(filters).filter(([k]) => k !== name); - let filtersStr = ''; - if (otherFilters.length > 0) { - filtersStr = otherFilters - .map(([n, value]) => { - const m = features.find((f) => f.name === n); - if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`; - const [min, max] = value as [number, number]; - return `${n}:${min}:${max}`; - }) - .join(','); - } - - const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; - const params = new URLSearchParams({ resolution: resolution.toString(), bounds: boundsStr }); - if (filtersStr) params.set('filters', filtersStr); - - fetch(`${getApiBaseUrl()}/api/hexagons?${params}`, { - signal: dragAbortRef.current.signal, - }) - .then((res) => res.json()) - .then((json: ApiResponse) => setDragData(json.features || [])) - .catch((err) => { - if (err instanceof Error && err.name !== 'AbortError') { - console.error('Failed to fetch drag data:', err); - } - }); - }, - [filters, features, bounds, resolution] - ); - - const handleDragChange = useCallback((value: [number, number]) => { - setDragValue(value); - }, []); - - const handleDragEnd = useCallback(() => { - if (activeFeature && dragValue) { - setFilters((prev) => ({ ...prev, [activeFeature]: dragValue })); - } - setActiveFeature(null); - setDragValue(null); - setDragData(null); - if (dragAbortRef.current) { - dragAbortRef.current.abort(); - dragAbortRef.current = null; - } - }, [activeFeature, dragValue]); - - const handleTogglePin = useCallback((name: string) => { - setPinnedFeature((prev) => (prev === name ? null : name)); - }, []); - - const handleCancelPin = useCallback(() => { - setPinnedFeature(null); - }, []); - - const fetchHexagonStats = useCallback( - async (h3: string, res: number, signal?: AbortSignal) => { - const params = new URLSearchParams({ - h3, - resolution: res.toString(), - }); - const filterEntries = Object.entries(filters); - if (filterEntries.length > 0) { - const filterStr = filterEntries - .map(([name, value]) => { - const meta = features.find((feature) => feature.name === name); - if (meta?.type === 'enum') { - return `${name}:${(value as string[]).join('|')}`; - } - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - params.append('filters', filterStr); - } - const response = await fetch(`${getApiBaseUrl()}/api/hexagon-stats?${params}`, { signal }); - return (await response.json()) as HexagonStatsResponse; - }, - [filters, features] - ); - - const fetchHexagonProperties = useCallback( - async (h3: string, res: number, offset = 0) => { - setLoadingProperties(true); - try { - const params = new URLSearchParams({ - h3, - resolution: res.toString(), - limit: '100', - offset: offset.toString(), - }); - - // Add current filters - const filterEntries = Object.entries(filters); - if (filterEntries.length > 0) { - const filterStr = filterEntries - .map(([name, value]) => { - const meta = features.find((f) => f.name === name); - if (meta?.type === 'enum') { - return `${name}:${(value as string[]).join('|')}`; - } - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - params.append('filters', filterStr); - } - - const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`); - const data: HexagonPropertiesResponse = await response.json(); - - if (offset === 0) { - setProperties(data.properties); - } else { - setProperties((prev) => [...prev, ...data.properties]); - } - setPropertiesTotal(data.total); - setPropertiesOffset(offset + data.properties.length); - } catch (err) { - console.error('Failed to fetch properties:', err); - } finally { - setLoadingProperties(false); - } - }, - [filters, features] - ); - - const handleHexagonClick = useCallback( - (h3: string) => { - if (selectedHexagon?.h3 === h3) { - // Deselect if clicking same hexagon - setSelectedHexagon(null); - setProperties([]); - setAreaStats(null); } else { - setSelectedHexagon({ h3, resolution }); - setPropertiesOffset(0); - setRightPaneTab('area'); // Auto-switch to area tab - setLoadingAreaStats(true); - fetchHexagonStats(h3, resolution) - .then((stats) => setAreaStats(stats)) - .catch((error) => { - if (error instanceof Error && error.name !== 'AbortError') { - console.error('Failed to fetch area stats:', error); - } - }) - .finally(() => setLoadingAreaStats(false)); + // Fall back to deriving page from pathname + const page = pathToPage(window.location.pathname); + setActivePage(page || 'home'); } - }, - [selectedHexagon, resolution, fetchHexagonStats] - ); + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); // eslint-disable-line react-hooks/exhaustive-deps - const handleHexagonHover = useCallback( - (h3: string | null) => { - setHoveredHexagon(h3); - if (!hoverMode || !h3 || h3 === selectedHexagon?.h3) { - if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current); - if (hoverAbortRef.current) hoverAbortRef.current.abort(); - setHoveredAreaStats(null); - setHoveredProperties(null); - setHoveredPropertiesTotal(0); - return; - } - - if (hoverDebounceRef.current) clearTimeout(hoverDebounceRef.current); - hoverDebounceRef.current = setTimeout(async () => { - if (hoverAbortRef.current) hoverAbortRef.current.abort(); - hoverAbortRef.current = new AbortController(); - const signal = hoverAbortRef.current.signal; - - try { - if (rightPaneTab === 'area') { - setLoadingHoveredAreaStats(true); - const stats = await fetchHexagonStats(h3, resolution, signal); - if (!signal.aborted) setHoveredAreaStats(stats); - } else if (rightPaneTab === 'properties') { - const params = new URLSearchParams({ - h3, - resolution: resolution.toString(), - limit: '3', - offset: '0', - }); - const filterEntries = Object.entries(filters); - if (filterEntries.length > 0) { - const filterStr = filterEntries - .map(([name, value]) => { - const meta = features.find((feature) => feature.name === name); - if (meta?.type === 'enum') return `${name}:${(value as string[]).join('|')}`; - const [min, max] = value as [number, number]; - return `${name}:${min}:${max}`; - }) - .join(','); - params.append('filters', filterStr); - } - const response = await fetch(`${getApiBaseUrl()}/api/hexagon-properties?${params}`, { - signal, - }); - const data: HexagonPropertiesResponse = await response.json(); - if (!signal.aborted) { - setHoveredProperties(data.properties); - setHoveredPropertiesTotal(data.total); - } - } - } catch (error) { - if (error instanceof Error && error.name !== 'AbortError') { - console.error('Failed to fetch hover data:', error); - } - } finally { - if (!signal.aborted) setLoadingHoveredAreaStats(false); - } - }, DEBOUNCE_MS); - }, - [hoverMode, selectedHexagon, rightPaneTab, resolution, filters, features, fetchHexagonStats] - ); - - const handleViewPropertiesFromArea = useCallback(() => { - if (selectedHexagon) { - setRightPaneTab('properties'); - setPropertiesOffset(0); - fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, 0); + // Fetch saved searches when page becomes active + const { fetchSearches } = savedSearches; + useEffect(() => { + if (activePage === 'saved-searches') { + fetchSearches(); } - }, [selectedHexagon, fetchHexagonProperties]); + }, [activePage, fetchSearches]); - const handleLoadMoreProperties = useCallback(() => { - if (selectedHexagon) { - fetchHexagonProperties(selectedHexagon.h3, selectedHexagon.resolution, propertiesOffset); - } - }, [selectedHexagon, propertiesOffset, fetchHexagonProperties]); + const [exportState, setExportState] = useState(null); - const handleCloseProperties = useCallback(() => { - setSelectedHexagon(null); - setProperties([]); - setAreaStats(null); - }, []); + if (isScreenshotMode) { + return ( + {}} + onNavigateTo={() => {}} + screenshotMode + ogMode={isOgMode} + /> + ); + } return ( -
-
+
+
setShowSaveModal(true) : null} + savingSearch={savedSearches.saving} + user={user} + onLoginClick={() => { + setAuthModalTab('login'); + setShowAuthModal(true); + }} + onRegisterClick={() => { + setAuthModalTab('register'); + setShowAuthModal(true); + }} + onLogout={logout} + isMobile={isMobile} + /> {activePage === 'home' ? ( - navigateTo('dashboard')} theme={theme} /> + navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} /> ) : activePage === 'data-sources' ? ( ) : activePage === 'faq' ? ( + ) : activePage === 'pricing' ? ( + navigateTo('dashboard')} /> + ) : activePage === 'saved-searches' ? ( + { + window.location.href = `/?${params}`; + }} + /> ) : ( -
- {initialLoading && ( -
-
- - - - -

- Connecting to server... -

-
-
- )} - { - navigateTo('data-sources', slug, featureName); - }} - openInfoFeature={pendingInfoFeature} - onClearOpenInfoFeature={() => setPendingInfoFeature(null)} - /> -
- - {loading && ( -
- Loading... -
- )} - navigateTo('data-sources')} /> -
-
- {/* Tab headers */} -
- - - -
- - {/* Tab content */} -
- {rightPaneTab === 'area' ? ( - - ) : rightPaneTab === 'properties' ? ( - navigateTo('data-sources', slug)} - isHoveredPreview={!!(hoverMode && hoveredProperties && hoveredHexagon && hoveredHexagon !== selectedHexagon?.h3)} - hoverMode={hoverMode} - onHoverModeChange={setHoverMode} - /> - ) : ( - navigateTo('data-sources', slug)} - /> - )} -
-
-
+ setPendingInfoFeature(null)} + onNavigateTo={navigateTo} + onExportStateChange={setExportState} + isMobile={isMobile} + /> + )} + {showAuthModal && ( + setShowAuthModal(false)} + onLogin={login} + onRegister={register} + onForgotPassword={requestPasswordReset} + loading={authLoading} + error={authError} + onClearError={clearError} + initialTab={authModalTab} + /> + )} + {showSaveModal && ( + setShowSaveModal(false)} + onSave={savedSearches.saveSearch} + saving={savedSearches.saving} + error={savedSearches.error} + /> )}
); diff --git a/frontend/src/components/AreaPane.tsx b/frontend/src/components/AreaPane.tsx deleted file mode 100644 index dda8d6c..0000000 --- a/frontend/src/components/AreaPane.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { useMemo } from 'react'; -import type { FeatureMeta, HexagonStatsResponse } from '../types'; - -interface AreaPaneProps { - stats: HexagonStatsResponse | null; - globalFeatures: FeatureMeta[]; - loading: boolean; - hexagonId: string | null; - isHoveredPreview: boolean; - hoverMode: boolean; - onHoverModeChange: (enabled: boolean) => void; - onViewProperties: () => void; - onClose: () => void; -} - -function formatValue(value: number): string { - if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; - if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}k`; - if (Number.isInteger(value)) return value.toLocaleString(); - return value.toFixed(1); -} - -// Group features by their group field from globalFeatures -function groupFeatures( - globalFeatures: FeatureMeta[] -): { name: string; features: FeatureMeta[] }[] { - const groups: { name: string; features: FeatureMeta[] }[] = []; - const seen = new Set(); - for (const feature of globalFeatures) { - const groupName = feature.group || 'Other'; - if (!seen.has(groupName)) { - seen.add(groupName); - groups.push({ name: groupName, features: [] }); - } - groups.find((group) => group.name === groupName)!.features.push(feature); - } - return groups; -} - -function MiniHistogram({ counts, maxCount }: { counts: number[]; maxCount: number }) { - if (maxCount === 0) return null; - // Downsample to ~20 bars for display - const targetBars = 20; - const step = Math.max(1, Math.floor(counts.length / targetBars)); - const bars: number[] = []; - for (let index = 0; index < counts.length; index += step) { - let sum = 0; - for (let offset = 0; offset < step && index + offset < counts.length; offset++) { - sum += counts[index + offset]; - } - bars.push(sum); - } - const barMax = Math.max(...bars, 1); - - return ( -
- {bars.map((count, index) => ( -
0 ? 1 : 0.1 }} - /> - ))} -
- ); -} - -function EnumBarChart({ counts }: { counts: Record }) { - const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA); - const maxCount = Math.max(...entries.map(([, count]) => count), 1); - - return ( -
- {entries.map(([label, count]) => ( -
- - {label} - -
-
-
- {count} -
- ))} -
- ); -} - -export default function AreaPane({ - stats, - globalFeatures, - loading, - hexagonId, - isHoveredPreview, - hoverMode, - onHoverModeChange, - onViewProperties, - onClose, -}: AreaPaneProps) { - const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]); - - // Build lookup maps from stats - const numericByName = useMemo(() => { - if (!stats) return new Map(); - return new Map(stats.numeric_features.map((feature) => [feature.name, feature])); - }, [stats]); - - const enumByName = useMemo(() => { - if (!stats) return new Map(); - return new Map(stats.enum_features.map((feature) => [feature.name, feature])); - }, [stats]); - - if (!hexagonId) { - return ( -
- Click a hexagon to view area statistics -
- ); - } - - return ( -
- {/* Header */} -
-
-
-

Area Statistics

- {isHoveredPreview && ( - - Preview - - )} -
-
- - -
-
- {stats && ( -

- {stats.count.toLocaleString()} properties -

- )} - {stats && ( - - )} -
- - {/* Stats content */} -
- {loading && !stats ? ( -
Loading...
- ) : stats ? ( -
- {featureGroups.map((group) => { - // Check if any feature in this group has data - const hasData = group.features.some( - (feature) => numericByName.has(feature.name) || enumByName.has(feature.name) - ); - if (!hasData) return null; - - return ( -
-

- {group.name} -

-
- {group.features.map((feature) => { - const numericStats = numericByName.get(feature.name); - const enumStats = enumByName.get(feature.name); - - if (numericStats) { - const maxCount = Math.max(...numericStats.histogram.counts); - return ( -
-
- - {feature.name} - - - {formatValue(numericStats.mean)} - -
-
- {formatValue(numericStats.min)} - {formatValue(numericStats.max)} -
- -
- ); - } - - if (enumStats) { - return ( -
- - {feature.name} - - -
- ); - } - - return null; - })} -
-
- ); - })} -
- ) : null} -
-
- ); -} diff --git a/frontend/src/components/Filters.tsx b/frontend/src/components/Filters.tsx deleted file mode 100644 index 6c8e396..0000000 --- a/frontend/src/components/Filters.tsx +++ /dev/null @@ -1,466 +0,0 @@ -import { memo, useState, useRef, useCallback, useMemo, useEffect } from 'react'; -import { Slider } from './ui/slider'; -import { Label } from './ui/label'; -import type { FeatureMeta, FeatureFilters } from '../types'; - -interface FiltersProps { - features: FeatureMeta[]; - filters: FeatureFilters; - activeFeature: string | null; - dragValue: [number, number] | null; - enabledFeatures: Set; - onAddFilter: (name: string) => void; - onRemoveFilter: (name: string) => void; - onFilterChange: (name: string, value: [number, number] | string[]) => void; - onDragStart: (name: string) => void; - onDragChange: (value: [number, number]) => void; - onDragEnd: () => void; - zoom: number; - pinnedFeature: string | null; - onTogglePin: (name: string) => void; - onCancelPin: () => void; - onNavigateToSource?: (slug: string, featureName: string) => void; - openInfoFeature?: string | null; - onClearOpenInfoFeature?: () => void; -} - -function EyeIcon({ filled, className }: { filled: boolean; className?: string }) { - return ( - - - - - ); -} - -function InfoPopup({ - feature, - onClose, - onNavigateToSource, -}: { - feature: FeatureMeta; - onClose: () => void; - onNavigateToSource?: (slug: string, featureName: string) => void; -}) { - const popupRef = useRef(null); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (popupRef.current && !popupRef.current.contains(e.target as Node)) { - onClose(); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [onClose]); - - return ( -
-
-
-

- {feature.name} -

- -
- {feature.description && ( -

{feature.description}

- )} - {feature.detail && ( -

{feature.detail}

- )} - {feature.source && onNavigateToSource && ( - - )} -
-
- ); -} - -function FeatureBrowser({ - availableFeatures, - allFeatures, - pinnedFeature, - onAddFilter, - onTogglePin, - onNavigateToSource, - openInfoFeature, - onClearOpenInfoFeature, -}: { - availableFeatures: FeatureMeta[]; - allFeatures: FeatureMeta[]; - pinnedFeature: string | null; - onAddFilter: (name: string) => void; - onTogglePin: (name: string) => void; - onNavigateToSource?: (slug: string, featureName: string) => void; - openInfoFeature?: string | null; - onClearOpenInfoFeature?: () => void; -}) { - const [search, setSearch] = useState(''); - const [infoFeature, setInfoFeature] = useState(null); - - // Auto-open info popup when navigating back - useEffect(() => { - if (openInfoFeature) { - const feat = allFeatures.find((f) => f.name === openInfoFeature); - if (feat) setInfoFeature(feat); - onClearOpenInfoFeature?.(); - } - }, [openInfoFeature, allFeatures, onClearOpenInfoFeature]); - - const filtered = useMemo(() => { - if (!search) return availableFeatures; - const lower = search.toLowerCase(); - return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower)); - }, [availableFeatures, search]); - - const grouped = useMemo(() => { - const groups: { name: string; features: FeatureMeta[] }[] = []; - const seen = new Map(); - for (const f of filtered) { - const g = f.group || 'Other'; - let arr = seen.get(g); - if (!arr) { - arr = []; - seen.set(g, arr); - groups.push({ name: g, features: arr }); - } - arr.push(f); - } - return groups; - }, [filtered]); - - return ( - <> -
- setSearch(e.target.value)} - className="w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400" - /> -
-
- {grouped.map((group) => ( -
-
- {group.name} -
- {group.features.map((f) => { - const isPinned = pinnedFeature === f.name; - return ( -
-
- {f.name} - {f.description && ( - {f.description} - )} -
-
- {f.detail && ( - - )} - - -
-
- ); - })} -
- ))} - {grouped.length === 0 && ( -
- {search ? 'No matching features' : 'All features are active'} -
- )} -
- {infoFeature && ( - setInfoFeature(null)} - onNavigateToSource={onNavigateToSource} - /> - )} - - ); -} - -function formatValue(value: number): string { - if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; - if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}k`; - if (Number.isInteger(value)) return value.toString(); - return value.toFixed(2); -} - -export default memo(function Filters({ - features, - filters, - activeFeature, - dragValue, - enabledFeatures, - onAddFilter, - onRemoveFilter, - onFilterChange, - onDragStart, - onDragChange, - onDragEnd, - zoom, - pinnedFeature, - onTogglePin, - onCancelPin, - onNavigateToSource, - openInfoFeature, - onClearOpenInfoFeature, -}: FiltersProps) { - const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name)); - const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); - - const containerRef = useRef(null); - const [splitFraction, setSplitFraction] = useState(0.65); - const draggingRef = useRef(false); - - const handleSeparatorPointerDown = useCallback( - (e: React.PointerEvent) => { - e.preventDefault(); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - draggingRef.current = true; - }, - [] - ); - - const handleSeparatorPointerMove = useCallback( - (e: React.PointerEvent) => { - if (!draggingRef.current || !containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - const y = e.clientY - rect.top; - const fraction = Math.min(0.8, Math.max(0.15, y / rect.height)); - setSplitFraction(fraction); - }, - [] - ); - - const handleSeparatorPointerUp = useCallback(() => { - draggingRef.current = false; - }, []); - - return ( -
- {/* Top: Active filters — user-resizable, scrollable */} -
- {/* Active Filters header */} -
-
- Active Filters - {enabledFeatureList.length > 0 && ( - - {enabledFeatureList.length} - - )} -
- Zoom {zoom.toFixed(1)} -
- -
- {enabledFeatureList.length === 0 && ( -
- - - - No active filters - Browse features below and click + to add a filter -
- )} - - {enabledFeatureList.map((feature) => { - if (feature.type === 'enum') { - const selectedValues = (filters[feature.name] as string[]) || []; - const allValues = feature.values || []; - return ( -
-
- -
- - -
-
-
- - -
-
- {allValues.map((val) => ( - - ))} -
-
- ); - } - - // Numeric feature - const isActive = activeFeature === feature.name; - const isPinned = pinnedFeature === feature.name; - const displayValue = - isActive && dragValue - ? dragValue - : (filters[feature.name] as [number, number]) || [feature.min!, feature.max!]; - const step = feature.step ?? (feature.max! - feature.min!) / 100; - - return ( -
-
- -
- - -
-
- onDragChange([min, max])} - onPointerDown={() => onDragStart(feature.name)} - onPointerUp={() => onDragEnd()} - /> -
- ); - })} -
-
- - {/* Draggable separator */} -
-
-
- - {/* Bottom: Feature browser — fills remaining space */} -
-
- Add Filter -
-
- -
-
-
- ); -}); diff --git a/frontend/src/components/HomePage.tsx b/frontend/src/components/HomePage.tsx deleted file mode 100644 index 86110f1..0000000 --- a/frontend/src/components/HomePage.tsx +++ /dev/null @@ -1,367 +0,0 @@ -import { useRef, useState, useEffect, useCallback } from 'react'; - -// --- Floating hex particle canvas that reacts to scroll --- - -const HEX_COUNT = 60; -const TAU = Math.PI * 2; - -interface Hex { - x: number; - y: number; - baseY: number; - size: number; - opacity: number; - speed: number; // horizontal drift px/s - phase: number; // for gentle bob -} - -function initHexes(w: number, h: number): Hex[] { - const hexes: Hex[] = []; - for (let i = 0; i < HEX_COUNT; i++) { - const y = Math.random() * h; - hexes.push({ - x: Math.random() * w, - y, - baseY: y, - size: 8 + Math.random() * 20, - opacity: 0.06 + Math.random() * 0.12, - speed: 6 + Math.random() * 14, - phase: Math.random() * TAU, - }); - } - return hexes; -} - -function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { - ctx.beginPath(); - for (let i = 0; i < 6; i++) { - const angle = (TAU / 6) * i - Math.PI / 6; - const px = cx + r * Math.cos(angle); - const py = cy + r * Math.sin(angle); - if (i === 0) ctx.moveTo(px, py); - else ctx.lineTo(px, py); - } - ctx.closePath(); -} - -function HexCanvas({ scrollProgress, isDark = false }: { scrollProgress: number; isDark?: boolean }) { - const canvasRef = useRef(null); - const hexesRef = useRef([]); - const animRef = useRef(0); - const scrollRef = useRef(scrollProgress); - scrollRef.current = scrollProgress; - const isDarkRef = useRef(isDark); - isDarkRef.current = isDark; - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - let w = 0; - let h = 0; - - function resize() { - const dpr = window.devicePixelRatio || 1; - const rect = canvas!.parentElement!.getBoundingClientRect(); - w = rect.width; - h = rect.height; - canvas!.width = w * dpr; - canvas!.height = h * dpr; - canvas!.style.width = `${w}px`; - canvas!.style.height = `${h}px`; - ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); - hexesRef.current = initHexes(w, h); - } - - resize(); - const ro = new ResizeObserver(resize); - ro.observe(canvas.parentElement!); - - let prev = performance.now(); - - function frame(now: number) { - const dt = (now - prev) / 1000; - prev = now; - const scroll = scrollRef.current; - ctx!.clearRect(0, 0, w, h); - - // Teal accent color, fade to 0 as user scrolls down - const globalAlpha = Math.max(0, 1 - scroll * 2); - - for (const hex of hexesRef.current) { - // drift right, wrap - hex.x = (hex.x + hex.speed * dt) % (w + hex.size * 2); - // gentle vertical bob + parallax push from scroll - const bob = Math.sin(now / 1000 + hex.phase) * 8; - const parallax = scroll * h * 0.3 * (hex.speed / 20); - hex.y = hex.baseY + bob - parallax; - - // wrap vertically - if (hex.y < -hex.size * 2) hex.y += h + hex.size * 4; - if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4; - - const dark = isDarkRef.current; - ctx!.globalAlpha = hex.opacity * globalAlpha * (dark ? 0.6 : 1); - ctx!.fillStyle = dark ? '#058172' : '#00a28c'; - drawHex(ctx!, hex.x, hex.y, hex.size); - ctx!.fill(); - - ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha * (dark ? 0.6 : 1); - ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa'; - ctx!.lineWidth = 1; - drawHex(ctx!, hex.x, hex.y, hex.size); - ctx!.stroke(); - } - - animRef.current = requestAnimationFrame(frame); - } - - animRef.current = requestAnimationFrame(frame); - return () => { - cancelAnimationFrame(animRef.current); - ro.disconnect(); - }; - }, []); - - return ( - - ); -} - -// --- Fade-in hook --- - -function useFadeInRef() { - const ref = useRef(null); - useEffect(() => { - const el = ref.current; - if (!el) return; - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - el.classList.add('fade-in-visible'); - observer.unobserve(el); - } - }, - { threshold: 0.15 } - ); - observer.observe(el); - return () => observer.disconnect(); - }, []); - return ref; -} - -// --- Page --- - -export default function HomePage({ onOpenDashboard, theme = 'light' }: { onOpenDashboard: () => void; theme?: 'light' | 'dark' }) { - const scrollRef = useRef(null); - const [scrollProgress, setScrollProgress] = useState(0); - - const handleScroll = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - const max = el.scrollHeight - el.clientHeight; - if (max <= 0) return; - setScrollProgress(el.scrollTop / max); - }, []); - - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - el.addEventListener('scroll', handleScroll, { passive: true }); - return () => el.removeEventListener('scroll', handleScroll); - }, [handleScroll]); - - const heroRef = useFadeInRef(); - const problemRef = useFadeInRef(); - const filtersRef = useFadeInRef(); - const howRef = useFadeInRef(); - const numbersRef = useFadeInRef(); - const ctaRef = useFadeInRef(); - - return ( -
- - -
- {/* Hero */} -
-
-

- Find where to live, not just what's for sale -

-

- Every neighbourhood -
- in England & Wales. -
- One map. Your rules. -

-

- Set the commute, budget, school rating, noise level, and crime threshold you'll - accept. Narrowit shows you every area that qualifies — instantly. -

-
- - - No signup · Free · Open data - -
-
-
- - {/* The flip */} -
-
-
-
-
-

- The old way -

-

- Pick a postcode. Google the schools. Check crime stats on another site. Look up - commute times. Realise it's too expensive. Start over. Repeat 40 times. -

-
-
-

- With Narrowit -

-

- Tell the map what you need. Every hexagon that lights up is a place worth - looking at. Drill into any one to see individual properties, prices, and energy - ratings. -

-
-
-
-
-
- - {/* Filter showcase */} -
-
-

- 12 datasets. One slider each. -

-

- Every filter narrows the map in real time. Combine as many as you like. -

-
- {FILTERS.map((f) => ( -
-
{f.icon}
-
{f.label}
-
{f.example}
-
- ))} -
-
-
- - {/* How it works */} -
-
-

- Three clicks to clarity -

-
- {STEPS.map((step, i) => ( -
- - {i + 1} - -
-

{step.title}

-

{step.body}

-
-
- ))} -
-
-
- - {/* Numbers */} -
-
-
- {STATS.map((s) => ( -
-
{s.value}
-
{s.label}
-
- ))} -
-
-
- - {/* Final CTA */} -
-
-

Ready to narrow it down?

-

- 100% open data. No account required. Just set your filters and go. -

- -
-
-
-
- ); -} - -// --- Data --- - -const FILTERS = [ - { icon: '\u00A3', label: 'Sale price', example: 'e.g. under \u00A3400k' }, - { icon: '\uD83D\uDE86', label: 'Commute time', example: 'e.g. < 45 min to Bank' }, - { icon: '\uD83C\uDFEB', label: 'School quality', example: 'Ofsted Outstanding' }, - { icon: '\uD83D\uDEA8', label: 'Crime rate', example: 'Low burglary areas' }, - { icon: '\u26A1', label: 'Energy rating', example: 'EPC band A\u2013C' }, - { icon: '\uD83D\uDCCF', label: 'Floor area', example: 'e.g. 80+ sqm' }, - { icon: '\uD83D\uDD07', label: 'Road noise', example: 'Below 55 dB Lden' }, - { icon: '\uD83C\uDF10', label: 'Broadband speed', example: '100+ Mbps available' }, -]; - -const STEPS = [ - { - title: 'Add your deal-breakers', - body: 'Slide the filters for everything you care about \u2014 price cap, max commute, school quality, noise. The map updates as you drag.', - }, - { - title: 'Spot the clusters', - body: 'Hexagons light up where properties match. Zoom in and they split into finer cells. At street level you see individual postcode boundaries.', - }, - { - title: 'Dive into a neighbourhood', - body: 'Click any hexagon to see every property inside it \u2014 sale prices, floor plans, energy ratings, tenure. Layer on cafes, GP surgeries, and parks from OpenStreetMap.', - }, -]; - -const STATS = [ - { value: '26M+', label: 'property records' }, - { value: '12', label: 'open datasets' }, - { value: '1.7M', label: 'postcodes mapped' }, -]; diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx deleted file mode 100644 index 8091e80..0000000 --- a/frontend/src/components/Map.tsx +++ /dev/null @@ -1,691 +0,0 @@ -import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; -import { Map as MapGL, useControl } from 'react-map-gl/maplibre'; -import type { MapRef } from 'react-map-gl/maplibre'; -import { MapboxOverlay } from '@deck.gl/mapbox'; -import { H3HexagonLayer } from '@deck.gl/geo-layers'; -import { IconLayer, TextLayer } from '@deck.gl/layers'; -import type { PickingInfo } from '@deck.gl/core'; -import 'maplibre-gl/dist/maplibre-gl.css'; -import type { HexagonData, ViewState, ViewChangeParams, Bounds, POI, FeatureMeta } from '../types'; - -interface MapProps { - data: HexagonData[]; - pois: POI[]; - onViewChange: (params: ViewChangeParams) => void; - viewFeature: string | null; - colorRange: [number, number] | null; - filterRange: [number, number] | null; - viewSource: 'drag' | 'eye' | null; - onCancelPin: () => void; - features: FeatureMeta[]; - selectedHexagonId: string | null; - hoveredHexagonId: string | null; - onHexagonClick: (h3: string) => void; - onHexagonHover: (h3: string | null) => void; - initialViewState?: ViewState; - theme?: 'light' | 'dark'; -} - -// Twemoji CDN base URL -const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/'; - -// Convert emoji to Twemoji URL -function emojiToTwemojiUrl(emoji: string): string { - // Convert emoji to Unicode codepoint hex - const codePoint = emoji.codePointAt(0); - if (!codePoint) return `${TWEMOJI_BASE}1f4cd.png`; // Default pin - const hex = codePoint.toString(16); - return `${TWEMOJI_BASE}${hex}.png`; -} - -const INITIAL_VIEW: ViewState = { - longitude: -1.5, - latitude: 53.5, - zoom: 6, - pitch: 0, -}; - -const MAP_STYLE_LIGHT = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'; -const MAP_STYLE_DARK = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; - -// Gradient stops for normalized [0,1] values -const GRADIENT: { t: number; color: [number, number, number] }[] = [ - { t: 0, color: [46, 204, 113] }, // Green - { t: 0.33, color: [241, 196, 15] }, // Yellow - { t: 0.66, color: [231, 76, 60] }, // Red - { t: 1, color: [142, 68, 173] }, // Purple -]; - -function normalizedToColor(t: number): [number, number, number] { - if (t <= 0) return GRADIENT[0].color; - if (t >= 1) return GRADIENT[GRADIENT.length - 1].color; - - for (let i = 0; i < GRADIENT.length - 1; i++) { - const lo = GRADIENT[i]; - const hi = GRADIENT[i + 1]; - if (t >= lo.t && t <= hi.t) { - const frac = (t - lo.t) / (hi.t - lo.t); - return [ - Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac), - Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac), - Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac), - ]; - } - } - return GRADIENT[GRADIENT.length - 1].color; -} - -function zoomToResolution(zoom: number): number { - if (zoom < 6) return 5; - if (zoom < 7) return 6; - if (zoom < 9.5) return 8; - if (zoom < 11) return 9; - if (zoom < 13) return 10; - if (zoom < 15) return 11; - return 12; -} - -function getBoundsFromViewState(viewState: ViewState, width: number, height: number): Bounds { - const { longitude, latitude, zoom } = viewState; - - // Clamp latitude to valid Mercator range to avoid math errors - const clampedLat = Math.max(-85, Math.min(85, latitude)); - - // Web Mercator projection math - const TILE_SIZE = 256; - const scale = Math.pow(2, zoom); - const worldSize = TILE_SIZE * scale; - - // Longitude is linear - const degreesPerPixelLng = 360 / worldSize; - const halfWidthDeg = (width / 2) * degreesPerPixelLng; - - // Latitude uses Mercator projection (non-linear) - const latRad = (clampedLat * Math.PI) / 180; - const mercatorY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2; - const centerPixelY = mercatorY * worldSize; - - const topPixelY = centerPixelY - height / 2; - const bottomPixelY = centerPixelY + height / 2; - - // Convert pixel Y back to latitude - const pixelYToLat = (pixelY: number): number => { - const mercY = Math.max(0.001, Math.min(0.999, pixelY / worldSize)); - const latRadians = Math.atan(Math.sinh(Math.PI * (1 - 2 * mercY))); - return (latRadians * 180) / Math.PI; - }; - - const north = Math.min(85, pixelYToLat(topPixelY)); - const south = Math.max(-85, pixelYToLat(bottomPixelY)); - const west = Math.max(-180, longitude - halfWidthDeg); - const east = Math.min(180, longitude + halfWidthDeg); - - return { south, west, north, east }; -} - -interface Dimensions { - width: number; - height: number; -} - -function DeckOverlay({ - layers, - getTooltip, -}: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - layers: any[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getTooltip: any; -}) { - const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); - const prevLayersRef = useRef(layers); - const prevTooltipRef = useRef(getTooltip); - if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) { - prevLayersRef.current = layers; - prevTooltipRef.current = getTooltip; - overlay.setProps({ layers, getTooltip }); - } - return null; -} - -// Vibrant density scale: light cyan → teal → deep indigo -const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [ - { t: 0, color: [130, 234, 220] }, // Light cyan (few) - { t: 0.5, color: [20, 140, 180] }, // Ocean blue (moderate) - { t: 1, color: [88, 28, 140] }, // Deep indigo (many) -]; - -function countToColor(t: number): [number, number, number] { - if (t <= 0) return DENSITY_GRADIENT[0].color; - if (t >= 1) return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color; - - for (let i = 0; i < DENSITY_GRADIENT.length - 1; i++) { - const lo = DENSITY_GRADIENT[i]; - const hi = DENSITY_GRADIENT[i + 1]; - if (t >= lo.t && t <= hi.t) { - const frac = (t - lo.t) / (hi.t - lo.t); - return [ - Math.round(lo.color[0] + (hi.color[0] - lo.color[0]) * frac), - Math.round(lo.color[1] + (hi.color[1] - lo.color[1]) * frac), - Math.round(lo.color[2] + (hi.color[2] - lo.color[2]) * frac), - ]; - } - } - return DENSITY_GRADIENT[DENSITY_GRADIENT.length - 1].color; -} - -function PostcodeSearch({ - onFlyTo, -}: { - onFlyTo: (lat: number, lng: number, zoom: number) => void; -}) { - const [query, setQuery] = useState(''); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - const trimmed = query.trim(); - if (!trimmed) return; - - setError(null); - setLoading(true); - try { - const res = await fetch( - `https://api.postcodes.io/postcodes/${encodeURIComponent(trimmed)}` - ); - if (!res.ok) { - setError('Postcode not found'); - return; - } - const json = await res.json(); - if (json.status === 200 && json.result) { - onFlyTo(json.result.latitude, json.result.longitude, 14); - setQuery(''); - } else { - setError('Postcode not found'); - } - } catch { - setError('Lookup failed'); - } finally { - setLoading(false); - } - }, - [query, onFlyTo] - ); - - return ( -
-
- { - setQuery(e.target.value); - setError(null); - }} - placeholder="Search postcode..." - className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-warm-100 dark:placeholder-warm-500" - /> - -
- {error && ( - {error} - )} -
- ); -} - -function MapLegend({ - featureLabel, - range, - showCancel, - onCancel, - mode, - enumValues, -}: { - featureLabel: string; - range: [number, number]; - showCancel: boolean; - onCancel: () => void; - mode: 'feature' | 'density'; - enumValues?: string[]; -}) { - const formatVal = (v: number) => { - if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; - if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(1)}k`; - if (Number.isInteger(v)) return v.toString(); - return v.toFixed(1); - }; - - const gradientStyle = - mode === 'density' - ? 'linear-gradient(to right, rgb(130, 234, 220), rgb(20, 140, 180), rgb(88, 28, 140))' - : 'linear-gradient(to right, rgb(46, 204, 113), rgb(241, 196, 15), rgb(231, 76, 60), rgb(142, 68, 173))'; - - return ( -
-
- {featureLabel} - {showCancel && ( - - )} -
-
-
- {mode === 'density' ? ( - <> - Few - Many - - ) : enumValues && enumValues.length > 0 ? ( - <> - {enumValues[0]} - {enumValues[enumValues.length - 1]} - - ) : ( - <> - {formatVal(range[0])} - {formatVal(range[1])} - - )} -
-
- ); -} - -export default memo(function Map({ - data, - pois, - onViewChange, - viewFeature, - colorRange, - filterRange, - viewSource, - onCancelPin, - features, - selectedHexagonId, - hoveredHexagonId, - onHexagonClick, - onHexagonHover, - initialViewState, - theme = 'light', -}: MapProps) { - const containerRef = useRef(null); - const [viewState, setViewState] = useState(initialViewState || INITIAL_VIEW); - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); - - // Track container dimensions with ResizeObserver - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const observer = new ResizeObserver((entries) => { - const { width, height } = entries[0].contentRect; - if (width > 0 && height > 0) { - setDimensions({ width, height }); - } - }); - - observer.observe(container); - return () => observer.disconnect(); - }, []); - - // Notify parent when view or dimensions change - useEffect(() => { - if (dimensions.width === 0 || dimensions.height === 0) return; - - const raw = getBoundsFromViewState(viewState, dimensions.width, dimensions.height); - const resolution = zoomToResolution(viewState.zoom); - - // Quantize bounds to 0.01° to reduce state churn and improve backend cache hits - const QUANT = 0.01; - const bounds: Bounds = { - south: Math.floor(raw.south / QUANT) * QUANT, - west: Math.floor(raw.west / QUANT) * QUANT, - north: Math.ceil(raw.north / QUANT) * QUANT, - east: Math.ceil(raw.east / QUANT) * QUANT, - }; - - onViewChange({ - resolution, - bounds, - zoom: viewState.zoom, - latitude: viewState.latitude, - longitude: viewState.longitude, - }); - }, [viewState, dimensions, onViewChange]); - - const handleMove = useCallback((evt: { viewState: ViewState }) => { - setViewState(evt.viewState); - }, []); - - const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => { - setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom })); - }, []); - - const themeRef = useRef(theme); - themeRef.current = theme; - - // Make place labels more legible over the colored hexagons - const handleMapLoad = useCallback( - (evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => { - const map = evt.target; - if (themeRef.current === 'light') { - for (const layer of map.getStyle().layers || []) { - if (layer.type !== 'symbol') continue; - map.setPaintProperty(layer.id, 'text-halo-color', 'rgba(255,255,255,1)'); - map.setPaintProperty(layer.id, 'text-halo-width', 2); - map.setPaintProperty(layer.id, 'text-color', '#222'); - } - // Make water more prominent - for (const layer of map.getStyle().layers || []) { - if (layer.id === 'water' || layer.id.startsWith('water')) { - map.setPaintProperty(layer.id, 'fill-color', '#6baed6'); - } - } - } - try { - map.setLayoutProperty('building', 'visibility', 'none'); - map.setLayoutProperty('building-top', 'visibility', 'none'); - } catch { - // layers may not exist in dark style - } - }, - [] - ); - - const mapStyle = theme === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT; - - // Popup state for POI hover - const [popupInfo, setPopupInfo] = useState<{ - x: number; - y: number; - name: string; - category: string; - } | null>(null); - - const handlePoiHover = useCallback((info: PickingInfo) => { - if (info.object && info.x !== undefined && info.y !== undefined) { - setPopupInfo({ - x: info.x, - y: info.y, - name: info.object.name, - category: info.object.category, - }); - } else { - setPopupInfo(null); - } - }, []); - - // Compute count range for count-based coloring - const countRange = useMemo(() => { - if (data.length === 0) return { min: 0, max: 1 }; - let min = Infinity; - let max = -Infinity; - for (const d of data) { - const c = d.count as number; - if (c < min) min = c; - if (c > max) max = c; - } - if (min === max) return { min, max: min + 1 }; - return { min, max }; - }, [data]); - - // Memoize feature lookup to avoid new reference each render - const colorFeatureMeta = useMemo( - () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), - [viewFeature, features] - ); - - // Use refs for values that change during drag so layers aren't recreated - const viewFeatureRef = useRef(viewFeature); - viewFeatureRef.current = viewFeature; - const colorRangeRef = useRef(colorRange); - colorRangeRef.current = colorRange; - const filterRangeRef = useRef(filterRange); - filterRangeRef.current = filterRange; - const colorFeatureMetaRef = useRef(colorFeatureMeta); - colorFeatureMetaRef.current = colorFeatureMeta; - const countRangeRef = useRef(countRange); - countRangeRef.current = countRange; - const selectedHexagonIdRef = useRef(selectedHexagonId); - selectedHexagonIdRef.current = selectedHexagonId; - const hoveredHexagonIdRef = useRef(hoveredHexagonId); - hoveredHexagonIdRef.current = hoveredHexagonId; - - // Stable click handler using ref - const onHexagonClickRef = useRef(onHexagonClick); - onHexagonClickRef.current = onHexagonClick; - const handleHexagonClick = useCallback((info: PickingInfo) => { - if (info.object && 'h3' in info.object) { - onHexagonClickRef.current(info.object.h3); - } - }, []); - - // Stable hover handler using ref - const onHexagonHoverRef = useRef(onHexagonHover); - onHexagonHoverRef.current = onHexagonHover; - const handleHexagonHover = useCallback((info: PickingInfo) => { - if (info.object && 'h3' in info.object) { - onHexagonHoverRef.current(info.object.h3); - } else { - onHexagonHoverRef.current(null); - } - }, []); - - // Stable hover handler using ref - const handlePoiHoverRef = useRef(handlePoiHover); - handlePoiHoverRef.current = handlePoiHover; - const stablePoiHover = useCallback((info: PickingInfo) => { - handlePoiHoverRef.current(info); - }, []); - - // Derive a trigger value from color-affecting state — avoids useEffect+setState double-render - const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}`; - - // Hexagon layer — only recreated when data or color trigger changes - const hexLayer = useMemo( - () => - new H3HexagonLayer({ - id: 'h3-hexagons', - data, - getHexagon: (d) => d.h3, - getFillColor: (d) => { - const vf = viewFeatureRef.current; - const clr = colorRangeRef.current; - const fr = filterRangeRef.current; - const cfm = colorFeatureMetaRef.current; - if (vf && clr && cfm) { - const val = d[`min_${vf}`]; - if (val == null) return [128, 128, 128, 80] as [number, number, number, number]; - // Gray out hexagons outside filter range - if (fr) { - const minVal = d[`min_${vf}`] as number; - const maxVal = d[`max_${vf}`] as number; - if (maxVal < fr[0] || minVal > fr[1]) { - return [180, 180, 180, 60] as [number, number, number, number]; - } - } - // Color using full slider range - const range = clr[1] - clr[0]; - if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number]; - const t = ((val as number) - clr[0]) / range; - const rgb = normalizedToColor(Math.max(0, Math.min(1, t))); - return [...rgb, 200] as [number, number, number, number]; - } - const cr = countRangeRef.current; - const c = d.count as number; - const t = (c - cr.min) / (cr.max - cr.min); - return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [ - number, - number, - number, - number, - ]; - }, - getLineColor: (d) => { - if (d.h3 === selectedHexagonIdRef.current) return [255, 255, 255, 255] as [number, number, number, number]; - if (d.h3 === hoveredHexagonIdRef.current) return [29, 228, 195, 200] as [number, number, number, number]; - return [0, 0, 0, 0] as [number, number, number, number]; - }, - getLineWidth: (d) => { - if (d.h3 === selectedHexagonIdRef.current) return 3; - if (d.h3 === hoveredHexagonIdRef.current) return 2; - return 0; - }, - lineWidthUnits: 'pixels', - updateTriggers: { - getFillColor: [colorTrigger], - getLineColor: [colorTrigger], - getLineWidth: [colorTrigger], - }, - extruded: false, - pickable: true, - opacity: 1, - highPrecision: true, - onClick: handleHexagonClick, - onHover: handleHexagonHover, - // @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps - beforeId: 'waterway_label', - }), - [data, colorTrigger, handleHexagonClick, handleHexagonHover] - ); - - // POI layer — independent, only recreated when POI data changes - const poiLayer = useMemo( - () => - new IconLayer({ - id: 'poi-icons', - data: pois, - getPosition: (d) => [d.lng, d.lat], - getIcon: (d) => ({ - url: emojiToTwemojiUrl(d.emoji), - width: 72, - height: 72, - }), - getSize: 24, - sizeMinPixels: 20, - sizeMaxPixels: 40, - pickable: true, - onHover: stablePoiHover, - }), - [pois, stablePoiHover] - ); - - // Postcode labels on high-res hexagons (resolution 11+, zoom >= 13) - const postcodeData = useMemo( - () => data.filter((d) => d.postcode && d.lat != null && d.lon != null), - [data] - ); - - const showPostcodes = viewState.zoom >= 13; - const postcodeLayer = useMemo( - () => - showPostcodes - ? new TextLayer({ - id: 'postcode-labels', - data: postcodeData, - getPosition: (d) => [d.lon as number, d.lat as number], - getText: (d) => d.postcode as string, - getSize: 11, - getColor: theme === 'dark' ? [220, 220, 220, 220] : [30, 30, 30, 220], - getTextAnchor: 'middle', - getAlignmentBaseline: 'center', - fontFamily: 'Inter, system-ui, sans-serif', - fontWeight: 600, - outlineWidth: 2, - outlineColor: theme === 'dark' ? [30, 30, 30, 200] : [255, 255, 255, 200], - billboard: false, - sizeUnits: 'pixels', - sizeMinPixels: 10, - sizeMaxPixels: 14, - }) - : null, - [postcodeData, showPostcodes, theme] - ); - - const layers = useMemo( - () => [hexLayer, poiLayer, ...(postcodeLayer ? [postcodeLayer] : [])], - [hexLayer, poiLayer, postcodeLayer] - ); - - return ( -
- - - - - {viewFeature && colorRange && colorFeatureMeta ? ( - - ) : ( - - )} - {popupInfo && ( -
- {popupInfo.name} -
{popupInfo.category}
-
- )} -
- ); -}); diff --git a/frontend/src/components/POIPane.tsx b/frontend/src/components/POIPane.tsx deleted file mode 100644 index e4df229..0000000 --- a/frontend/src/components/POIPane.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; -import type { POICategoryGroup } from '../types'; - -interface POIPaneProps { - groups: POICategoryGroup[]; - selectedCategories: Set; - onCategoriesChange: (categories: Set) => void; - poiCount: number; - onNavigateToSource?: (slug: string) => void; -} - -export default function POIPane({ - groups, - selectedCategories, - onCategoriesChange, - poiCount, - onNavigateToSource, -}: POIPaneProps) { - const [dropdownOpen, setDropdownOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); - const [showInfo, setShowInfo] = useState(false); - const dropdownRef = useRef(null); - const infoPopupRef = useRef(null); - - // Close dropdown when clicking outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setDropdownOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // Close info popup when clicking outside - useEffect(() => { - if (!showInfo) return; - function handleClickOutside(e: MouseEvent) { - if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) { - setShowInfo(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [showInfo]); - - const allCategories = groups.flatMap((g) => g.categories); - - const toggleCategory = (category: string) => { - const newSet = new Set(selectedCategories); - if (newSet.has(category)) { - newSet.delete(category); - } else { - newSet.add(category); - } - onCategoriesChange(newSet); - }; - - const selectAll = () => { - onCategoriesChange(new Set(allCategories)); - }; - - const selectNone = () => { - onCategoriesChange(new Set()); - }; - - const toggleGroup = useCallback( - (groupName: string) => { - const group = groups.find((g) => g.name === groupName); - if (!group) return; - const allSelected = group.categories.every((c) => selectedCategories.has(c)); - const newSet = new Set(selectedCategories); - if (allSelected) { - group.categories.forEach((c) => newSet.delete(c)); - } else { - group.categories.forEach((c) => newSet.add(c)); - } - onCategoriesChange(newSet); - }, - [groups, selectedCategories, onCategoriesChange] - ); - - const toggleCollapse = (groupName: string) => { - setCollapsedGroups((prev) => { - const next = new Set(prev); - if (next.has(groupName)) { - next.delete(groupName); - } else { - next.add(groupName); - } - return next; - }); - }; - - const lowerSearch = searchTerm.toLowerCase(); - - // Filter groups and categories by search term - const filteredGroups = groups - .map((group) => { - if (!searchTerm) return group; - const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch)); - const groupMatches = group.name.toLowerCase().includes(lowerSearch); - if (groupMatches) return group; - if (matchingCats.length === 0) return null; - return { ...group, categories: matchingCats }; - }) - .filter(Boolean) as POICategoryGroup[]; - - const selectedCount = selectedCategories.size; - - return ( -
-
-

Points of Interest

- -
- - {showInfo && ( -
-
-
-

- Points of Interest -

- -
-

- Points of interest are sourced from OpenStreetMap via Geofabrik extracts. - Categories include public transport stops, shops, restaurants, healthcare - facilities, leisure venues, and more. Data is filtered and mapped to - friendly names with exhaustive category coverage. -

- {onNavigateToSource && ( - - )} -
-
- )} - -
- - - {dropdownOpen && ( -
-
- - | - -
-
- setSearchTerm(e.target.value)} - className="w-full px-2 py-1 text-sm border border-warm-300 dark:border-navy-700 rounded bg-white dark:bg-navy-950 dark:text-warm-200 dark:placeholder-warm-500" - /> -
-
- {filteredGroups.map((group) => { - const groupSelected = group.categories.filter((c) => - selectedCategories.has(c) - ).length; - const allInGroupSelected = groupSelected === group.categories.length; - const someInGroupSelected = groupSelected > 0 && !allInGroupSelected; - const isCollapsed = collapsedGroups.has(group.name) && !searchTerm; - - return ( -
-
- - - - {groupSelected}/{group.categories.length} - -
- {!isCollapsed && - group.categories.map((category) => ( - - ))} -
- ); - })} -
-
- )} -
- - {selectedCount > 0 && ( -
-
- {poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible -
-
- {selectedCount} categor{selectedCount !== 1 ? 'ies' : 'y'} selected -
-
- )} - -
-

Select categories to display POIs on the map.

-

Zoom in for better visibility of individual locations.

-
-
- ); -} diff --git a/frontend/src/components/PropertiesPane.tsx b/frontend/src/components/PropertiesPane.tsx deleted file mode 100644 index 58f5b35..0000000 --- a/frontend/src/components/PropertiesPane.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import React, { useMemo, useState, useRef, useEffect } from 'react'; -import { Property } from '../types'; - -interface PropertiesPaneProps { - properties: Property[]; - total: number; - loading: boolean; - hexagonId: string | null; - onLoadMore: () => void; - onClose: () => void; - onNavigateToSource?: (slug: string) => void; - isHoveredPreview?: boolean; - hoverMode?: boolean; - onHoverModeChange?: (enabled: boolean) => void; -} - -type SortBy = 'price' | 'size' | 'energy'; - -export function PropertiesPane({ - properties, - total, - loading, - hexagonId, - onLoadMore, - onClose, - onNavigateToSource, - isHoveredPreview, - hoverMode, - onHoverModeChange, -}: PropertiesPaneProps) { - const [sortBy, setSortBy] = useState('price'); - const [search, setSearch] = useState(''); - const [showInfo, setShowInfo] = useState(false); - const infoPopupRef = useRef(null); - - useEffect(() => { - if (!showInfo) return; - function handleClickOutside(e: MouseEvent) { - if (infoPopupRef.current && !infoPopupRef.current.contains(e.target as Node)) { - setShowInfo(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [showInfo]); - - // Filter and sort properties - const filteredAndSorted = useMemo(() => { - const query = search.trim().toLowerCase(); - const filtered = query - ? properties.filter((p) => { - const addr = (p.address || '').toLowerCase(); - const pc = (p.postcode || '').toLowerCase(); - return addr.includes(query) || pc.includes(query); - }) - : properties; - return [...filtered].sort((a, b) => { - switch (sortBy) { - case 'price': - return ((b.latest_price as number) || 0) - ((a.latest_price as number) || 0); - case 'size': - return ((b.total_floor_area as number) || 0) - ((a.total_floor_area as number) || 0); - case 'energy': - return (a.current_energy_rating || 'Z').localeCompare(b.current_energy_rating || 'Z'); - } - }); - }, [properties, sortBy, search]); - - if (!hexagonId) { - return ( -
- Click a hexagon to view properties -
- ); - } - - return ( -
- {/* Header */} -
-
-
-

Properties

- {isHoveredPreview && ( - - Preview - - )} - -
-
- {onHoverModeChange && ( - - )} - -
-
-

- {search.trim() - ? `${filteredAndSorted.length} match${filteredAndSorted.length !== 1 ? 'es' : ''} in ${properties.length} loaded` - : `Showing ${properties.length} of ${total} properties`} -

- {showInfo && ( -
-
-
-

- Property Data -

- -
-

- Property data combines Energy Performance Certificates (EPC) with HM Land - Registry Price Paid records, fuzzy-matched by address within each postcode. - Includes floor area, energy ratings, construction age, and tenure from EPC - surveys, plus the most recent sale price from the Land Registry. -

- {onNavigateToSource && ( - - )} -
-
- )} -
- - {/* Search and sort controls */} -
- setSearch(e.target.value)} - placeholder="Search by address or postcode..." - className="w-full p-2 border border-warm-300 dark:border-navy-700 rounded text-sm bg-white dark:bg-navy-800 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500" - /> - -
- - {/* Properties list */} -
- {loading && properties.length === 0 ? ( -
Loading...
- ) : ( - <> - {filteredAndSorted.map((property, idx) => ( - - ))} - {properties.length < total && ( - - )} - - )} -
-
- ); -} - -function formatDuration(d: string): string { - if (d === 'F') return 'Freehold'; - if (d === 'L') return 'Leasehold'; - return d; -} - -function formatAge(value: number, approximate = true): string { - if (value >= 1000) return approximate ? `~${Math.round(value)}` : `${Math.round(value)}`; - return Math.round(value).toString(); -} - -// Helper to get a numeric value from a property, trying multiple field names -function getNum(property: Property, ...keys: string[]): number | undefined { - for (const key of keys) { - const v = property[key]; - if (v !== undefined && v !== null && typeof v === 'number') return v; - } - return undefined; -} - -// Property card component showing all fields -function PropertyCard({ property }: { property: Property }) { - const fmt = (value: number | undefined, decimals = 0): string => { - if (value === undefined) return ''; - return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString(); - }; - - const price = getNum(property, 'Last known price', 'latest_price'); - const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm'); - const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area'); - const rooms = getNum( - property, - 'Rooms (including bedrooms & bathrooms)', - 'number_habitable_rooms' - ); - const age = getNum(property, 'Approximate construction age', 'construction_age_band'); - - return ( -
- {/* Address & postcode */} -
{property.address || 'Unknown Address'}
-
{property.postcode}
- - {/* Price */} - {price !== undefined && ( -
- £{fmt(price)} - {pricePerSqm !== undefined && ( - (£{fmt(pricePerSqm)}/m²) - )} -
- )} - - {/* Property details grid */} -
- {property.property_type && ( -
- Type: {property.property_type} -
- )} - {property.built_form && ( -
- Built form: {property.built_form} -
- )} - {property.duration && ( -
- Tenure: {formatDuration(property.duration)} -
- )} - {floorArea !== undefined && ( -
- Floor area: {fmt(floorArea)}m² -
- )} - {rooms !== undefined && ( -
- Rooms: {fmt(rooms)} -
- )} - {age !== undefined && ( -
- Built: {formatAge(age, property.is_construction_date_approximate ?? true)} -
- )} - {property.current_energy_rating && ( -
- EPC rating: {property.current_energy_rating} -
- )} - {property.potential_energy_rating && ( -
- EPC potential: {property.potential_energy_rating} -
- )} -
-
- ); -} diff --git a/frontend/src/components/DataSources.tsx b/frontend/src/components/data-sources/DataSources.tsx similarity index 70% rename from frontend/src/components/DataSources.tsx rename to frontend/src/components/data-sources/DataSources.tsx index 386fb35..80cd48d 100644 --- a/frontend/src/components/DataSources.tsx +++ b/frontend/src/components/data-sources/DataSources.tsx @@ -2,7 +2,7 @@ export default function DataSources({ onNavigate }: { onNavigate: () => void }) return ( diff --git a/frontend/src/components/DataSourcesPage.tsx b/frontend/src/components/data-sources/DataSourcesPage.tsx similarity index 82% rename from frontend/src/components/DataSourcesPage.tsx rename to frontend/src/components/data-sources/DataSourcesPage.tsx index f836a26..1e1ac70 100644 --- a/frontend/src/components/DataSourcesPage.tsx +++ b/frontend/src/components/data-sources/DataSourcesPage.tsx @@ -13,7 +13,8 @@ const DATA_SOURCES = [ id: 'epc', name: 'Energy Performance Certificates (EPC)', origin: 'Ministry of Housing, Communities & Local Government', - use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets.', + use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.', + optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure', url: 'https://epc.opendatacommunities.org/downloads/domestic', license: 'Open Government Licence v3.0', }, @@ -97,6 +98,22 @@ const DATA_SOURCES = [ url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025', license: 'Open Government Licence v3.0', }, + { + id: 'geosure', + name: 'GeoSure Ground Stability', + origin: 'Ordnance Survey', + use: 'Ground stability hazard ratings on a 5km hex grid covering Great Britain. Six risk categories (collapsible deposits, compressible ground, landslides, running sand, shrink-swell, and soluble rocks) rated Low, Moderate, or Significant. Spatial-joined to postcodes via centroid intersection.', + url: 'https://osdatahub.os.uk/downloads/open/GeoSure', + license: 'Open Government Licence v3.0', + }, + { + id: 'council-tax', + name: 'Council Tax Levels 2025-26', + origin: 'Ministry of Housing, Communities & Local Government', + use: 'Annual council tax rates for Bands A-H for all 296 billing authorities in England, for a dwelling occupied by two adults. Joined to properties via local authority district code from the NSPL postcode lookup.', + url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026', + license: 'Open Government Licence v3.0', + }, ]; export default function DataSourcesPage() { @@ -135,7 +152,9 @@ export default function DataSourcesPage() {
{ cardRefs.current[source.id] = el; }} + ref={(el) => { + cardRefs.current[source.id] = el; + }} className={`bg-white dark:bg-navy-800 rounded-lg border p-5 ${ highlightedId === source.id ? 'border-teal-400 ring-2 ring-teal-400' @@ -143,12 +162,16 @@ export default function DataSourcesPage() { }`} >
-

{source.name}

+

+ {source.name} +

{source.license}
-

Source: {source.origin}

+

+ Source: {source.origin} +

{source.use}

{source.url} + {'optOutUrl' in source && source.optOutUrl && ( + + )}
))}
diff --git a/frontend/src/components/FAQPage.tsx b/frontend/src/components/faq/FAQPage.tsx similarity index 77% rename from frontend/src/components/FAQPage.tsx rename to frontend/src/components/faq/FAQPage.tsx index 36bd032..34b67de 100644 --- a/frontend/src/components/FAQPage.tsx +++ b/frontend/src/components/faq/FAQPage.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { ChevronIcon } from '../ui/icons/ChevronIcon'; interface FAQItem { question: string; @@ -9,7 +10,7 @@ const FAQ_ITEMS: FAQItem[] = [ { question: 'What is this application?', answer: - 'Narrowit is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.', + 'Perfect Postcodes is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.', }, { question: 'Where does the data come from?', @@ -29,7 +30,7 @@ const FAQ_ITEMS: FAQItem[] = [ { question: 'What does the eye icon do on a filter?', answer: - 'The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature\'s value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.', + "The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature's value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.", }, { question: 'How fresh is the data?', @@ -39,7 +40,7 @@ const FAQ_ITEMS: FAQItem[] = [ { question: 'How are EPC records matched to Land Registry sales?', answer: - 'EPC and Land Registry records don\'t share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.', + "EPC and Land Registry records don't share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.", }, { question: 'What are Points of Interest (POIs)?', @@ -64,7 +65,7 @@ const FAQ_ITEMS: FAQItem[] = [ { question: 'Does this work on mobile?', answer: - 'The app is designed for desktop browsers where you have enough screen space for the map, filter panel, and POI/properties panel side by side. It will load on mobile but the experience is best on a larger screen.', + 'Yes. On mobile, the dashboard uses a vertical split layout with the map on top and a tabbed panel below for filters, area stats, properties, and POIs. Tapping a hexagon opens a full-screen drawer with the details. The full desktop experience with side-by-side panels is available on screens 768px and wider.', }, ]; @@ -78,15 +79,10 @@ function FAQItemCard({ item }: { item: FAQItem }) { onClick={() => setOpen(!open)} > {item.question} - - - + /> {open && (
@@ -105,7 +101,7 @@ export default function FAQPage() { Frequently Asked Questions

- Common questions about how Narrowit works, where the data comes from, and how to use the + Common questions about how Perfect Postcodes works, where the data comes from, and how to use the map.

diff --git a/frontend/src/components/home/BottomIllustration.tsx b/frontend/src/components/home/BottomIllustration.tsx new file mode 100644 index 0000000..bdcd353 --- /dev/null +++ b/frontend/src/components/home/BottomIllustration.tsx @@ -0,0 +1,22 @@ +interface Props { + isDark: boolean; +} + +export default function BottomIllustration({ isDark }: Props) { + const hillColor = isDark ? '#16a34a' : '#22c55e'; + + return ( +
+ + {/* Green hill */} + + {/* Inner shadow for depth */} + + + + {/* House */} + + +
+ ); +} diff --git a/frontend/src/components/home/CategoryArt.tsx b/frontend/src/components/home/CategoryArt.tsx new file mode 100644 index 0000000..ea5e46c --- /dev/null +++ b/frontend/src/components/home/CategoryArt.tsx @@ -0,0 +1,118 @@ +/** + * Decorative mini SVGs for homepage category cards. + * Purely visual — rendered at low opacity in the corner of each card. + */ +export default function CategoryArt({ + category, + className = '', +}: { + category: string; + className?: string; +}) { + const props = { className, width: 36, height: 36, viewBox: '0 0 36 36', fill: 'none' }; + + switch (category) { + case 'Property': + // Ascending bar chart + return ( + + + + + + ); + case 'Transport': + // Converging route lines + return ( + + + + + + + ); + case 'Crime': + // Shield outline + return ( + + + + + ); + case 'Education': + // Mortarboard / books + return ( + + + + + + ); + case 'Amenities': + // Scattered dots (map pins) + return ( + + + + + + + + + ); + case 'Demographics': + // Pie/donut segment + return ( + + + + + + ); + case 'Environment': + // Terrain wave lines + return ( + + + + + + ); + case 'Broadband': + // Signal waves (wifi) + return ( + + + + + + + ); + case 'Deprivation': + // Scale / balance + return ( + + + + + + + + ); + default: + return null; + } +} diff --git a/frontend/src/components/home/HexCanvas.tsx b/frontend/src/components/home/HexCanvas.tsx new file mode 100644 index 0000000..210477b --- /dev/null +++ b/frontend/src/components/home/HexCanvas.tsx @@ -0,0 +1,131 @@ +import { useRef, useEffect } from 'react'; + +const HEX_COUNT = 70; +const TAU = Math.PI * 2; + +interface Hex { + x: number; + y: number; + baseY: number; + size: number; + opacity: number; + speed: number; + phase: number; +} + +function initHexes(w: number, h: number): Hex[] { + const hexes: Hex[] = []; + for (let i = 0; i < HEX_COUNT; i++) { + const y = Math.random() * h; + const side = Math.random() < 0.5 ? 'left' : 'right'; + const x = side === 'left' ? Math.random() * w * 0.3 : w * 0.7 + Math.random() * w * 0.3; + hexes.push({ + x, + y, + baseY: y, + size: 8 + Math.random() * 20, + opacity: 0.08 + Math.random() * 0.15, + speed: 6 + Math.random() * 14, + phase: Math.random() * TAU, + }); + } + return hexes; +} + +function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const angle = (TAU / 6) * i - Math.PI / 6; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); +} + +export default function HexCanvas({ isDark = false }: { isDark?: boolean }) { + const canvasRef = useRef(null); + const hexesRef = useRef([]); + const animRef = useRef(0); + const isDarkRef = useRef(isDark); + isDarkRef.current = isDark; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let w = 0; + let h = 0; + + function resize() { + const dpr = window.devicePixelRatio || 1; + const rect = canvas!.parentElement!.getBoundingClientRect(); + w = rect.width; + h = rect.height; + canvas!.width = w * dpr; + canvas!.height = h * dpr; + canvas!.style.width = `${w}px`; + canvas!.style.height = `${h}px`; + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0); + hexesRef.current = initHexes(w, h); + } + + resize(); + const ro = new ResizeObserver(resize); + ro.observe(canvas.parentElement!); + + let prev = performance.now(); + + function frame(now: number) { + const dt = (now - prev) / 1000; + prev = now; + ctx!.clearRect(0, 0, w, h); + + for (const hex of hexesRef.current) { + hex.x += hex.speed * dt * 0.3; + if (hex.x > w * 0.3 + hex.size && hex.x < w * 0.7 - hex.size) { + hex.x = w * 0.7 + hex.size; + } + if (hex.x > w + hex.size * 2) { + hex.x = -hex.size * 2; + hex.y = Math.random() * h; + hex.baseY = hex.y; + } + + const bob = Math.sin(now / 1000 + hex.phase) * 8; + hex.y = hex.baseY + bob; + + const dark = isDarkRef.current; + ctx!.globalAlpha = hex.opacity * (dark ? 0.6 : 1); + ctx!.fillStyle = dark ? '#058172' : '#00a28c'; + drawHex(ctx!, hex.x, hex.y, hex.size); + ctx!.fill(); + + ctx!.globalAlpha = hex.opacity * 0.5 * (dark ? 0.6 : 1); + ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa'; + ctx!.lineWidth = 1; + drawHex(ctx!, hex.x, hex.y, hex.size); + ctx!.stroke(); + } + + animRef.current = requestAnimationFrame(frame); + } + + animRef.current = requestAnimationFrame(frame); + return () => { + cancelAnimationFrame(animRef.current); + ro.disconnect(); + }; + }, []); + + return ( + + ); +} diff --git a/frontend/src/components/home/HomeDemo.tsx b/frontend/src/components/home/HomeDemo.tsx new file mode 100644 index 0000000..3f77084 --- /dev/null +++ b/frontend/src/components/home/HomeDemo.tsx @@ -0,0 +1,243 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import MapComponent from '../map/Map'; +import { Slider } from '../ui/Slider'; +import { apiUrl, authHeaders } from '../../lib/api'; +import { formatValue } from '../../lib/format'; +import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts'; +import { gradientToCss } from '../../lib/utils'; +import { TickerValue } from '../ui/TickerValue'; +import type { FeatureMeta, HexagonData } from '../../types'; + +const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 }; +const DEMO_FEATURE_NAMES = ['Estimated current price', 'Good+ primary schools within 5km', 'Number of restaurants within 2km']; +const DEMO_BOUNDS = '49,-9.5,57,5'; +const DEMO_RESOLUTION = 5; + +const noop = () => {}; +const featureGradientStyle = gradientToCss(FEATURE_GRADIENT); + +interface HomeDemoProps { + features: FeatureMeta[]; + theme: 'light' | 'dark'; +} + +export default function HomeDemo({ features, theme }: HomeDemoProps) { + const [hexData, setHexData] = useState([]); + const [sliderValues, setSliderValues] = useState>({}); + const [activeFeature, setActiveFeature] = useState(null); + const [dragValue, setDragValue] = useState<[number, number] | null>(null); + const [dragHexData, setDragHexData] = useState(null); + const fetchTimeoutRef = useRef>(); + const abortRef = useRef(); + const dragAbortRef = useRef(); + const activeFeatureRef = useRef(null); + activeFeatureRef.current = activeFeature; + + const demoFeatures = useMemo( + () => + DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter( + Boolean + ) as FeatureMeta[], + [features] + ); + + // Initialize slider values when features arrive + useEffect(() => { + if (demoFeatures.length === 0) return; + const initial: Record = {}; + for (const f of demoFeatures) { + if (f.min != null && f.max != null) { + initial[f.name] = [f.min, f.max]; + } + } + setSliderValues(initial); + }, [demoFeatures]); + + // Feature coloring only during drag; density (property count) otherwise + const viewFeatureName = activeFeature; + const viewMeta = viewFeatureName ? features.find((f) => f.name === viewFeatureName) : null; + const colorRange: [number, number] | null = + viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null; + const filterRange: [number, number] | null = activeFeature && dragValue ? dragValue : null; + const displayData = dragHexData ?? hexData; + + // Fetch hexagons (debounced) — skipped while dragging + const fetchHexagons = useCallback(() => { + if (activeFeatureRef.current) return; + if (features.length === 0 || Object.keys(sliderValues).length === 0) return; + const params = new URLSearchParams({ + resolution: String(DEMO_RESOLUTION), + bounds: DEMO_BOUNDS, + }); + const filterParts: string[] = []; + for (const [name, [min, max]] of Object.entries(sliderValues)) { + const meta = features.find((f) => f.name === name); + if (meta?.min != null && meta?.max != null) { + if (min !== meta.min || max !== meta.max) { + filterParts.push(`${name}:${min}:${max}`); + } + } + } + if (filterParts.length > 0) { + params.set('filters', filterParts.join(',')); + } + abortRef.current?.abort(); + abortRef.current = new AbortController(); + fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal })) + .then((res) => res.json()) + .then((data: { features: HexagonData[] }) => setHexData(data.features)) + .catch(() => {}); + }, [features, sliderValues]); + + useEffect(() => { + clearTimeout(fetchTimeoutRef.current); + fetchTimeoutRef.current = setTimeout(fetchHexagons, 200); + return () => clearTimeout(fetchTimeoutRef.current); + }, [fetchHexagons]); + + useEffect(() => { + return () => { + abortRef.current?.abort(); + dragAbortRef.current?.abort(); + clearTimeout(fetchTimeoutRef.current); + }; + }, []); + + // Drag start: fetch preview data with other filters only, fields=dragged feature + const handleDragStart = useCallback( + (name: string) => { + setActiveFeature(name); + const currentVal = sliderValues[name]; + const meta = features.find((f) => f.name === name); + setDragValue(currentVal || (meta?.min != null ? [meta.min, meta.max!] : null)); + + const params = new URLSearchParams({ + resolution: String(DEMO_RESOLUTION), + bounds: DEMO_BOUNDS, + }); + const otherFilterParts: string[] = []; + for (const [n, [min, max]] of Object.entries(sliderValues)) { + if (n === name) continue; + const m = features.find((f) => f.name === n); + if (m?.min != null && m?.max != null && (min !== m.min || max !== m.max)) { + otherFilterParts.push(`${n}:${min}:${max}`); + } + } + if (otherFilterParts.length > 0) { + params.set('filters', otherFilterParts.join(',')); + } + params.set('fields', name); + + dragAbortRef.current?.abort(); + dragAbortRef.current = new AbortController(); + fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal })) + .then((res) => res.json()) + .then((data: { features: HexagonData[] }) => setDragHexData(data.features)) + .catch(() => {}); + }, + [features, sliderValues] + ); + + const handleSliderChange = useCallback( + (name: string, value: [number, number]) => { + setSliderValues((prev) => ({ ...prev, [name]: value })); + if (activeFeatureRef.current === name) { + setDragValue(value); + } + }, + [] + ); + + const handleDragEnd = useCallback(() => { + setActiveFeature(null); + setDragValue(null); + setDragHexData(null); + }, []); + + return ( +
+ {/* Map */} +
+
+
+ +
+ {/* Colour spectrum legend */} +
+
+
+ {activeFeature ? viewMeta?.name || activeFeature : 'Property density'} +
+
+ {colorRange && ( +
+ + +
+ )} +
+
+
+ + {/* Sliders */} +
+ {demoFeatures.map((feature) => { + const value = sliderValues[feature.name]; + if (!value || feature.min == null || feature.max == null) return null; + const isActive = activeFeature === feature.name; + return ( +
+
+ + {feature.name} + + + {formatValue(value[0], feature)} – {formatValue(value[1], feature)} + +
+ handleSliderChange(feature.name, [min, max])} + onPointerDown={() => handleDragStart(feature.name)} + onPointerUp={() => handleDragEnd()} + /> +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx new file mode 100644 index 0000000..bd47980 --- /dev/null +++ b/frontend/src/components/home/HomePage.tsx @@ -0,0 +1,326 @@ +import { useRef, useState, useEffect } from 'react'; +import { useFadeInRef } from '../../hooks/useFadeIn'; +import HexCanvas from './HexCanvas'; +import HomeDemo from './HomeDemo'; +import BottomIllustration from './BottomIllustration'; +import CategoryArt from './CategoryArt'; +import { TickerValue } from '../ui/TickerValue'; +import type { FeatureMeta } from '../../types'; + +export default function HomePage({ + onOpenDashboard, + onOpenPricing, + theme = 'light', + features = [], +}: { + onOpenDashboard: () => void; + onOpenPricing: () => void; + theme?: 'light' | 'dark'; + features?: FeatureMeta[]; +}) { + const scrollRef = useRef(null); + const [statsActive, setStatsActive] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setStatsActive(true), 300); + return () => clearTimeout(timer); + }, []); + + const heroRef = useFadeInRef(); + const demoRef = useFadeInRef(); + const scaleRef = useFadeInRef(); + const problemRef = useFadeInRef(); + const ctaRef = useFadeInRef(); + + return ( +
+
+ {/* Hero — full-bleed */} +
+ + {/* Radial teal glow */} +
+
+

+ Browsing listings is not a strategy. Knowing what you want is. +

+

+ Find your{' '} + perfect postcode +
+ before you find your property. +

+

+ Set the sliders to your expectations and the map highlights the areas that actually + match. Instantly. +

+
+ + +
+
+
+
+ +
+
properties
+
+
+
+ +
+
data layers
+
+
+
Every
+
postcode in England
+
+
+
+
+ + {/* Map + Slider demo */} +
+
+

+ See it in action +

+

+ Drag the sliders and watch the map respond. Every postcode scored, every filter instant. +

+
+ +
+
+
+ + {/* Scale — "That's just two" + category cards */} +
+
+

+ That's just three. We've built 43. +

+

+ Spanning transport links, amenities, demographics, environment risk, broadband speeds, + crime, and more. +

+
+ {CATEGORIES.map((c) => ( +
+
+
+
+ {c.icon} +
+ + {c.label} + +
+ +
+
+ ))} +
+
+
+ + {/* Problem / solution / philosophy */} +
+ {/* Cereal box — quirky margin note, hidden on narrow screens */} +
+
+ Discounted cereal box +
+

+ Your home is not a box of cereal. Don't let a discount on the wrong + property distract you from finding the right one. +

+
+ +
+

+ Here's the problem with property search: listings only show you what's on + the market{' '} + right now{' '} + — a thin slice of what an area is actually like. And even if you could look + beyond them, there are{' '} + + millions of postcodes + {' '} + across England. You can't research them all yourself. +

+

+ We built this for you — years of historical transactions and public records, + extended with proprietary algorithms so the map doesn't just show raw data, it{' '} + + surfaces the patterns that matter + + . +

+

+ Understand areas first. Then find the right property within them, with expectations + you've set — not ones the market set for you. +

+
+
+ + {/* Final CTA */} +
+
+

+ The biggest financial decision of your life +
+ deserves proper tools behind it. +

+

+ One payment, lifetime access. Set your filters and go. +

+
+ + +
+
+
+ + {/* Bottom illustration */} + +
+
+ ); +} + +interface Category { + icon: string; + label: string; + group: string; + borderClass: string; + hoverBgClass: string; + iconBgClass: string; + artColorClass: string; +} + +const CATEGORIES: Category[] = [ + { + icon: '\u{1F3E0}', + label: 'Property', + group: 'Property', + borderClass: 'border-l-teal-400 dark:border-l-teal-500', + hoverBgClass: 'hover:bg-teal-50/50 dark:hover:bg-teal-900/20', + iconBgClass: 'bg-teal-100 dark:bg-teal-900/40', + artColorClass: 'text-teal-400 dark:text-teal-600', + + }, + { + icon: '\u{1F686}', + label: 'Transport', + group: 'Transport', + borderClass: 'border-l-blue-400 dark:border-l-blue-500', + hoverBgClass: 'hover:bg-blue-50/50 dark:hover:bg-blue-900/20', + iconBgClass: 'bg-blue-100 dark:bg-blue-900/40', + artColorClass: 'text-blue-400 dark:text-blue-600', + + }, + { + icon: '\u{1F3EB}', + label: 'Schools', + group: 'Education', + borderClass: 'border-l-amber-400 dark:border-l-amber-500', + hoverBgClass: 'hover:bg-amber-50/50 dark:hover:bg-amber-900/20', + iconBgClass: 'bg-amber-100 dark:bg-amber-900/40', + artColorClass: 'text-amber-400 dark:text-amber-600', + + }, + { + icon: '\u{1F6A8}', + label: 'Crime', + group: 'Crime', + borderClass: 'border-l-rose-400 dark:border-l-rose-500', + hoverBgClass: 'hover:bg-rose-50/50 dark:hover:bg-rose-900/20', + iconBgClass: 'bg-rose-100 dark:bg-rose-900/40', + artColorClass: 'text-rose-400 dark:text-rose-600', + + }, + { + icon: '\u{1F465}', + label: 'Demographics', + group: 'Demographics', + borderClass: 'border-l-violet-400 dark:border-l-violet-500', + hoverBgClass: 'hover:bg-violet-50/50 dark:hover:bg-violet-900/20', + iconBgClass: 'bg-violet-100 dark:bg-violet-900/40', + artColorClass: 'text-violet-400 dark:text-violet-600', + + }, + { + icon: '\u{1F3EA}', + label: 'Amenities', + group: 'Amenities', + borderClass: 'border-l-emerald-400 dark:border-l-emerald-500', + hoverBgClass: 'hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20', + iconBgClass: 'bg-emerald-100 dark:bg-emerald-900/40', + artColorClass: 'text-emerald-400 dark:text-emerald-600', + + }, + { + icon: '\u{1F30D}', + label: 'Environment', + group: 'Environment', + + borderClass: 'border-l-orange-400 dark:border-l-orange-500', + hoverBgClass: 'hover:bg-orange-50/50 dark:hover:bg-orange-900/20', + iconBgClass: 'bg-orange-100 dark:bg-orange-900/40', + artColorClass: 'text-orange-400 dark:text-orange-600', + + }, + { + icon: '\u{1F4E1}', + label: 'Broadband', + group: 'Environment', + + borderClass: 'border-l-sky-400 dark:border-l-sky-500', + hoverBgClass: 'hover:bg-sky-50/50 dark:hover:bg-sky-900/20', + iconBgClass: 'bg-sky-100 dark:bg-sky-900/40', + artColorClass: 'text-sky-400 dark:text-sky-600', + + }, + { + icon: '\u{1F4CA}', + label: 'Deprivation', + group: 'Deprivation', + borderClass: 'border-l-fuchsia-400 dark:border-l-fuchsia-500', + hoverBgClass: 'hover:bg-fuchsia-50/50 dark:hover:bg-fuchsia-900/20', + iconBgClass: 'bg-fuchsia-100 dark:bg-fuchsia-900/40', + artColorClass: 'text-fuchsia-400 dark:text-fuchsia-600', + + }, +]; diff --git a/frontend/src/components/map/AISummaryCard.tsx b/frontend/src/components/map/AISummaryCard.tsx new file mode 100644 index 0000000..4248142 --- /dev/null +++ b/frontend/src/components/map/AISummaryCard.tsx @@ -0,0 +1,58 @@ +import { ChevronIcon } from '../ui/icons'; +import { LightbulbIcon } from '../ui/icons/LightbulbIcon'; + +interface AISummaryCardProps { + summary?: string; + loading?: boolean; + error?: string | null; + expanded: boolean; + onToggleExpanded: () => void; +} + +export default function AISummaryCard({ + summary, + loading, + error, + expanded, + onToggleExpanded, +}: AISummaryCardProps) { + if (!summary && !loading && !error) return null; + + return ( +
+
+ + {expanded && ( + <> + {error ? ( +
+ Failed to generate summary. +
+ ) : loading ? ( +
+
+
+
+ ) : ( +

{summary}

+ )} + + )} +
+
+ ); +} diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx new file mode 100644 index 0000000..acca3ad --- /dev/null +++ b/frontend/src/components/map/AreaPane.tsx @@ -0,0 +1,412 @@ +import { useMemo, useState } from 'react'; +import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups'; +import type { + FeatureFilters, + FeatureMeta, + HexagonStatsResponse, + PostcodeFeature, +} from '../../types'; +import type { HexagonLocation } from '../../lib/external-search'; +import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format'; +import { groupFeaturesByCategory } from '../../lib/features'; +import { STACKED_GROUPS, STACKED_ENUM_GROUPS } from '../../lib/consts'; +import { DualHistogram, LoadingSkeleton } from './DualHistogram'; +import EnumBarChart from './EnumBarChart'; +import StackedBarChart from './StackedBarChart'; +import StackedEnumChart from './StackedEnumChart'; +import PriceHistoryChart from './PriceHistoryChart'; +import ExternalSearchLinks from './ExternalSearchLinks'; +import { InfoIcon, CloseIcon } from '../ui/icons'; +import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; +import { IconButton } from '../ui/IconButton'; +import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; +import { EmptyState } from '../ui/EmptyState'; +import { FeatureLabel } from '../ui/FeatureLabel'; +import AISummaryCard from './AISummaryCard'; +import StreetViewEmbed from './StreetViewEmbed'; +import HistogramLegend from './HistogramLegend'; + +interface AreaPaneProps { + stats: HexagonStatsResponse | null; + globalFeatures: FeatureMeta[]; + loading: boolean; + hexagonId: string | null; + isPostcode?: boolean; + postcodeData?: PostcodeFeature | null; + onViewProperties: () => void; + onClose: () => void; + hexagonLocation: HexagonLocation | null; + filters: FeatureFilters; + onNavigateToSource?: (slug: string, featureName: string) => void; + aiSummary?: string; + aiSummaryLoading?: boolean; + aiSummaryError?: string | null; +} + +export default function AreaPane({ + stats, + globalFeatures, + loading, + hexagonId, + isPostcode = false, + postcodeData, + onViewProperties, + onClose, + hexagonLocation, + filters, + onNavigateToSource, + aiSummary, + aiSummaryLoading, + aiSummaryError, +}: AreaPaneProps) { + // For postcodes, use local data for count + const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count; + const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]); + const [infoFeature, setInfoFeature] = useState(null); + const [collapsedGroups, toggleGroup] = useCollapsibleGroups(); + const [aiSummaryExpanded, setAiSummaryExpanded] = useState(true); + + const numericByName = useMemo(() => { + if (!stats) return new Map(); + return new Map(stats.numeric_features.map((feature) => [feature.name, feature])); + }, [stats]); + + const enumByName = useMemo(() => { + if (!stats) return new Map(); + return new Map(stats.enum_features.map((feature) => [feature.name, feature])); + }, [stats]); + + const globalFeatureByName = useMemo( + () => new Map(globalFeatures.map((f) => [f.name, f])), + [globalFeatures] + ); + + if (!hexagonId) { + return ( + } + title="No area selected" + description="Click a hexagon or postcode to view area statistics" + centered + /> + ); + } + + return ( +
+
+
+
+
+

+ {isPostcode ? hexagonId : 'Area Statistics'} +

+ {isPostcode && ( + Postcode + )} +
+ {loading && stats && ( +
+ )} +
+ + + +
+ {propertyCount != null && ( +

+ {propertyCount.toLocaleString()} properties +

+ )} + {!isPostcode && stats && ( + + )} +
+ + {hexagonLocation && stats && ( + + )} + +
+ setAiSummaryExpanded(!aiSummaryExpanded)} + /> + {loading && !stats ? ( + + ) : stats ? ( +
+ + {featureGroups.map((group) => { + const hasData = group.features.some( + (feature) => numericByName.has(feature.name) || enumByName.has(feature.name) + ); + if (!hasData) return null; + + const stackedCharts = STACKED_GROUPS[group.name]; + const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name]; + + // Features that are part of a stacked enum config (rendered as compact charts) + const stackedEnumFeatureNames = new Set( + (stackedEnumCharts?.flatMap((c) => + [c.feature, ...c.components].filter(Boolean) + ) as string[]) ?? [] + ); + + const isExpanded = !collapsedGroups.has(group.name); + + return ( +
+ toggleGroup(group.name)} + className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + /> + {isExpanded && ( +
+ {/* Price History in Property group */} + {group.name === 'Property' && + stats.price_history && + (() => { + // Only show chart if there are at least 2 unique years + const uniqueYears = new Set( + stats.price_history.map((p) => Math.floor(p.year)) + ); + return uniqueYears.size > 1; + })() && ( +
+ + Price History + + +
+ )} + {stackedCharts + ? // Render stacked charts for this group + stackedCharts.map((chart) => { + const segments = chart.components + .map((name) => ({ + name, + value: numericByName.get(name)?.mean ?? 0, + })) + .filter((s) => s.value > 0); + + // Use aggregate feature stats if available, otherwise sum components + const aggregateStats = chart.feature + ? numericByName.get(chart.feature) + : undefined; + const total = aggregateStats + ? aggregateStats.mean + : segments.reduce((sum, s) => sum + s.value, 0); + + const featureMeta = chart.feature + ? globalFeatureByName.get(chart.feature) + : undefined; + + if (total === 0) return null; + + return ( +
+
+ {featureMeta ? ( + + ) : ( + + {chart.label} + + )} + + {formatValue(total)} + {chart.unit ? ` ${chart.unit}` : ''} + +
+ +
+ ); + }) + : // Default: render each feature individually (skip stacked enum features) + group.features + .filter((f) => !stackedEnumFeatureNames.has(f.name)) + .map((feature) => { + const numericStats = numericByName.get(feature.name); + const enumStats = enumByName.get(feature.name); + + if (numericStats) { + const globalFeature = globalFeatureByName.get(feature.name); + const globalHistogram = globalFeature?.histogram; + const globalMean = globalHistogram + ? calculateHistogramMean(globalHistogram) + : undefined; + + return ( +
+
+ + + {formatValue(numericStats.mean, feature)} + +
+ {numericStats.histogram && + (globalHistogram ? ( + + ) : ( + + ))} +
+ ); + } + + if (enumStats) { + return ( +
+ + +
+ ); + } + + return null; + })} + {/* Stacked enum charts */} + {stackedEnumCharts?.map((chart) => { + const featureMeta = chart.feature + ? globalFeatureByName.get(chart.feature) + : undefined; + + // Single component: render as a stacked bar (like crime charts) + if (chart.components.length === 1) { + const stats = enumByName.get(chart.components[0]); + if (!stats) return null; + + const segments = chart.valueOrder + .map((value) => ({ name: value, value: stats.counts[value] ?? 0 })) + .filter((s) => s.value > 0); + const total = segments.reduce((sum, s) => sum + s.value, 0); + if (total === 0) return null; + + return ( +
+
+ {featureMeta ? ( + + ) : ( + + {chart.label} + + )} + + {total.toLocaleString()} + +
+ [v, chart.valueColors[i]]) + )} + /> +
+ ); + } + + // Multi-component: render as compact multi-row chart (like risk features) + const components = chart.components + .map((name) => { + const stats = enumByName.get(name); + return stats ? { label: name, stats } : null; + }) + .filter((c): c is NonNullable => c !== null); + + if (components.length === 0) return null; + + return ( +
+
+ {featureMeta ? ( + + ) : ( + + {chart.label} + + )} +
+ +
+ ); + })} +
+ )} +
+ ); + })} + {hexagonLocation && } +
+ ) : null} +
+ + {infoFeature && ( + setInfoFeature(null)} + onNavigateToSource={onNavigateToSource} + /> + )} +
+ ); +} diff --git a/frontend/src/components/map/DualHistogram.tsx b/frontend/src/components/map/DualHistogram.tsx new file mode 100644 index 0000000..c1813fe --- /dev/null +++ b/frontend/src/components/map/DualHistogram.tsx @@ -0,0 +1,173 @@ +function downsampleBars(counts: number[], targetBars: number): number[] { + const step = Math.max(1, Math.floor(counts.length / targetBars)); + const bars: number[] = []; + for (let index = 0; index < counts.length; index += step) { + let sum = 0; + for (let offset = 0; offset < step && index + offset < counts.length; offset++) { + sum += counts[index + offset]; + } + bars.push(sum); + } + return bars; +} + +function pickTicks(min: number, max: number, count: number): number[] { + if (max <= min) return [min]; + const range = max - min; + const rawStep = range / (count - 1); + const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); + const nice = [1, 2, 2.5, 3, 4, 5, 10].find((n) => n * magnitude >= rawStep) ?? 10; + const step = nice * magnitude; + const start = Math.ceil(min / step) * step; + const ticks: number[] = []; + for (let v = start; v <= max + step * 0.01; v += step) { + ticks.push(v); + } + // Ensure at least min and max are represented + if (ticks.length === 0) return [min, max]; + return ticks; +} + +export function DualHistogram({ + localCounts, + globalCounts, + p1, + p99, + globalMean, + formatLabel, +}: { + localCounts: number[]; + globalCounts: number[]; + p1: number; + p99: number; + globalMean?: number; + formatLabel?: (value: number) => string; +}) { + const targetBars = 25; + const localBars = downsampleBars(localCounts, targetBars); + const globalBars = downsampleBars(globalCounts, targetBars); + + const barCount = Math.min(localBars.length, globalBars.length); + const localMax = Math.max(...localBars, 1); + const globalMax = Math.max(...globalBars, 1); + + const fmt = + formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1))); + + // Compute center value for each bar. + // Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier. + const middleBins = Math.max(barCount - 2, 0); + const middleWidth = middleBins > 0 && p99 > p1 ? (p99 - p1) / middleBins : 0; + const barCenters: number[] = Array.from({ length: barCount }, (_, i) => { + if (i === 0) return p1; // outlier bin, label as p1 + if (i === barCount - 1) return p99; // outlier bin, label as p99 + return p1 + (i - 1 + 0.5) * middleWidth; + }); + + // Pick nice tick values and assign each to the nearest bar + const ticks = p99 > p1 ? pickTicks(p1, p99, 6) : []; + const tickBars = new Map(); // bar index → label + for (const v of ticks) { + let bestBar = 1; + let bestDist = Infinity; + for (let i = 1; i < barCount - 1; i++) { + const dist = Math.abs(barCenters[i] - v); + if (dist < bestDist) { + bestDist = dist; + bestBar = i; + } + } + if (!tickBars.has(bestBar)) tickBars.set(bestBar, fmt(v)); + } + + // Mean line: position as fraction across the bar area + const meanFrac = globalMean != null && p99 > p1 ? (globalMean - p1) / (p99 - p1) : null; + // Account for outlier bins: middle region spans bars 1..n-2 + const meanPct = meanFrac != null ? ((1 + meanFrac * middleBins) / barCount) * 100 : null; + + return ( +
+
+ {Array.from({ length: barCount }).map((_, index) => { + const globalHeight = (globalBars[index] / globalMax) * 100; + const localHeight = (localBars[index] / localMax) * 100; + return ( +
+
+
0 ? 1 : 0.1, + }} + /> +
+ ); + })} + {meanPct != null && meanPct >= 0 && meanPct <= 100 && ( +
+ )} +
+ {tickBars.size > 0 && ( +
+ {Array.from({ length: barCount }).map((_, index) => ( +
+ {tickBars.has(index) && ( + + {tickBars.get(index)} + + )} +
+ ))} +
+ )} +
+ ); +} + +export function SkeletonHistogram() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 15 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+
+ ); +} + +export function LoadingSkeleton() { + return ( +
+ {[0, 1, 2].map((groupIdx) => ( +
+
+
+ {Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/map/EnumBarChart.tsx b/frontend/src/components/map/EnumBarChart.tsx new file mode 100644 index 0000000..8b305ae --- /dev/null +++ b/frontend/src/components/map/EnumBarChart.tsx @@ -0,0 +1,23 @@ +export default function EnumBarChart({ counts }: { counts: Record }) { + const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA); + const maxCount = Math.max(...entries.map(([, count]) => count), 1); + + return ( +
+ {entries.map(([label, count]) => ( +
+ + {label} + +
+
+
+ {count} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/map/ExternalSearchLinks.tsx b/frontend/src/components/map/ExternalSearchLinks.tsx new file mode 100644 index 0000000..c43f84c --- /dev/null +++ b/frontend/src/components/map/ExternalSearchLinks.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import type { FeatureFilters } from '../../types'; +import { + buildPropertySearchUrls, + H3_RADIUS_MILES, + type HexagonLocation, +} from '../../lib/external-search'; + +export default function ExternalSearchLinks({ + location, + filters, +}: { + location: HexagonLocation; + filters: FeatureFilters; +}) { + const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]); + const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1; + const label = `${radiusMiles}mi radius`; + + return ( +
+

+ Search {label} on +

+ +
+ ); +} diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx new file mode 100644 index 0000000..1367b69 --- /dev/null +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -0,0 +1,129 @@ +import { useState, useMemo, useEffect } from 'react'; +import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups'; +import { SearchInput } from '../ui/SearchInput'; +import { FilterIcon } from '../ui/icons'; +import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; +import { EmptyState } from '../ui/EmptyState'; +import type { FeatureMeta } from '../../types'; +import { groupFeaturesByCategory } from '../../lib/features'; +import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; +import { FeatureActions } from '../ui/FeatureIcons'; +import { FeatureLabel } from '../ui/FeatureLabel'; + +interface FeatureBrowserProps { + availableFeatures: FeatureMeta[]; + allFeatures: FeatureMeta[]; + pinnedFeature: string | null; + onAddFilter: (name: string) => void; + onTogglePin: (name: string) => void; + onNavigateToSource?: (slug: string, featureName: string) => void; + openInfoFeature?: string | null; + onClearOpenInfoFeature?: () => void; +} + +export default function FeatureBrowser({ + availableFeatures, + allFeatures, + pinnedFeature, + onAddFilter, + onTogglePin, + onNavigateToSource, + openInfoFeature, + onClearOpenInfoFeature, +}: FeatureBrowserProps) { + const [search, setSearch] = useState(''); + const [infoFeature, setInfoFeature] = useState(null); + const [expandedGroups, toggleGroup] = useCollapsibleGroups(); + + useEffect(() => { + if (openInfoFeature) { + const feat = allFeatures.find((f) => f.name === openInfoFeature); + if (feat) setInfoFeature(feat); + onClearOpenInfoFeature?.(); + } + }, [openInfoFeature, allFeatures, onClearOpenInfoFeature]); + + const filtered = useMemo(() => { + if (!search) return availableFeatures; + const lower = search.toLowerCase(); + return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower)); + }, [availableFeatures, search]); + + const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]); + + // When searching, expand all groups so results are visible + const isSearching = search.length > 0; + + return ( + <> +
+ +
+
+ {grouped.map((group) => { + const isExpanded = isSearching || expandedGroups.has(group.name); + return ( +
+ toggleGroup(group.name)} + className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + > + + {group.features.length} + + + {isExpanded && + group.features.map((f) => { + const isPinned = pinnedFeature === f.name; + return ( +
+
+ + {f.description && ( + + {f.description} + + )} +
+ +
+ ); + })} +
+ ); + })} + {grouped.length === 0 ? ( + } + title={search ? 'No matching features' : 'All features are active'} + description={ + search ? 'Try a different search term' : 'Remove a filter to see available features' + } + className="px-3 py-4" + /> + ) : ( +

+ Everyone cares about different things. Pick the filters that matter most to you. +

+ )} +
+ {infoFeature && ( + setInfoFeature(null)} + onNavigateToSource={onNavigateToSource} + /> + )} + + ); +} diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx new file mode 100644 index 0000000..a2c9e78 --- /dev/null +++ b/frontend/src/components/map/Filters.tsx @@ -0,0 +1,273 @@ +import { memo, useState } from 'react'; +import { Slider } from '../ui/Slider'; +import { FilterIcon, LightbulbIcon } from '../ui/icons'; +import { EmptyState } from '../ui/EmptyState'; +import type { FeatureMeta, FeatureFilters } from '../../types'; +import { formatFilterValue } from '../../lib/format'; +import InfoPopup from '../ui/InfoPopup'; +import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; +import { FeatureActions } from '../ui/FeatureIcons'; +import { FeatureLabel } from '../ui/FeatureLabel'; +import FeatureBrowser from './FeatureBrowser'; + +interface FiltersProps { + features: FeatureMeta[]; + filters: FeatureFilters; + activeFeature: string | null; + dragValue: [number, number] | null; + enabledFeatures: Set; + onAddFilter: (name: string) => void; + onRemoveFilter: (name: string) => void; + onFilterChange: (name: string, value: [number, number] | string[]) => void; + onDragStart: (name: string) => void; + onDragChange: (value: [number, number]) => void; + onDragEnd: () => void; + zoom: number; + itemCount: number; + usePostcodeView: boolean; + pinnedFeature: string | null; + onTogglePin: (name: string) => void; + onCancelPin: () => void; + onNavigateToSource?: (slug: string, featureName: string) => void; + openInfoFeature?: string | null; + onClearOpenInfoFeature?: () => void; +} + +export default memo(function Filters({ + features, + filters, + activeFeature, + dragValue, + enabledFeatures, + onAddFilter, + onRemoveFilter, + onFilterChange, + onDragStart, + onDragChange, + onDragEnd, + zoom, + itemCount, + usePostcodeView, + pinnedFeature, + onTogglePin, + onCancelPin: _onCancelPin, + onNavigateToSource, + openInfoFeature, + onClearOpenInfoFeature, +}: FiltersProps) { + const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name)); + const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name)); + + const [showPhilosophy, setShowPhilosophy] = useState(false); + const [activeInfoFeature, setActiveInfoFeature] = useState(null); + + return ( +
+
+ +
+
+
+
+ + Active Filters + + {enabledFeatureList.length > 0 && ( + + {enabledFeatureList.length} + + )} +
+ + {itemCount.toLocaleString()} {usePostcodeView ? 'postcodes' : 'hexagons'} · z + {zoom.toFixed(1)} + +
+ +
+ {enabledFeatureList.length === 0 && ( + } + title="No active filters" + description="Browse features below and click + to add a filter" + /> + )} + + {enabledFeatureList.map((feature) => { + if (feature.type === 'enum') { + const selectedValues = (filters[feature.name] as string[]) || []; + const allValues = feature.values || []; + return ( +
+
+ + +
+ +
+ {allValues.map((val) => ( + + ))} +
+
+ ); + } + + const isActive = activeFeature === feature.name; + const isPinned = pinnedFeature === feature.name; + const displayValue = + isActive && dragValue + ? dragValue + : (filters[feature.name] as [number, number]) || [feature.min!, feature.max!]; + const step = feature.step ?? (feature.max! - feature.min!) / 100; + + return ( +
+ +
+ + {formatFilterValue(displayValue[0])} - {formatFilterValue(displayValue[1])} + + +
+ onDragChange([min, max])} + onPointerDown={() => onDragStart(feature.name)} + onPointerUp={() => onDragEnd()} + /> +
+ ); + })} +
+
+ +
+
+ Add Filter +
+
+ +
+
+ + {showPhilosophy && ( + setShowPhilosophy(false)}> +
+
+

+ Be intentional, not reactive +

+

+ Your future home isn't a box of cereal you grab because it's on sale. + Don't let a seemingly good deal turn into lifelong regret. Instead of waiting + for listings to appear, define what you actually want and go find it. +

+
+ +
+

+ See the full picture +

+

+ Current listings show only a fraction of the market. There are too few to give you a + complete picture, yet too many to evaluate one by one. We aggregate millions of + historical sales so you can understand what's truly available in any area. +

+
+ +
+

+ Your priorities, your filters +

+

+ We all care about different things. Some want peace and quiet; others want to be + near the action. Use our filters to define exactly what matters to you and discover + postcodes that match. +

+
+ +
+

+ Find the right place, not just the right listing +

+

+ The best areas to live don't always have properties listed right now. We help + you identify where you should be looking, so when something does come up, + you're ready. +

+
+ +
+

+ Know what's possible +

+

+ We'd rather tell you upfront if your expectations are unrealistic than have you + spend months searching for something that doesn't exist. +

+
+
+
+ )} + + {activeInfoFeature && ( + setActiveInfoFeature(null)} + onNavigateToSource={onNavigateToSource} + /> + )} +
+ ); +}); diff --git a/frontend/src/components/map/HistogramLegend.tsx b/frontend/src/components/map/HistogramLegend.tsx new file mode 100644 index 0000000..612c6a1 --- /dev/null +++ b/frontend/src/components/map/HistogramLegend.tsx @@ -0,0 +1,29 @@ +export default function HistogramLegend() { + return ( +
+
+
+
+ + Teal bars show the + distribution in this selected area + +
+
+
+ + Gray bars show the + overall distribution across all areas + +
+
+
+ + Dashed line{' '} + indicates the global average + +
+
+
+ ); +} diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx new file mode 100644 index 0000000..bb06df3 --- /dev/null +++ b/frontend/src/components/map/HoverCard.tsx @@ -0,0 +1,99 @@ +import { memo } from 'react'; +import type { FeatureFilters } from '../../types'; +import { formatValue } from '../../lib/format'; + +interface HoverCardData { + count: number; + [key: string]: string | number | [number, number] | null; +} + +interface HoverCardProps { + x: number; + y: number; + id: string; + isPostcode: boolean; + data: HoverCardData | null; + filters: FeatureFilters; +} + +export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) { + const activeFilterNames = Object.keys(filters); + + // Get key stats to show from local data (min_ values) + const getDisplayStats = () => { + if (!data) return []; + + const results: { name: string; value: string }[] = []; + + // Show stats for active filters (up to 4) + for (const name of activeFilterNames.slice(0, 4)) { + const val = data[`avg_${name}`] ?? data[`min_${name}`]; + if (val != null && typeof val === 'number') { + results.push({ name, value: formatValue(val) }); + } + } + + return results; + }; + + const displayStats = getDisplayStats(); + const count = data?.count; + + return ( +
+ {/* Arrow */} +
+ +
+ {/* Header */} +
+ + {isPostcode ? id : 'Area'} + +
+ + {/* Property count */} + {count != null && ( +
+ {count.toLocaleString()} {count === 1 ? 'property' : 'properties'} +
+ )} + + {/* Quick stats */} + {displayStats.length > 0 && ( +
+ {displayStats.map((stat) => ( +
+ {stat.name} + + {stat.value} + +
+ ))} +
+ )} + + {/* Hint */} + {data && ( +
+ Click for details +
+ )} +
+
+ ); +}); diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx new file mode 100644 index 0000000..e785ee8 --- /dev/null +++ b/frontend/src/components/map/Map.tsx @@ -0,0 +1,286 @@ +import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; +import { Map as MapGL, useControl } from 'react-map-gl/maplibre'; +import type { MapRef } from 'react-map-gl/maplibre'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { + HexagonData, + PostcodeFeature, + ViewState, + ViewChangeParams, + POI, + FeatureMeta, + Bounds, +} from '../../types'; + +import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils'; +import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS } from '../../lib/consts'; +import PostcodeSearch, { type SearchedPostcode } from './PostcodeSearch'; +import MapLegend from './MapLegend'; +import HoverCard from './HoverCard'; +import type { FeatureFilters } from '../../types'; +import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers'; + +interface MapProps { + data: HexagonData[]; + postcodeData: PostcodeFeature[]; + usePostcodeView: boolean; + pois: POI[]; + onViewChange: (params: ViewChangeParams) => void; + viewFeature: string | null; + colorRange: [number, number] | null; + filterRange: [number, number] | null; + viewSource: 'drag' | 'eye' | null; + onCancelPin: () => void; + features: FeatureMeta[]; + selectedHexagonId: string | null; + hoveredHexagonId: string | null; + onHexagonClick: (id: string, isPostcode?: boolean) => void; + onHexagonHover: (h3: string | null, x?: number, y?: number) => void; + initialViewState?: ViewState; + theme?: 'light' | 'dark'; + screenshotMode?: boolean; + ogMode?: boolean; + filters?: FeatureFilters; + searchedPostcode?: SearchedPostcode | null; + onPostcodeSearched?: (postcode: SearchedPostcode | null) => void; + bounds?: Bounds | null; + hideLegend?: boolean; +} + +interface Dimensions { + width: number; + height: number; +} + +function DeckOverlay({ + layers, + getTooltip, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + layers: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getTooltip: any; +}) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })); + const prevLayersRef = useRef(layers); + const prevTooltipRef = useRef(getTooltip); + if (layers !== prevLayersRef.current || getTooltip !== prevTooltipRef.current) { + prevLayersRef.current = layers; + prevTooltipRef.current = getTooltip; + overlay.setProps({ layers, getTooltip }); + } + return null; +} + +export default memo(function Map({ + data, + postcodeData, + usePostcodeView, + pois, + onViewChange, + viewFeature, + colorRange, + filterRange, + viewSource, + onCancelPin, + features, + selectedHexagonId, + hoveredHexagonId, + onHexagonClick, + onHexagonHover, + initialViewState, + theme = 'light', + screenshotMode = false, + ogMode = false, + filters = {}, + searchedPostcode, + onPostcodeSearched, + bounds: viewportBounds, + hideLegend = false, +}: MapProps) { + const containerRef = useRef(null); + const [viewState, setViewState] = useState(initialViewState || INITIAL_VIEW_STATE); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + if (width > 0 && height > 0) { + setDimensions({ width, height }); + } + }); + + observer.observe(container); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (dimensions.width === 0 || dimensions.height === 0) return; + + // Send exact viewport bounds - server will filter to only return + // hexagons/postcodes that intersect this precise AABB + const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height); + const resolution = zoomToResolution(viewState.zoom); + + onViewChange({ + resolution, + bounds, + zoom: viewState.zoom, + latitude: viewState.latitude, + longitude: viewState.longitude, + }); + }, [viewState, dimensions, onViewChange]); + + const handleMove = useCallback((evt: { viewState: ViewState }) => { + setViewState(evt.viewState); + }, []); + + const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => { + setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom })); + }, []); + + const handleMapLoad = useCallback( + (_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => { + // Road opacity is set in getMapStyle + }, + [] + ); + + const mapStyle = useMemo(() => getMapStyle(theme), [theme]); + + const { + layers, + popupInfo, + hoverPosition, + countRange, + postcodeCountRange, + colorFeatureMeta, + handleMouseLeave, + } = useDeckLayers({ + data, + postcodeData, + usePostcodeView, + pois, + viewFeature, + colorRange, + filterRange, + features, + selectedHexagonId, + hoveredHexagonId, + onHexagonClick, + onHexagonHover, + theme, + searchedPostcode, + bounds: viewportBounds, + }); + + return ( +
+ + + + {screenshotMode ? ( + ogMode ? ( +
+

+ Your perfect postcodes +

+
+ ) : null + ) : ( + <> + + {!hideLegend && + (viewFeature && colorRange && colorFeatureMeta ? ( + + ) : ( + + ))} + {popupInfo && ( +
+ {popupInfo.name} +
{popupInfo.category}
+ {osmIdToUrl(popupInfo.id) && ( + + View on OSM + + )} +
+ )} + {hoverPosition && hoveredHexagonId && hoveredHexagonId !== selectedHexagonId && ( + f.properties.postcode === hoveredHexagonId) + ?.properties || null + : data.find((d) => d.h3 === hoveredHexagonId) || null + } + filters={filters} + /> + )} + + )} +
+ ); +}); diff --git a/frontend/src/components/map/MapLegend.tsx b/frontend/src/components/map/MapLegend.tsx new file mode 100644 index 0000000..0531b35 --- /dev/null +++ b/frontend/src/components/map/MapLegend.tsx @@ -0,0 +1,71 @@ +import { formatValue } from '../../lib/format'; +import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts'; +import { gradientToCss } from '../../lib/utils'; +import { CloseIcon } from '../ui/icons/CloseIcon'; +import { TickerValue } from '../ui/TickerValue'; + +export default function MapLegend({ + featureLabel, + range, + showCancel, + onCancel, + mode, + enumValues, + theme = 'light', + inline = false, +}: { + featureLabel: string; + range: [number, number]; + showCancel: boolean; + onCancel: () => void; + mode: 'feature' | 'density'; + enumValues?: string[]; + theme?: 'light' | 'dark'; + inline?: boolean; +}) { + const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; + const gradientStyle = + mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT); + + return ( +
+
+ {featureLabel} + {showCancel && ( + + )} +
+
+
+ {mode === 'density' ? ( + <> + + + + ) : enumValues && enumValues.length > 0 ? ( + <> + {enumValues[0]} + {enumValues[enumValues.length - 1]} + + ) : ( + <> + + + + )} +
+
+ ); +} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx new file mode 100644 index 0000000..bfb019b --- /dev/null +++ b/frontend/src/components/map/MapPage.tsx @@ -0,0 +1,569 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState } from '../../types'; +import type { SearchedPostcode } from './PostcodeSearch'; +import type { Page } from '../ui/Header'; +import Map from './Map'; +import Filters from './Filters'; +import POIPane from './POIPane'; +import { PropertiesPane } from './PropertiesPane'; +import AreaPane from './AreaPane'; +import MobileDrawer from './MobileDrawer'; +import DataSources from '../data-sources/DataSources'; +import MapLegend from './MapLegend'; +import { TabButton } from '../ui/TabButton'; +import { useMapData } from '../../hooks/useMapData'; +import { usePOIData } from '../../hooks/usePOIData'; +import { useFilters } from '../../hooks/useFilters'; +import { useHexagonSelection } from '../../hooks/useHexagonSelection'; +import { usePaneResize } from '../../hooks/usePaneResize'; +import { useAreaSummary } from '../../hooks/useAreaSummary'; +import { useUrlSync } from '../../hooks/useUrlSync'; +import { apiUrl, buildFilterString } from '../../lib/api'; +import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; + +export interface ExportState { + onExport: () => void; + exporting: boolean; +} + +type MobileBottomTab = 'filters' | 'pois' | 'area'; + +interface MapPageProps { + features: FeatureMeta[]; + poiCategoryGroups: POICategoryGroup[]; + initialFilters: FeatureFilters; + initialViewState: ViewState; + initialPOICategories: Set; + initialTab: 'pois' | 'properties' | 'area'; + initialLoading: boolean; + theme: 'light' | 'dark'; + pendingInfoFeature: string | null; + onClearPendingInfoFeature: () => void; + onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void; + onExportStateChange?: (state: ExportState) => void; + screenshotMode?: boolean; + ogMode?: boolean; + isMobile?: boolean; +} + +export default function MapPage({ + features, + poiCategoryGroups, + initialFilters, + initialViewState, + initialPOICategories, + initialTab, + initialLoading, + theme, + pendingInfoFeature, + onClearPendingInfoFeature, + onNavigateTo, + onExportStateChange, + screenshotMode, + ogMode, + isMobile = false, +}: MapPageProps) { + const [searchedPostcode, setSearchedPostcode] = useState(null); + const [selectedPOICategories, setSelectedPOICategories] = + useState>(initialPOICategories); + + const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left'); + const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right'); + + // Mobile state + const [mobileBottomTab, setMobileBottomTab] = useState('filters'); + const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); + + // Initialize filters first + const { + filters, + activeFeature, + dragValue, + dragData, + pinnedFeature, + enabledFeatures, + viewFeature, + viewSource, + filterRange, + handleAddFilter, + handleFilterChange, + handleRemoveFilter, + handleDragStart, + handleDragChange, + handleDragEnd, + handleTogglePin, + handleCancelPin, + updateBoundsInfo, + } = useFilters({ + initialFilters, + features, + }); + + // Map data hook + const mapData = useMapData({ + filters, + features, + viewFeature, + activeFeature, + dragValue, + dragData, + }); + + // Keep filter bounds in sync with map data + useEffect(() => { + updateBoundsInfo(mapData.bounds, mapData.resolution); + }, [mapData.bounds, mapData.resolution, updateBoundsInfo]); + + // Hexagon selection hook + const selection = useHexagonSelection({ + filters, + features, + resolution: mapData.resolution, + }); + + // POI data + const pois = usePOIData(mapData.bounds, selectedPOICategories); + + // Sync current state to URL + useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab); + + // Set initial view and tab from URL state + useEffect(() => { + mapData.setInitialView(initialViewState); + selection.setRightPaneTab(initialTab); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // On mobile, open drawer and switch tab when hexagon is clicked + const { handleHexagonClick } = selection; + const handleMobileHexagonClick = useCallback( + (id: string, isPostcode?: boolean) => { + handleHexagonClick(id, isPostcode); + if (id) { + setMobileDrawerOpen(true); + setMobileBottomTab('area'); + } + }, + [handleHexagonClick] + ); + + // Compute hexagon location for external links + const hexagonLocation = useMemo(() => { + const hexId = selection.selectedHexagon?.id; + const isPostcode = selection.selectedHexagon?.type === 'postcode'; + + if (isPostcode) { + // For postcodes, get centroid from postcodeData + const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId); + if (!postcodeFeature?.properties.centroid) return null; + const [lon, lat] = postcodeFeature.properties.centroid; + return { lat, lon, resolution: mapData.resolution }; + } else { + // For hexagons, get lat/lon from hexagon data + const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null; + if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null; + return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution }; + } + }, [ + selection.selectedHexagon?.id, + selection.selectedHexagon?.type, + mapData.data, + mapData.postcodeData, + mapData.resolution, + ]); + + // AI area summary + const aiSummary = useAreaSummary({ + stats: selection.areaStats, + hexagonId: selection.selectedHexagon?.id || null, + isPostcode: selection.selectedHexagon?.type === 'postcode', + filters, + features, + }); + + // Export to Excel + const [exporting, setExporting] = useState(false); + const handleExport = useCallback(() => { + if (!mapData.bounds || exporting) return; + const { south, west, north, east } = mapData.bounds; + const params = new URLSearchParams({ + bounds: `${south},${west},${north},${east}`, + }); + const filterStr = buildFilterString(filters, features); + if (filterStr) params.set('filters', filterStr); + const url = apiUrl('export', params); + + setExporting(true); + fetch(url) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.blob(); + }) + .then((blob) => { + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = 'perfect-postcodes-export.xlsx'; + link.click(); + URL.revokeObjectURL(link.href); + }) + .catch((err) => console.error('Export failed:', err)) + .finally(() => setExporting(false)); + }, [mapData.bounds, filters, features, exporting]); + + // Report export state to parent (Header) + useEffect(() => { + onExportStateChange?.({ onExport: handleExport, exporting }); + }, [handleExport, exporting, onExportStateChange]); + + // Mobile legend data (computed from API-fetched data, which is already viewport-scoped) + const mobileLegendMeta = useMemo( + () => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null), + [viewFeature, features] + ); + const mobileDensityRange = useMemo((): [number, number] => { + const items = mapData.usePostcodeView ? mapData.postcodeData : mapData.data; + if (items.length === 0) return [0, 1]; + let min = Infinity; + let max = -Infinity; + for (const d of items) { + const c = + 'count' in d + ? (d as { count: number }).count + : (d as { properties: { count: number } }).properties.count; + if (c < min) min = c; + if (c > max) max = c; + } + if (min === Infinity) return [0, 1]; + if (min === max) return [min, min + 1]; + return [min, max]; + }, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]); + + // Signal screenshot readiness once map data has loaded + useEffect(() => { + if (screenshotMode && !mapData.loading && mapData.data.length > 0) { + window.__screenshot_ready = true; + } + }, [screenshotMode, mapData.loading, mapData.data.length]); + + if (screenshotMode) { + return ( +
+ {}} + features={features} + selectedHexagonId={null} + hoveredHexagonId={null} + onHexagonClick={() => {}} + onHexagonHover={() => {}} + initialViewState={initialViewState} + theme={theme} + screenshotMode + ogMode={ogMode} + bounds={mapData.bounds} + /> +
+ ); + } + + // Shared pane content renderers + const renderAreaPane = () => ( + f.properties.postcode === selection.selectedHexagon?.id + ) || null + : null + } + onViewProperties={selection.handleViewPropertiesFromArea} + onClose={selection.handleCloseSelection} + hexagonLocation={hexagonLocation} + filters={filters} + onNavigateToSource={(slug, featureName) => onNavigateTo('data-sources', slug, featureName)} + aiSummary={aiSummary.summary} + aiSummaryLoading={aiSummary.loading} + aiSummaryError={aiSummary.error} + /> + ); + + const renderPropertiesPane = () => ( + onNavigateTo('data-sources', slug)} + /> + ); + + const renderPOIPane = () => ( + onNavigateTo('data-sources', slug)} + /> + ); + + const renderFilters = () => ( + onNavigateTo('data-sources', slug, featureName)} + openInfoFeature={pendingInfoFeature} + onClearOpenInfoFeature={onClearPendingInfoFeature} + /> + ); + + // Mobile layout + if (isMobile) { + return ( +
+ {initialLoading && ( +
+
+ +

+ Connecting to server... +

+
+
+ )} + + {/* Map — 45% */} +
+ + {mapData.loading && ( +
+ Loading... +
+ )} + onNavigateTo('data-sources')} /> +
+ + {/* Bottom panel — 55% */} +
+ {/* Legend */} + {viewFeature && mapData.colorRange && mobileLegendMeta ? ( + + ) : ( + + )} + {/* Tab bar */} +
+ setMobileBottomTab('filters')} + /> + setMobileBottomTab('pois')} + /> +
+ + {/* Tab content */} +
+ {mobileBottomTab === 'pois' ? ( +
{renderPOIPane()}
+ ) : ( + renderFilters() + )} +
+
+ + {/* Mobile drawer for full-screen hexagon details */} + {mobileDrawerOpen && selection.selectedHexagon && ( + setMobileDrawerOpen(false)} + renderArea={renderAreaPane} + renderProperties={renderPropertiesPane} + renderPOIs={renderPOIPane} + /> + )} +
+ ); + } + + // Desktop layout (unchanged) + return ( +
+ {initialLoading && ( +
+
+ +

+ Connecting to server... +

+
+
+ )} + + {/* Left Pane */} +
+
{renderFilters()}
+
+
+
+
+ + {/* Map */} +
+ + {mapData.loading && ( +
+ Loading... +
+ )} + onNavigateTo('data-sources')} /> +
+ + {/* Right Pane */} +
+
+
+
+
+
+ selection.setRightPaneTab('area')} + /> + + selection.setRightPaneTab('pois')} + /> +
+ +
+ {selection.rightPaneTab === 'area' + ? renderAreaPane() + : selection.rightPaneTab === 'properties' + ? renderPropertiesPane() + : renderPOIPane()} +
+
+
+
+ ); +} diff --git a/frontend/src/components/map/MobileDrawer.tsx b/frontend/src/components/map/MobileDrawer.tsx new file mode 100644 index 0000000..20ec451 --- /dev/null +++ b/frontend/src/components/map/MobileDrawer.tsx @@ -0,0 +1,63 @@ +import { useState, useEffect } from 'react'; +import { CloseIcon } from '../ui/icons/CloseIcon'; +import { TabButton } from '../ui/TabButton'; + +type DrawerTab = 'area' | 'properties' | 'pois'; + +interface MobileDrawerProps { + onClose: () => void; + renderArea: () => React.ReactNode; + renderProperties: () => React.ReactNode; + renderPOIs: () => React.ReactNode; +} + +export default function MobileDrawer({ + onClose, + renderArea, + renderProperties, + renderPOIs, +}: MobileDrawerProps) { + const [tab, setTab] = useState('area'); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onClose]); + + return ( +
+ {/* Backdrop — top 10% */} +
+ + {/* Panel — bottom 90% */} +
+ {/* Tab bar + close */} +
+ setTab('area')} /> + setTab('properties')} + /> + setTab('pois')} /> + +
+ + {/* Content */} +
+ {tab === 'area' ? renderArea() : tab === 'properties' ? renderProperties() : renderPOIs()} +
+
+
+ ); +} diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx new file mode 100644 index 0000000..f35ee93 --- /dev/null +++ b/frontend/src/components/map/POIPane.tsx @@ -0,0 +1,204 @@ +import { useState, useCallback } from 'react'; +import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups'; +import type { POICategoryGroup } from '../../types'; +import InfoPopup from '../ui/InfoPopup'; +import { SearchInput } from '../ui/SearchInput'; +import { InfoIcon, ChevronIcon } from '../ui/icons'; +import { IconButton } from '../ui/IconButton'; + +interface POIPaneProps { + groups: POICategoryGroup[]; + selectedCategories: Set; + onCategoriesChange: (categories: Set) => void; + poiCount: number; + onNavigateToSource?: (slug: string) => void; +} + +export default function POIPane({ + groups, + selectedCategories, + onCategoriesChange, + poiCount, + onNavigateToSource, +}: POIPaneProps) { + const [searchTerm, setSearchTerm] = useState(''); + const [collapsedGroups, toggleCollapse] = useCollapsibleGroups(); + const [showInfo, setShowInfo] = useState(false); + + const allCategories = groups.flatMap((g) => g.categories); + + const toggleCategory = (category: string) => { + const newSet = new Set(selectedCategories); + if (newSet.has(category)) { + newSet.delete(category); + } else { + newSet.add(category); + } + onCategoriesChange(newSet); + }; + + const selectAll = () => { + onCategoriesChange(new Set(allCategories)); + }; + + const selectNone = () => { + onCategoriesChange(new Set()); + }; + + const toggleGroup = useCallback( + (groupName: string) => { + const group = groups.find((g) => g.name === groupName); + if (!group) return; + const allSelected = group.categories.every((c) => selectedCategories.has(c)); + const newSet = new Set(selectedCategories); + if (allSelected) { + group.categories.forEach((c) => newSet.delete(c)); + } else { + group.categories.forEach((c) => newSet.add(c)); + } + onCategoriesChange(newSet); + }, + [groups, selectedCategories, onCategoriesChange] + ); + + const lowerSearch = searchTerm.toLowerCase(); + + const filteredGroups = groups + .map((group) => { + if (!searchTerm) return group; + const matchingCats = group.categories.filter((c) => c.toLowerCase().includes(lowerSearch)); + const groupMatches = group.name.toLowerCase().includes(lowerSearch); + if (groupMatches) return group; + if (matchingCats.length === 0) return null; + return { ...group, categories: matchingCats }; + }) + .filter(Boolean) as POICategoryGroup[]; + + const selectedCount = selectedCategories.size; + + return ( +
+
+
+

Points of Interest

+ setShowInfo(true)} title="Data source info"> + + +
+ + {showInfo && ( + setShowInfo(false)} + sourceLink={ + onNavigateToSource + ? { + label: 'View data source', + onClick: () => { + onNavigateToSource('osm-pois'); + setShowInfo(false); + }, + } + : undefined + } + > +

+ Points of interest are sourced from OpenStreetMap via Geofabrik extracts. Categories + include public transport stops, shops, restaurants, healthcare facilities, leisure + venues, and more. Data is filtered and mapped to friendly names with exhaustive + category coverage. +

+
+ )} + + + +
+
+ + +
+ + {selectedCount}/{allCategories.length} selected + +
+ + {selectedCount > 0 && ( +
+ + {poiCount.toLocaleString()} POI{poiCount !== 1 ? 's' : ''} visible + +
+ )} +
+ +
+ {filteredGroups.map((group) => { + const groupSelected = group.categories.filter((c) => selectedCategories.has(c)).length; + const allInGroupSelected = groupSelected === group.categories.length; + const someInGroupSelected = groupSelected > 0 && !allInGroupSelected; + const isCollapsed = collapsedGroups.has(group.name) && !searchTerm; + + return ( +
+
+ + + + {groupSelected}/{group.categories.length} + +
+ {!isCollapsed && + group.categories.map((category) => ( + + ))} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/map/PostcodeSearch.tsx b/frontend/src/components/map/PostcodeSearch.tsx new file mode 100644 index 0000000..93c46bd --- /dev/null +++ b/frontend/src/components/map/PostcodeSearch.tsx @@ -0,0 +1,81 @@ +import { useState, useCallback } from 'react'; +import type { PostcodeGeometry } from '../../types'; +import { authHeaders } from '../../lib/api'; + +export interface SearchedPostcode { + postcode: string; + geometry: PostcodeGeometry; +} + +export default function PostcodeSearch({ + onFlyTo, + onPostcodeSearched, +}: { + onFlyTo: (lat: number, lng: number, zoom: number) => void; + onPostcodeSearched?: (postcode: SearchedPostcode | null) => void; +}) { + const [query, setQuery] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = query.trim(); + if (!trimmed) return; + + setError(null); + setLoading(true); + try { + const res = await fetch(`/api/postcode/${encodeURIComponent(trimmed)}`, authHeaders()); + if (!res.ok) { + setError('Postcode not found'); + return; + } + const json: { + postcode: string; + latitude: number; + longitude: number; + geometry: PostcodeGeometry; + } = await res.json(); + onFlyTo(json.latitude, json.longitude, 16); + onPostcodeSearched?.({ postcode: json.postcode, geometry: json.geometry }); + setQuery(''); + } catch { + setError('Lookup failed'); + } finally { + setLoading(false); + } + }, + [query, onFlyTo, onPostcodeSearched] + ); + + return ( +
+
+ { + setQuery(e.target.value); + setError(null); + }} + placeholder="Search postcode..." + className="px-3 py-2 text-sm w-40 border-none outline-none bg-white dark:bg-navy-800 dark:text-white dark:placeholder-warm-400" + /> + +
+ {error && ( + + {error} + + )} +
+ ); +} diff --git a/frontend/src/components/map/PriceHistoryChart.tsx b/frontend/src/components/map/PriceHistoryChart.tsx new file mode 100644 index 0000000..2452c49 --- /dev/null +++ b/frontend/src/components/map/PriceHistoryChart.tsx @@ -0,0 +1,183 @@ +import { useMemo, useRef, useState, useEffect } from 'react'; +import type { PricePoint } from '../../types'; +import { formatValue } from '../../lib/format'; + +interface PriceHistoryChartProps { + points: PricePoint[]; +} + +const PADDING = { top: 8, right: 8, bottom: 20, left: 42 }; +const HEIGHT = 120; +const priceFmt = { prefix: '£' }; + +export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver((entries) => { + const w = entries[0].contentRect.width; + if (w > 0) setWidth(w); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const { yearMin, yearMax, priceMin, priceMax, medians, priceTicks } = useMemo(() => { + let yMin = Infinity, + yMax = -Infinity; + for (const p of points) { + if (p.year < yMin) yMin = p.year; + if (p.year > yMax) yMax = p.year; + } + + // Use p5/p95 to clip outliers + const sorted = points.map((p) => p.price).sort((a, b) => a - b); + const p5 = sorted[Math.floor(sorted.length * 0.05)]; + const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95))]; + const pRange = p95 - p5 || 1; + const pMin = Math.max(0, p5 - pRange * 0.1); + const pMax = p95 + pRange * 0.1; + + // Yearly medians (robust to outliers) + const byYear = new Map(); + for (const p of points) { + const yr = Math.floor(p.year); + const arr = byYear.get(yr); + if (arr) arr.push(p.price); + else byYear.set(yr, [p.price]); + } + const meds = Array.from(byYear.entries()) + .map(([yr, prices]) => { + prices.sort((a, b) => a - b); + const mid = Math.floor(prices.length / 2); + const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2; + return { year: yr + 0.5, price: median }; + }) + .sort((a, b) => a.year - b.year); + + const ticks = niceTicksForRange(pMin, pMax, 4); + + return { + yearMin: yMin, + yearMax: yMax, + priceMin: pMin, + priceMax: pMax, + medians: meds, + priceTicks: ticks, + }; + }, [points]); + + const plotW = width - PADDING.left - PADDING.right; + const plotH = HEIGHT - PADDING.top - PADDING.bottom; + const yearRange = yearMax - yearMin || 1; + + const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW; + const scaleY = (price: number) => { + const t = (price - priceMin) / (priceMax - priceMin || 1); + return PADDING.top + (1 - Math.max(0, Math.min(1, t))) * plotH; + }; + + // Year labels: every 5 years + const yearStart = Math.ceil(yearMin / 5) * 5; + const yearLabels: number[] = []; + for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y); + + const medianPolyline = medians.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' '); + + return ( +
+ {width > 0 && ( + + {/* Grid lines */} + {priceTicks.map((tick) => ( + + ))} + + {/* Dots (clamp outliers to visible range) */} + {points.map((p, i) => ( + + ))} + + {/* Median line */} + {medians.length > 1 && ( + + )} + + {/* Y-axis labels */} + {priceTicks.map((tick) => ( + + {formatValue(tick, priceFmt)} + + ))} + + {/* X-axis year labels */} + {yearLabels.map((yr) => ( + + {yr} + + ))} + + )} +
+ ); +} + +/** Generate ~count nice round tick values spanning [min, max]. */ +function niceTicksForRange(min: number, max: number, count: number): number[] { + const range = max - min; + if (range <= 0) return [min]; + const rough = range / count; + const magnitude = Math.pow(10, Math.floor(Math.log10(rough))); + let step: number; + const normalized = rough / magnitude; + if (normalized <= 1.5) step = magnitude; + else if (normalized <= 3.5) step = 2 * magnitude; + else if (normalized <= 7.5) step = 5 * magnitude; + else step = 10 * magnitude; + + const ticks: number[] = []; + const start = Math.ceil(min / step) * step; + for (let t = start; t <= max; t += step) { + ticks.push(t); + } + return ticks; +} diff --git a/frontend/src/components/map/PropertiesPane.tsx b/frontend/src/components/map/PropertiesPane.tsx new file mode 100644 index 0000000..23d898d --- /dev/null +++ b/frontend/src/components/map/PropertiesPane.tsx @@ -0,0 +1,246 @@ +import React, { useMemo, useState } from 'react'; +import { Property } from '../../types'; +import { formatDuration, formatAge, formatNumber } from '../../lib/format'; +import { getNum } from '../../lib/property-fields'; +import InfoPopup from '../ui/InfoPopup'; +import { SearchInput } from '../ui/SearchInput'; +import { EmptyState } from '../ui/EmptyState'; +import { InfoIcon } from '../ui/icons'; + +interface PropertiesPaneProps { + properties: Property[]; + total: number; + loading: boolean; + hexagonId: string | null; + onLoadMore: () => void; + onClose: () => void; + onNavigateToSource?: (slug: string) => void; +} + +export function PropertiesPane({ + properties, + total, + loading, + hexagonId, + onLoadMore, + onClose: _onClose, + onNavigateToSource, +}: PropertiesPaneProps) { + const [search, setSearch] = useState(''); + const [showInfo, setShowInfo] = useState(false); + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + return query + ? properties.filter((p) => { + const addr = (p.address || '').toLowerCase(); + const pc = (p.postcode || '').toLowerCase(); + return addr.includes(query) || pc.includes(query); + }) + : properties; + }, [properties, search]); + + if (!hexagonId) { + return ( + } + title="No area selected" + description="Click a hexagon or postcode to view area statistics" + centered + /> + ); + } + + return ( +
+ {showInfo && ( + setShowInfo(false)} + sourceLink={ + onNavigateToSource + ? { + label: 'View data source', + onClick: () => { + onNavigateToSource('epc'); + setShowInfo(false); + }, + } + : undefined + } + > +

+ Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price + Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy + ratings, construction age, and tenure from EPC surveys, plus the most recent sale price + from the Land Registry. +

+
+ )} + +
+ +
+ +
+ {loading && properties.length === 0 ? ( + + ) : ( + <> + {filtered.map((property, idx) => ( + + ))} + {properties.length < total && ( + + )} + + )} +
+
+ ); +} + +function PropertyLoadingSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, idx) => ( +
+ {/* Address */} +
+ {/* Postcode */} +
+ {/* Price */} +
+ {/* Property details grid */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+ ))} +
+ ); +} + +function PropertyCard({ property }: { property: Property }) { + const price = getNum(property, 'Last known price', 'latest_price'); + const estimatedPrice = getNum(property, 'Estimated current price'); + const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm'); + const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area'); + const rooms = getNum( + property, + 'Rooms (including bedrooms & bathrooms)', + 'number_habitable_rooms' + ); + const age = getNum(property, 'Approximate construction age', 'construction_age_band'); + const councilTax = getNum(property, 'Council tax (£/yr)'); + const councilTaxD = getNum(property, 'Council tax Band D (£/yr)'); + + return ( +
+
+ {property.address || 'Unknown Address'} +
+
{property.postcode}
+ + {price !== undefined && ( +
+ £{formatNumber(price)} + {pricePerSqm !== undefined && ( + + {' '} + (£{formatNumber(pricePerSqm)}/m²) + + )} +
+ )} + {estimatedPrice !== undefined && ( +
+ Est. value:{' '} + + £{formatNumber(estimatedPrice)} + +
+ )} + +
+ {property.property_type && ( +
+ Type: {property.property_type} +
+ )} + {property.built_form && ( +
+ Built form:{' '} + {property.built_form} +
+ )} + {property.duration && ( +
+ Tenure:{' '} + {formatDuration(property.duration)} +
+ )} + {floorArea !== undefined && ( +
+ Floor area:{' '} + {formatNumber(floorArea)}m² +
+ )} + {rooms !== undefined && ( +
+ Rooms: {formatNumber(rooms)} +
+ )} + {age !== undefined && ( +
+ Built:{' '} + {formatAge(age, property.is_construction_date_approximate ?? true)} +
+ )} + {property.current_energy_rating && ( +
+ EPC rating:{' '} + {property.current_energy_rating} +
+ )} + {property.potential_energy_rating && ( +
+ EPC potential:{' '} + {property.potential_energy_rating} +
+ )} + {councilTax !== undefined ? ( +
+ Council tax: £ + {formatNumber(councilTax)}/yr +
+ ) : councilTaxD !== undefined ? ( +
+ Council tax (D): £ + {formatNumber(councilTaxD)}/yr +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/map/StackedBarChart.tsx b/frontend/src/components/map/StackedBarChart.tsx new file mode 100644 index 0000000..6c3edd6 --- /dev/null +++ b/frontend/src/components/map/StackedBarChart.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import { SEGMENT_COLORS } from '../../lib/consts'; +import { formatValue } from '../../lib/format'; + +interface Segment { + name: string; + value: number; +} + +interface StackedBarChartProps { + segments: Segment[]; + total: number; + /** Optional custom colors keyed by segment name. Falls back to SEGMENT_COLORS. */ + colorMap?: Record; +} + +/** Strip common suffixes/prefixes to produce short legend labels */ +function shortenLabel(name: string): string { + return name + .replace(' (avg/yr)', '') + .replace(/^% /, '') + .replace('and sexual offences', '') + .replace('and arson', '') + .replace('from the person', '') + .replace('Possession of weapons', 'Weapons') + .replace('Anti-social behaviour', 'Anti-social') + .replace('Criminal damage', 'Damage') + .trim(); +} + +export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) { + const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]); + + if (total === 0) { + return
No data
; + } + + return ( +
+ {/* Stacked bar */} +
+ {sortedSegments.map((segment, i) => { + const pct = (segment.value / total) * 100; + if (pct < 0.5) return null; + return ( +
+ ); + })} +
+ + {/* Legend */} +
+ {sortedSegments.map((segment, i) => ( +
+ + + {shortenLabel(segment.name)} + + + {formatValue(segment.value)} + +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/map/StackedEnumChart.tsx b/frontend/src/components/map/StackedEnumChart.tsx new file mode 100644 index 0000000..96fb7f5 --- /dev/null +++ b/frontend/src/components/map/StackedEnumChart.tsx @@ -0,0 +1,76 @@ +import type { EnumFeatureStats } from '../../types'; + +interface StackedEnumChartProps { + components: { label: string; stats: EnumFeatureStats }[]; + valueOrder: string[]; + valueColors: string[]; +} + +/** Strip common suffixes to produce short row labels */ +function shortenLabel(name: string): string { + return name.replace(/ risk$/, ''); +} + +export default function StackedEnumChart({ + components, + valueOrder, + valueColors, +}: StackedEnumChartProps) { + const visibleRows = components.filter(({ stats }) => { + const total = Object.values(stats.counts).reduce((a, b) => a + b, 0); + if (total === 0) return false; + const lowCount = stats.counts[valueOrder[0]] ?? 0; + return total - lowCount > 0; + }); + + if (visibleRows.length === 0) { + return
All low
; + } + + return ( +
+ {visibleRows.map(({ label, stats }) => { + const total = Object.values(stats.counts).reduce((a, b) => a + b, 0); + + return ( +
+ + {shortenLabel(label)} + +
+ {valueOrder.map((value, i) => { + const count = stats.counts[value] ?? 0; + const pct = (count / total) * 100; + if (pct < 0.5) return null; + return ( +
+ ); + })} +
+
+ ); + })} + + {/* Legend */} +
+ {valueOrder.map((value, i) => ( +
+ + {value} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/map/StreetViewEmbed.tsx b/frontend/src/components/map/StreetViewEmbed.tsx new file mode 100644 index 0000000..22653d9 --- /dev/null +++ b/frontend/src/components/map/StreetViewEmbed.tsx @@ -0,0 +1,26 @@ +import type { HexagonLocation } from '../../lib/external-search'; + +interface StreetViewEmbedProps { + location: HexagonLocation; +} + +export default function StreetViewEmbed({ location }: StreetViewEmbedProps) { + return ( +
+
+ Street View +
+
+
+