diff --git a/CLAUDE.md b/CLAUDE.md index 413546f..4946480 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,12 @@ # CBPOA — 武汉儿童呼吸疾病风险评估系统 -FastAPI + React + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。 +FastAPI + React + `@geoscene/core` + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。 ## Development ```bash # Frontend (pnpm) -cd frontend && pnpm dev # localhost:5173 → proxies /api to :8000 +cd frontend && pnpm dev # localhost:3000 → proxies /api to :8000 # Backend (Python venv) cd backend && uvicorn main:app --reload # localhost:8000 @@ -22,6 +22,7 @@ cd scripts && python train_model.py # PyTorch + MLflow | API endpoint | `backend/routers/` | | Database / PostGIS | `backend/database.py` | | UI component | `frontend/src/components/` | +| GeoScene map helpers | `frontend/src/geoscene/` | | Page view | `frontend/src/pages/` | | API client / cache | `frontend/src/services/api.ts` | | State management | `frontend/src/stores/` | diff --git a/backend/config.py b/backend/config.py index 6750402..7e2c849 100644 --- a/backend/config.py +++ b/backend/config.py @@ -13,7 +13,7 @@ load_dotenv() # Paths # ============================================================================ -PROJECT_ROOT = Path(__file__).parent.parent +PROJECT_ROOT = Path(os.environ.get("CBPOA_ROOT", Path(__file__).parent.parent)) DATA_DIR = PROJECT_ROOT / "outputs" / "daily" REPORTS_DIR = PROJECT_ROOT / "outputs" / "reports" WUHAN_BOUNDARY_PATH = PROJECT_ROOT / "Datas" / "武汉市.geojson" diff --git a/backend/routers/alerts.py b/backend/routers/alerts.py index af9802a..cfc23be 100644 --- a/backend/routers/alerts.py +++ b/backend/routers/alerts.py @@ -13,6 +13,7 @@ from config import DATA_DIR, ALERT_P1_RISK, ALERT_P2_RISK, WUHAN_BOUNDS, LAT_STE from models import Alert, AlertResponse from utils.date_helpers import get_latest_date, validate_date_format from utils.risk import risk_value_to_level +from utils.district_lookup import district_for_grid router = APIRouter(prefix="/api/alerts", tags=["alerts"]) @@ -95,13 +96,14 @@ def _generate_alerts_cached(date: str) -> List[Alert]: lat, lon = grid_id_to_center(grid_id) risk_level = risk_value_to_level(max_risk) + district = district_for_grid(grid_id) alerts.append( Alert( alert_id=f"alert_{date}_{grid_id}", grid_id=grid_id, - region="武汉市", - street=f"Grid {grid_id}", + region=district, + street=grid_id, latitude=lat, longitude=lon, risk_value=max_risk, diff --git a/backend/routers/analysis.py b/backend/routers/analysis.py index be3dd31..e36a238 100644 --- a/backend/routers/analysis.py +++ b/backend/routers/analysis.py @@ -17,6 +17,8 @@ from utils.date_helpers import get_latest_date from utils.geojson import parse_geojson_file, load_districts from utils.geo import point_in_polygon from utils.risk import calculate_trend +from utils.daily_risk_avg import daily_avg_risk +from utils.district_lookup import grid_district_lookup router = APIRouter(prefix="/api/analysis", tags=["analysis"]) @@ -83,22 +85,10 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)): for i in range(days): date = base_date - timedelta(days=days - 1 - i) date_str = date.strftime("%Y%m%d") - filepath = DATA_DIR / f"risk_{date_str}.geojson" - - if filepath.exists(): - grids = parse_geojson_file(filepath) - if grids: - avg_risk = sum(g["risk_value"] for g in grids) / len(grids) - values.append(round(avg_risk, 4)) - else: - values.append(0) - else: - values.append(0) + # Disk+memory cached mean — avoids re-parsing ~45MB GeoJSON every request + values.append(daily_avg_risk(date_str)) dates.append(date.strftime("%Y-%m-%d")) - # Preserve the full requested date range: a "7天" request must return 7 - # contiguous points. Days with no geojson (or empty grids) stay 0 rather - # than being dropped, which previously produced fewer, non-contiguous points. trend_direction = calculate_trend(values) return TrendResponse( @@ -110,15 +100,8 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)): @lru_cache(maxsize=1) def _grid_district_lookup() -> dict: - """Map precomputed r{row}_c{col} grid id -> district name (loaded once).""" - path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet" - if not path.exists(): - return {} - df = pd.read_parquet(path) - # Some grids have a null district_name; drop them so the lookup only ever - # returns valid strings (missing keys fall back to "其他"). - df = df.dropna(subset=["district_name"]) - return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str))) + """Backward-compatible alias — prefer utils.district_lookup.""" + return grid_district_lookup() @lru_cache(maxsize=1) diff --git a/backend/utils/daily_risk_avg.py b/backend/utils/daily_risk_avg.py new file mode 100644 index 0000000..2dd76ef --- /dev/null +++ b/backend/utils/daily_risk_avg.py @@ -0,0 +1,81 @@ +"""Fast daily city-wide mean risk_1d with on-disk cache. + +Avoids re-parsing ~45MB GeoJSON on every /analysis/trend request. +""" +from __future__ import annotations + +import json +import logging +from functools import lru_cache +from pathlib import Path + +from config import DATA_DIR, PROJECT_ROOT + +logger = logging.getLogger(__name__) + +_CACHE_PATH = PROJECT_ROOT / "processed" / "daily_avg_risk.json" + + +def _read_disk_cache() -> dict[str, float]: + if not _CACHE_PATH.exists(): + return {} + try: + raw = json.loads(_CACHE_PATH.read_text(encoding="utf-8")) + return {str(k): float(v) for k, v in raw.items()} + except (OSError, json.JSONDecodeError, TypeError, ValueError): + return {} + + +def _write_disk_cache(cache: dict[str, float]) -> None: + try: + _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + _CACHE_PATH.write_text( + json.dumps(cache, ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + except OSError as e: + logger.warning("Failed to persist daily avg risk cache: %s", e) + + +def _compute_mean_risk_1d(filepath: Path) -> float: + """Parse one risk GeoJSON and return mean risk_1d (0 if empty/missing).""" + try: + with open(filepath, "r", encoding="utf-8") as f: + geojson = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.warning("Failed to parse %s: %s", filepath, e) + return 0.0 + + total = 0.0 + n = 0 + for feature in geojson.get("features", []): + props = feature.get("properties") or {} + r = props.get("risk_1d") + if r is None: + continue + total += float(r) + n += 1 + return round(total / n, 4) if n else 0.0 + + +@lru_cache(maxsize=64) +def daily_avg_risk(date_yyyymmdd: str) -> float: + """Mean risk_1d for YYYYMMDD. Memory + disk cached.""" + disk = _read_disk_cache() + if date_yyyymmdd in disk: + return disk[date_yyyymmdd] + + filepath = DATA_DIR / f"risk_{date_yyyymmdd}.geojson" + if not filepath.exists(): + return 0.0 + + avg = _compute_mean_risk_1d(filepath) + disk[date_yyyymmdd] = avg + _write_disk_cache(disk) + return avg + + +def warm_daily_avg_risk(dates: list[str]) -> None: + """Precompute missing dates into the disk cache (blocking).""" + for d in dates: + daily_avg_risk(d) diff --git a/backend/utils/district_lookup.py b/backend/utils/district_lookup.py new file mode 100644 index 0000000..b93b285 --- /dev/null +++ b/backend/utils/district_lookup.py @@ -0,0 +1,21 @@ +"""Map 100m grid_id (r{row}_c{col}) → Wuhan district name.""" +from functools import lru_cache + +import pandas as pd + +from config import PROJECT_ROOT + + +@lru_cache(maxsize=1) +def grid_district_lookup() -> dict[str, str]: + """Loaded once from processed/grid_district_mapping.parquet.""" + path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet" + if not path.exists(): + return {} + df = pd.read_parquet(path) + df = df.dropna(subset=["district_name"]) + return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str))) + + +def district_for_grid(grid_id: str, default: str = "其他") -> str: + return grid_district_lookup().get(grid_id, default) diff --git a/deploy/backend/Dockerfile b/deploy/backend/Dockerfile index 6944e91..43fe69a 100644 --- a/deploy/backend/Dockerfile +++ b/deploy/backend/Dockerfile @@ -1,32 +1,26 @@ FROM python:3.11-slim -# Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ - libpq-dev \ + libpq-dev curl \ && rm -rf /var/lib/apt/lists/* -# Create non-root user RUN groupadd --gid 1000 appgroup && \ useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser -WORKDIR /home/appuser +WORKDIR /app -# Copy requirements and install dependencies COPY --chown=appuser:appgroup requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt \ + -i https://pypi.tuna.tsinghua.edu.cn/simple -# Copy backend code COPY --chown=appuser:appgroup . . -# Switch to non-root user USER appuser -# Expose port +ENV CBPOA_ROOT=/data EXPOSE 8000 -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ CMD curl -f http://localhost:8000/docs || exit 1 -# Run uvicorn CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deploy/cbpoa-api.service b/deploy/cbpoa-api.service new file mode 100644 index 0000000..0d4cf44 --- /dev/null +++ b/deploy/cbpoa-api.service @@ -0,0 +1,17 @@ +[Unit] +Description=CBPOA FastAPI backend +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/cbpoa/backend +Environment=PATH=/opt/cbpoa/backend/.venv/bin:/usr/bin +EnvironmentFile=-/opt/cbpoa/backend/.env +ExecStart=/opt/cbpoa/backend/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 1 +Restart=on-failure +RestartSec=5 +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f1300b7..3c9b444 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,83 +1,47 @@ -version: '3.8' - services: - postgres: - image: postgis/postgis:15-3.3 - container_name: wuhan_postgres - environment: - POSTGRES_DB: wuhan_disease - POSTGRES_USER: wuhan_user - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wuhan_password} - volumes: - - postgres_data:/var/lib/postgresql/data - - ./init.sql:/docker-entrypoint-initdb.d/init.sql - ports: - - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U wuhan_user -d wuhan_disease"] - interval: 5s - timeout: 5s - retries: 5 - networks: - - wuhan_network - backend: build: context: ../backend - dockerfile: Dockerfile - container_name: wuhan_backend + dockerfile: ../deploy/backend/Dockerfile + container_name: cbpoa_backend environment: - DATABASE_URL: postgresql://wuhan_user:password@postgres:5432/wuhan_disease - POSTGRES_HOST: postgres - POSTGRES_PORT: 5432 - depends_on: - postgres: - condition: service_healthy + CBPOA_ROOT: /data + CORS_ORIGINS: "*" + volumes: + - ../outputs:/data/outputs:ro + - ../processed:/data/processed:ro + - ../Datas:/data/Datas:ro ports: - "8000:8000" healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8000/docs || exit 1"] - interval: 10s + interval: 15s timeout: 5s retries: 5 - start_period: 30s + start_period: 40s + restart: unless-stopped networks: - - wuhan_network + - cbpoa_net frontend: build: context: ../frontend - dockerfile: Dockerfile - container_name: wuhan_frontend - environment: - VITE_API_URL: http://localhost:8000 + dockerfile: ../deploy/frontend/Dockerfile + container_name: cbpoa_frontend depends_on: - - backend + backend: + condition: service_healthy ports: - - "3000:80" + - "80:80" healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"] - interval: 10s + test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"] + interval: 15s timeout: 5s retries: 5 + restart: unless-stopped networks: - - wuhan_network - - # jupyter: - # image: jupyter/scipy-notebook:latest - # container_name: wuhan_jupyter - # ports: - # - "8888:8888" - # volumes: - # - ../processed:/home/jovyan/processed - # - ../Datas:/home/jovyan/Datas - # networks: - # - wuhan_network - -volumes: - postgres_data: - driver: local + - cbpoa_net networks: - wuhan_network: - driver: bridge \ No newline at end of file + cbpoa_net: + driver: bridge diff --git a/deploy/frontend/.dockerignore b/deploy/frontend/.dockerignore index b731a00..61ea26a 100644 --- a/deploy/frontend/.dockerignore +++ b/deploy/frontend/.dockerignore @@ -3,4 +3,4 @@ node_modules *.md tests .env* -dist \ No newline at end of file +dist diff --git a/deploy/frontend/Dockerfile b/deploy/frontend/Dockerfile index d741184..31a9c14 100644 --- a/deploy/frontend/Dockerfile +++ b/deploy/frontend/Dockerfile @@ -5,16 +5,11 @@ FROM node:20-alpine AS builder WORKDIR /app -# Copy package files COPY package.json pnpm-lock.yaml ./ - -# Install dependencies (using pnpm since lock file is pnpm-lock.yaml) RUN npm install -g pnpm && pnpm install --frozen-lockfile -# Copy source code COPY . . - -# Build the application +ENV VITE_API_URL=/api RUN pnpm run build # ============================================================================= @@ -22,15 +17,10 @@ RUN pnpm run build # ============================================================================= FROM nginx:alpine AS production -# Copy custom nginx config for SPA routing -COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/default.conf - -# Copy built assets from builder +COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /app/dist /usr/share/nginx/html -# Expose port 80 EXPOSE 80 -# Health check for nginx HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-redirect --quiet --tries=1 --spider http://localhost/ || exit 1 \ No newline at end of file + CMD wget --no-redirect --quiet --tries=1 --spider http://localhost/ || exit 1 diff --git a/deploy/nginx-cbpoa.conf b/deploy/nginx-cbpoa.conf new file mode 100644 index 0000000..0ff2330 --- /dev/null +++ b/deploy/nginx-cbpoa.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name _; + + root /var/www/cbpoa; + index index.html; + + client_max_body_size 20m; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/geo+json; + gzip_min_length 1000; + + location /api/ { + proxy_pass http://127.0.0.1:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # Gaode basemap proxy (same as Vite /basemap-gaode) + location ~ ^/basemap-gaode/(\d+)/(\d+)/(\d+) { + proxy_pass https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=$1&x=$2&y=$3; + proxy_set_header Host webrd01.is.autonavi.com; + proxy_ssl_server_name on; + proxy_hide_header Set-Cookie; + expires 1d; + add_header Cache-Control "public"; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..d08a752 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,15 @@ +# Frontend env (Vite) + +# API (default: Vite proxy /api → :8000) +# VITE_API_URL=/api + +# 天地图个人密钥 https://console.tianditu.gov.cn/ +# 不设则用高德矢量底图(GeoScene CN 仅有 tianditu-* 命名底图,且内置 tk 会 418) +# VITE_TIANDITU_TK= + +# GeoScene Enterprise (optional — unused in POC) +# VITE_GEOSCENE_PORTAL_URL=https://cn18:7443/geoscene +# VITE_LAYER_DISTRICTS_URL= +# VITE_LAYER_RISK_URL= +# VITE_LAYER_CASES_URL= +# VITE_GEOSCENE_WEBMAP_ID= diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index e2cf6e5..ed80325 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -1,19 +1,21 @@ -# Frontend — React + TypeScript + Leaflet +# Frontend — React + TypeScript + GeoScene Maps SDK ## Stack - React 18, TypeScript 5, Vite 5 - Tailwind CSS, Recharts, Zustand (state), Axios -- Leaflet / react-leaflet (maps) +- `@geoscene/core` (Maps SDK for JavaScript) — POC uses Tianditu basemap + local GeoJSON / FastAPI risk tiles - Playwright (e2e tests) ## Structure ``` frontend/src/ - main.tsx # Entry point + main.tsx # Entry (+ GeoScene theme CSS) App.tsx # Router setup + geoscene/ # MapView helpers + layer factories components/ # Reusable UI (maps, charts, nav) + legacy/ # Unused former Leaflet map experiments pages/ # Route-level views services/api.ts # Axios client with TTL cache + request dedup stores/ # Zustand stores @@ -28,15 +30,21 @@ frontend/src/ ## Patterns - Components: PascalCase, one per file, default export -- API calls: use `services/api.ts` wrappers (`riskApi`, `alertApi`, `caseApi`, `gridApi`) — they handle caching and request dedup -- State: Zustand stores in `stores/`, typed with TypeScript interfaces from `types/` -- Styling: Tailwind utility classes, no CSS modules +- Map components: imperative `@geoscene/core` via `createMapView` — do not pass MapView instances between components +- Coordinates: GeoScene uses `[longitude, latitude]` +- API calls: use `services/api.ts` wrappers (`riskApi`, `alertApi`, `caseApi`, `gridApi`) +- State: Zustand stores in `stores/` +- Styling: Tailwind utility classes + +## Env (optional Enterprise later) + +See `.env.example` for `VITE_GEOSCENE_PORTAL_URL` / `VITE_LAYER_*`. POC runs without them. ## Running ```bash cd frontend -pnpm dev # localhost:5173, proxies /api → localhost:8000 +pnpm dev # localhost:3000, proxies /api → localhost:8000 pnpm build # tsc + vite build → dist/ ``` @@ -47,3 +55,4 @@ pnpm build # tsc + vite build → dist/ - Don't mix data fetching with presentation — fetch in pages, render in components - Don't inline styles when Tailwind classes work - Don't create god components (>200 lines) — extract sub-components +- Don't reintroduce Leaflet or role/perspective switchers diff --git a/frontend/e2e/doctor-view.spec.ts b/frontend/e2e/doctor-view.spec.ts deleted file mode 100644 index ef1e3ec..0000000 --- a/frontend/e2e/doctor-view.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Phase-3 acceptance tests: role-aware 预警 (alerts) view. - * - * Two view-preset invariants (D2 — frontend presets, NOT access control): - * - * 1. PRIVACY INVARIANT (doctor / ?view=cluster): the doctor sees ONLY the aggregated - * density raster + the disease filter — ZERO individual patient/case point markers. - * The page mirrors every individual marker it would actually render into a hidden - * data-testid="patient-point" element (the live Leaflet CircleMarkers are canvas/SVG - * objects with no testid and can't be counted directly). In cluster mode the page - * forces showAlertMarkers=false, so that mirror set is empty → patient-point count 0. - * - * 2. 官员 (official) GRID-HIDE: the 100m 网格 is meaningless for leadership, so the grid - * toggle wrapper (data-testid="grid-layer-wrapper") is not rendered at all. - * - * Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically - * (no live :8000 backend). Role is seeded via localStorage['cbpoa_role']. - */ -import { test, expect, Page } from '@playwright/test'; -import { TESTIDS } from '../src/utils/testids'; - -/** - * Seed auth token (+ optional role) and mock all /api/** calls before page load. - * Response shapes copied from user-flows.spec.ts. - */ -async function seedAuthAndMockApi(page: Page, role?: string) { - await page.addInitScript((r) => { - localStorage.setItem('cbpoa_token', 'e2e-test-token'); - if (r) localStorage.setItem('cbpoa_role', r); - }, role ?? ''); - - await page.route('/api/**', (route) => { - const url = route.request().url(); - - if (url.includes('/alerts')) { - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ alerts: [], total: 0 }), - }); - return; - } - if (url.includes('/history/aggregated')) { - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ aggregations: [], total_records: 0 }), - }); - return; - } - if (url.includes('/grids')) { - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ type: 'FeatureCollection', features: [] }), - }); - return; - } - if (url.includes('/cases/demographics')) { - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - age_distribution: [], - gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } }, - age_diagnosis_matrix: [], - }), - }); - return; - } - if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) { - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); - return; - } - if (url.includes('/diagnoses') || url.includes('/diagnosis-list')) { - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); - return; - } - if (url.includes('/cases/districts') || url.includes('/districts')) { - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); - return; - } - if (url.includes('/cases/trend') || url.includes('/cases')) { - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ data: [], total: 0 }), - }); - return; - } - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ data: [], items: [], total: 0 }), - }); - }); -} - -test.describe('role-aware 预警 view (Phase 3)', () => { - test.use({ viewport: { width: 1280, height: 800 } }); - - test('医生 /alerts?view=cluster: cluster-view mounts, disease filter present, ZERO patient points', async ({ - page, - }) => { - await seedAuthAndMockApi(page, 'doctor'); - await page.goto('/alerts?view=cluster'); - - // Page + the aggregated density (cluster) map both mount. - await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); - await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toBeVisible(); - - // The disease filter is the doctor's core tool — it must be on the page. - await expect(page.getByText('按病种筛选')).toBeVisible(); - - // PRIVACY INVARIANT: not a single individual patient/case point may be rendered. - // Asserted at the data level (the mirrored DOM set), independent of Leaflet internals. - await expect(page.getByTestId(TESTIDS.patientPoint)).toHaveCount(0); - - // The 预警标记 toggle (which would turn individual markers on) must be absent, - // so there is no way for the doctor to opt out of the privacy invariant. - await expect(page.getByRole('button', { name: '预警标记' })).toHaveCount(0); - }); - - test('官员 /alerts: 100m grid hidden — grid-layer-wrapper not rendered', async ({ page }) => { - await seedAuthAndMockApi(page, 'official'); - await page.goto('/alerts'); - - await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); - - // The grid toggle wrapper must be entirely absent for leadership. - await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(0); - }); - - test('admin /alerts: full behavior — grid toggle present, no forced cluster view', async ({ page }) => { - await seedAuthAndMockApi(page, 'admin'); - await page.goto('/alerts'); - - await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); - // Admin keeps the grid toggle and is NOT forced into cluster view. - await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(1); - await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toHaveCount(0); - }); -}); diff --git a/frontend/e2e/roles.spec.ts b/frontend/e2e/roles.spec.ts deleted file mode 100644 index fd34003..0000000 --- a/frontend/e2e/roles.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Phase-3 acceptance tests: 视角/perspective switcher (D2 — frontend view-presets only). - * - * Roles are NOT access control: the switcher only changes the default landing page + - * granularity/filter presets. This suite verifies the switcher renders, selecting a role - * navigates to that role's default landing URL (with its query params), and the choice - * survives a reload (persisted to localStorage['cbpoa_role']). - * - * Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically. - */ -import { test, expect, Page } from '@playwright/test'; -import { TESTIDS } from '../src/utils/testids'; - -/** Seed auth token and mock all /api/** calls (shapes copied from user-flows.spec.ts). */ -async function seedAuthAndMockApi(page: Page) { - await page.addInitScript(() => { - localStorage.setItem('cbpoa_token', 'e2e-test-token'); - }); - - await page.route('/api/**', (route) => { - const url = route.request().url(); - - if (url.includes('/alerts')) { - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ alerts: [], total: 0 }), - }); - return; - } - if (url.includes('/history/aggregated')) { - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ aggregations: [], total_records: 0 }), - }); - return; - } - if (url.includes('/grids')) { - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ type: 'FeatureCollection', features: [] }), - }); - return; - } - if (url.includes('/cases/demographics')) { - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - age_distribution: [], - gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } }, - age_diagnosis_matrix: [], - }), - }); - return; - } - if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) { - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); - return; - } - if (url.includes('/cases/districts') || url.includes('/districts')) { - route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); - return; - } - if (url.includes('/cases/trend') || url.includes('/cases')) { - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ data: [], total: 0 }), - }); - return; - } - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ data: [], items: [], total: 0 }), - }); - }); -} - -test.describe('视角/perspective switcher (Phase 3)', () => { - // Use a desktop viewport so the top bar renders the switcher inline. - test.use({ viewport: { width: 1280, height: 800 } }); - - test.beforeEach(async ({ page }) => { - await seedAuthAndMockApi(page); - }); - - test('perspective-switcher is visible in the top bar', async ({ page }) => { - await page.goto('/monitoring'); - await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toBeVisible(); - }); - - test('selecting 厅领导 (official) navigates to /overview?granularity=district', async ({ page }) => { - await page.goto('/monitoring'); - const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`); - await expect(switcher).toBeVisible(); - - await switcher.selectOption('official'); - - await expect(page).toHaveURL(/\/overview/); - await expect(page).toHaveURL(/granularity=district/); - }); - - test('selecting 医生 (doctor) navigates to /alerts?view=cluster', async ({ page }) => { - await page.goto('/monitoring'); - const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`); - await expect(switcher).toBeVisible(); - - await switcher.selectOption('doctor'); - - await expect(page).toHaveURL(/\/alerts/); - await expect(page).toHaveURL(/view=cluster/); - }); - - test('selected role persists across reload (localStorage cbpoa_role)', async ({ page }) => { - await page.goto('/monitoring'); - const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`); - await switcher.selectOption('doctor'); - await expect(page).toHaveURL(/\/alerts/); - - // localStorage should now hold the chosen role. - const stored = await page.evaluate(() => localStorage.getItem('cbpoa_role')); - expect(stored).toBe('doctor'); - - await page.reload(); - - // After reload the switcher reflects the persisted role. - await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toHaveValue('doctor'); - }); -}); diff --git a/frontend/index.html b/frontend/index.html index 9ffc5dd..ff71d54 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,11 +4,10 @@ - 武汉儿童呼吸道疾病风险预测平台 + CBPOA · 武汉儿童呼吸疾病风险评估系统 - - +
diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..ede75a9 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 20m; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/geo+json; + gzip_min_length 1000; + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + location ~ ^/basemap-gaode/(\d+)/(\d+)/(\d+) { + proxy_pass https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=$1&x=$2&y=$3; + proxy_set_header Host webrd01.is.autonavi.com; + proxy_ssl_server_name on; + proxy_hide_header Set-Cookie; + expires 1d; + add_header Cache-Control "public"; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json index a8194e3..af5b21c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,12 +9,11 @@ "preview": "vite preview" }, "dependencies": { + "@geoscene/core": "4.32.10", "axios": "^1.6.7", - "leaflet": "^1.9.4", "lucide-react": "^0.330.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-leaflet": "^4.2.1", "react-router-dom": "^6.30.4", "recharts": "^2.12.0", "zustand": "^4.5.0" @@ -23,7 +22,6 @@ "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^14.3.1", - "@types/leaflet": "^1.9.8", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@vitejs/plugin-react": "^4.2.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 065fa83..72ae3a0 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,12 +8,12 @@ importers: .: dependencies: + '@geoscene/core': + specifier: 4.32.10 + version: 4.32.10 axios: specifier: ^1.6.7 version: 1.15.2 - leaflet: - specifier: ^1.9.4 - version: 1.9.4 lucide-react: specifier: ^0.330.0 version: 0.330.0(react@18.3.1) @@ -23,9 +23,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - react-leaflet: - specifier: ^4.2.1 - version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: ^6.30.4 version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -45,9 +42,6 @@ importers: '@testing-library/react': specifier: ^14.3.1 version: 14.3.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/leaflet': - specifier: ^1.9.8 - version: 1.9.21 '@types/react': specifier: ^18.2.55 version: 18.3.28 @@ -88,6 +82,18 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@arcgis/lumina@4.34.9': + resolution: {integrity: sha512-efqO+SwR+1IYf29AATh1l2FUeypRyRINTBNkaJY+KkaFe+8gqSJ45qOmputhyzF5WTRDb7WhOYgnChjp6VYPpA==} + peerDependencies: + '@lit/context': ^1.1.5 + lit: ^3.3.0 + peerDependenciesMeta: + '@lit/context': + optional: true + + '@arcgis/toolkit@4.34.9': + resolution: {integrity: sha512-wFST+eVnCwmg9NyICVyn9bsBnR+TlWklsGqG3L7xqSTgfXo6TuCThE7wtTb8xWxsTBkGvImqMUgpgLuwQuTQ1g==} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -344,6 +350,32 @@ packages: cpu: [x64] os: [win32] + '@esri/arcgis-html-sanitizer@4.1.0': + resolution: {integrity: sha512-einEveDJ/k1180NOp78PB/4Hje9eBy3dyOGLLtLn6bSkizpUfCwuYBIXOA7Y3F/k/BsTQXgKqUVwQ0eiscWMdA==} + engines: {node: '>=18.0.0'} + + '@esri/calcite-components@3.3.3': + resolution: {integrity: sha512-tw+EfJ3pb+Odj71W6E9GUkm8rMbNxfW1KeiI8GgsKDzhr39hMKwY+zYYFFYuO0FONxWGvAB+B8yqB0NvH7WeHw==} + + '@esri/calcite-ui-icons@4.3.0': + resolution: {integrity: sha512-iOOuRurpjFxFVw6+aXW2JpSkRBrdOpBcbdibfPOmSPqMd1aoHBtYmYXetKoH9vfrXoBiPyO2PkDnczhsu/N9IA==} + hasBin: true + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@geoscene/core@4.32.10': + resolution: {integrity: sha512-suiMmwX2dGbAfRlu0KRfAzwQOZrVz6eU/YU0kAsU4VmRSZxCir+iSnvZQzoFAdTmoiivKMRSfnKwSeOqIN4ptg==} + + '@interactjs/types@1.10.27': + resolution: {integrity: sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==} + '@jest/schemas@29.6.3': resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -364,6 +396,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lit-labs/ssr-dom-shim@1.6.0': + resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==} + + '@lit/reactive-element@2.1.2': + resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -376,17 +414,16 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@open-wc/dedupe-mixin@1.4.0': + resolution: {integrity: sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==} + '@playwright/test@1.59.1': resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} engines: {node: '>=18'} hasBin: true - '@react-leaflet/core@2.1.0': - resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==} - peerDependencies: - leaflet: ^1.9.0 - react: ^18.0.0 - react-dom: ^18.0.0 + '@polymer/polymer@3.5.2': + resolution: {integrity: sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==} '@remix-run/router@1.23.3': resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} @@ -596,12 +633,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/geojson@7946.0.16': - resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - - '@types/leaflet@1.9.21': - resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} - '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -613,6 +644,55 @@ packages: '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@types/sortablejs@1.15.9': + resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@vaadin/a11y-base@24.6.11': + resolution: {integrity: sha512-yBZ0QGPngbItIJQx3FRIa9IXDW2Ftf6SFFPGhbdAZafJPBlFi6FElP9cVtL3qjJlI5KKBp/UXEcC8ehPK207gw==} + + '@vaadin/checkbox@24.6.11': + resolution: {integrity: sha512-Uvd6gZ3xQQrZTtCJL6f4uLbg6mXsAKjiZto7Je39yJwUHz8r5MIQr+4mLF4zc6mYVSH/Ihj/a4n9FOuTwSEuQw==} + + '@vaadin/component-base@24.6.11': + resolution: {integrity: sha512-7jR6vcJeCBgY2CNbAPLOcUTsxYspqdkA0slUGk3GwfgsRDD5FLkzqQDSM5+yE6O2+4Wah2Tk+kG/GsKGtlUlwg==} + + '@vaadin/field-base@24.6.11': + resolution: {integrity: sha512-dRjxKzbW3xQAau1xuO8uZepVWaImS2wEyKDK9Oh+y8iiu4smYEmo9e4aqMqQN/sOHU6OSa4YtbyJZlvD1sBXrA==} + + '@vaadin/grid@24.6.11': + resolution: {integrity: sha512-10ra384y81iIPwrVCsJwZda4vrdVeDk7SaZSXHe+pM8dVNAvBfmCNomdc9XdC6Q289GHt1AHn/3SaN+G3Wr7FQ==} + + '@vaadin/icon@24.6.11': + resolution: {integrity: sha512-CKOh+I84+GZRfMHrhtATtrw3bSW5eUArgGT4cKsOY3asoCZXUdTObPD/PqKfP4e2uAA1bgLl27kOc+W8dmibJA==} + + '@vaadin/input-container@24.6.11': + resolution: {integrity: sha512-fT1DK1QDp6VNKaHKkHxuuF3OlbNWbZOtK9IcLs3Q78/9jzhs8gg/nhIbQbyvhIhvjjHncIhzp0vPiw1l7Xxl+Q==} + + '@vaadin/lit-renderer@24.6.11': + resolution: {integrity: sha512-JugFumbBQP4r28+HcbdDUVVGs5VRsqanLsifjkVrz/xb4saWv460lEYco5ES+StH+xZ2IuJZmEjEFUBSrVR/tA==} + + '@vaadin/text-field@24.6.11': + resolution: {integrity: sha512-pqDPTf5AGwz5CcMfyFmF2215WzwWpjfudKlCje6u2qOcA/9kqBYCTQolemVYCtMDwn0yHXFSp4dU8UasxMCUJA==} + + '@vaadin/vaadin-development-mode-detector@2.0.7': + resolution: {integrity: sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==} + + '@vaadin/vaadin-lumo-styles@24.6.11': + resolution: {integrity: sha512-WRluczao8lZgImdtl66v09YjFULb1iLAhcU48aiR9igAT7h6aLeHYBvRH3AA/gBlUNwHd4xlBSl89p4HP2GGog==} + + '@vaadin/vaadin-material-styles@24.6.11': + resolution: {integrity: sha512-tDumwlaDp/s9u++MPi64I1o2ls/drWOZf4xVPhztUjt3NwYJUeVXtwu39q0wBRIeRM7UBrs06kug2CVT72U4qQ==} + + '@vaadin/vaadin-themable-mixin@24.6.11': + resolution: {integrity: sha512-xCmn3X+2C7nI9LQn2OqLLkLw7VeJOCo99DlHwnxeLZpJJ/s8bjDXcIWflS+IOChzHixgEFkDSoLcNYoCR1RvYg==} + + '@vaadin/vaadin-usage-statistics@2.1.3': + resolution: {integrity: sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -634,6 +714,13 @@ packages: '@vitest/utils@1.6.1': resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@webcomponents/shadycss@1.11.2': + resolution: {integrity: sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==} + + '@zip.js/zip.js@2.7.73': + resolution: {integrity: sha512-I2UP8/rdQE5hTtVVL08B7P8XuwXiKuuMUPjNuFOVL/9b+8IsExR9S5jz2H58u0rJjU4M1BikLgqEMG8gZJZVBw==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'} + acorn-walk@8.3.5: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} @@ -764,17 +851,41 @@ packages: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + composed-offset-position@0.0.6: + resolution: {integrity: sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==} + peerDependencies: + '@floating-ui/utils': ^0.2.5 + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -793,6 +904,9 @@ packages: engines: {node: '>=4'} hasBin: true + cssfilter@0.0.10: + resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -932,6 +1046,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -975,6 +1092,9 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -1089,6 +1209,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + interactjs@1.10.27: + resolution: {integrity: sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1219,9 +1342,6 @@ packages: engines: {node: '>=6'} hasBin: true - leaflet@1.9.4: - resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1229,6 +1349,15 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lit-element@4.2.2: + resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==} + + lit-html@3.3.3: + resolution: {integrity: sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==} + + lit@3.3.3: + resolution: {integrity: sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==} + local-pkg@0.5.1: resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} engines: {node: '>=14'} @@ -1254,6 +1383,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1261,6 +1394,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1500,13 +1638,6 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-leaflet@4.2.1: - resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==} - peerDependencies: - leaflet: ^1.9.0 - react: ^18.0.0 - react-dom: ^18.0.0 - react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -1648,6 +1779,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sortablejs@1.15.7: + resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1689,6 +1823,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} @@ -1701,6 +1838,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + timezone-groups@0.10.4: + resolution: {integrity: sha512-AnkJYrbb7uPkDCEqGeVJiawZNiwVlSkkeX4jZg1gTEguClhyX+/Ezn07KB6DT29tG3UN418ldmS/W6KqGOTDjg==} + engines: {node: '>=18.12.0'} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -1734,10 +1875,17 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-detect@4.1.0: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1893,6 +2041,11 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xss@1.0.13: + resolution: {integrity: sha512-clu7dxTm1e8Mo5fz3n/oW3UCXBfV89xZ72jM8yzo1vR/pIS0w3sgB3XV2H8Vm6zfGnHL0FzvLJPJEBhd86/z4Q==} + engines: {node: '>= 0.10.0'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1921,6 +2074,17 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@arcgis/lumina@4.34.9(lit@3.3.3)': + dependencies: + '@arcgis/toolkit': 4.34.9 + csstype: 3.2.3 + lit: 3.3.3 + tslib: 2.8.1 + + '@arcgis/toolkit@4.34.9': + dependencies: + tslib: 2.8.1 + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -2132,6 +2296,56 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@esri/arcgis-html-sanitizer@4.1.0': + dependencies: + xss: 1.0.13 + + '@esri/calcite-components@3.3.3': + dependencies: + '@arcgis/lumina': 4.34.9(lit@3.3.3) + '@arcgis/toolkit': 4.34.9 + '@esri/calcite-ui-icons': 4.3.0 + '@floating-ui/dom': 1.8.0 + '@floating-ui/utils': 0.2.12 + '@types/sortablejs': 1.15.9 + color: 5.0.3 + composed-offset-position: 0.0.6(@floating-ui/utils@0.2.12) + es-toolkit: 1.49.0 + focus-trap: 7.8.0 + interactjs: 1.10.27 + lit: 3.3.3 + sortablejs: 1.15.7 + timezone-groups: 0.10.4 + type-fest: 4.41.0 + transitivePeerDependencies: + - '@lit/context' + + '@esri/calcite-ui-icons@4.3.0': {} + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@geoscene/core@4.32.10': + dependencies: + '@esri/arcgis-html-sanitizer': 4.1.0 + '@esri/calcite-components': 3.3.3 + '@vaadin/grid': 24.6.11 + '@zip.js/zip.js': 2.7.73 + luxon: 3.5.0 + marked: 15.0.12 + transitivePeerDependencies: + - '@lit/context' + + '@interactjs/types@1.10.27': {} + '@jest/schemas@29.6.3': dependencies: '@sinclair/typebox': 0.27.10 @@ -2155,6 +2369,12 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lit-labs/ssr-dom-shim@1.6.0': {} + + '@lit/reactive-element@2.1.2': + dependencies: + '@lit-labs/ssr-dom-shim': 1.6.0 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2167,15 +2387,15 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@open-wc/dedupe-mixin@1.4.0': {} + '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 - '@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@polymer/polymer@3.5.2': dependencies: - leaflet: 1.9.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@webcomponents/shadycss': 1.11.2 '@remix-run/router@1.23.3': {} @@ -2337,12 +2557,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/geojson@7946.0.16': {} - - '@types/leaflet@1.9.21': - dependencies: - '@types/geojson': 7946.0.16 - '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.28)': @@ -2354,6 +2568,118 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/sortablejs@1.15.9': {} + + '@types/trusted-types@2.0.7': {} + + '@vaadin/a11y-base@24.6.11': + dependencies: + '@open-wc/dedupe-mixin': 1.4.0 + '@polymer/polymer': 3.5.2 + '@vaadin/component-base': 24.6.11 + lit: 3.3.3 + + '@vaadin/checkbox@24.6.11': + dependencies: + '@open-wc/dedupe-mixin': 1.4.0 + '@polymer/polymer': 3.5.2 + '@vaadin/a11y-base': 24.6.11 + '@vaadin/component-base': 24.6.11 + '@vaadin/field-base': 24.6.11 + '@vaadin/vaadin-lumo-styles': 24.6.11 + '@vaadin/vaadin-material-styles': 24.6.11 + '@vaadin/vaadin-themable-mixin': 24.6.11 + lit: 3.3.3 + + '@vaadin/component-base@24.6.11': + dependencies: + '@open-wc/dedupe-mixin': 1.4.0 + '@polymer/polymer': 3.5.2 + '@vaadin/vaadin-development-mode-detector': 2.0.7 + '@vaadin/vaadin-usage-statistics': 2.1.3 + lit: 3.3.3 + + '@vaadin/field-base@24.6.11': + dependencies: + '@open-wc/dedupe-mixin': 1.4.0 + '@polymer/polymer': 3.5.2 + '@vaadin/a11y-base': 24.6.11 + '@vaadin/component-base': 24.6.11 + lit: 3.3.3 + + '@vaadin/grid@24.6.11': + dependencies: + '@open-wc/dedupe-mixin': 1.4.0 + '@polymer/polymer': 3.5.2 + '@vaadin/a11y-base': 24.6.11 + '@vaadin/checkbox': 24.6.11 + '@vaadin/component-base': 24.6.11 + '@vaadin/lit-renderer': 24.6.11 + '@vaadin/text-field': 24.6.11 + '@vaadin/vaadin-lumo-styles': 24.6.11 + '@vaadin/vaadin-material-styles': 24.6.11 + '@vaadin/vaadin-themable-mixin': 24.6.11 + lit: 3.3.3 + + '@vaadin/icon@24.6.11': + dependencies: + '@open-wc/dedupe-mixin': 1.4.0 + '@polymer/polymer': 3.5.2 + '@vaadin/component-base': 24.6.11 + '@vaadin/vaadin-lumo-styles': 24.6.11 + '@vaadin/vaadin-themable-mixin': 24.6.11 + lit: 3.3.3 + + '@vaadin/input-container@24.6.11': + dependencies: + '@polymer/polymer': 3.5.2 + '@vaadin/component-base': 24.6.11 + '@vaadin/vaadin-lumo-styles': 24.6.11 + '@vaadin/vaadin-material-styles': 24.6.11 + '@vaadin/vaadin-themable-mixin': 24.6.11 + lit: 3.3.3 + + '@vaadin/lit-renderer@24.6.11': + dependencies: + lit: 3.3.3 + + '@vaadin/text-field@24.6.11': + dependencies: + '@open-wc/dedupe-mixin': 1.4.0 + '@polymer/polymer': 3.5.2 + '@vaadin/a11y-base': 24.6.11 + '@vaadin/component-base': 24.6.11 + '@vaadin/field-base': 24.6.11 + '@vaadin/input-container': 24.6.11 + '@vaadin/vaadin-lumo-styles': 24.6.11 + '@vaadin/vaadin-material-styles': 24.6.11 + '@vaadin/vaadin-themable-mixin': 24.6.11 + lit: 3.3.3 + + '@vaadin/vaadin-development-mode-detector@2.0.7': {} + + '@vaadin/vaadin-lumo-styles@24.6.11': + dependencies: + '@polymer/polymer': 3.5.2 + '@vaadin/component-base': 24.6.11 + '@vaadin/icon': 24.6.11 + '@vaadin/vaadin-themable-mixin': 24.6.11 + + '@vaadin/vaadin-material-styles@24.6.11': + dependencies: + '@polymer/polymer': 3.5.2 + '@vaadin/component-base': 24.6.11 + '@vaadin/vaadin-themable-mixin': 24.6.11 + + '@vaadin/vaadin-themable-mixin@24.6.11': + dependencies: + '@open-wc/dedupe-mixin': 1.4.0 + lit: 3.3.3 + + '@vaadin/vaadin-usage-statistics@2.1.3': + dependencies: + '@vaadin/vaadin-development-mode-detector': 2.0.7 + '@vitejs/plugin-react@4.7.0(vite@5.4.21)': dependencies: '@babel/core': 7.29.0 @@ -2395,6 +2721,10 @@ snapshots: loupe: 2.3.7 pretty-format: 29.7.0 + '@webcomponents/shadycss@1.11.2': {} + + '@zip.js/zip.js@2.7.73': {} + acorn-walk@8.3.5: dependencies: acorn: 8.16.0 @@ -2532,14 +2862,35 @@ snapshots: dependencies: color-name: 1.1.4 + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + color-name@1.1.4: {} + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 + commander@2.20.3: {} + commander@4.1.1: {} + composed-offset-position@0.0.6(@floating-ui/utils@0.2.12): + dependencies: + '@floating-ui/utils': 0.2.12 + confbox@0.1.8: {} convert-source-map@2.0.0: {} @@ -2554,6 +2905,8 @@ snapshots: cssesc@3.0.0: {} + cssfilter@0.0.10: {} + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -2703,6 +3056,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.3 + es-toolkit@1.49.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -2771,6 +3126,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + follow-redirects@1.16.0: {} for-each@0.3.5: @@ -2875,6 +3234,10 @@ snapshots: indent-string@4.0.0: {} + interactjs@1.10.27: + dependencies: + '@interactjs/types': 1.10.27 + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3010,12 +3373,26 @@ snapshots: json5@2.2.3: {} - leaflet@1.9.4: {} - lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} + lit-element@4.2.2: + dependencies: + '@lit-labs/ssr-dom-shim': 1.6.0 + '@lit/reactive-element': 2.1.2 + lit-html: 3.3.3 + + lit-html@3.3.3: + dependencies: + '@types/trusted-types': 2.0.7 + + lit@3.3.3: + dependencies: + '@lit/reactive-element': 2.1.2 + lit-element: 4.2.2 + lit-html: 3.3.3 + local-pkg@0.5.1: dependencies: mlly: 1.8.2 @@ -3041,12 +3418,16 @@ snapshots: dependencies: react: 18.3.1 + luxon@3.5.0: {} + lz-string@1.5.0: {} magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@15.0.12: {} + math-intrinsics@1.1.0: {} merge-stream@2.0.0: {} @@ -3246,13 +3627,6 @@ snapshots: react-is@18.3.1: {} - react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - leaflet: 1.9.4 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-refresh@0.17.0: {} react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -3449,6 +3823,8 @@ snapshots: signal-exit@4.1.0: {} + sortablejs@1.15.7: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -3488,6 +3864,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.5.0: {} + tailwindcss@3.4.19: dependencies: '@alloc/quick-lru': 5.2.0 @@ -3524,6 +3902,8 @@ snapshots: dependencies: any-promise: 1.3.0 + timezone-groups@0.10.4: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -3554,8 +3934,12 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} + type-detect@4.1.0: {} + type-fest@4.41.0: {} + typescript@5.9.3: {} ufo@1.6.4: {} @@ -3713,6 +4097,11 @@ snapshots: xmlchars@2.2.0: {} + xss@1.0.13: + dependencies: + commander: 2.20.3 + cssfilter: 0.0.10 + yallist@3.1.1: {} yocto-queue@1.2.2: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 62c0bfe..159cc2b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -75,7 +75,12 @@ function App() { return ( - + {token ? ( ) : ( diff --git a/frontend/src/components.test.ts b/frontend/src/components.test.ts index e9fc7bd..a8ea596 100644 --- a/frontend/src/components.test.ts +++ b/frontend/src/components.test.ts @@ -30,11 +30,6 @@ describe('Component exports', () => { expect((mod as any).default || mod.DiseaseFilter).toBeDefined(); }); - it('ChatBot 可以被导入', async () => { - const mod = await import('@/components/ChatBot'); - expect((mod as any).default || mod.ChatBot).toBeDefined(); - }); - it('TimelinePlayer 可以被导入', async () => { const mod = await import('@/components/TimelinePlayer'); expect((mod as any).default || mod.TimelinePlayer).toBeDefined(); @@ -45,11 +40,6 @@ describe('Component exports', () => { expect((mod as any).default || mod.StatisticalCharts).toBeDefined(); }); - it('RiskMap 可以被导入', async () => { - const mod = await import('@/components/RiskMap'); - expect((mod as any).default || mod.RiskMap).toBeDefined(); - }); - it('AlertMap 可以被导入', async () => { const mod = await import('@/components/AlertMap'); expect((mod as any).default || mod.AlertMap).toBeDefined(); @@ -60,9 +50,9 @@ describe('Component exports', () => { expect((mod as any).default || mod.CaseLocationMap).toBeDefined(); }); - it('CaseMap 可以被导入', async () => { - const mod = await import('@/components/CaseMap'); - expect((mod as any).default || mod.CaseMap).toBeDefined(); + it('geoscene createMapView 可以被导入', async () => { + const mod = await import('@/geoscene'); + expect(mod.createMapView).toBeDefined(); }); it('DistributionChart 可以被导入', async () => { @@ -75,11 +65,6 @@ describe('Component exports', () => { expect((mod as any).default || mod.GridStatsOverlay).toBeDefined(); }); - it('LodGridLayer 可以被导入', async () => { - const mod = await import('@/components/LodGridLayer'); - expect((mod as any).default || mod.LodGridLayer).toBeDefined(); - }); - it('AdminBreadcrumb 可以被导入', async () => { const mod = await import('@/components/AdminBreadcrumb'); expect((mod as any).default || mod.AdminBreadcrumb).toBeDefined(); diff --git a/frontend/src/components/AlertMap.tsx b/frontend/src/components/AlertMap.tsx index 519a3f1..0068c47 100644 --- a/frontend/src/components/AlertMap.tsx +++ b/frontend/src/components/AlertMap.tsx @@ -1,9 +1,18 @@ import { useEffect, useRef, useState, useCallback, memo } from 'react'; -import L from 'leaflet'; +import type MapView from '@geoscene/core/views/MapView'; +import type WebTileLayer from '@geoscene/core/layers/WebTileLayer'; +import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer'; +import Graphic from '@geoscene/core/Graphic'; +import Point from '@geoscene/core/geometry/Point'; +import Polygon from '@geoscene/core/geometry/Polygon'; +import SimpleFillSymbol from '@geoscene/core/symbols/SimpleFillSymbol'; +import * as reactiveUtils from '@geoscene/core/core/reactiveUtils'; import { GridStatsOverlay } from '@/components/GridStatsOverlay'; import { riskApi } from '@/services/api'; import type { RiskGridStats } from '@/services/api'; import type { Alert } from '@/types'; +import { createMapView } from '@/geoscene/createMapView'; +import { createRiskTileLayer, createGraphicsLayer, pointGraphic } from '@/geoscene/layers'; export interface CellInfo { lat: number; @@ -28,10 +37,8 @@ interface AlertMapProps { isFullscreen?: boolean; } -const WUHAN_CENTER: [number, number] = [30.59, 114.31]; const GRID_OPACITY = 0.72; -// Mirrors the server-side colormap in backend/utils/risk_raster.py. const RISK_LEGEND: [number, number, string][] = [ [0.25, 0.4, '#38b000'], [0.4, 0.6, '#facc15'], @@ -57,79 +64,83 @@ function AlertMapComponent({ isFullscreen = false, }: AlertMapProps) { const mapRef = useRef(null); - const mapInstanceRef = useRef(null); - const riskTileRef = useRef(null); - const alertLayerRef = useRef(null); - const markerMapRef = useRef>(new Map()); - const selectedMarkerRef = useRef(null); + const viewRef = useRef(null); + const destroyRef = useRef<(() => void) | null>(null); + const riskLayerRef = useRef(null); + const alertLayerRef = useRef(null); + const selectLayerRef = useRef(null); const clickHandlerRef = useRef(onGridClick); const cellInfoRef = useRef(onCellInfo); - const resizeObserverRef = useRef(null); - // Latest inputs the once-subscribed map handlers read, so we never have to - // re-subscribe (and tear down listeners) when prop/callback identities change. const inputsRef = useRef({ filteredAlerts, showAlertMarkers, forecastDay }); const [gridStats, setGridStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); + const [mapReady, setMapReady] = useState(false); - useEffect(() => { clickHandlerRef.current = onGridClick; }, [onGridClick]); - useEffect(() => { cellInfoRef.current = onCellInfo; }, [onCellInfo]); + useEffect(() => { + clickHandlerRef.current = onGridClick; + }, [onGridClick]); + useEffect(() => { + cellInfoRef.current = onCellInfo; + }, [onCellInfo]); useEffect(() => { inputsRef.current = { filteredAlerts, showAlertMarkers, forecastDay }; }, [filteredAlerts, showAlertMarkers, forecastDay]); - // --- Initialize map once --- useEffect(() => { - if (!mapRef.current || mapInstanceRef.current) return; + if (!mapRef.current || viewRef.current) return; - const map = L.map(mapRef.current, { - center: WUHAN_CENTER, - zoom: 9, - zoomControl: true, - preferCanvas: true, + const { map, view, destroy } = createMapView({ + container: mapRef.current, + zoom: 10, }); - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - maxZoom: 19, - }).addTo(map); + const riskTiles = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0); + const alertLayer = createGraphicsLayer('预警点'); + const selectLayer = createGraphicsLayer('选中'); - // Full-Wuhan 100m risk grid as raster tiles. The browser only fetches PNGs - // (Leaflet caches them per z/x/y); LOD is inherent in the tile pyramid. - const riskTiles = L.tileLayer(riskApi.tileUrlTemplate(forecastDay), { - opacity: showGrid ? GRID_OPACITY : 0, - maxNativeZoom: 16, - maxZoom: 19, - updateWhenZooming: false, - keepBuffer: 2, - zIndex: 200, - }).addTo(map); - riskTileRef.current = riskTiles; + map.addMany([riskTiles, alertLayer, selectLayer]); - const alertLayer = L.layerGroup().addTo(map); + riskLayerRef.current = riskTiles; alertLayerRef.current = alertLayer; + selectLayerRef.current = selectLayer; + viewRef.current = view; + destroyRef.current = destroy; - // Click-to-inspect: query the 100m cell under the cursor. Clicks on alert - // markers are consumed by the marker handler and never reach this. - map.on('click', async (e: L.LeafletMouseEvent) => { - const { lat, lng } = e.latlng; + try { + view.ui.move('zoom', 'bottom-left'); + } catch { + /* ignore */ + } + + view.when(() => setMapReady(true)).catch(() => setMapReady(true)); + + const clickHandle = view.on('click', async (event) => { + if (!event.mapPoint) return; + const lat = event.mapPoint.latitude; + const lng = event.mapPoint.longitude; + if (lat == null || lng == null) return; const { filteredAlerts: alerts, forecastDay: day } = inputsRef.current; try { const cell = await riskApi.getCell(lat, lng, day); - // Nearest alert (squared degree distance — cheap, no sqrt). let nearestId: string | null = null; let minSq = Infinity; for (const a of alerts) { const dx = a.latitude - lat; const dy = a.longitude - lng; const d = dx * dx + dy * dy; - if (d < minSq) { minSq = d; nearestId = a.grid_id; } + if (d < minSq) { + minSq = d; + nearestId = a.grid_id; + } } const nearestDist = Math.sqrt(minSq); if (nearestId && nearestDist < 0.01) { clickHandlerRef.current(nearestId); } else if (cellInfoRef.current) { cellInfoRef.current({ - lat, lon: lng, + lat, + lon: lng, risk: cell.risk_value, grid_id: cell.grid_id, risk_1d: cell.risk_1d, @@ -139,160 +150,199 @@ function AlertMapComponent({ nearestAlertDist: nearestDist, }); } - } catch { /* transient fetch error — ignore the click */ } + } catch { + /* ignore transient click errors */ + } }); - mapInstanceRef.current = map; - - const resizeObserver = new ResizeObserver(() => { - mapInstanceRef.current?.invalidateSize({ animate: false }); - }); - resizeObserver.observe(mapRef.current); - resizeObserverRef.current = resizeObserver; - return () => { - resizeObserver.disconnect(); - resizeObserverRef.current = null; - markerMapRef.current.clear(); + clickHandle.remove(); + riskLayerRef.current = null; alertLayerRef.current = null; - riskTileRef.current = null; - map.remove(); - mapInstanceRef.current = null; + selectLayerRef.current = null; + viewRef.current = null; + destroy(); + destroyRef.current = null; + setMapReady(false); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // --- Update risk tiles + stats when the forecast horizon changes --- useEffect(() => { - const layer = riskTileRef.current; - if (layer) layer.setUrl(riskApi.tileUrlTemplate(forecastDay)); + const map = viewRef.current?.map; + if (!map || !mapReady) return; + + if (riskLayerRef.current) { + map.remove(riskLayerRef.current); + riskLayerRef.current.destroy(); + } + const next = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0); + map.add(next); + riskLayerRef.current = next; let cancelled = false; setStatsLoading(true); - riskApi.getGridStats(forecastDay) - .then((s) => { if (!cancelled) setGridStats(s); }) - .catch(() => { if (!cancelled) setGridStats(null); }) - .finally(() => { if (!cancelled) setStatsLoading(false); }); - return () => { cancelled = true; }; - }, [forecastDay]); + riskApi + .getGridStats(forecastDay) + .then((s) => { + if (!cancelled) setGridStats(s); + }) + .catch(() => { + if (!cancelled) setGridStats(null); + }) + .finally(() => { + if (!cancelled) setStatsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [forecastDay, mapReady]); - // --- Toggle grid visibility without rebuilding tiles --- useEffect(() => { - riskTileRef.current?.setOpacity(showGrid ? GRID_OPACITY : 0); + if (riskLayerRef.current) { + riskLayerRef.current.opacity = showGrid ? GRID_OPACITY : 0; + } }, [showGrid]); - // --- Render alert markers via diffing against a persistent layer group --- const renderAlertMarkers = useCallback(() => { - const map = mapInstanceRef.current; const layer = alertLayerRef.current; - if (!map || !layer) return; + const view = viewRef.current; + if (!layer || !view) return; + + layer.removeAll(); - const markerMap = markerMapRef.current; const { filteredAlerts: alerts, showAlertMarkers: showMarkers } = inputsRef.current; + if (!showMarkers || !alerts?.length) return; - if (!showMarkers || !alerts || alerts.length === 0) { - if (markerMap.size > 0) { layer.clearLayers(); markerMap.clear(); } - return; - } - - const b = map.getBounds(); - const south = b.getSouth(), north = b.getNorth(), west = b.getWest(), east = b.getEast(); + const extent = view.extent; const maxMarkers = 500; const step = Math.max(1, Math.floor(alerts.length / maxMarkers)); + const graphics: Graphic[] = []; - const desired = new Map(); for (let i = 0; i < alerts.length; i += step) { const alert = alerts[i]; if (!alert.latitude || !alert.longitude) continue; - if (alert.latitude < south || alert.latitude > north || - alert.longitude < west || alert.longitude > east) continue; - const key = alert.grid_id || `${alert.latitude},${alert.longitude},${i}`; - desired.set(key, alert); - } - - for (const [key, marker] of markerMap) { - if (!desired.has(key)) { layer.removeLayer(marker); markerMap.delete(key); } - } - - for (const [key, alert] of desired) { - if (markerMap.has(key)) continue; + if (extent) { + if ( + alert.longitude < extent.xmin || + alert.longitude > extent.xmax || + alert.latitude < extent.ymin || + alert.latitude > extent.ymax + ) { + continue; + } + } const isP1 = alert.priority === 'P1'; - const marker = L.circleMarker([alert.latitude, alert.longitude], { - radius: isP1 ? 6 : 4, - fillColor: isP1 ? '#ef4444' : '#f97316', - fillOpacity: 0.7, - color: isP1 ? '#ef4444' : '#f97316', - weight: 2, - dashArray: isP1 ? undefined : '4 2', - }); - marker.bindTooltip( - `
- ${alert.priority} · ${(alert.risk_value * 100).toFixed(0)}%
- ${alert.region || ''} ${alert.street || ''} -
`, - { direction: 'top', offset: [0, -5] } + const g = pointGraphic( + alert.longitude, + alert.latitude, + isP1 ? '#ef4444' : '#f97316', + isP1 ? 10 : 7, + { + grid_id: alert.grid_id, + priority: alert.priority, + risk_value: alert.risk_value, + region: alert.region, + street: alert.street, + } ); - const gridId = alert.grid_id; - marker.on('click', () => { if (gridId) clickHandlerRef.current(gridId); }); - marker.addTo(layer); - markerMap.set(key, marker); + g.popupTemplate = { + title: '{priority}', + content: '{region} {street}
风险 {(risk_value * 100).toFixed(0)}%', + }; + graphics.push(g); } + + layer.addMany(graphics); }, []); useEffect(() => { + if (!mapReady) return; renderAlertMarkers(); - }, [filteredAlerts, showAlertMarkers, renderAlertMarkers]); + }, [filteredAlerts, showAlertMarkers, mapReady, renderAlertMarkers]); - // Re-render markers on pan/zoom, throttled, subscribed once per map instance. useEffect(() => { - const map = mapInstanceRef.current; - if (!map) return; + const view = viewRef.current; + if (!view || !mapReady) return; let throttle: ReturnType | null = null; - const handleMove = () => { - if (throttle) return; - throttle = setTimeout(() => { throttle = null; renderAlertMarkers(); }, 150); - }; - map.on('moveend', handleMove); + const handle = reactiveUtils.watch( + () => view.extent, + () => { + if (throttle) return; + throttle = setTimeout(() => { + throttle = null; + renderAlertMarkers(); + }, 150); + } + ); return () => { - map.off('moveend', handleMove); + handle.remove(); if (throttle) clearTimeout(throttle); }; - }, [renderAlertMarkers]); + }, [mapReady, renderAlertMarkers]); - // --- Selected alert highlight (located from the alert list, no grid scan) --- useEffect(() => { - const map = mapInstanceRef.current; - if (!map) return; - if (selectedMarkerRef.current) { - try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ } - selectedMarkerRef.current = null; - } + const layer = selectLayerRef.current; + const view = viewRef.current; + if (!layer || !view) return; + layer.removeAll(); if (!selectedGridId) return; const alert = filteredAlerts.find((a) => a.grid_id === selectedGridId); if (!alert) return; - const latHalf = 0.00045, lonHalf = 0.00052; - const rect = L.rectangle( - [[alert.latitude - latHalf, alert.longitude - lonHalf], - [alert.latitude + latHalf, alert.longitude + lonHalf]], - { fillColor: '#3b82f6', fillOpacity: 0.3, color: '#3b82f6', weight: 3 } - ).addTo(map); - selectedMarkerRef.current = rect; - map.flyTo([alert.latitude, alert.longitude], Math.max(map.getZoom(), 13), { duration: 0.5 }); + + const latHalf = 0.00045; + const lonHalf = 0.00052; + const ring = [ + [alert.longitude - lonHalf, alert.latitude - latHalf], + [alert.longitude + lonHalf, alert.latitude - latHalf], + [alert.longitude + lonHalf, alert.latitude + latHalf], + [alert.longitude - lonHalf, alert.latitude + latHalf], + [alert.longitude - lonHalf, alert.latitude - latHalf], + ]; + layer.add( + new Graphic({ + geometry: new Polygon({ rings: [ring], spatialReference: { wkid: 4326 } }), + symbol: new SimpleFillSymbol({ + color: [59, 130, 246, 0.3], + outline: { color: [59, 130, 246], width: 2 }, + }), + }) + ); + view.goTo( + { + center: new Point({ longitude: alert.longitude, latitude: alert.latitude }), + zoom: Math.max(view.zoom, 13), + }, + { duration: 500 } + ).catch(() => undefined); }, [selectedGridId, filteredAlerts]); - // --- Invalidate size after fullscreen toggle (CSS transition ~200ms) --- useEffect(() => { - const map = mapInstanceRef.current; - if (!map) return; - map.invalidateSize({ animate: false }); - const timer = setTimeout(() => map.invalidateSize({ animate: true }), 200); + // MapView observes container size; force a layout tick after fullscreen CSS settles. + const timer = setTimeout(() => { + const el = mapRef.current; + if (el) { + el.style.height = el.style.height; + } + }, 200); return () => clearTimeout(timer); }, [isFullscreen]); - const containerHeight = isFullscreen ? 'calc(100vh - 120px)' : 'calc(100vh - 280px)'; + // 工作台零内边距后,给地图更多垂直空间(非小卡片) + const containerHeight = isFullscreen ? 'calc(100vh - 100px)' : 'calc(100vh - 220px)'; return (
-
+
+ {!mapReady && ( +
+ 地图加载中… +
+ )}
风险等级 (100m 网格)
- {RISK_LEGEND.slice().reverse().map(([min, max, color]) => ( -
-
- - {getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%) - -
- ))} + {RISK_LEGEND.slice() + .reverse() + .map(([min, max, color]) => ( +
+
+ + {getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%) + +
+ ))}
<25% 不显示 diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 8cbdbaa..c678c94 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react'; -import { Outlet } from 'react-router-dom'; +import { Outlet, useLocation } from 'react-router-dom'; import { TopNav } from '@/components/TopNav'; import { SideNav } from '@/components/SideNav'; import { RouteErrorBoundary } from '@/components/RouteErrorBoundary'; @@ -10,7 +10,6 @@ interface AppShellProps { onLogout?: () => void; } -// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。 function getFocusable(container: HTMLElement): HTMLElement[] { return Array.from( container.querySelectorAll( @@ -19,28 +18,29 @@ function getFocusable(container: HTMLElement): HTMLElement[] { ).filter((el) => el.offsetParent !== null || el === document.activeElement); } -// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。 +/** 监测 / 预警:地图工作台,主区零内边距、禁止外层滚动,把高度留给地图。 */ +function isMapWorkbench(pathname: string): boolean { + return pathname.startsWith('/monitoring') || pathname.startsWith('/alerts'); +} + export function AppShell({ onLogout }: AppShellProps) { const alerts = useRiskStore((s) => s.alerts); + const location = useLocation(); + const mapWorkbench = isMapWorkbench(location.pathname); const [drawerOpen, setDrawerOpen] = useState(false); - // 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。 const [expandedNav, setExpandedNav] = useState('monitoring'); const drawerRef = useRef(null); const openDrawer = useCallback(() => setDrawerOpen(true), []); const closeDrawer = useCallback(() => setDrawerOpen(false), []); - // 抽屉作为模态:ESC 关闭、锁定 body 滚动、焦点移入并在关闭后归还给汉堡。 useEffect(() => { if (!drawerOpen) return; const opener = document.activeElement as HTMLElement | null; - - // 锁定 body 滚动,关闭时还原原值。 const prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; - // 焦点移入抽屉(优先第一个可聚焦元素,否则聚焦抽屉容器本身)。 const drawer = drawerRef.current; const focusables = drawer ? getFocusable(drawer) : []; (focusables[0] ?? drawer)?.focus(); @@ -51,7 +51,6 @@ export function AppShell({ onLogout }: AppShellProps) { closeDrawer(); return; } - // 焦点循环陷阱:Tab 在抽屉内首尾元素之间循环。 if (e.key === 'Tab' && drawer) { const items = getFocusable(drawer); if (items.length === 0) { @@ -77,7 +76,6 @@ export function AppShell({ onLogout }: AppShellProps) { return () => { document.removeEventListener('keydown', onKeyDown); document.body.style.overflow = prevOverflow; - // 关闭后把焦点还给打开抽屉的元素(汉堡按钮),回退到按 testid 查询。 const restoreTarget = opener ?? document.querySelector(`[data-testid="${TESTIDS.hamburger}"]`); @@ -86,15 +84,22 @@ export function AppShell({ onLogout }: AppShellProps) { }, [drawerOpen, closeDrawer]); return ( -
+
- {/* lg 及以上:持久侧栏导轨 */} - {/* lg 以下:离屏抽屉 + 遮罩 */} {drawerOpen && (