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