Compare commits
1 Commits
feature/ge
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 59bb135514 |
@@ -1,12 +1,12 @@
|
||||
# CBPOA — 武汉儿童呼吸疾病风险评估系统
|
||||
|
||||
FastAPI + React + `@geoscene/core` + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
|
||||
FastAPI + React + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Frontend (pnpm)
|
||||
cd frontend && pnpm dev # localhost:3000 → proxies /api to :8000
|
||||
cd frontend && pnpm dev # localhost:5173 → proxies /api to :8000
|
||||
|
||||
# Backend (Python venv)
|
||||
cd backend && uvicorn main:app --reload # localhost:8000
|
||||
@@ -22,7 +22,6 @@ 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/` |
|
||||
|
||||
@@ -13,7 +13,7 @@ load_dotenv()
|
||||
# Paths
|
||||
# ============================================================================
|
||||
|
||||
PROJECT_ROOT = Path(os.environ.get("CBPOA_ROOT", Path(__file__).parent.parent))
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
DATA_DIR = PROJECT_ROOT / "outputs" / "daily"
|
||||
REPORTS_DIR = PROJECT_ROOT / "outputs" / "reports"
|
||||
WUHAN_BOUNDARY_PATH = PROJECT_ROOT / "Datas" / "武汉市.geojson"
|
||||
|
||||
@@ -13,7 +13,6 @@ 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"])
|
||||
|
||||
@@ -96,14 +95,13 @@ 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=district,
|
||||
street=grid_id,
|
||||
region="武汉市",
|
||||
street=f"Grid {grid_id}",
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
risk_value=max_risk,
|
||||
|
||||
@@ -17,8 +17,6 @@ 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"])
|
||||
|
||||
@@ -85,10 +83,22 @@ 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")
|
||||
# Disk+memory cached mean — avoids re-parsing ~45MB GeoJSON every request
|
||||
values.append(daily_avg_risk(date_str))
|
||||
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)
|
||||
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(
|
||||
@@ -100,8 +110,15 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _grid_district_lookup() -> dict:
|
||||
"""Backward-compatible alias — prefer utils.district_lookup."""
|
||||
return grid_district_lookup()
|
||||
"""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)))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,21 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,26 +1,32 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev curl \
|
||||
libpq-dev \
|
||||
&& 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 /app
|
||||
WORKDIR /home/appuser
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
COPY --chown=appuser:appgroup requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
-i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy backend code
|
||||
COPY --chown=appuser:appgroup . .
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
ENV CBPOA_ROOT=/data
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/docs || exit 1
|
||||
|
||||
# Run uvicorn
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
[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
|
||||
@@ -1,47 +1,83 @@
|
||||
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: ../deploy/backend/Dockerfile
|
||||
container_name: cbpoa_backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: wuhan_backend
|
||||
environment:
|
||||
CBPOA_ROOT: /data
|
||||
CORS_ORIGINS: "*"
|
||||
volumes:
|
||||
- ../outputs:/data/outputs:ro
|
||||
- ../processed:/data/processed:ro
|
||||
- ../Datas:/data/Datas:ro
|
||||
DATABASE_URL: postgresql://wuhan_user:password@postgres:5432/wuhan_disease
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8000:8000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8000/docs || exit 1"]
|
||||
interval: 15s
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
start_period: 30s
|
||||
networks:
|
||||
- cbpoa_net
|
||||
- wuhan_network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
dockerfile: ../deploy/frontend/Dockerfile
|
||||
container_name: cbpoa_frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: wuhan_frontend
|
||||
environment:
|
||||
VITE_API_URL: http://localhost:8000
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
- backend
|
||||
ports:
|
||||
- "80:80"
|
||||
- "3000:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
|
||||
interval: 15s
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- cbpoa_net
|
||||
- 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
|
||||
|
||||
networks:
|
||||
cbpoa_net:
|
||||
driver: bridge
|
||||
wuhan_network:
|
||||
driver: bridge
|
||||
@@ -3,4 +3,4 @@ node_modules
|
||||
*.md
|
||||
tests
|
||||
.env*
|
||||
dist
|
||||
dist
|
||||
@@ -5,11 +5,16 @@ 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 . .
|
||||
ENV VITE_API_URL=/api
|
||||
|
||||
# Build the application
|
||||
RUN pnpm run build
|
||||
|
||||
# =============================================================================
|
||||
@@ -17,10 +22,15 @@ RUN pnpm run build
|
||||
# =============================================================================
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# 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 --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
|
||||
CMD wget --no-redirect --quiet --tries=1 --spider http://localhost/ || exit 1
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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=
|
||||
@@ -1,21 +1,19 @@
|
||||
# Frontend — React + TypeScript + GeoScene Maps SDK
|
||||
# Frontend — React + TypeScript + Leaflet
|
||||
|
||||
## Stack
|
||||
|
||||
- React 18, TypeScript 5, Vite 5
|
||||
- Tailwind CSS, Recharts, Zustand (state), Axios
|
||||
- `@geoscene/core` (Maps SDK for JavaScript) — POC uses Tianditu basemap + local GeoJSON / FastAPI risk tiles
|
||||
- Leaflet / react-leaflet (maps)
|
||||
- Playwright (e2e tests)
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
main.tsx # Entry (+ GeoScene theme CSS)
|
||||
main.tsx # Entry point
|
||||
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
|
||||
@@ -30,21 +28,15 @@ frontend/src/
|
||||
## Patterns
|
||||
|
||||
- Components: PascalCase, one per file, default export
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm dev # localhost:3000, proxies /api → localhost:8000
|
||||
pnpm dev # localhost:5173, proxies /api → localhost:8000
|
||||
pnpm build # tsc + vite build → dist/
|
||||
```
|
||||
|
||||
@@ -55,4 +47,3 @@ 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
|
||||
|
||||
143
frontend/e2e/doctor-view.spec.ts
Normal file
143
frontend/e2e/doctor-view.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
133
frontend/e2e/roles.spec.ts
Normal file
133
frontend/e2e/roles.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,11 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CBPOA · 武汉儿童呼吸疾病风险评估系统</title>
|
||||
<title>武汉儿童呼吸道疾病风险预测平台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&family=Noto+Sans+SC:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600&family=Source+Sans+Pro:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,12 @@
|
||||
"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"
|
||||
@@ -22,6 +23,7 @@
|
||||
"@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",
|
||||
|
||||
489
frontend/pnpm-lock.yaml
generated
489
frontend/pnpm-lock.yaml
generated
@@ -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,6 +23,9 @@ 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)
|
||||
@@ -42,6 +45,9 @@ 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
|
||||
@@ -82,18 +88,6 @@ 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==}
|
||||
|
||||
@@ -350,32 +344,6 @@ 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}
|
||||
@@ -396,12 +364,6 @@ 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'}
|
||||
@@ -414,16 +376,17 @@ 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
|
||||
|
||||
'@polymer/polymer@3.5.2':
|
||||
resolution: {integrity: sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==}
|
||||
'@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
|
||||
|
||||
'@remix-run/router@1.23.3':
|
||||
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
|
||||
@@ -633,6 +596,12 @@ 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==}
|
||||
|
||||
@@ -644,55 +613,6 @@ 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}
|
||||
@@ -714,13 +634,6 @@ 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'}
|
||||
@@ -851,41 +764,17 @@ 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==}
|
||||
|
||||
@@ -904,9 +793,6 @@ 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'}
|
||||
@@ -1046,9 +932,6 @@ 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'}
|
||||
@@ -1092,9 +975,6 @@ 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'}
|
||||
@@ -1209,9 +1089,6 @@ 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'}
|
||||
@@ -1342,6 +1219,9 @@ 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'}
|
||||
@@ -1349,15 +1229,6 @@ 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'}
|
||||
@@ -1383,10 +1254,6 @@ 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
|
||||
@@ -1394,11 +1261,6 @@ 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'}
|
||||
@@ -1638,6 +1500,13 @@ 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'}
|
||||
@@ -1779,9 +1648,6 @@ 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'}
|
||||
@@ -1823,9 +1689,6 @@ 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'}
|
||||
@@ -1838,10 +1701,6 @@ 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==}
|
||||
|
||||
@@ -1875,17 +1734,10 @@ 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'}
|
||||
@@ -2041,11 +1893,6 @@ 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==}
|
||||
|
||||
@@ -2074,17 +1921,6 @@ 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)
|
||||
@@ -2296,56 +2132,6 @@ 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
|
||||
@@ -2369,12 +2155,6 @@ 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
|
||||
@@ -2387,15 +2167,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
|
||||
|
||||
'@polymer/polymer@3.5.2':
|
||||
'@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@webcomponents/shadycss': 1.11.2
|
||||
leaflet: 1.9.4
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@remix-run/router@1.23.3': {}
|
||||
|
||||
@@ -2557,6 +2337,12 @@ 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)':
|
||||
@@ -2568,118 +2354,6 @@ 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
|
||||
@@ -2721,10 +2395,6 @@ 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
|
||||
@@ -2862,35 +2532,14 @@ 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: {}
|
||||
@@ -2905,8 +2554,6 @@ snapshots:
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
cssfilter@0.0.10: {}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 3.2.0
|
||||
@@ -3056,8 +2703,6 @@ 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
|
||||
@@ -3126,10 +2771,6 @@ 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:
|
||||
@@ -3234,10 +2875,6 @@ 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
|
||||
@@ -3373,26 +3010,12 @@ 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
|
||||
@@ -3418,16 +3041,12 @@ 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: {}
|
||||
@@ -3627,6 +3246,13 @@ 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):
|
||||
@@ -3823,8 +3449,6 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
sortablejs@1.15.7: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
@@ -3864,8 +3488,6 @@ snapshots:
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tabbable@6.5.0: {}
|
||||
|
||||
tailwindcss@3.4.19:
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
@@ -3902,8 +3524,6 @@ snapshots:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
timezone-groups@0.10.4: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
@@ -3934,12 +3554,8 @@ 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: {}
|
||||
@@ -4097,11 +3713,6 @@ 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: {}
|
||||
|
||||
@@ -75,12 +75,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
{token ? (
|
||||
<AuthedApp onLogout={handleLogout} />
|
||||
) : (
|
||||
|
||||
@@ -30,6 +30,11 @@ 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();
|
||||
@@ -40,6 +45,11 @@ 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();
|
||||
@@ -50,9 +60,9 @@ describe('Component exports', () => {
|
||||
expect((mod as any).default || mod.CaseLocationMap).toBeDefined();
|
||||
});
|
||||
|
||||
it('geoscene createMapView 可以被导入', async () => {
|
||||
const mod = await import('@/geoscene');
|
||||
expect(mod.createMapView).toBeDefined();
|
||||
it('CaseMap 可以被导入', async () => {
|
||||
const mod = await import('@/components/CaseMap');
|
||||
expect((mod as any).default || mod.CaseMap).toBeDefined();
|
||||
});
|
||||
|
||||
it('DistributionChart 可以被导入', async () => {
|
||||
@@ -65,6 +75,11 @@ 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();
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { useEffect, useRef, useState, useCallback, memo } from 'react';
|
||||
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 L from 'leaflet';
|
||||
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;
|
||||
@@ -37,8 +28,10 @@ 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'],
|
||||
@@ -64,83 +57,79 @@ function AlertMapComponent({
|
||||
isFullscreen = false,
|
||||
}: AlertMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<MapView | null>(null);
|
||||
const destroyRef = useRef<(() => void) | null>(null);
|
||||
const riskLayerRef = useRef<WebTileLayer | null>(null);
|
||||
const alertLayerRef = useRef<GraphicsLayer | null>(null);
|
||||
const selectLayerRef = useRef<GraphicsLayer | null>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const riskTileRef = useRef<L.TileLayer | null>(null);
|
||||
const alertLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const markerMapRef = useRef<Map<string, L.CircleMarker>>(new Map());
|
||||
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
|
||||
const clickHandlerRef = useRef(onGridClick);
|
||||
const cellInfoRef = useRef(onCellInfo);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(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<RiskGridStats | null>(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 || viewRef.current) return;
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
const { map, view, destroy } = createMapView({
|
||||
container: mapRef.current,
|
||||
zoom: 10,
|
||||
const map = L.map(mapRef.current, {
|
||||
center: WUHAN_CENTER,
|
||||
zoom: 9,
|
||||
zoomControl: true,
|
||||
preferCanvas: true,
|
||||
});
|
||||
|
||||
const riskTiles = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0);
|
||||
const alertLayer = createGraphicsLayer('预警点');
|
||||
const selectLayer = createGraphicsLayer('选中');
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
map.addMany([riskTiles, alertLayer, selectLayer]);
|
||||
// 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;
|
||||
|
||||
riskLayerRef.current = riskTiles;
|
||||
const alertLayer = L.layerGroup().addTo(map);
|
||||
alertLayerRef.current = alertLayer;
|
||||
selectLayerRef.current = selectLayer;
|
||||
viewRef.current = view;
|
||||
destroyRef.current = destroy;
|
||||
|
||||
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;
|
||||
// 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;
|
||||
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,
|
||||
@@ -150,199 +139,160 @@ function AlertMapComponent({
|
||||
nearestAlertDist: nearestDist,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore transient click errors */
|
||||
}
|
||||
} catch { /* transient fetch error — ignore the click */ }
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
mapInstanceRef.current?.invalidateSize({ animate: false });
|
||||
});
|
||||
resizeObserver.observe(mapRef.current);
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
return () => {
|
||||
clickHandle.remove();
|
||||
riskLayerRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
markerMapRef.current.clear();
|
||||
alertLayerRef.current = null;
|
||||
selectLayerRef.current = null;
|
||||
viewRef.current = null;
|
||||
destroy();
|
||||
destroyRef.current = null;
|
||||
setMapReady(false);
|
||||
riskTileRef.current = null;
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// --- Update risk tiles + stats when the forecast horizon changes ---
|
||||
useEffect(() => {
|
||||
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;
|
||||
const layer = riskTileRef.current;
|
||||
if (layer) layer.setUrl(riskApi.tileUrlTemplate(forecastDay));
|
||||
|
||||
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, mapReady]);
|
||||
riskApi.getGridStats(forecastDay)
|
||||
.then((s) => { if (!cancelled) setGridStats(s); })
|
||||
.catch(() => { if (!cancelled) setGridStats(null); })
|
||||
.finally(() => { if (!cancelled) setStatsLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [forecastDay]);
|
||||
|
||||
// --- Toggle grid visibility without rebuilding tiles ---
|
||||
useEffect(() => {
|
||||
if (riskLayerRef.current) {
|
||||
riskLayerRef.current.opacity = showGrid ? GRID_OPACITY : 0;
|
||||
}
|
||||
riskTileRef.current?.setOpacity(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;
|
||||
const view = viewRef.current;
|
||||
if (!layer || !view) return;
|
||||
|
||||
layer.removeAll();
|
||||
if (!map || !layer) return;
|
||||
|
||||
const markerMap = markerMapRef.current;
|
||||
const { filteredAlerts: alerts, showAlertMarkers: showMarkers } = inputsRef.current;
|
||||
if (!showMarkers || !alerts?.length) return;
|
||||
|
||||
const extent = view.extent;
|
||||
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 maxMarkers = 500;
|
||||
const step = Math.max(1, Math.floor(alerts.length / maxMarkers));
|
||||
const graphics: Graphic[] = [];
|
||||
|
||||
const desired = new Map<string, Alert>();
|
||||
for (let i = 0; i < alerts.length; i += step) {
|
||||
const alert = alerts[i];
|
||||
if (!alert.latitude || !alert.longitude) 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 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,
|
||||
}
|
||||
);
|
||||
g.popupTemplate = {
|
||||
title: '{priority}',
|
||||
content: '{region} {street}<br/>风险 {(risk_value * 100).toFixed(0)}%',
|
||||
};
|
||||
graphics.push(g);
|
||||
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);
|
||||
}
|
||||
|
||||
layer.addMany(graphics);
|
||||
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;
|
||||
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(
|
||||
`<div style="font-size:12px;">
|
||||
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
|
||||
${alert.region || ''} ${alert.street || ''}
|
||||
</div>`,
|
||||
{ direction: 'top', offset: [0, -5] }
|
||||
);
|
||||
const gridId = alert.grid_id;
|
||||
marker.on('click', () => { if (gridId) clickHandlerRef.current(gridId); });
|
||||
marker.addTo(layer);
|
||||
markerMap.set(key, marker);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady) return;
|
||||
renderAlertMarkers();
|
||||
}, [filteredAlerts, showAlertMarkers, mapReady, renderAlertMarkers]);
|
||||
}, [filteredAlerts, showAlertMarkers, renderAlertMarkers]);
|
||||
|
||||
// Re-render markers on pan/zoom, throttled, subscribed once per map instance.
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !mapReady) return;
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
let throttle: ReturnType<typeof setTimeout> | null = null;
|
||||
const handle = reactiveUtils.watch(
|
||||
() => view.extent,
|
||||
() => {
|
||||
if (throttle) return;
|
||||
throttle = setTimeout(() => {
|
||||
throttle = null;
|
||||
renderAlertMarkers();
|
||||
}, 150);
|
||||
}
|
||||
);
|
||||
const handleMove = () => {
|
||||
if (throttle) return;
|
||||
throttle = setTimeout(() => { throttle = null; renderAlertMarkers(); }, 150);
|
||||
};
|
||||
map.on('moveend', handleMove);
|
||||
return () => {
|
||||
handle.remove();
|
||||
map.off('moveend', handleMove);
|
||||
if (throttle) clearTimeout(throttle);
|
||||
};
|
||||
}, [mapReady, renderAlertMarkers]);
|
||||
}, [renderAlertMarkers]);
|
||||
|
||||
// --- Selected alert highlight (located from the alert list, no grid scan) ---
|
||||
useEffect(() => {
|
||||
const layer = selectLayerRef.current;
|
||||
const view = viewRef.current;
|
||||
if (!layer || !view) return;
|
||||
layer.removeAll();
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
if (selectedMarkerRef.current) {
|
||||
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
|
||||
selectedMarkerRef.current = null;
|
||||
}
|
||||
if (!selectedGridId) return;
|
||||
const alert = filteredAlerts.find((a) => a.grid_id === selectedGridId);
|
||||
if (!alert) return;
|
||||
|
||||
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);
|
||||
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 });
|
||||
}, [selectedGridId, filteredAlerts]);
|
||||
|
||||
// --- Invalidate size after fullscreen toggle (CSS transition ~200ms) ---
|
||||
useEffect(() => {
|
||||
// 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);
|
||||
const map = mapInstanceRef.current;
|
||||
if (!map) return;
|
||||
map.invalidateSize({ animate: false });
|
||||
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isFullscreen]);
|
||||
|
||||
// 工作台零内边距后,给地图更多垂直空间(非小卡片)
|
||||
const containerHeight = isFullscreen ? 'calc(100vh - 100px)' : 'calc(100vh - 220px)';
|
||||
const containerHeight = isFullscreen ? 'calc(100vh - 120px)' : 'calc(100vh - 280px)';
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={mapRef}
|
||||
className="w-full rounded-lg overflow-hidden bg-slate-100"
|
||||
style={{ height: containerHeight }}
|
||||
/>
|
||||
{!mapReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/70 rounded-lg text-[13px] text-text-muted">
|
||||
地图加载中…
|
||||
</div>
|
||||
)}
|
||||
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
|
||||
|
||||
<GridStatsOverlay
|
||||
count={gridStats?.cell_count ?? 0}
|
||||
@@ -355,16 +305,14 @@ function AlertMapComponent({
|
||||
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">风险等级 (100m 网格)</div>
|
||||
<div className="space-y-1.5">
|
||||
{RISK_LEGEND.slice()
|
||||
.reverse()
|
||||
.map(([min, max, color]) => (
|
||||
<div key={color} className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
|
||||
<span className="text-[11px] text-text-secondary">
|
||||
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{RISK_LEGEND.slice().reverse().map(([min, max, color]) => (
|
||||
<div key={color} className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
|
||||
<span className="text-[11px] text-text-secondary">
|
||||
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border border-border-light bg-transparent" />
|
||||
<span className="text-[11px] text-text-muted"><25% 不显示</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { TopNav } from '@/components/TopNav';
|
||||
import { SideNav } from '@/components/SideNav';
|
||||
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
|
||||
@@ -10,6 +10,7 @@ interface AppShellProps {
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。
|
||||
function getFocusable(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
@@ -18,29 +19,28 @@ 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<string | null>('monitoring');
|
||||
const drawerRef = useRef<HTMLElement>(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,6 +51,7 @@ export function AppShell({ onLogout }: AppShellProps) {
|
||||
closeDrawer();
|
||||
return;
|
||||
}
|
||||
// 焦点循环陷阱:Tab 在抽屉内首尾元素之间循环。
|
||||
if (e.key === 'Tab' && drawer) {
|
||||
const items = getFocusable(drawer);
|
||||
if (items.length === 0) {
|
||||
@@ -76,6 +77,7 @@ export function AppShell({ onLogout }: AppShellProps) {
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
// 关闭后把焦点还给打开抽屉的元素(汉堡按钮),回退到按 testid 查询。
|
||||
const restoreTarget =
|
||||
opener ??
|
||||
document.querySelector<HTMLElement>(`[data-testid="${TESTIDS.hamburger}"]`);
|
||||
@@ -84,22 +86,15 @@ export function AppShell({ onLogout }: AppShellProps) {
|
||||
}, [drawerOpen, closeDrawer]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={TESTIDS.appShell}
|
||||
className="h-screen bg-bg-page flex flex-col overflow-hidden"
|
||||
>
|
||||
<div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden">
|
||||
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} isMenuOpen={drawerOpen} />
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* lg 及以上:持久侧栏导轨 */}
|
||||
<aside
|
||||
data-testid={TESTIDS.sidebarRail}
|
||||
className="hidden lg:flex lg:flex-col w-[212px] shrink-0 bg-bg-card/95 border-r border-border backdrop-blur-sm"
|
||||
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
|
||||
>
|
||||
<div className="px-4 pt-4 pb-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
||||
工作台
|
||||
</p>
|
||||
</div>
|
||||
<SideNav
|
||||
alertCount={alerts.length}
|
||||
expanded={expandedNav}
|
||||
@@ -107,9 +102,10 @@ export function AppShell({ onLogout }: AppShellProps) {
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* lg 以下:离屏抽屉 + 遮罩 */}
|
||||
{drawerOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-slate-900/35 lg:hidden"
|
||||
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
|
||||
onClick={closeDrawer}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -122,14 +118,10 @@ export function AppShell({ onLogout }: AppShellProps) {
|
||||
aria-label="导航菜单"
|
||||
tabIndex={-1}
|
||||
data-testid={TESTIDS.appDrawer}
|
||||
className={`fixed top-0 left-0 bottom-0 z-50 w-[280px] max-w-[85vw] bg-bg-card border-r border-border shadow-lift transition-transform duration-200 lg:hidden ${
|
||||
className={`fixed top-0 left-0 bottom-0 z-50 w-[260px] max-w-[80vw] bg-bg-card border-r border-border shadow-xl transition-transform duration-200 lg:hidden ${
|
||||
drawerOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="px-4 pt-5 pb-2 border-b border-border-light">
|
||||
<p className="brand-mark text-xl leading-none">CBPOA</p>
|
||||
<p className="mt-1 text-[11px] text-text-muted">儿童呼吸风险监测</p>
|
||||
</div>
|
||||
<SideNav
|
||||
alertCount={alerts.length}
|
||||
onNavigate={closeDrawer}
|
||||
@@ -138,13 +130,7 @@ export function AppShell({ onLogout }: AppShellProps) {
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
className={
|
||||
mapWorkbench
|
||||
? 'flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col'
|
||||
: 'flex-1 min-w-0 overflow-auto p-5'
|
||||
}
|
||||
>
|
||||
<main className="flex-1 min-w-0 overflow-auto p-5">
|
||||
<RouteErrorBoundary>
|
||||
<Outlet />
|
||||
</RouteErrorBoundary>
|
||||
|
||||
@@ -9,30 +9,30 @@
|
||||
|
||||
## Component Types
|
||||
|
||||
**Map components** (`*Map.tsx`, `DistrictChoropleth`) — `@geoscene/core`:
|
||||
- Use `createMapView` / layer factories from `@/geoscene`
|
||||
- Coordinate system: `[longitude, latitude]` (GeoScene convention)
|
||||
- Destroy MapView on unmount
|
||||
**Map components** (`*Map.tsx`) — Leaflet-based maps:
|
||||
- Use `react-leaflet` / direct Leaflet manipulation via `useRef`
|
||||
- Risk coloring: centralized `RISK_COLORS` and `RISK_LABELS` constants
|
||||
- Coordinate system: `[lat, lng]` (Leaflet convention, NOT `[lng, lat]`)
|
||||
|
||||
**Chart components** (`*Chart*.tsx`) — Recharts:
|
||||
- Responsive containers with `width="100%" height={...}`
|
||||
|
||||
**Navigation** (`TopNav.tsx`, `SideNav.tsx`):
|
||||
- No data fetching — pure navigation/presentation
|
||||
- No role/perspective switcher
|
||||
|
||||
**Overlay/Utility** (`GridStatsOverlay`, `ErrorBanner`, `StatCard`, `TimelinePlayer`):
|
||||
- Small, focused, reusable across pages
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Components receive data via props, never fetch directly (maps may call tile/cell APIs they own)
|
||||
- Components receive data via props, never fetch directly
|
||||
- Callbacks passed as props: `onGridSelect`, `onClosePanel`, `onForecastChange`
|
||||
- Complex stateful behavior extracted to custom hooks (e.g., `useTimelineStore`)
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't fetch page-level data in leaf components — receive via props or store hooks
|
||||
- Don't fetch data in components — receive via props or store hooks
|
||||
- Don't create god components (>200 lines) — extract sub-components
|
||||
- Don't use `any` in prop types — use `unknown` and narrow
|
||||
- Don't pass MapView instances between components
|
||||
- Don't use CSS modules or inline styles — Tailwind only (map symbol colors excepted)
|
||||
- Don't pass Leaflet map instances between components — each map manages its own instance
|
||||
- Don't use CSS modules or inline styles — Tailwind only
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useEffect, useRef, useState, memo } from 'react';
|
||||
import type MapView from '@geoscene/core/views/MapView';
|
||||
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
|
||||
import Graphic from '@geoscene/core/Graphic';
|
||||
import L from 'leaflet';
|
||||
import { geocodedApi } from '@/services/api';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import type { GeocodedCase } from '@/types';
|
||||
import { createMapView } from '@/geoscene/createMapView';
|
||||
import { createGraphicsLayer, pointGraphic, jitterLonLat } from '@/geoscene/layers';
|
||||
|
||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||||
|
||||
// 视图模式:
|
||||
// 'points' —— 个体病例点(默认,非医生视角)。逐病例渲染 circleMarker,
|
||||
// 并在 DOM 中输出隐藏的 patient-point 镜像供 e2e 计数。
|
||||
// 'density' —— 聚合密度(医生视角,隐私不变量)。仅按行政区/街道聚合的密度圆,
|
||||
// 不渲染任何个体点,patient-point 数量必须为 0。
|
||||
type CaseMapMode = 'points' | 'density';
|
||||
|
||||
interface CaseLocationMapProps {
|
||||
@@ -18,6 +21,7 @@ interface CaseLocationMapProps {
|
||||
mode?: CaseMapMode;
|
||||
}
|
||||
|
||||
// 聚合中心:按 street(无则 district)分组,取经纬度均值 + 计数。
|
||||
interface DensityCluster {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -54,64 +58,59 @@ function CaseLocationMapComponent({
|
||||
mode = 'points',
|
||||
}: CaseLocationMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<MapView | null>(null);
|
||||
const layerRef = useRef<GraphicsLayer | null>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const fittedScopeRef = useRef<string | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [caseCount, setCaseCount] = useState(0);
|
||||
// points 模式下的隐藏 DOM 镜像(每病例一项,供 e2e 对 patient-point 计数)。
|
||||
// density 模式下保持为空数组 —— 隐私不变量:医生视角下 patient-point 必须为 0。
|
||||
const [pointKeys, setPointKeys] = useState<string[]>([]);
|
||||
const [clusterCount, setClusterCount] = useState(0);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || viewRef.current) return;
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
cancelledRef.current = false;
|
||||
const { map, view, destroy } = createMapView({
|
||||
container: mapRef.current,
|
||||
|
||||
const map = L.map(mapRef.current, {
|
||||
center: WUHAN_CENTER,
|
||||
zoom: 11,
|
||||
zoomControl: true,
|
||||
});
|
||||
const layer = createGraphicsLayer('病例');
|
||||
map.add(layer);
|
||||
layerRef.current = layer;
|
||||
viewRef.current = view;
|
||||
|
||||
// 缩放控件移到左下,避开右上观测日与图例
|
||||
try {
|
||||
view.ui.move('zoom', 'bottom-left');
|
||||
} catch {
|
||||
/* zoom widget may be absent */
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
layerRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
// ResizeObserver: auto-invalidate when container size changes (window resize, layout shifts)
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.invalidateSize({ animate: false });
|
||||
}
|
||||
});
|
||||
if (mapRef.current) {
|
||||
resizeObserver.observe(mapRef.current);
|
||||
}
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
layerRef.current = null;
|
||||
viewRef.current = null;
|
||||
fittedScopeRef.current = null;
|
||||
destroy();
|
||||
setMapReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady || !viewRef.current || !layerRef.current) return;
|
||||
|
||||
cancelledRef.current = false;
|
||||
const layer = layerRef.current;
|
||||
const view = viewRef.current;
|
||||
const scopeKey = `${district ?? ''}|${street ?? ''}|${mode}`;
|
||||
const shouldFit = fittedScopeRef.current !== scopeKey;
|
||||
|
||||
geocodedApi
|
||||
.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
|
||||
// Fetch case locations
|
||||
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
|
||||
.then((data) => {
|
||||
if (cancelledRef.current) return;
|
||||
const cases: GeocodedCase[] = data.cases || [];
|
||||
layer.removeAll();
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
layer.clearLayers();
|
||||
|
||||
// Deduplicate by case_id to avoid overlapping markers
|
||||
const seen = new Set<string>();
|
||||
let unique: GeocodedCase[] = [];
|
||||
for (const c of cases) {
|
||||
@@ -120,64 +119,83 @@ function CaseLocationMapComponent({
|
||||
unique.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side street filtering
|
||||
if (street) {
|
||||
unique = unique.filter((c) => c.street === street);
|
||||
}
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
if (mode === 'density') {
|
||||
// 医生视角:仅渲染聚合密度圆(按街道/区聚合),不渲染任何个体点。
|
||||
const clusters = aggregateClusters(unique);
|
||||
const maxCount = clusters.reduce((m, c) => Math.max(m, c.count), 1);
|
||||
const graphics = clusters.map((cl) => {
|
||||
const size = 10 + Math.round((cl.count / maxCount) * 22);
|
||||
const g = pointGraphic(cl.longitude, cl.latitude, '#7c3aed', size, {
|
||||
label: cl.label,
|
||||
count: cl.count,
|
||||
|
||||
for (const cl of clusters) {
|
||||
// 半径随计数缩放(8–28px),明确表达「密度」而非个体位置。
|
||||
const radius = 8 + Math.round((cl.count / maxCount) * 20);
|
||||
const marker = L.circleMarker([cl.latitude, cl.longitude], {
|
||||
radius,
|
||||
fillColor: '#7c3aed',
|
||||
fillOpacity: 0.35,
|
||||
color: '#7c3aed',
|
||||
weight: 1.5,
|
||||
});
|
||||
g.popupTemplate = {
|
||||
title: '{label}',
|
||||
content: '病例数: {count}',
|
||||
};
|
||||
return g;
|
||||
});
|
||||
layer.addMany(graphics);
|
||||
marker.bindTooltip(
|
||||
`<div style="font-size:12px"><strong>${cl.label}</strong><br/>病例数: ${cl.count}</div>`,
|
||||
{ direction: 'top', offset: [0, -4] }
|
||||
);
|
||||
marker.addTo(layer);
|
||||
}
|
||||
|
||||
setClusterCount(clusters.length);
|
||||
setCaseCount(unique.length);
|
||||
setPointKeys([]);
|
||||
setPointKeys([]); // 隐私不变量:density 下无个体点镜像
|
||||
setIsLoading(false);
|
||||
if (shouldFit && graphics.length > 0) {
|
||||
fittedScopeRef.current = scopeKey;
|
||||
view.goTo(graphics).catch(() => undefined);
|
||||
|
||||
if (clusters.length > 0) {
|
||||
const bounds = L.latLngBounds(clusters.map((c) => [c.latitude, c.longitude]));
|
||||
map.fitBounds(bounds, { padding: [30, 30] });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// points 模式(默认):逐病例渲染个体 circleMarker。
|
||||
const keys: string[] = [];
|
||||
const graphics: Graphic[] = [];
|
||||
for (const c of unique) {
|
||||
if (!c.latitude || !c.longitude) continue;
|
||||
|
||||
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
|
||||
const [lon, lat] = jitterLonLat(c.longitude, c.latitude, c.case_id, 70);
|
||||
const g = pointGraphic(lon, lat, color, 6, {
|
||||
district: c.district,
|
||||
street: c.street,
|
||||
case_type: c.case_type,
|
||||
const marker = L.circleMarker([c.latitude, c.longitude], {
|
||||
radius: 3,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.6,
|
||||
color: color,
|
||||
weight: 1,
|
||||
});
|
||||
g.popupTemplate = {
|
||||
title: '{district} {street}',
|
||||
content: '类型: {case_type}',
|
||||
};
|
||||
graphics.push(g);
|
||||
|
||||
marker.bindTooltip(
|
||||
`<div style="font-size:12px">
|
||||
<strong>${c.district}</strong> ${c.street}<br/>
|
||||
类型: ${c.case_type === 'inpatient' ? '住院' : '门诊'}
|
||||
</div>`,
|
||||
{ direction: 'top', offset: [0, -4] }
|
||||
);
|
||||
|
||||
marker.addTo(layer);
|
||||
keys.push(c.case_id);
|
||||
}
|
||||
layer.addMany(graphics);
|
||||
|
||||
setClusterCount(0);
|
||||
setCaseCount(unique.length);
|
||||
setPointKeys(keys);
|
||||
setPointKeys(keys); // 隐藏 DOM 镜像供 e2e 计数
|
||||
setIsLoading(false);
|
||||
if (shouldFit && graphics.length > 0) {
|
||||
fittedScopeRef.current = scopeKey;
|
||||
view.goTo(graphics).catch(() => undefined);
|
||||
|
||||
// Fit bounds to case locations
|
||||
if (unique.length > 0) {
|
||||
const bounds = L.latLngBounds(unique.map((c) => [c.latitude, c.longitude]));
|
||||
map.fitBounds(bounds, { padding: [30, 30] });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -186,69 +204,43 @@ function CaseLocationMapComponent({
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, [district, street, date, mode, mapReady]);
|
||||
}, [district, street, date, mode]);
|
||||
|
||||
const isDensity = mode === 'density';
|
||||
|
||||
const fillParent = height === '100%' || height === '100vh';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${fillParent ? 'h-full min-h-0' : ''}`}
|
||||
data-case-map-mode={mode}
|
||||
>
|
||||
<div
|
||||
ref={mapRef}
|
||||
className={`w-full overflow-hidden bg-bg-hover ${fillParent ? 'h-full rounded-none' : 'rounded-xl'}`}
|
||||
style={{ height: fillParent ? '100%' : height, width: '100%' }}
|
||||
/>
|
||||
{!mapReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/80">
|
||||
<div className="text-[13px] text-text-muted">加载地图…</div>
|
||||
<div className="relative" data-case-map-mode={mode}>
|
||||
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
|
||||
<div className="text-sm text-gray-500">加载病例位置...</div>
|
||||
</div>
|
||||
)}
|
||||
{mapReady && isLoading && (
|
||||
<div className="absolute top-14 right-3 z-[1000] bg-bg-card/95 px-2.5 py-1 rounded-md border border-border shadow-soft text-[11px] text-text-muted">
|
||||
更新病例…
|
||||
{!isLoading && !isDensity && (
|
||||
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
||||
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span> 个病例位置
|
||||
<span className="ml-2 text-red-500">● 住院</span>
|
||||
<span className="ml-1 text-blue-500">● 门诊</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && mapReady && (
|
||||
<div
|
||||
className="absolute bottom-3 left-14 z-[1000] bg-bg-card/95 px-3 py-2 rounded-lg border border-border shadow-soft text-[12px] max-w-[220px]"
|
||||
aria-label="病例图例"
|
||||
>
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-1.5 tracking-wide">
|
||||
病例分布
|
||||
</div>
|
||||
{!isDensity ? (
|
||||
<>
|
||||
<div className="font-mono tabular-nums text-text-primary mb-1.5">
|
||||
<span className="text-primary font-semibold">{caseCount.toLocaleString()}</span>
|
||||
<span className="text-text-muted ml-1">个位置</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] text-text-muted">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#3b82f6]" aria-hidden />
|
||||
门诊
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-[#ef4444]" aria-hidden />
|
||||
住院
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-mono tabular-nums text-text-primary mb-1">
|
||||
<span className="font-semibold text-mist-deep">{clusterCount.toLocaleString()}</span>
|
||||
<span className="text-text-muted ml-1">个聚合区</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted">按区/街聚合 · 圆点越大病例越多</div>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && isDensity && (
|
||||
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
||||
<span className="text-purple-600 font-semibold">{clusterCount.toLocaleString()}</span> 个聚合区域
|
||||
<span className="ml-2 text-gray-500">按区域聚合密度(隐私保护)</span>
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
隐藏 DOM 镜像:points 模式下每病例输出一个 patient-point 节点,使 e2e 能对
|
||||
Leaflet canvas 之外的真实 DOM 做计数断言。density 模式下 pointKeys 恒为空,
|
||||
因此医生视角下 [data-testid=patient-point] 数量必为 0(隐私不变量)。
|
||||
*/}
|
||||
<div className="hidden" aria-hidden="true">
|
||||
{pointKeys.map((id) => (
|
||||
<span key={id} data-testid={TESTIDS.patientPoint} data-case-id={id} />
|
||||
|
||||
377
frontend/src/components/CaseMap.tsx
Normal file
377
frontend/src/components/CaseMap.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Skeleton } from '@/components/ui';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { geocodedApi } from '@/services/api';
|
||||
import type { CaseGrid, GeocodedCase } from '@/types';
|
||||
|
||||
interface CaseMapProps {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'grid' | 'point';
|
||||
|
||||
// Grid is 100m x 100m at Wuhan latitude (~30.5°N)
|
||||
const GRID_HALF_SIZE_LAT = 0.00045; // ~50m in degrees
|
||||
const GRID_HALF_SIZE_LON = 0.00052; // ~50m in degrees
|
||||
|
||||
function getGridBounds(g: { latitude: number; longitude: number }) {
|
||||
if (typeof g.latitude !== 'number' || typeof g.longitude !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
lat_min: g.latitude - GRID_HALF_SIZE_LAT,
|
||||
lat_max: g.latitude + GRID_HALF_SIZE_LAT,
|
||||
lon_min: g.longitude - GRID_HALF_SIZE_LON,
|
||||
lon_max: g.longitude + GRID_HALF_SIZE_LON,
|
||||
};
|
||||
}
|
||||
|
||||
const RISK_COLORS = {
|
||||
high: '#ff4444',
|
||||
medium: '#ffaa44',
|
||||
low: '#44bb44',
|
||||
};
|
||||
|
||||
function getRiskColor(riskIndex: number): string {
|
||||
if (riskIndex >= 0.67) return RISK_COLORS.high;
|
||||
if (riskIndex >= 0.33) return RISK_COLORS.medium;
|
||||
return RISK_COLORS.low;
|
||||
}
|
||||
|
||||
function getRiskLabel(riskIndex: number): string {
|
||||
if (riskIndex >= 0.67) return '高风险';
|
||||
if (riskIndex >= 0.33) return '中风险';
|
||||
return '低风险';
|
||||
}
|
||||
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
function CaseMapComponent({ height = '480px' }: CaseMapProps) {
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<any>(null);
|
||||
const gridLayerRef = useRef<any>(null);
|
||||
const pointLayerRef = useRef<any>(null);
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [grids, setGrids] = useState<CaseGrid[]>([]);
|
||||
const [cases, setCases] = useState<GeocodedCase[]>([]);
|
||||
const [totalCases, setTotalCases] = useState(0);
|
||||
const [gridCount, setGridCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchData() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [gridRes, geoRes] = await Promise.all([
|
||||
geocodedApi.getGrid(),
|
||||
geocodedApi.getGeocoded({ limit: 5000 }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setGrids(gridRes.grids || []);
|
||||
setGridCount(gridRes.total_count || 0);
|
||||
setTotalCases(gridRes.total_cases || 0);
|
||||
setCases(geoRes.cases || []);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapDivRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(mapDivRef.current, {
|
||||
center: [30.59, 114.31],
|
||||
zoom: 11,
|
||||
zoomControl: true,
|
||||
preferCanvas: false,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
const handleZoom = debounce(() => renderLayers(), 150);
|
||||
const handleMove = debounce(() => renderLayers(), 150);
|
||||
|
||||
map.on('zoomend', handleZoom);
|
||||
map.on('moveend', handleMove);
|
||||
|
||||
return () => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
gridLayerRef.current = null;
|
||||
pointLayerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
renderLayers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [grids, cases, viewMode]);
|
||||
|
||||
const renderLayers = useCallback(() => {
|
||||
if (!mapRef.current) return;
|
||||
const map = mapRef.current;
|
||||
|
||||
if (gridLayerRef.current) {
|
||||
try { map.removeLayer(gridLayerRef.current); } catch { /* silent */ }
|
||||
gridLayerRef.current = null;
|
||||
}
|
||||
if (pointLayerRef.current) {
|
||||
try { map.removeLayer(pointLayerRef.current); } catch { /* silent */ }
|
||||
pointLayerRef.current = null;
|
||||
}
|
||||
|
||||
const zoom = map.getZoom();
|
||||
|
||||
if (viewMode === 'grid') {
|
||||
const gridLayer = L.layerGroup();
|
||||
const bounds = map.getBounds();
|
||||
|
||||
let rendered = 0;
|
||||
const maxRender = 5000;
|
||||
|
||||
for (const g of grids) {
|
||||
if (rendered >= maxRender) break;
|
||||
|
||||
const gBounds = getGridBounds(g);
|
||||
if (!gBounds) continue;
|
||||
|
||||
if (
|
||||
gBounds.lat_max < bounds.getSouth() ||
|
||||
gBounds.lat_min > bounds.getNorth() ||
|
||||
gBounds.lon_max < bounds.getWest() ||
|
||||
gBounds.lon_min > bounds.getEast()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const color = getRiskColor(g.risk_index);
|
||||
const opacity = 0.5 + g.risk_index * 0.35;
|
||||
|
||||
const rect = L.rectangle(
|
||||
[[gBounds.lat_min, gBounds.lon_min], [gBounds.lat_max, gBounds.lon_max]],
|
||||
{
|
||||
fillColor: color,
|
||||
fillOpacity: opacity,
|
||||
color: color,
|
||||
weight: zoom >= 14 ? 1 : 0,
|
||||
opacity: 0.3,
|
||||
}
|
||||
);
|
||||
|
||||
rect.bindTooltip(
|
||||
`<div style="font-size: 12px;">
|
||||
<strong>网格 ${g.grid_id}</strong><br/>
|
||||
病例数: ${g.total_cases.toLocaleString()}<br/>
|
||||
风险指数: ${(g.risk_index * 100).toFixed(1)}%<br/>
|
||||
<span style="color: ${color}; font-weight: 600;">${getRiskLabel(g.risk_index)}</span>
|
||||
</div>`,
|
||||
{ direction: 'top', offset: [0, -5] }
|
||||
);
|
||||
|
||||
rect.addTo(gridLayer);
|
||||
rendered++;
|
||||
}
|
||||
|
||||
gridLayer.addTo(map);
|
||||
gridLayerRef.current = gridLayer;
|
||||
} else {
|
||||
const pointLayer = L.layerGroup();
|
||||
const bounds = map.getBounds();
|
||||
|
||||
const caseColor = (c: GeocodedCase) =>
|
||||
c.case_type === 'inpatient' ? '#DC2626' : '#2563EB';
|
||||
|
||||
// Viewport culling + maxRender to avoid Leaflet canvas intersects bug
|
||||
const maxRender = 500;
|
||||
let rendered = 0;
|
||||
|
||||
for (const c of cases) {
|
||||
if (rendered >= maxRender) break;
|
||||
if (typeof c.latitude !== 'number' || typeof c.longitude !== 'number') continue;
|
||||
|
||||
// Viewport culling - skip points outside visible area
|
||||
if (
|
||||
c.latitude < bounds.getSouth() ||
|
||||
c.latitude > bounds.getNorth() ||
|
||||
c.longitude < bounds.getWest() ||
|
||||
c.longitude > bounds.getEast()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use tiny rectangles instead of circleMarker to avoid Leaflet 1.9.4 intersects bug
|
||||
const size = zoom >= 14 ? 0.00005 : zoom >= 12 ? 0.00003 : 0.00002;
|
||||
const rect = L.rectangle(
|
||||
[[c.latitude - size, c.longitude - size], [c.latitude + size, c.longitude + size]],
|
||||
{
|
||||
fillColor: caseColor(c),
|
||||
fillOpacity: 0.8,
|
||||
color: '#FFFFFF',
|
||||
weight: 0.5,
|
||||
}
|
||||
);
|
||||
|
||||
rect.bindTooltip(
|
||||
`<div style="font-size: 12px;">
|
||||
<strong>${c.case_type === 'inpatient' ? '住院' : '门诊'}病例</strong><br/>
|
||||
坐标:${c.latitude.toFixed(5)}, ${c.longitude.toFixed(5)}
|
||||
</div>`,
|
||||
{ direction: 'top', offset: [0, -5] }
|
||||
);
|
||||
|
||||
rect.addTo(pointLayer);
|
||||
rendered++;
|
||||
}
|
||||
|
||||
pointLayer.addTo(map);
|
||||
pointLayerRef.current = pointLayer;
|
||||
}
|
||||
}, [grids, cases, viewMode]);
|
||||
|
||||
const handleToggle = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border-light">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
|
||||
</svg>
|
||||
<span className="font-medium text-[14px]">病例空间分布</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
|
||||
<button
|
||||
onClick={() => handleToggle('grid')}
|
||||
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-bg-card text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
网格视图
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle('point')}
|
||||
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
||||
viewMode === 'point'
|
||||
? 'bg-bg-card text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
点分布
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-text-muted">
|
||||
{viewMode === 'grid' ? '100×100m 网格' : '个体病例定位'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative" style={{ height }}>
|
||||
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
|
||||
|
||||
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
|
||||
{viewMode === 'grid' ? (
|
||||
<>
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">风险等级</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.high }} />
|
||||
<span className="text-[11px] text-text-secondary">高风险 (>67%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.medium }} />
|
||||
<span className="text-[11px] text-text-secondary">中风险 (33-67%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.low }} />
|
||||
<span className="text-[11px] text-text-secondary">低风险 (<33%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-2">病例类型</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#DC2626' }} />
|
||||
<span className="text-[11px] text-text-secondary">住院病例</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: '#2563EB' }} />
|
||||
<span className="text-[11px] text-text-secondary">门诊病例</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 left-4 space-y-2 z-[1000]">
|
||||
<div className="bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm px-3 py-2">
|
||||
<div className="text-[11px] text-text-secondary">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-3 w-20 inline-block align-middle" />
|
||||
) : error ? (
|
||||
<span className="text-danger">加载失败: {error}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-text-primary">{totalCases.toLocaleString()}</span> 例病例
|
||||
<span className="mx-2 text-border">|</span>
|
||||
{viewMode === 'grid' ? (
|
||||
<>
|
||||
<span className="font-semibold text-text-primary">{gridCount.toLocaleString()}</span> 个网格
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-text-primary">{cases.length.toLocaleString()}</span> 个定位点
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isLoading && !error && viewMode === 'grid' && (
|
||||
<div className="bg-success/10 backdrop-blur rounded-lg border border-success/30 shadow-sm px-3 py-2">
|
||||
<div className="text-[11px] text-success font-medium">
|
||||
基于真实病例地理编码数据
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CaseMap = memo(CaseMapComponent);
|
||||
185
frontend/src/components/ChatBot.tsx
Normal file
185
frontend/src/components/ChatBot.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { MessageSquare, X, Send, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { chatApi } from '@/services/api';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ChatBot() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, isLoading, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || isLoading) return;
|
||||
|
||||
const userMessage: Message = { role: 'user', content: trimmed };
|
||||
const updatedMessages = [...messages, userMessage];
|
||||
setMessages(updatedMessages);
|
||||
setInput('');
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const data = await chatApi.sendMessage(
|
||||
updatedMessages.map((m) => ({ role: m.role, content: m.content }))
|
||||
);
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: data.reply }]);
|
||||
} catch (err: any) {
|
||||
const errMsg = err?.response?.data?.detail || err?.message || '请求失败,请稍后重试';
|
||||
setError(errMsg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [input, isLoading, messages]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setError(null);
|
||||
handleSend();
|
||||
}, [handleSend]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Float toggle button */}
|
||||
<button
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className={`fixed bottom-5 right-5 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary shadow-lg transition-all hover:bg-primary-light ${
|
||||
isOpen ? 'scale-0 opacity-0' : 'scale-100 opacity-100'
|
||||
}`}
|
||||
aria-label={isOpen ? '关闭聊天' : '打开聊天'}
|
||||
>
|
||||
<MessageSquare className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Chat panel */}
|
||||
{isOpen && (
|
||||
<div className="fixed bottom-20 right-5 z-50 flex w-[380px] flex-col rounded-xl border border-border bg-bg-card shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between rounded-t-xl bg-primary p-3 text-white">
|
||||
<h3 className="text-[14px] font-semibold">AI 健康风险助手</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="rounded p-1 transition-colors hover:bg-white/20"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages area */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex flex-col gap-3 overflow-y-auto p-4"
|
||||
style={{ height: '420px' }}
|
||||
>
|
||||
{messages.length === 0 && !error && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-12 text-center">
|
||||
<MessageSquare className="mb-3 h-10 w-10 text-text-muted" />
|
||||
<p className="text-[13px] text-text-muted">
|
||||
向我提问关于空气质量和儿童呼吸健康的问题
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl px-4 py-2 text-[13px] leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? 'rounded-br-sm bg-primary text-white'
|
||||
: 'rounded-bl-sm bg-bg-page text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex items-center gap-2 rounded-2xl rounded-bl-sm bg-bg-page px-4 py-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-text-muted" />
|
||||
<span className="text-[12px] text-text-muted">正在思考...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-start gap-2 rounded-lg border border-danger/20 bg-danger-light px-4 py-3">
|
||||
<span className="text-[13px] text-danger">{error}</span>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1 rounded px-2.5 py-1 text-[12px] font-medium text-danger transition-colors hover:bg-danger/10"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex gap-2 border-t border-border p-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入您的问题..."
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-lg border border-border bg-bg-page px-3 py-2 text-[13px] text-text-primary placeholder-text-muted outline-none transition-colors focus:border-primary disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="flex items-center justify-center rounded-lg bg-primary px-4 text-[13px] font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
375
frontend/src/components/LodGridLayer.tsx
Normal file
375
frontend/src/components/LodGridLayer.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import { useLodGrid, type MapBounds } from '@/hooks/useLodGrid';
|
||||
|
||||
const RISK_COLORS: [number, number, string][] = [
|
||||
[0.0, 0.2, '#22c55e'],
|
||||
[0.2, 0.4, '#3b82f6'],
|
||||
[0.4, 0.6, '#eab308'],
|
||||
[0.6, 0.8, '#f97316'],
|
||||
[0.8, 1.0, '#ef4444'],
|
||||
];
|
||||
|
||||
// Pre-computed color buckets for fillStyle caching
|
||||
const COLOR_BUCKETS: Record<string, { full: string; dim: string }> = {};
|
||||
for (const [, , color] of RISK_COLORS) {
|
||||
COLOR_BUCKETS[color] = { full: color, dim: color + '14' };
|
||||
}
|
||||
|
||||
function getRiskColor(value: number): string {
|
||||
for (const [min, max, color] of RISK_COLORS) {
|
||||
if (value >= min && value < max) return color;
|
||||
}
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
// 100m grid step in degrees
|
||||
const LAT_STEP = 0.0009;
|
||||
const LON_STEP = 0.001046;
|
||||
|
||||
// Mercator helpers (avoid per-cell latLngToContainerPoint)
|
||||
function latToMercY(lat: number): number {
|
||||
return 128 - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / (2 * Math.PI);
|
||||
}
|
||||
|
||||
function lonToMercX(lon: number): number {
|
||||
return ((lon + 180) / 360) * 256;
|
||||
}
|
||||
|
||||
interface LodGridLayerProps {
|
||||
map: L.Map | null;
|
||||
forecastDay: 1 | 3 | 7;
|
||||
visible?: boolean;
|
||||
riskRange?: [number, number];
|
||||
onCellClick?: (lat: number, lon: number, risk: number) => void;
|
||||
}
|
||||
|
||||
export function LodGridLayer({
|
||||
map,
|
||||
forecastDay,
|
||||
visible = true,
|
||||
riskRange,
|
||||
onCellClick,
|
||||
}: LodGridLayerProps) {
|
||||
const [zoom, setZoom] = useState(map?.getZoom() ?? 10);
|
||||
const [mapBounds, setMapBounds] = useState<MapBounds | undefined>();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const paneRef = useRef<HTMLElement | null>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const clickCallbackRef = useRef(onCellClick);
|
||||
const gridsRef = useRef<number[][]>([]);
|
||||
const forecastDayRef = useRef(forecastDay);
|
||||
const riskRangeRef = useRef(riskRange);
|
||||
const visibleRef = useRef(visible);
|
||||
const drawnOriginRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => { clickCallbackRef.current = onCellClick; }, [onCellClick]);
|
||||
useEffect(() => { forecastDayRef.current = forecastDay; }, [forecastDay]);
|
||||
useEffect(() => { riskRangeRef.current = riskRange; }, [riskRange]);
|
||||
useEffect(() => { visibleRef.current = visible; }, [visible]);
|
||||
|
||||
// Track map bounds and zoom
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const update = () => {
|
||||
const b = map.getBounds();
|
||||
setMapBounds({
|
||||
min_lat: b.getSouth(),
|
||||
max_lat: b.getNorth(),
|
||||
min_lon: b.getWest(),
|
||||
max_lon: b.getEast(),
|
||||
});
|
||||
setZoom(map.getZoom());
|
||||
};
|
||||
update();
|
||||
map.on('moveend', update);
|
||||
map.on('zoomend', update);
|
||||
return () => {
|
||||
map.off('moveend', update);
|
||||
map.off('zoomend', update);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
const { grids } = useLodGrid(zoom, forecastDay, mapBounds);
|
||||
|
||||
// Update gridsRef only when we have actual data (preserve stale data during loading)
|
||||
useEffect(() => {
|
||||
if (grids.length > 0) {
|
||||
gridsRef.current = grids;
|
||||
}
|
||||
}, [grids]);
|
||||
|
||||
// Create canvas overlay pane and attach to map
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const pane = map.createPane('lod-grid-pane');
|
||||
pane.style.zIndex = '450';
|
||||
pane.style.pointerEvents = 'none';
|
||||
paneRef.current = pane;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.display = visibleRef.current ? '' : 'none';
|
||||
pane.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
// Handle map clicks for grid cell selection
|
||||
const handleMapClick = (e: L.LeafletMouseEvent) => {
|
||||
if (!clickCallbackRef.current) return;
|
||||
const currentGrids = gridsRef.current;
|
||||
if (!currentGrids || currentGrids.length === 0) return;
|
||||
|
||||
const { lat, lng } = e.latlng;
|
||||
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
|
||||
let nearestDist = Infinity;
|
||||
let nearestRisk = 0;
|
||||
let nearestLat = 0;
|
||||
let nearestLon = 0;
|
||||
|
||||
for (const g of currentGrids) {
|
||||
const d = Math.sqrt((g[0] - lat) ** 2 + (g[1] - lng) ** 2);
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
nearestRisk = g[riskIdx] ?? 0;
|
||||
nearestLat = g[0];
|
||||
nearestLon = g[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestDist < 0.01) {
|
||||
clickCallbackRef.current(nearestLat, nearestLon, nearestRisk);
|
||||
}
|
||||
};
|
||||
|
||||
map.on('click', handleMapClick);
|
||||
|
||||
// Full redraw function
|
||||
const redraw = () => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
animFrameRef.current = requestAnimationFrame(() => {
|
||||
const container = map.getContainer();
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Reset drift transform after redraw
|
||||
canvas.style.transform = '';
|
||||
drawnOriginRef.current = null;
|
||||
|
||||
// Visibility is controlled via canvas CSS display (see visible effect),
|
||||
// so we still draw pixels even when hidden to keep them ready on re-show.
|
||||
const currentGrids = gridsRef.current;
|
||||
if (!currentGrids || currentGrids.length === 0) return;
|
||||
|
||||
const z = map.getZoom();
|
||||
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
|
||||
const range = riskRangeRef.current;
|
||||
const mapBounds = map.getBounds();
|
||||
const south = mapBounds.getSouth();
|
||||
const north = mapBounds.getNorth();
|
||||
const west = mapBounds.getWest();
|
||||
const east = mapBounds.getEast();
|
||||
|
||||
// Use Mercator math for pixel conversion (avoids per-cell latLngToContainerPoint)
|
||||
const scale = 2 ** z;
|
||||
const origin = map.getPixelOrigin();
|
||||
drawnOriginRef.current = { x: origin.x, y: origin.y };
|
||||
|
||||
// Pre-compute Mercator Y steps for cell size at this zoom
|
||||
const halfLat = LAT_STEP / 2;
|
||||
const halfLon = LON_STEP / 2;
|
||||
|
||||
// Group cells by color to minimize fillStyle changes
|
||||
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
|
||||
|
||||
// Viewport culling margin in degrees
|
||||
const margin = 0.02;
|
||||
const isHighZoom = z >= 12;
|
||||
const isMedZoom = z >= 10;
|
||||
|
||||
for (const g of currentGrids) {
|
||||
const lat = g[0];
|
||||
const lon = g[1];
|
||||
const risk = g[riskIdx] ?? 0;
|
||||
|
||||
// Pre-filter: skip zero-risk cells (majority of cells at most zooms)
|
||||
if (risk === 0) continue;
|
||||
|
||||
// Viewport culling
|
||||
if (lat < south - margin || lat > north + margin ||
|
||||
lon < west - margin || lon > east + margin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Risk range filter
|
||||
let alpha = 0.85;
|
||||
if (range) {
|
||||
if (risk < range[0]) {
|
||||
alpha = 0.08;
|
||||
} else if (risk > range[1]) {
|
||||
alpha = 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
const color = getRiskColor(risk);
|
||||
|
||||
if (isHighZoom) {
|
||||
// Compute cell rectangle using Mercator math
|
||||
const lx = lonToMercX(lon - halfLon) * scale - origin.x;
|
||||
const rx = lonToMercX(lon + halfLon) * scale - origin.x;
|
||||
const ty = latToMercY(lat + halfLat) * scale - origin.y;
|
||||
const by = latToMercY(lat - halfLat) * scale - origin.y;
|
||||
const cellW = rx - lx;
|
||||
const cellH = by - ty;
|
||||
|
||||
if (cellW < 0.5 || cellH < 0.5) continue;
|
||||
|
||||
// Group by color+alpha for batch rendering
|
||||
const key = alpha < 1 ? `${color}_${alpha}` : color;
|
||||
if (!colorGroups[key]) colorGroups[key] = [];
|
||||
colorGroups[key].push({ x: lx, y: ty, w: cellW, h: cellH });
|
||||
} else {
|
||||
// Medium/low zoom: compute center pixel
|
||||
const cx = lonToMercX(lon) * scale - origin.x;
|
||||
const cy = latToMercY(lat) * scale - origin.y;
|
||||
|
||||
const key = alpha < 1 ? `${color}_${alpha}` : color;
|
||||
if (!colorGroups[key]) colorGroups[key] = [];
|
||||
colorGroups[key].push({ x: cx, y: cy, w: 0, h: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Render grouped cells
|
||||
for (const [key, cells] of Object.entries(colorGroups)) {
|
||||
const parts = key.split('_');
|
||||
const color = parts[0];
|
||||
const alpha = parts.length > 1 ? parseFloat(parts[1]) : 1;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = color;
|
||||
|
||||
if (isHighZoom) {
|
||||
for (const c of cells) {
|
||||
ctx.fillRect(c.x, c.y, c.w, c.h);
|
||||
}
|
||||
// Stroke only at high enough cell sizes
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (const c of cells) {
|
||||
if (c.w > 2 && c.h > 2) {
|
||||
ctx.strokeRect(c.x, c.y, c.w, c.h);
|
||||
}
|
||||
}
|
||||
} else if (isMedZoom) {
|
||||
const size = Math.max(2, Math.min(6, z - 7));
|
||||
const halfSize = size / 2;
|
||||
for (const c of cells) {
|
||||
ctx.fillRect(c.x - halfSize, c.y - halfSize, size, size);
|
||||
}
|
||||
} else {
|
||||
const radius = Math.max(1, Math.min(3, z - 5));
|
||||
for (const c of cells) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
};
|
||||
|
||||
// Debounced full redraw (avoid thrashing during rapid pan/zoom)
|
||||
let redrawTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const debouncedRedraw = () => {
|
||||
if (redrawTimer) clearTimeout(redrawTimer);
|
||||
redrawTimer = setTimeout(redraw, 300);
|
||||
};
|
||||
|
||||
// During pan: apply CSS transform to track tile movement (fixes drift)
|
||||
const onMove = () => {
|
||||
const drawn = drawnOriginRef.current;
|
||||
if (!drawn) {
|
||||
// No previous draw yet, just request a redraw
|
||||
redraw();
|
||||
return;
|
||||
}
|
||||
const current = map.getPixelOrigin();
|
||||
const dx = drawn.x - current.x;
|
||||
const dy = drawn.y - current.y;
|
||||
canvas.style.transform = `translate(${dx}px, ${dy}px)`;
|
||||
};
|
||||
|
||||
// On moveend/zoomend: reset transform and do debounced full redraw
|
||||
const onMoveEnd = () => {
|
||||
canvas.style.transform = '';
|
||||
drawnOriginRef.current = null;
|
||||
debouncedRedraw();
|
||||
};
|
||||
|
||||
const onResize = () => redraw();
|
||||
|
||||
map.on('move', onMove);
|
||||
map.on('moveend', onMoveEnd);
|
||||
map.on('zoomend', onMoveEnd);
|
||||
map.on('resize', onResize);
|
||||
|
||||
// Store redraw reference for external triggers
|
||||
(canvas as any).__lodRedraw = redraw;
|
||||
|
||||
// Initial draw
|
||||
redraw();
|
||||
|
||||
return () => {
|
||||
map.off('move', onMove);
|
||||
map.off('moveend', onMoveEnd);
|
||||
map.off('zoomend', onMoveEnd);
|
||||
map.off('resize', onResize);
|
||||
map.off('click', handleMapClick);
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
if (redrawTimer) clearTimeout(redrawTimer);
|
||||
pane.removeChild(canvas);
|
||||
if (pane.parentNode) pane.parentNode.removeChild(pane);
|
||||
canvasRef.current = null;
|
||||
paneRef.current = null;
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// Trigger redraw when data/geometry-affecting inputs change.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas && (canvas as any).__lodRedraw) {
|
||||
(canvas as any).__lodRedraw();
|
||||
}
|
||||
}, [grids, forecastDay, riskRange]);
|
||||
|
||||
// Visibility toggle: hide/show via CSS instead of a full geometry redraw.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
canvas.style.display = visible ? '' : 'none';
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return null;
|
||||
}
|
||||
407
frontend/src/components/RiskMap.tsx
Normal file
407
frontend/src/components/RiskMap.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { GridRisk, GridDetail, ForecastDay } from '@/types';
|
||||
|
||||
interface RiskMapProps {
|
||||
grids: GridRisk[];
|
||||
selectedGridId: string | null;
|
||||
selectedGrid: GridDetail | null;
|
||||
forecastDay: ForecastDay;
|
||||
onGridSelect: (gridId: string) => void;
|
||||
onClosePanel: () => void;
|
||||
onFullscreen: () => void;
|
||||
onForecastChange: (day: ForecastDay) => void;
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
const RISK_COLORS: Record<string, string> = {
|
||||
low: '#22c55e',
|
||||
medium_low: '#3b82f6',
|
||||
medium: '#eab308',
|
||||
medium_high: '#f97316',
|
||||
high: '#ef4444',
|
||||
};
|
||||
|
||||
const RISK_LABELS: Record<string, string> = {
|
||||
low: '低风险',
|
||||
medium_low: '中低',
|
||||
medium: '中风险',
|
||||
medium_high: '中高',
|
||||
high: '高风险',
|
||||
};
|
||||
|
||||
const WUHAN_BOUNDS = {
|
||||
minLat: 29.97,
|
||||
maxLat: 31.37,
|
||||
minLon: 113.69,
|
||||
maxLon: 115.07,
|
||||
};
|
||||
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
// Mercator helpers (avoid per-cell latLngToContainerPoint) — mirrors LodGridLayer.
|
||||
function latToMercY(lat: number): number {
|
||||
return 128 - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / (2 * Math.PI);
|
||||
}
|
||||
|
||||
function lonToMercX(lon: number): number {
|
||||
return ((lon + 180) / 360) * 256;
|
||||
}
|
||||
|
||||
function riskColorForValue(riskValue: number): string {
|
||||
if (riskValue >= 0.7) return RISK_COLORS.high;
|
||||
if (riskValue >= 0.5) return RISK_COLORS.medium_high;
|
||||
if (riskValue >= 0.3) return RISK_COLORS.medium_low;
|
||||
return RISK_COLORS.low;
|
||||
}
|
||||
|
||||
function RiskMapComponent(props: RiskMapProps) {
|
||||
const {
|
||||
grids,
|
||||
selectedGrid,
|
||||
forecastDay,
|
||||
onGridSelect,
|
||||
onClosePanel,
|
||||
onFullscreen,
|
||||
onForecastChange,
|
||||
isFullscreen,
|
||||
} = props;
|
||||
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<any>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const paneRef = useRef<HTMLElement | null>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const redrawRef = useRef<() => void>(() => {});
|
||||
const zoomRef = useRef(9);
|
||||
const callbacksRef = useRef({ onGridSelect, onClosePanel, onFullscreen, onForecastChange });
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const gridMapRef = useRef<Map<string, GridRisk>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
|
||||
}, [onGridSelect, onClosePanel, onFullscreen, onForecastChange]);
|
||||
|
||||
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';
|
||||
|
||||
// Invalidate map size when container size changes (window resize, fullscreen, layout shifts)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
// Fullscreen transition: wait for CSS transition to complete
|
||||
const timer = setTimeout(() => {
|
||||
map.invalidateSize({ animate: true });
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isFullscreen, containerHeight]);
|
||||
|
||||
const gridMap = useMemo(() => {
|
||||
const map = new Map<string, GridRisk>();
|
||||
grids.forEach((g) => {
|
||||
const key = `${g.latitude.toFixed(4)}-${g.longitude.toFixed(4)}`;
|
||||
map.set(key, g);
|
||||
});
|
||||
return map;
|
||||
}, [grids]);
|
||||
|
||||
// Keep gridMap accessible to the canvas render fn (read via ref, no re-init).
|
||||
useEffect(() => {
|
||||
gridMapRef.current = gridMap;
|
||||
redrawRef.current();
|
||||
}, [gridMap]);
|
||||
|
||||
// Create the map + tile layer + canvas overlay + handlers ONCE.
|
||||
useEffect(() => {
|
||||
if (!mapDivRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(mapDivRef.current, {
|
||||
center: [(WUHAN_BOUNDS.minLat + WUHAN_BOUNDS.maxLat) / 2, (WUHAN_BOUNDS.minLon + WUHAN_BOUNDS.maxLon) / 2],
|
||||
zoom: 9,
|
||||
zoomControl: true,
|
||||
preferCanvas: true,
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
// Canvas overlay pane for batched grid rendering (replaces per-cell rectangles).
|
||||
const pane = map.createPane('risk-grid-pane');
|
||||
pane.style.zIndex = '450';
|
||||
pane.style.pointerEvents = 'none';
|
||||
paneRef.current = pane;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
pane.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
// ResizeObserver: auto-invalidate map size when container changes
|
||||
const resizeObserver = new ResizeObserver(
|
||||
debounce(() => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.invalidateSize({ animate: false });
|
||||
}
|
||||
}, 100)
|
||||
);
|
||||
if (mapDivRef.current) {
|
||||
resizeObserver.observe(mapDivRef.current);
|
||||
}
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
// Batched canvas render: group cells by color and fillRect on one canvas.
|
||||
function renderGridLayer() {
|
||||
if (!mapRef.current || !canvasRef.current) return;
|
||||
const map = mapRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
animFrameRef.current = requestAnimationFrame(() => {
|
||||
const container = map.getContainer();
|
||||
const w = container.clientWidth;
|
||||
const h = container.clientHeight;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const zoom = map.getZoom();
|
||||
let cellSize: number;
|
||||
let step: number;
|
||||
if (zoom <= 8) { cellSize = 0.1; step = 10; }
|
||||
else if (zoom <= 10) { cellSize = 0.025; step = 4; }
|
||||
else if (zoom <= 12) { cellSize = 0.01; step = 2; }
|
||||
else { cellSize = 0.005; step = 2; }
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const minLat = Math.max(bounds.getSouth(), WUHAN_BOUNDS.minLat);
|
||||
const maxLat = Math.min(bounds.getNorth(), WUHAN_BOUNDS.maxLat);
|
||||
const minLon = Math.max(bounds.getWest(), WUHAN_BOUNDS.minLon);
|
||||
const maxLon = Math.min(bounds.getEast(), WUHAN_BOUNDS.maxLon);
|
||||
|
||||
const latStart = Math.floor((minLat - WUHAN_BOUNDS.minLat) / cellSize) * cellSize + WUHAN_BOUNDS.minLat;
|
||||
const lonStart = Math.floor((minLon - WUHAN_BOUNDS.minLon) / cellSize) * cellSize + WUHAN_BOUNDS.minLon;
|
||||
|
||||
const currentGridMap = gridMapRef.current;
|
||||
if (currentGridMap.size > 5000) {
|
||||
console.warn(`[RiskMap] Data too dense: ${currentGridMap.size} grid cells, rendering may be slow`);
|
||||
}
|
||||
|
||||
const scale = 2 ** zoom;
|
||||
const origin = map.getPixelOrigin();
|
||||
const cellDeg = cellSize * step;
|
||||
|
||||
// Group cells by color to minimize fillStyle changes.
|
||||
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
|
||||
|
||||
let count = 0;
|
||||
const maxCount = 1500;
|
||||
for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellDeg) {
|
||||
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellDeg) {
|
||||
const key = `${lat.toFixed(4)}-${lon.toFixed(4)}`;
|
||||
const grid = currentGridMap.get(key);
|
||||
const riskValue = grid?.risk_value ?? 0.5;
|
||||
const color = riskColorForValue(riskValue);
|
||||
|
||||
const lx = lonToMercX(lon) * scale - origin.x;
|
||||
const rx = lonToMercX(lon + cellDeg) * scale - origin.x;
|
||||
const ty = latToMercY(lat + cellDeg) * scale - origin.y;
|
||||
const by = latToMercY(lat) * scale - origin.y;
|
||||
|
||||
if (!colorGroups[color]) colorGroups[color] = [];
|
||||
colorGroups[color].push({ x: lx, y: ty, w: rx - lx, h: by - ty });
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 0.6;
|
||||
for (const [color, cells] of Object.entries(colorGroups)) {
|
||||
ctx.fillStyle = color;
|
||||
for (const c of cells) {
|
||||
ctx.fillRect(c.x, c.y, c.w, c.h);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
}
|
||||
|
||||
redrawRef.current = renderGridLayer;
|
||||
|
||||
// Single map-level click handler: nearest-cell lookup (replaces 1500 handlers).
|
||||
const handleMapClick = (e: L.LeafletMouseEvent) => {
|
||||
const currentGridMap = gridMapRef.current;
|
||||
if (currentGridMap.size === 0) return;
|
||||
const { lat, lng } = e.latlng;
|
||||
let nearestDist = Infinity;
|
||||
let nearestGrid: GridRisk | null = null;
|
||||
for (const grid of currentGridMap.values()) {
|
||||
const d = (grid.latitude - lat) ** 2 + (grid.longitude - lng) ** 2;
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
nearestGrid = grid;
|
||||
}
|
||||
}
|
||||
if (nearestGrid && nearestDist < 0.01 * 0.01) {
|
||||
callbacksRef.current.onGridSelect(nearestGrid.grid_id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoom = debounce(() => {
|
||||
zoomRef.current = map.getZoom();
|
||||
renderGridLayer();
|
||||
}, 150);
|
||||
|
||||
const handleMove = debounce(() => {
|
||||
renderGridLayer();
|
||||
}, 150);
|
||||
|
||||
map.on('zoomend', handleZoom);
|
||||
map.on('moveend', handleMove);
|
||||
map.on('resize', renderGridLayer);
|
||||
map.on('click', handleMapClick);
|
||||
|
||||
// Initial render
|
||||
renderGridLayer();
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
redrawRef.current = () => {};
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
}
|
||||
canvasRef.current = null;
|
||||
paneRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleForecastChange = useCallback((d: ForecastDay) => {
|
||||
callbacksRef.current.onForecastChange(d);
|
||||
}, []);
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
callbacksRef.current.onFullscreen();
|
||||
}, []);
|
||||
|
||||
const handleClosePanel = useCallback(() => {
|
||||
callbacksRef.current.onClosePanel();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
|
||||
</svg>
|
||||
<span className="font-medium text-[14px]">武汉市儿童呼吸道疾病风险监控</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-0.5 bg-gray-100 p-0.5 rounded">
|
||||
{([0, 1, 3, 7] as ForecastDay[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => handleForecastChange(d)}
|
||||
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
|
||||
forecastDay === d ? 'bg-blue-500 text-white' : 'text-gray-600 hover:text-blue-500'
|
||||
}`}
|
||||
>
|
||||
{d === 0 ? '今日' : d + '天后'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="px-3 py-1.5 text-[12px] text-gray-600 bg-gray-100 border border-gray-200 rounded hover:border-blue-400 transition-colors"
|
||||
>
|
||||
全屏
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative" style={{ height: containerHeight }}>
|
||||
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
|
||||
|
||||
<div className="absolute bottom-4 right-4 bg-white px-4 py-3 rounded-lg border border-gray-200 shadow-sm z-[1000]">
|
||||
<div className="text-[11px] font-semibold text-gray-600 mb-2">风险等级</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(RISK_LABELS).map(([level, label]) => (
|
||||
<div key={level} className="flex items-center gap-1.5 text-[11px] text-gray-600">
|
||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS[level] }} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-4 left-4 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm z-[1000]">
|
||||
<div className="text-[11px] text-gray-600">
|
||||
<span className="font-semibold text-gray-900">{grids.length.toLocaleString()}</span> 个监测点
|
||||
<span className="mx-2 text-gray-300">|</span>
|
||||
{forecastDay === 0 ? '实时监测' : forecastDay + '天预报'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedGrid && (
|
||||
<div className="absolute top-4 right-4 w-[280px] bg-white border border-gray-200 rounded-lg shadow-lg z-[1001]">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<span className="text-[13px] font-semibold">网格详情</span>
|
||||
<button onClick={handleClosePanel} className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100">
|
||||
<svg className="w-3.5 h-3.5 fill-gray-400" viewBox="0 0 24 24">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className={`rounded-md p-3 mb-4 ${selectedGrid.risk_value >= 0.7 ? 'bg-red-50' : 'bg-yellow-50'}`}>
|
||||
<div className="text-[12px] text-gray-500 mb-1">风险指数</div>
|
||||
<div className={`text-[24px] font-bold ${selectedGrid.risk_value >= 0.7 ? 'text-red-600' : 'text-yellow-600'}`}>
|
||||
{Math.round(selectedGrid.risk_value * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-[12px]">
|
||||
<div className="flex justify-between py-1.5 border-b border-gray-100">
|
||||
<span className="text-gray-400">区域</span>
|
||||
<span className="font-medium">{selectedGrid.region || '--'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-1.5 border-b border-gray-100">
|
||||
<span className="text-gray-400">街道</span>
|
||||
<span className="font-medium">{selectedGrid.street || '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const RiskMap = memo(RiskMapComponent);
|
||||
13
frontend/src/components/RoleRedirect.tsx
Normal file
13
frontend/src/components/RoleRedirect.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useSessionStore } from '@/stores';
|
||||
import { roleDefaultPath } from '@/utils/roleViews';
|
||||
|
||||
/**
|
||||
* 根据当前视角(role)把裸路径 `/` 重定向到该视角的默认落地页。
|
||||
* D2:纯前端视图预设 —— role 只决定默认落地页,不是访问控制。
|
||||
* 角色来源被隔离在 sessionStore 的 getRoleSource() 接缝里。
|
||||
*/
|
||||
export function RoleRedirect(): JSX.Element {
|
||||
const role = useSessionStore((s) => s.role);
|
||||
return <Navigate to={roleDefaultPath(role)} replace />;
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import { TESTIDS } from '@/utils/testids';
|
||||
|
||||
interface SideNavProps {
|
||||
alertCount?: number;
|
||||
// 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。
|
||||
onNavigate?: () => void;
|
||||
// 受控的展开手风琴分组:由 AppShell 提供时,导轨与抽屉两份实例保持同步。
|
||||
// 不传则回退到内部 state,向后兼容独立使用。
|
||||
expanded?: string | null;
|
||||
onExpandedChange?: (moduleId: string | null) => void;
|
||||
}
|
||||
@@ -66,9 +69,11 @@ export function SideNav({
|
||||
}: SideNavProps) {
|
||||
const location = useLocation();
|
||||
|
||||
// 当前路径命中的模块默认展开。
|
||||
const moduleForPath = (pathname: string) =>
|
||||
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
|
||||
|
||||
// 受控/非受控双模式:父级传入 expanded 时由父级管理,否则回退内部 state。
|
||||
const [internalExpanded, setInternalExpanded] = useState<string | null>(() =>
|
||||
moduleForPath(location.pathname)
|
||||
);
|
||||
@@ -86,41 +91,28 @@ export function SideNav({
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="h-full overflow-y-auto py-2 px-2.5">
|
||||
<nav className="h-full overflow-y-auto py-4 px-2">
|
||||
{modules.map((module) => (
|
||||
<div key={module.id} className="mb-1.5">
|
||||
<div key={module.id} className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(expanded === module.id ? null : module.id)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-semibold transition-colors ${
|
||||
className={`w-full flex items-center gap-[10px] px-3 py-[9px] rounded-md text-[14px] font-semibold transition-colors ${
|
||||
isActiveModule(module.id)
|
||||
? 'bg-primary-muted text-primary'
|
||||
: 'text-text-primary hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 h-4 flex items-center justify-center opacity-90">{module.icon}</span>
|
||||
<span className="flex-1 text-left">{module.label}</span>
|
||||
<span className="w-4 h-4 flex items-center justify-center">{module.icon}</span>
|
||||
<span>{module.label}</span>
|
||||
{module.id === 'alert' && alertCount > 0 && (
|
||||
<span className="bg-danger-light text-danger text-[10px] font-semibold px-1.5 py-0.5 rounded-md tabular-nums">
|
||||
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
|
||||
{alertCount > 99 ? '99+' : alertCount}
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-text-muted shrink-0 transition-transform ${
|
||||
expanded === module.id ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{expanded === module.id && (
|
||||
<div className="mt-0.5 ml-3 pl-3 border-l border-border-light">
|
||||
<div className="mt-1 pl-7">
|
||||
{module.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
@@ -128,7 +120,7 @@ export function SideNav({
|
||||
data-testid={item.testid}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
`block w-full text-left px-2.5 py-1.5 rounded-md text-[12.5px] font-medium transition-colors ${
|
||||
`block w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-bg-active text-primary'
|
||||
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
||||
|
||||
@@ -27,12 +27,12 @@ export const StatCard = React.memo(function StatCard({
|
||||
}: StatCardProps) {
|
||||
const trendIndicator = trend ? (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[11px] font-medium ${
|
||||
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
|
||||
trend.direction === 'up'
|
||||
? 'text-danger'
|
||||
? 'text-green-600'
|
||||
: trend.direction === 'down'
|
||||
? 'text-success'
|
||||
: 'text-text-muted'
|
||||
? 'text-red-600'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{trend.direction === 'up' && <span aria-hidden>▲</span>}
|
||||
@@ -42,28 +42,32 @@ export const StatCard = React.memo(function StatCard({
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const sparklineSvg =
|
||||
sparkline && sparkline.data.length >= 2 ? (
|
||||
<svg width="64" height="26" className="shrink-0" aria-hidden="true">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={sparkline.color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={sparkline.data
|
||||
.map((val, i) => {
|
||||
const x = (i / (sparkline.data.length - 1)) * 62 + 1;
|
||||
const max = Math.max(...sparkline.data);
|
||||
const min = Math.min(...sparkline.data);
|
||||
const range = max - min || 1;
|
||||
const y = 24 - ((val - min) / range) * 20 - 1;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ')}
|
||||
/>
|
||||
</svg>
|
||||
) : null;
|
||||
const sparklineSvg = sparkline && sparkline.data.length >= 2 ? (
|
||||
<svg
|
||||
width="60"
|
||||
height="24"
|
||||
className="shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={sparkline.color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={sparkline.data
|
||||
.map((val, i) => {
|
||||
const x = (i / (sparkline.data.length - 1)) * 58 + 1;
|
||||
const max = Math.max(...sparkline.data);
|
||||
const min = Math.min(...sparkline.data);
|
||||
const range = max - min || 1;
|
||||
const y = 22 - ((val - min) / range) * 20 - 1;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ')}
|
||||
/>
|
||||
</svg>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -80,22 +84,24 @@ export const StatCard = React.memo(function StatCard({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`stat-card ${onClick ? 'cursor-pointer' : ''}`}
|
||||
className={`bg-white rounded-lg border border-gray-200 p-4 ${
|
||||
onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] text-text-secondary mb-1.5 pl-1">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
|
||||
{icon}
|
||||
<span className="font-medium tracking-wide">{label}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-2 pl-1">
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<div
|
||||
className="data-num text-[22px] leading-none"
|
||||
className="text-2xl font-bold text-gray-900"
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{sparklineSvg}
|
||||
</div>
|
||||
{trendIndicator && <div className="mt-1.5 pl-1">{trendIndicator}</div>}
|
||||
{trendIndicator && <div className="mt-1">{trendIndicator}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,9 +28,8 @@ export function TimelinePlayer({
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const advanceRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
setPlaying(isPlaying);
|
||||
}, [isPlaying]);
|
||||
// Keep internal play state in sync when the parent/store changes isPlaying.
|
||||
useEffect(() => { setPlaying(isPlaying); }, [isPlaying]);
|
||||
|
||||
const generateDateRange = useCallback((start: string, end: string) => {
|
||||
const dates: string[] = [];
|
||||
@@ -45,22 +44,15 @@ export function TimelinePlayer({
|
||||
return dates;
|
||||
}, []);
|
||||
|
||||
const dateRange = useMemo(
|
||||
() => generateDateRange(startDate, endDate),
|
||||
[startDate, endDate, generateDateRange]
|
||||
);
|
||||
|
||||
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
|
||||
// Compute index arithmetically from the day difference instead of indexOf.
|
||||
const currentIndex = useMemo(() => {
|
||||
if (dateRange.length === 0) return -1;
|
||||
const ms = new Date(currentDate).getTime() - new Date(startDate).getTime();
|
||||
const idx = Math.round(ms / 86400000);
|
||||
return idx >= 0 && idx < dateRange.length ? idx : dateRange.indexOf(currentDate);
|
||||
}, [startDate, currentDate, dateRange]);
|
||||
|
||||
const progress = useMemo(
|
||||
() => ((currentIndex + 1) / dateRange.length) * 100,
|
||||
[currentIndex, dateRange.length]
|
||||
);
|
||||
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
setPlaying(true);
|
||||
@@ -73,8 +65,11 @@ export function TimelinePlayer({
|
||||
}, [onPlayPause]);
|
||||
|
||||
const togglePlay = () => {
|
||||
if (playing) pause();
|
||||
else play();
|
||||
if (playing) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
};
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
@@ -86,24 +81,33 @@ export function TimelinePlayer({
|
||||
onDateChange(dateRange[0]);
|
||||
};
|
||||
|
||||
// Keep the advance logic in a ref so the interval doesn't get recreated each
|
||||
// tick when goToNext's identity changes.
|
||||
useEffect(() => {
|
||||
advanceRef.current = goToNext;
|
||||
}, [goToNext]);
|
||||
|
||||
// Interval is created once per play/speed change (not per tick).
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
const interval = 1000 / speed;
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
advanceRef.current();
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [playing, speed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIndex >= dateRange.length - 1) pause();
|
||||
if (currentIndex >= dateRange.length - 1) {
|
||||
pause();
|
||||
}
|
||||
}, [currentIndex, dateRange.length, pause]);
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -112,15 +116,24 @@ export function TimelinePlayer({
|
||||
};
|
||||
|
||||
const handleSpeedChange = () => {
|
||||
const idx = SPEEDS.indexOf(speed);
|
||||
const nextIndex = (idx + 1) % SPEEDS.length;
|
||||
const currentIndex = SPEEDS.indexOf(speed);
|
||||
const nextIndex = (currentIndex + 1) % SPEEDS.length;
|
||||
onSpeedChange?.(SPEEDS[nextIndex]);
|
||||
};
|
||||
|
||||
const formatSpeed = (s: number) => (s >= 1 ? `${s}x` : `${s.toFixed(1)}x`);
|
||||
const formatSpeed = (s: number) => {
|
||||
return s >= 1 ? `${s}x` : `${s.toFixed(1)}x`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
@@ -128,82 +141,72 @@ export function TimelinePlayer({
|
||||
});
|
||||
};
|
||||
|
||||
const fmtShort = (d: string) =>
|
||||
new Date(d).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
|
||||
return (
|
||||
<div className="timeline-dock" role="region" aria-label="时间轴播放器">
|
||||
<div className="timeline-dock__inner">
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
{/* 播放控件 */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToStart}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"
|
||||
title="跳到开始"
|
||||
aria-label="跳到开始"
|
||||
>
|
||||
<SkipBack className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className="p-2 bg-primary text-white rounded-xl hover:bg-primary-deep transition-colors shadow-brand"
|
||||
aria-label={playing ? '暂停' : '播放'}
|
||||
>
|
||||
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToNext}
|
||||
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"
|
||||
title="跳到下一天"
|
||||
aria-label="跳到下一天"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-[9999] w-64">
|
||||
<div className="bg-white/95 backdrop-blur-xl border border-gray-200/80 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] px-4 py-3">
|
||||
{/* Date display */}
|
||||
<div className="text-center mb-3">
|
||||
<div className="font-medium text-gray-900 text-sm">{formatDate(currentDate)}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
第 {currentIndex + 1} / {dateRange.length} 天
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度轨 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1.5">
|
||||
<div className="font-mono text-[13px] font-semibold tabular-nums text-text-primary">
|
||||
{formatDate(currentDate)}
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted tabular-nums font-mono">
|
||||
{currentIndex + 1} / {dateRange.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="timeline-dock__track">
|
||||
<div className="timeline-dock__fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={progress}
|
||||
onChange={handleSliderChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
aria-label="时间进度"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-text-muted mt-1">
|
||||
<span>{fmtShort(startDate)}</span>
|
||||
<span>{fmtShort(endDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Vertical slider */}
|
||||
<div className="flex justify-center mb-3">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={progress}
|
||||
onChange={handleSliderChange}
|
||||
className="h-1.5 w-full bg-gray-200 rounded-full appearance-none cursor-pointer accent-blue-600"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${progress}%, #e5e7eb ${progress}%, #e5e7eb 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-gray-400 mb-3">
|
||||
<span>{new Date(startDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
|
||||
<span>{new Date(endDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
|
||||
</div>
|
||||
|
||||
{/* 倍速 */}
|
||||
{/* Transport controls */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={goToStart}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
||||
title="跳到开始"
|
||||
>
|
||||
<SkipBack className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-2.5 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors shadow-md"
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="w-5 h-5" />
|
||||
) : (
|
||||
<Play className="w-5 h-5 ml-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
||||
title="跳到下一天"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Speed */}
|
||||
<div className="flex items-center justify-center gap-2 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSpeedChange}
|
||||
className="shrink-0 px-2.5 py-1 text-[11px] font-mono font-semibold text-text-secondary
|
||||
bg-bg-hover border border-border rounded-lg hover:border-primary hover:text-primary
|
||||
transition-colors"
|
||||
className="px-2 py-0.5 text-xs font-medium text-gray-600 bg-gray-100/80 rounded-full hover:bg-gray-200 transition-colors"
|
||||
title="调整播放速度"
|
||||
aria-label={`播放速度 ${formatSpeed(speed)}`}
|
||||
>
|
||||
{formatSpeed(speed)}
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import { useSessionStore, ROLES, type Role } from '@/stores/sessionStore';
|
||||
import { ROLE_LABELS, roleDefaultPath } from '@/utils/roleViews';
|
||||
|
||||
interface TopNavProps {
|
||||
onLogout?: () => void;
|
||||
// 移动端汉堡按钮:切换侧栏抽屉。
|
||||
onToggleMenu?: () => void;
|
||||
// 抽屉是否展开(用于汉堡按钮的 aria-expanded)。
|
||||
isMenuOpen?: boolean;
|
||||
}
|
||||
|
||||
@@ -13,16 +18,44 @@ function Clock() {
|
||||
const id = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
|
||||
}
|
||||
|
||||
// 视角切换器:纯前端视图预设(D2)。刻意标注「视角」而非「权限」——不是访问控制。
|
||||
// 切换时持久化角色并跳转到该视角的默认落地页。
|
||||
function PerspectiveSwitcher() {
|
||||
const role = useSessionStore((s) => s.role);
|
||||
const setRole = useSessionStore((s) => s.setRole);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleChange = (next: Role) => {
|
||||
setRole(next);
|
||||
navigate(roleDefaultPath(next));
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="font-mono tabular-nums">
|
||||
{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<label className="flex items-center gap-1.5 text-[13px] text-text-secondary">
|
||||
<span className="text-text-muted hidden sm:inline">视角</span>
|
||||
<select
|
||||
data-testid={TESTIDS.perspectiveSwitcher}
|
||||
value={role}
|
||||
onChange={(e) => handleChange(e.target.value as Role)}
|
||||
aria-label="切换视角"
|
||||
className="bg-bg-card border border-border rounded-md px-2 py-1 text-[13px] text-text-primary hover:bg-bg-hover focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r} data-testid={`${TESTIDS.perspectiveOption}-${r}`}>
|
||||
{ROLE_LABELS[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
|
||||
return (
|
||||
<nav className="h-[54px] shrink-0 bg-bg-card/95 border-b border-border flex items-center px-4 sm:px-5 z-50 backdrop-blur-md">
|
||||
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
|
||||
{onToggleMenu && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -31,7 +64,7 @@ export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavPro
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-controls="app-drawer"
|
||||
data-testid={TESTIDS.hamburger}
|
||||
className="lg:hidden mr-2.5 -ml-0.5 w-9 h-9 flex items-center justify-center rounded-lg text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
className="lg:hidden mr-3 -ml-1 w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -39,38 +72,37 @@ export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavPro
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-mist flex items-center justify-center shadow-soft shrink-0"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
|
||||
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
||||
<path d="M12 3c-1.2 2.4-3.5 4-6 4 .6 3.4 2.8 6.2 6 7.5 3.2-1.3 5.4-4.1 6-7.5-2.5 0-4.8-1.6-6-4zm0 14.5c-2.2-.9-4-2.5-5.2-4.5C5.5 15.2 4 18 4 21h16c0-3-1.5-5.8-2.8-8-1.2 2-3 3.6-5.2 4.5z" />
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<span className="brand-mark text-[16px] block truncate">CBPOA</span>
|
||||
<span className="text-[11px] text-text-muted hidden sm:block truncate">
|
||||
武汉儿童呼吸疾病风险评估
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-display font-semibold text-[15px] text-text-primary">
|
||||
WuhanChildRisk
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border ml-4 mr-4 hidden md:block" />
|
||||
<div className="w-px h-5 bg-border ml-4 mr-4 hidden sm:block" />
|
||||
|
||||
<span className="text-[12px] text-text-secondary hidden md:inline truncate">
|
||||
监测 · 预警 · 空间风险
|
||||
<span className="text-[13px] text-text-secondary hidden sm:inline">
|
||||
儿童呼吸道疾病风险监测预警平台
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<div className="ml-auto flex items-center gap-5">
|
||||
<span className="text-[12px] text-text-muted hidden sm:inline">
|
||||
<Clock />
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
<PerspectiveSwitcher />
|
||||
</div>
|
||||
{onLogout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="text-[12px] text-text-muted hover:text-danger transition-colors px-2 py-1 rounded-md hover:bg-danger-light/60"
|
||||
className="text-[12px] text-text-muted hover:text-danger transition-colors"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import { DiseaseFilter } from '@/components/DiseaseFilter';
|
||||
import { HORIZON_LABELS } from './types';
|
||||
|
||||
interface AlertsFilterBarProps {
|
||||
@@ -17,8 +18,12 @@ interface AlertsFilterBarProps {
|
||||
onToggleGrid: () => void;
|
||||
sortBy: 'risk' | 'time';
|
||||
onSortByChange: (sortBy: 'risk' | 'time') => void;
|
||||
// 视角驱动的两条不变量(结果由 orchestrator 计算后下传):
|
||||
isCluster: boolean; // 聚类(医生)视角:隐藏「预警标记」切换 + 挂载病种过滤
|
||||
isOfficial: boolean; // 官员视角:隐藏网格切换
|
||||
}
|
||||
|
||||
// Toolbar Row 2: Filters (时效/优先级/风险值/图层切换/排序).
|
||||
export const AlertsFilterBar = React.memo(function AlertsFilterBar({
|
||||
selectedHorizon,
|
||||
onHorizonChange,
|
||||
@@ -34,6 +39,8 @@ export const AlertsFilterBar = React.memo(function AlertsFilterBar({
|
||||
onToggleGrid,
|
||||
sortBy,
|
||||
onSortByChange,
|
||||
isCluster,
|
||||
isOfficial,
|
||||
}: AlertsFilterBarProps) {
|
||||
return (
|
||||
<div className="card p-3 mb-4">
|
||||
@@ -71,8 +78,8 @@ export const AlertsFilterBar = React.memo(function AlertsFilterBar({
|
||||
? priority === 'P1'
|
||||
? 'bg-danger text-white'
|
||||
: priority === 'P2'
|
||||
? 'bg-warning text-white'
|
||||
: 'bg-primary text-white'
|
||||
? 'bg-warning text-white'
|
||||
: 'bg-primary text-white'
|
||||
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
||||
}`}
|
||||
>
|
||||
@@ -122,28 +129,36 @@ export const AlertsFilterBar = React.memo(function AlertsFilterBar({
|
||||
>
|
||||
地图
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleAlertMarkers}
|
||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
showAlertMarkers
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'bg-bg-page text-text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
预警标记
|
||||
</button>
|
||||
<div data-testid={TESTIDS.gridLayerWrapper}>
|
||||
{/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */}
|
||||
{!isCluster && (
|
||||
<button
|
||||
onClick={onToggleGrid}
|
||||
onClick={onToggleAlertMarkers}
|
||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
showGrid
|
||||
showAlertMarkers
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'bg-bg-page text-text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
风险层
|
||||
预警标记
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 网格切换:官员视角隐藏整块(100m 网格对其无意义/太超前)。 */}
|
||||
{!isOfficial && (
|
||||
<div data-testid={TESTIDS.gridLayerWrapper}>
|
||||
<button
|
||||
onClick={onToggleGrid}
|
||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||
showGrid
|
||||
? 'bg-primary/10 text-primary border border-primary/30'
|
||||
: 'bg-bg-page text-text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
网格
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 聚类(医生)视角:病种过滤是其核心工具,挂载于此。 */}
|
||||
{isCluster && <DiseaseFilter />}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
@@ -8,6 +8,7 @@ interface AlertsHeaderProps {
|
||||
onTabChange: (tab: 'list' | 'stats') => void;
|
||||
}
|
||||
|
||||
// 页头(标题 + 计数)+ 页内 tab 切换条(不走 router)。
|
||||
export const AlertsHeader = React.memo(function AlertsHeader({
|
||||
total,
|
||||
p1,
|
||||
@@ -17,52 +18,34 @@ export const AlertsHeader = React.memo(function AlertsHeader({
|
||||
}: AlertsHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-end justify-between mb-4 flex-wrap gap-x-4 gap-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-[20px] font-semibold text-text-primary mb-0.5">
|
||||
风险预警
|
||||
</h1>
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1">风险预警</h1>
|
||||
<p className="text-[12px] text-text-muted truncate">
|
||||
100m 网格风险预测 · 多时间尺度预警 · 病例–气象关联
|
||||
100m网格风险预测 · 多时间尺度预警 · 病例-气象关联分析
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 指挥台式计数,非 badge 堆叠 */}
|
||||
<div
|
||||
className="command-rail !flex-none stagger-children"
|
||||
role="group"
|
||||
aria-label="预警计数"
|
||||
>
|
||||
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
|
||||
<div className="command-rail__label">全部</div>
|
||||
<div className="command-rail__value text-[20px]">{total}</div>
|
||||
</div>
|
||||
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
|
||||
<div className="command-rail__label text-danger">P1 紧急</div>
|
||||
<div className="command-rail__value text-[20px] text-danger">{p1}</div>
|
||||
</div>
|
||||
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
|
||||
<div className="command-rail__label text-warning">P2 关注</div>
|
||||
<div className="command-rail__value text-[20px] text-warning">{p2}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] shrink-0 flex-wrap">
|
||||
<span className="text-text-muted">共 <span className="font-semibold text-text-primary">{total}</span> 条预警</span>
|
||||
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {p1}</span>
|
||||
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {p2}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-strip mb-4 border-b border-border" role="tablist" aria-label="预警视图">
|
||||
{(
|
||||
[
|
||||
{ key: 'list' as const, label: '预警列表' },
|
||||
{ key: 'stats' as const, label: '风险统计' },
|
||||
] as const
|
||||
).map((tab) => (
|
||||
{/* Tab strip — in-page, no router */}
|
||||
<div className="flex gap-1 mb-4 border-b border-border">
|
||||
{([
|
||||
{ key: 'list', label: '预警列表' },
|
||||
{ key: 'stats', label: '风险统计' },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={`tab-strip__item ${
|
||||
activeTab === tab.key ? 'tab-strip__item--active' : ''
|
||||
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
@@ -7,51 +7,39 @@ interface RiskDistributionSummaryProps {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 预警列表顶部风险分布 — 连续 risk-strip,非四块同质小卡。 */
|
||||
// 预警列表 tab 顶部的风险分布概要(4 卡)。
|
||||
export const RiskDistributionSummary = React.memo(function RiskDistributionSummary({
|
||||
riskStats,
|
||||
total,
|
||||
}: RiskDistributionSummaryProps) {
|
||||
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
|
||||
|
||||
return (
|
||||
<div className="risk-strip mb-4" role="group" aria-label="风险分布">
|
||||
<div className="risk-strip__cell">
|
||||
<div className="risk-strip__label">高风险 (≥0.8)</div>
|
||||
<div className="risk-strip__value text-danger">{riskStats.high}</div>
|
||||
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
|
||||
<div className="h-full bg-danger rounded-full" style={{ width: `${pct(riskStats.high)}%` }} />
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<div className="card p-3">
|
||||
<div className="text-[11px] text-text-muted mb-1">高风险 (≥0.8)</div>
|
||||
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
|
||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-danger rounded-full" style={{ width: `${total > 0 ? (riskStats.high / total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="risk-strip__cell">
|
||||
<div className="risk-strip__label">中高风险 (0.6–0.8)</div>
|
||||
<div className="risk-strip__value text-warning">{riskStats.mediumHigh}</div>
|
||||
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-warning rounded-full"
|
||||
style={{ width: `${pct(riskStats.mediumHigh)}%` }}
|
||||
/>
|
||||
<div className="card p-3">
|
||||
<div className="text-[11px] text-text-muted mb-1">中高风险 (0.6-0.8)</div>
|
||||
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
|
||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-warning rounded-full" style={{ width: `${total > 0 ? (riskStats.mediumHigh / total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="risk-strip__cell">
|
||||
<div className="risk-strip__label">中风险 (0.4–0.6)</div>
|
||||
<div className="risk-strip__value text-primary">{riskStats.medium}</div>
|
||||
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: `${pct(riskStats.medium)}%` }}
|
||||
/>
|
||||
<div className="card p-3">
|
||||
<div className="text-[11px] text-text-muted mb-1">中风险 (0.4-0.6)</div>
|
||||
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
|
||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full" style={{ width: `${total > 0 ? (riskStats.medium / total) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="risk-strip__cell">
|
||||
<div className="risk-strip__label">平均风险</div>
|
||||
<div className="risk-strip__value">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
|
||||
<div className="mt-2 text-[10px] text-text-muted truncate">
|
||||
高发区{' '}
|
||||
{riskStats.topDistricts
|
||||
.slice(0, 2)
|
||||
.map(([d, n]) => `${d}(${n})`)
|
||||
.join(' · ') || '—'}
|
||||
<div className="card p-3">
|
||||
<div className="text-[11px] text-text-muted mb-1">平均风险</div>
|
||||
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
|
||||
<div className="mt-1.5 text-[10px] text-text-muted">
|
||||
高风险区域: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,54 +63,47 @@ const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, on
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-xl border transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary-muted/40 shadow-soft ring-1 ring-primary/30'
|
||||
: 'border-border/80 bg-bg-card/90 hover:border-primary/40 hover:shadow-soft'
|
||||
className={`card overflow-hidden transition-colors cursor-pointer ${
|
||||
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div
|
||||
className={`absolute left-0 top-0 bottom-0 w-1 ${isP1 ? 'bg-danger' : 'bg-warning'}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="pl-4 pr-3.5 py-3">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={`text-[10px] font-bold tracking-wide px-1.5 py-0.5 rounded ${
|
||||
isP1 ? 'bg-danger-light text-danger' : 'bg-warning-light text-warning'
|
||||
}`}
|
||||
>
|
||||
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
|
||||
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
||||
{alert.priority}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted truncate">
|
||||
<span className="text-[10px] text-text-muted">
|
||||
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`data-num text-[18px] ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
||||
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
||||
{riskPercent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="text-[13px] font-semibold text-text-primary truncate">
|
||||
{alert.region} · {alert.street}
|
||||
<div className="p-4">
|
||||
<div className="mb-3">
|
||||
<div className="text-[13px] font-semibold mb-1">
|
||||
{alert.region} - {alert.street}
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted">
|
||||
网格:{alert.grid_id}
|
||||
</div>
|
||||
<div className="text-[11px] text-text-muted font-mono">{alert.grid_id}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`text-[12px] px-2.5 py-1.5 rounded-lg mb-2 line-clamp-2 ${
|
||||
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
|
||||
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
|
||||
}`}>
|
||||
{alert.reason}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-text-muted gap-2">
|
||||
<span className="truncate">预测 {alert.forecast_time}</span>
|
||||
<span className="shrink-0">生成 {alert.timestamp}</span>
|
||||
<div className="flex items-center justify-between text-[11px] text-text-muted">
|
||||
<span>预测时间:{alert.forecast_time}</span>
|
||||
<span>生成:{alert.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +118,7 @@ interface AlertsListProps {
|
||||
|
||||
export const AlertsList = React.memo(function AlertsList({ filteredAlerts, selectedAlert, onCardClick }: AlertsListProps) {
|
||||
return (
|
||||
<div className="space-y-2 max-h-none overflow-visible">
|
||||
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
|
||||
{filteredAlerts.slice(0, 50).map((alert) => (
|
||||
<AlertCard
|
||||
key={alert.alert_id}
|
||||
|
||||
@@ -8,12 +8,14 @@ import { AlertsList, RiskDistributionSummary } from './AlertsList';
|
||||
import type { ExtendedAlert, RiskStats } from './types';
|
||||
|
||||
interface AlertsListTabProps {
|
||||
// toolbar
|
||||
forecastDay: 1 | 3 | 7;
|
||||
onForecastDayChange: (day: 1 | 3 | 7) => void;
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
onExportCsv: () => void;
|
||||
onExportJson: () => void;
|
||||
// filter bar
|
||||
selectedHorizon: number | 'all';
|
||||
onHorizonChange: (horizon: number | 'all') => void;
|
||||
selectedPriority: 'all' | 'P1' | 'P2';
|
||||
@@ -28,6 +30,7 @@ interface AlertsListTabProps {
|
||||
onToggleGrid: () => void;
|
||||
sortBy: 'risk' | 'time';
|
||||
onSortByChange: (sortBy: 'risk' | 'time') => void;
|
||||
// data
|
||||
riskStats: RiskStats;
|
||||
filteredAlerts: ExtendedAlert[];
|
||||
isLoading: boolean;
|
||||
@@ -36,10 +39,21 @@ interface AlertsListTabProps {
|
||||
onGridClick: (gridId: string) => void;
|
||||
onCellInfo: (info: CellInfo) => void;
|
||||
onCardClick: (id: string) => void;
|
||||
// privacy/role results (computed by orchestrator)
|
||||
effectiveShowAlertMarkers: boolean;
|
||||
isCluster: boolean;
|
||||
isOfficial: boolean;
|
||||
}
|
||||
|
||||
export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsListTabProps) {
|
||||
const { filteredAlerts, isLoading, isFullscreen, showMap, riskStats } = props;
|
||||
const {
|
||||
filteredAlerts,
|
||||
isLoading,
|
||||
isCluster,
|
||||
isFullscreen,
|
||||
showMap,
|
||||
riskStats,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,64 +81,46 @@ export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsList
|
||||
onToggleGrid={props.onToggleGrid}
|
||||
sortBy={props.sortBy}
|
||||
onSortByChange={props.onSortByChange}
|
||||
isCluster={isCluster}
|
||||
isOfficial={props.isOfficial}
|
||||
/>
|
||||
|
||||
<RiskDistributionSummary riskStats={riskStats} total={filteredAlerts.length} />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="workbench-panel p-8">
|
||||
<div className="card p-8">
|
||||
<LoadingState />
|
||||
</div>
|
||||
) : filteredAlerts.length === 0 && !showMap ? (
|
||||
<div className="workbench-panel p-8 text-center">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
|
||||
) : filteredAlerts.length === 0 && !isCluster ? (
|
||||
// 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
|
||||
<div className="card p-8 text-center">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
||||
</svg>
|
||||
<div className="text-text-muted text-[13px]">暂无符合条件的预警</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`grid gap-0 overflow-hidden rounded-2xl border border-border shadow-soft ${
|
||||
isFullscreen ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-[1fr_380px]'
|
||||
}`}
|
||||
>
|
||||
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
|
||||
{showMap && (
|
||||
<div className="map-stage min-h-[420px] relative">
|
||||
<div className="map-chrome">
|
||||
<div className="map-chrome__chip">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-danger animate-pulse" aria-hidden />
|
||||
<span className="text-[12px] font-semibold text-text-primary">风险网格地图</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0">
|
||||
<AlertsMapPanel
|
||||
selectedGridId={props.selectedGridId}
|
||||
onGridClick={props.onGridClick}
|
||||
onCellInfo={props.onCellInfo}
|
||||
forecastDay={props.forecastDay}
|
||||
showAlertMarkers={props.showAlertMarkers}
|
||||
showGrid={props.showGrid}
|
||||
filteredAlerts={filteredAlerts}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AlertsMapPanel
|
||||
selectedGridId={props.selectedGridId}
|
||||
onGridClick={props.onGridClick}
|
||||
onCellInfo={props.onCellInfo}
|
||||
forecastDay={props.forecastDay}
|
||||
effectiveShowAlertMarkers={props.effectiveShowAlertMarkers}
|
||||
showGrid={props.showGrid}
|
||||
filteredAlerts={filteredAlerts}
|
||||
isFullscreen={isFullscreen}
|
||||
isCluster={isCluster}
|
||||
isOfficial={props.isOfficial}
|
||||
/>
|
||||
)}
|
||||
{!isFullscreen && (
|
||||
<aside className="glass-wing max-h-[min(720px,70vh)] overflow-auto border-l-0 lg:border-l border-t lg:border-t-0 border-border">
|
||||
<div className="glass-wing__section !border-b-0 flex-1">
|
||||
<h3 className="glass-wing__title">预警列表</h3>
|
||||
<AlertsList
|
||||
filteredAlerts={filteredAlerts}
|
||||
selectedAlert={props.selectedAlert}
|
||||
onCardClick={props.onCardClick}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
<AlertsList
|
||||
filteredAlerts={filteredAlerts}
|
||||
selectedAlert={props.selectedAlert}
|
||||
onCardClick={props.onCardClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import { AlertMap } from '@/components/AlertMap';
|
||||
import type { CellInfo } from '@/components/AlertMap';
|
||||
import type { ExtendedAlert } from './types';
|
||||
@@ -8,10 +9,13 @@ interface AlertsMapPanelProps {
|
||||
onGridClick: (gridId: string) => void;
|
||||
onCellInfo: (info: CellInfo) => void;
|
||||
forecastDay: 1 | 3 | 7;
|
||||
showAlertMarkers: boolean;
|
||||
// effectiveShowAlertMarkers:唯一真值,cluster 模式恒为 false(隐私不变量),由 orchestrator 计算。
|
||||
effectiveShowAlertMarkers: boolean;
|
||||
showGrid: boolean;
|
||||
filteredAlerts: ExtendedAlert[];
|
||||
isFullscreen: boolean;
|
||||
isCluster: boolean;
|
||||
isOfficial: boolean;
|
||||
}
|
||||
|
||||
export const AlertsMapPanel = React.memo(function AlertsMapPanel({
|
||||
@@ -19,21 +23,42 @@ export const AlertsMapPanel = React.memo(function AlertsMapPanel({
|
||||
onGridClick,
|
||||
onCellInfo,
|
||||
forecastDay,
|
||||
showAlertMarkers,
|
||||
effectiveShowAlertMarkers,
|
||||
showGrid,
|
||||
filteredAlerts,
|
||||
isFullscreen,
|
||||
isCluster,
|
||||
isOfficial,
|
||||
}: AlertsMapPanelProps) {
|
||||
return (
|
||||
<AlertMap
|
||||
selectedGridId={selectedGridId}
|
||||
onGridClick={onGridClick}
|
||||
onCellInfo={onCellInfo}
|
||||
forecastDay={forecastDay}
|
||||
showAlertMarkers={showAlertMarkers}
|
||||
showGrid={showGrid}
|
||||
filteredAlerts={filteredAlerts}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
<div data-testid={isCluster ? TESTIDS.clusterView : undefined}>
|
||||
<AlertMap
|
||||
selectedGridId={selectedGridId}
|
||||
onGridClick={onGridClick}
|
||||
onCellInfo={onCellInfo}
|
||||
forecastDay={forecastDay}
|
||||
showAlertMarkers={effectiveShowAlertMarkers}
|
||||
showGrid={isOfficial ? false : showGrid}
|
||||
filteredAlerts={filteredAlerts}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
{/*
|
||||
隐私不变量的「数据级」可断言点:每渲染一个个体病例点标记,就在此输出一个
|
||||
data-testid="patient-point" 的隐藏标记。Leaflet 的 CircleMarker 是 canvas/SVG
|
||||
内部对象、不带 testid,无法被 e2e 直接计数;这里把「实际会显示的个体点集合」
|
||||
镜像成 DOM,使测试可断言医生/聚类视角下 patient-point 计数恒为 0,
|
||||
而无需窥探 Leaflet 内部。effectiveShowAlertMarkers 在 cluster 模式恒为 false,
|
||||
故该集合为空。
|
||||
*/}
|
||||
{effectiveShowAlertMarkers &&
|
||||
filteredAlerts.map((a) => (
|
||||
<span
|
||||
key={a.alert_id}
|
||||
data-testid={TESTIDS.patientPoint}
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { LoadingState } from '@/components/ui';
|
||||
import { StatCard } from '@/components/StatCard';
|
||||
import { StatisticalCharts } from '@/components/StatisticalCharts';
|
||||
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import type { RiskStats } from './types';
|
||||
@@ -17,142 +18,112 @@ export const AlertsRiskPanel = React.memo(function AlertsRiskPanel({
|
||||
trendLoading,
|
||||
trendError,
|
||||
}: AlertsRiskPanelProps) {
|
||||
const alertPie = useMemo(
|
||||
() => [
|
||||
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#C2410C' },
|
||||
{ name: 'P2 (关注)', value: riskStats.p2, color: '#C27803' },
|
||||
],
|
||||
[riskStats.p1, riskStats.p2]
|
||||
);
|
||||
// Severity donut data (P1/P2)
|
||||
const alertPie = useMemo(() => ([
|
||||
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
|
||||
{ name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
|
||||
]), [riskStats.p1, riskStats.p2]);
|
||||
|
||||
const topDistrictMax = useMemo(
|
||||
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
|
||||
[riskStats.topDistricts]
|
||||
[riskStats.topDistricts],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 风险分布连续条 */}
|
||||
<div className="risk-strip" role="group" aria-label="风险等级统计">
|
||||
<div className="risk-strip__cell">
|
||||
<div className="risk-strip__label">高风险 (≥0.8)</div>
|
||||
<div className="risk-strip__value text-danger">{riskStats.high}</div>
|
||||
</div>
|
||||
<div className="risk-strip__cell">
|
||||
<div className="risk-strip__label">中高风险 (0.6–0.8)</div>
|
||||
<div className="risk-strip__value text-warning">{riskStats.mediumHigh}</div>
|
||||
</div>
|
||||
<div className="risk-strip__cell">
|
||||
<div className="risk-strip__label">中风险 (0.4–0.6)</div>
|
||||
<div className="risk-strip__value text-primary">{riskStats.medium}</div>
|
||||
</div>
|
||||
<div className="risk-strip__cell">
|
||||
<div className="risk-strip__label">平均风险</div>
|
||||
<div className="risk-strip__value">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* Risk distribution as StatCards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
|
||||
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
|
||||
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
|
||||
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
|
||||
</div>
|
||||
|
||||
{/* 趋势图 */}
|
||||
<section className="workbench-panel" aria-label="风险趋势">
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">风险趋势</h3>
|
||||
<p className="workbench-panel__sub">近 14 日风险指数变化</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workbench-panel__body">
|
||||
{trendLoading ? (
|
||||
<LoadingState />
|
||||
) : trendError ? (
|
||||
<div className="text-center py-8 text-danger text-[13px]">{trendError}</div>
|
||||
) : trendData.length === 0 ? (
|
||||
<div className="text-center py-8 text-text-muted text-[13px]">暂无风险趋势数据</div>
|
||||
) : (
|
||||
<StatisticalCharts data={trendData} showCases={false} showRisk height={280} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{/* Risk trend chart (real data from /api/analysis/trend) */}
|
||||
{trendLoading ? (
|
||||
<div className="card p-8"><LoadingState /></div>
|
||||
) : trendError ? (
|
||||
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
|
||||
) : trendData.length === 0 ? (
|
||||
<div className="card p-8 text-center text-text-muted text-[13px]">暂无风险趋势数据</div>
|
||||
) : (
|
||||
<StatisticalCharts
|
||||
data={trendData}
|
||||
showCases={false}
|
||||
showRisk
|
||||
height={280}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<section className="workbench-panel" aria-label="高风险区域">
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">高风险区县 Top 5</h3>
|
||||
<p className="workbench-panel__sub">按区县预警条数排序</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Top high-risk districts bar */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
高风险区域 Top 5
|
||||
</div>
|
||||
<div className="workbench-panel__body space-y-3">
|
||||
{riskStats.topDistricts.length > 0 ? (
|
||||
riskStats.topDistricts.map(([district, count], i) => (
|
||||
<div key={district} className="district-bar !cursor-default hover:!bg-transparent !px-0">
|
||||
<div className="w-4 text-[10px] font-mono text-text-muted tabular-nums">{i + 1}</div>
|
||||
<div className="w-16 text-[12px] font-medium text-text-secondary shrink-0 truncate">
|
||||
{district}
|
||||
{riskStats.topDistricts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{riskStats.topDistricts.map(([district, count]) => (
|
||||
<div key={district}>
|
||||
<div className="flex items-center justify-between text-[12px] mb-1">
|
||||
<span className="text-text-primary font-medium">{district}</span>
|
||||
<span className="text-text-muted">{count} 条</span>
|
||||
</div>
|
||||
<div className="district-bar__track">
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="district-bar__fill bg-danger/80"
|
||||
style={{
|
||||
width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%`,
|
||||
}}
|
||||
className="h-full bg-danger rounded-full"
|
||||
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-12 text-right data-num text-[12px]">{count}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-[13px]">暂无区域数据</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="workbench-panel" aria-label="预警严重度">
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">预警严重度分布</h3>
|
||||
<p className="workbench-panel__sub">P1 / P2 占比</p>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-[13px]">暂无区域数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert severity donut (P1/P2) */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
|
||||
预警严重度分布
|
||||
</div>
|
||||
<div className="workbench-panel__body">
|
||||
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={alertPie}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
>
|
||||
{alertPie.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #D4DEE4',
|
||||
borderRadius: '10px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number, name: string) => [value, name]}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
formatter={(value: string) => (
|
||||
<span className="text-text-secondary">{value}</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-[13px]">暂无预警数据</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={alertPie}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
>
|
||||
{alertPie.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number, name: string) => [value, name]}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
formatter={(value: string) => <span className="text-text-secondary">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-text-muted text-[13px]">暂无预警数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
/**
|
||||
* 住院临床分析页图表字面色值集中处。
|
||||
* 对齐江雾青绿 / 雾蓝体系,避免紫系默认配色。
|
||||
* Recharts 需要原始 hex,无法用 Tailwind class,故在此集中定义,避免散落 magic hex。
|
||||
*/
|
||||
export const CLINICAL_COLORS = {
|
||||
primary: '#0F766E',
|
||||
los: '#0F766E',
|
||||
box: '#14B8A6',
|
||||
boxMedian: '#0D5C56',
|
||||
grid: '#D4DEE4',
|
||||
axis: '#5A6F7A',
|
||||
axisLabel: '#1A2B33',
|
||||
tooltipBorder: '#D4DEE4',
|
||||
tooltipText: '#1A2B33',
|
||||
primary: '#2563EB', // primary
|
||||
los: '#2563EB',
|
||||
box: '#3B82F6', // 箱体填充
|
||||
boxMedian: '#1D4ED8', // 中位刻度
|
||||
grid: '#E2E8F0',
|
||||
axis: '#64748B',
|
||||
axisLabel: '#374151',
|
||||
tooltipBorder: '#E2E8F0',
|
||||
tooltipText: '#1E293B',
|
||||
// 出院结局按严重程度配色:治愈/好转偏绿,未愈/死亡偏红,其他中性
|
||||
outcome: {
|
||||
治愈: '#0D9488',
|
||||
好转: '#5EEAD4',
|
||||
其他: '#8A9BA5',
|
||||
未愈: '#C27803',
|
||||
死亡: '#C2410C',
|
||||
治愈: '#16A34A',
|
||||
好转: '#4ADE80',
|
||||
其他: '#94A3B8',
|
||||
未愈: '#F97316',
|
||||
死亡: '#DC2626',
|
||||
} as Record<string, string>,
|
||||
outcomeFallback: '#8A9BA5',
|
||||
routePalette: ['#0F766E', '#5B8FA8', '#14B8A6', '#C27803', '#0D9488', '#C2410C'],
|
||||
outcomeFallback: '#94A3B8',
|
||||
// 入院途径 donut 顺序色板
|
||||
routePalette: ['#2563EB', '#0891B2', '#7C3AED', '#D97706', '#16A34A', '#DC2626'],
|
||||
} as const;
|
||||
|
||||
/** Recharts tooltip 通用样式。 */
|
||||
export const TOOLTIP_STYLE = {
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
} as const;
|
||||
|
||||
@@ -20,14 +20,6 @@ function formatDateLabel(dateStr: string): string {
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
const TOOLTIP = {
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: '1px solid #D4DEE4',
|
||||
borderRadius: '10px',
|
||||
fontSize: '12px',
|
||||
boxShadow: '0 4px 16px rgba(26,43,51,0.08)',
|
||||
};
|
||||
|
||||
interface CaseStatsTabProps {
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
@@ -41,7 +33,7 @@ interface CaseStatsTabProps {
|
||||
onDismissError: () => void;
|
||||
}
|
||||
|
||||
/** 病例统计 tab — 诊断 / 趋势 / 日历,工作台面板构图。 */
|
||||
// 病例统计 tab —— 诊断分布 / 病例与AQI趋势 / 日历热力图。纯展示,数据由父级按需加载。
|
||||
export const CaseStatsTab = memo(function CaseStatsTab({
|
||||
loading,
|
||||
loaded,
|
||||
@@ -57,138 +49,97 @@ export const CaseStatsTab = memo(function CaseStatsTab({
|
||||
if (loading && !loaded) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-[1400px]">
|
||||
{error && <ErrorBanner error={error} onRetry={onRetry} onDismiss={onDismissError} />}
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
onDismiss={onDismissError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
|
||||
{/* Top 5 诊断 — 占 2 列 */}
|
||||
<section className="workbench-panel xl:col-span-2" aria-label="Top 5 诊断分布">
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">Top 5 诊断分布</h3>
|
||||
<p className="workbench-panel__sub">门诊 / 住院堆叠</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workbench-panel__body">
|
||||
{topDiagnoses.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart
|
||||
data={[...topDiagnoses].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 12, left: 8, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E8EEF1" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#8A9BA5' }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="diagnosis"
|
||||
tick={{ fontSize: 11, fill: '#5A6F7A' }}
|
||||
width={96}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP}
|
||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Bar dataKey="outpatient" stackId="a" fill="#C27803" name="门诊" barSize={18} radius={[0, 0, 0, 0]} />
|
||||
<Bar dataKey="inpatient" stackId="a" fill="#C2410C" name="住院" barSize={18} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-12 text-text-muted text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 病例与 AQI 趋势 — 占 3 列 */}
|
||||
<section className="workbench-panel xl:col-span-3" aria-label="病例与AQI趋势">
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">病例与 AQI 趋势</h3>
|
||||
<p className="workbench-panel__sub">截至 {currentDate} 的近 30 日窗口</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workbench-panel__body">
|
||||
{caseTrend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E8EEF1" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 10, fill: '#8A9BA5' }}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={{ stroke: '#D4DEE4' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 10, fill: '#8A9BA5' }}
|
||||
axisLine={{ stroke: '#D4DEE4' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 10, fill: '#C27803' }}
|
||||
axisLine={{ stroke: '#D4DEE4' }}
|
||||
/>
|
||||
<Tooltip contentStyle={TOOLTIP} labelStyle={{ color: '#1A2B33', fontWeight: 600 }} />
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="cases"
|
||||
name="病例数"
|
||||
stroke="#0F766E"
|
||||
strokeWidth={2.25}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="aqi"
|
||||
name="AQI"
|
||||
stroke="#C27803"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 4"
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-12 text-text-muted text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{/* Top 5 诊断分布 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 诊断分布</h3>
|
||||
{topDiagnoses.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart
|
||||
data={[...topDiagnoses].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="diagnosis"
|
||||
tick={{ fontSize: 11, fill: '#374151' }}
|
||||
width={100}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
|
||||
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 日历热力图 — 通栏 */}
|
||||
<section className="workbench-panel" aria-label="每日病例日历">
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">
|
||||
{heatmapYear ? `${heatmapYear} 年` : ''}每日病例日历
|
||||
</h3>
|
||||
<p className="workbench-panel__sub">密度越高表示当日病例越多</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workbench-panel__body">
|
||||
{heatmapYear && heatmapData.length > 0 ? (
|
||||
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
|
||||
) : (
|
||||
<div className="text-center py-12 text-text-muted text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">病例与AQI趋势</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">截至 {currentDate} 的近30日窗口</p>
|
||||
{caseTrend.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={{ stroke: '#E2E8F0' }}
|
||||
/>
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
||||
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 日历热力图 (year derived from data) */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{heatmapYear ? `${heatmapYear}年 ` : ''}每日病例日历
|
||||
</h3>
|
||||
{heatmapYear && heatmapData.length > 0 ? (
|
||||
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ interface DistrictStatsTabProps {
|
||||
onSort: (col: string) => void;
|
||||
}
|
||||
|
||||
/** 区域统计 tab — 热力表工作台面板。 */
|
||||
// 区域统计 tab —— 区域指标热力表。纯展示,排序键由父级持有。
|
||||
export const DistrictStatsTab = memo(function DistrictStatsTab({
|
||||
loading,
|
||||
loaded,
|
||||
@@ -27,47 +27,40 @@ export const DistrictStatsTab = memo(function DistrictStatsTab({
|
||||
if (loading && !loaded) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-[1400px]">
|
||||
{error && <ErrorBanner error={error} onRetry={onRetry} onDismiss={onDismissError} />}
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
onDismiss={onDismissError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="workbench-panel" aria-label="区域指标热力表">
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">区域指标热力表</h3>
|
||||
<p className="workbench-panel__sub">点击列标题排序 · 颜色深浅反映相对强度</p>
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<span className="text-[11px] font-mono tabular-nums text-text-muted bg-bg-hover px-2.5 py-1 rounded-md border border-border">
|
||||
{rows.length} 个区域
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="workbench-panel__body">
|
||||
{rows.length > 0 ? (
|
||||
<div className="rounded-xl border border-border/80 overflow-hidden bg-bg-elevated/50">
|
||||
<MetricHeatmapTable
|
||||
rows={rows}
|
||||
columns={[
|
||||
{ key: 'total', label: '病例' },
|
||||
{ key: 'outpatient', label: '门诊' },
|
||||
{ key: 'inpatient', label: '住院' },
|
||||
{ key: 'inpatient_ratio', label: '住院占比%' },
|
||||
]}
|
||||
data={data}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-text-muted text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">区域指标热力表</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">点击列标题排序</p>
|
||||
{rows.length > 0 ? (
|
||||
<MetricHeatmapTable
|
||||
rows={rows}
|
||||
columns={[
|
||||
{ key: 'total', label: '病例' },
|
||||
{ key: 'outpatient', label: '门诊' },
|
||||
{ key: 'inpatient', label: '住院' },
|
||||
{ key: 'inpatient_ratio', label: '住院占比%' },
|
||||
]}
|
||||
data={data}
|
||||
onSort={onSort}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
|
||||
import { StatCard } from '@/components/StatCard';
|
||||
import type { MonitoringStats } from './types';
|
||||
|
||||
interface MonitoringStatsBarProps {
|
||||
@@ -7,113 +8,49 @@ interface MonitoringStatsBarProps {
|
||||
sparkline7d: number[];
|
||||
}
|
||||
|
||||
function MiniSpark({ data, color }: { data: number[]; color: string }) {
|
||||
if (data.length < 2) return null;
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1;
|
||||
const points = data
|
||||
.map((val, i) => {
|
||||
const x = (i / (data.length - 1)) * 72 + 1;
|
||||
const y = 22 - ((val - min) / range) * 18 - 1;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
const area = `1,23 ${points} 73,23`;
|
||||
|
||||
// 监测页顶部统计条 —— 纯展示,已自适应(grid-cols-2 sm:grid-cols-3 lg:grid-cols-6)。
|
||||
export const MonitoringStatsBar = memo(function MonitoringStatsBar({ stats, sparkline7d }: MonitoringStatsBarProps) {
|
||||
return (
|
||||
<svg width="74" height="24" className="shrink-0" aria-hidden="true">
|
||||
<polygon fill={`${color}22`} points={area} />
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
points={points}
|
||||
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
icon={<Calendar className="w-4 h-4 text-blue-600" />}
|
||||
label="当日病例"
|
||||
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="w-4 h-4 text-indigo-600" />}
|
||||
label="7日均值"
|
||||
value={stats.avg7d.toLocaleString()}
|
||||
sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={
|
||||
stats.trend === 'up' ? <TrendingUp className="w-4 h-4 text-red-500" /> :
|
||||
stats.trend === 'down' ? <TrendingDown className="w-4 h-4 text-green-500" /> :
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
}
|
||||
label="趋势"
|
||||
value={stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
|
||||
trend={{
|
||||
direction: stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'stable',
|
||||
value: stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳',
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Zap className="w-4 h-4 text-amber-500" />}
|
||||
label="峰值日"
|
||||
value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<BarChart3 className="w-4 h-4 text-purple-500" />}
|
||||
label="标准差"
|
||||
value={stats.stdDev.toLocaleString()}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Stethoscope className="w-4 h-4 text-orange-500" />}
|
||||
label="门诊 / 住院"
|
||||
value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 监测页指挥台指标带 — 6 KPI + sparkline,连续仪表条构图。 */
|
||||
export const MonitoringStatsBar = memo(function MonitoringStatsBar({
|
||||
stats,
|
||||
sparkline7d,
|
||||
}: MonitoringStatsBarProps) {
|
||||
const trendLabel = stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳';
|
||||
const trendClass =
|
||||
stats.trend === 'up' ? 'text-danger' : stats.trend === 'down' ? 'text-success' : 'text-text-muted';
|
||||
|
||||
return (
|
||||
<div className="command-rail flex-1 min-w-0 stagger-children" role="group" aria-label="监测关键指标">
|
||||
<div className="command-rail__cell command-rail__cell--hero">
|
||||
<div className="command-rail__label">
|
||||
<Calendar className="w-3.5 h-3.5 text-primary" aria-hidden />
|
||||
当日病例
|
||||
</div>
|
||||
<div className="command-rail__value">
|
||||
{stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="command-rail__cell">
|
||||
<div className="command-rail__label">
|
||||
<Activity className="w-3.5 h-3.5 text-mist" aria-hidden />
|
||||
7日均值
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<div className="command-rail__value text-[20px]">{stats.avg7d.toLocaleString()}</div>
|
||||
{sparkline7d.length >= 2 && <MiniSpark data={sparkline7d} color="#0F766E" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="command-rail__cell">
|
||||
<div className="command-rail__label">
|
||||
{stats.trend === 'up' ? (
|
||||
<TrendingUp className="w-3.5 h-3.5 text-danger" aria-hidden />
|
||||
) : stats.trend === 'down' ? (
|
||||
<TrendingDown className="w-3.5 h-3.5 text-success" aria-hidden />
|
||||
) : (
|
||||
<Activity className="w-3.5 h-3.5 text-text-muted" aria-hidden />
|
||||
)}
|
||||
趋势
|
||||
</div>
|
||||
<div className={`command-rail__value text-[20px] ${trendClass}`}>{trendLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="command-rail__cell">
|
||||
<div className="command-rail__label">
|
||||
<Zap className="w-3.5 h-3.5 text-warning" aria-hidden />
|
||||
峰值日
|
||||
</div>
|
||||
<div className="command-rail__value text-[18px]">
|
||||
{stats.maxDay.cases.toLocaleString()}
|
||||
<span className="ml-1.5 text-[12px] font-medium text-text-muted font-sans tracking-normal">
|
||||
{stats.maxDay.date.slice(5)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="command-rail__cell">
|
||||
<div className="command-rail__label">
|
||||
<BarChart3 className="w-3.5 h-3.5 text-mist-deep" aria-hidden />
|
||||
标准差
|
||||
</div>
|
||||
<div className="command-rail__value text-[20px]">{stats.stdDev.toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<div className="command-rail__cell">
|
||||
<div className="command-rail__label">
|
||||
<Stethoscope className="w-3.5 h-3.5 text-primary-light" aria-hidden />
|
||||
门诊 / 住院
|
||||
</div>
|
||||
<div className="command-rail__value text-[17px]">
|
||||
<span className="text-warning">{stats.totalOutpatient.toLocaleString()}</span>
|
||||
<span className="mx-1 text-text-muted font-sans font-normal">/</span>
|
||||
<span className="text-danger">{stats.totalInpatient.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,11 +17,7 @@ interface OverviewTabProps {
|
||||
onDistrictSelect: (district: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 概览 tab — 沉浸式地图舞台 + 玻璃统计翼(趋势 / 区县 roll-up)。
|
||||
* 功能保留:病例地图、AQI/病例趋势、区县分解、粒度切换。
|
||||
* 播放时间轴时地图始终挂载,仅侧翼显示刷新态,避免底图闪烁。
|
||||
*/
|
||||
// 概览 tab —— 病例分布地图 + 统计图表 + 区县 roll-up(粒度真相来源在父级 URL)。
|
||||
export const OverviewTab = memo(function OverviewTab({
|
||||
isLoading,
|
||||
chartData,
|
||||
@@ -33,106 +29,62 @@ export const OverviewTab = memo(function OverviewTab({
|
||||
onGranularityChange,
|
||||
onDistrictSelect,
|
||||
}: OverviewTabProps) {
|
||||
const totalDistrictCases = useMemo(
|
||||
() => districtCases.reduce((s, d) => s + d.total, 0),
|
||||
[districtCases]
|
||||
);
|
||||
const showWingSkeleton = isLoading && districtCases.length === 0;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col xl:flex-row h-full min-h-0 pb-[4.5rem]">
|
||||
{/* 地图舞台 — 全高沉浸;永不因 isLoading 卸载 */}
|
||||
<section className="map-stage min-h-[380px] xl:min-h-0 border-b xl:border-b-0">
|
||||
<div className="map-chrome">
|
||||
<div className="map-chrome__chip">
|
||||
<span className="text-[11px] text-text-muted">观测日</span>
|
||||
<span className="font-mono text-[12px] font-semibold tabular-nums text-text-primary">
|
||||
{currentDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-[6] h-16
|
||||
bg-gradient-to-t from-[#dce6eb]/70 to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute inset-0">
|
||||
<CaseLocationMap
|
||||
height="100%"
|
||||
district={selectedDistrict}
|
||||
street={selectedStreet}
|
||||
date={currentDate}
|
||||
<div className="space-y-6">
|
||||
{/* Case Location Map */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">病例分布地图</h3>
|
||||
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
|
||||
</div>
|
||||
|
||||
{/* Statistical Charts */}
|
||||
<StatisticalCharts
|
||||
data={chartData}
|
||||
height={350}
|
||||
showCases={true}
|
||||
showAQI={true}
|
||||
/>
|
||||
|
||||
{/* District breakdown — 区域 roll-up(URL 粒度真相来源) */}
|
||||
<div data-testid={TESTIDS.districtRollup} className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">区县病例分布</h3>
|
||||
<Segmented<Granularity>
|
||||
testid={TESTIDS.granularityControl}
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'city', label: '全市' },
|
||||
{ value: 'district', label: '区域' },
|
||||
{ value: 'street', label: '街道' },
|
||||
]}
|
||||
value={granularity}
|
||||
onChange={onGranularityChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 玻璃统计翼 */}
|
||||
<aside className="glass-wing xl:w-[400px] 2xl:w-[440px] shrink-0 max-h-[48vh] xl:max-h-none overflow-auto">
|
||||
{showWingSkeleton ? (
|
||||
<div className="flex items-center justify-center min-h-[280px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||
<div className="space-y-2">
|
||||
<DistrictBreakdown
|
||||
districtCases={districtCases}
|
||||
selectedDistrict={selectedDistrict}
|
||||
onDistrictSelect={onDistrictSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="w-3 h-3 bg-orange-400 rounded-sm" />门诊
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="glass-wing__section">
|
||||
<h3 className="glass-wing__title">病例与 AQI 趋势</h3>
|
||||
<div className="rounded-xl border border-border/70 bg-bg-card/80 p-1.5 shadow-soft">
|
||||
<StatisticalCharts data={chartData} height={240} showCases={true} showAQI={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-testid={TESTIDS.districtRollup} className="glass-wing__section !pb-4">
|
||||
<div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
|
||||
<div>
|
||||
<h3 className="glass-wing__title !mb-1">区县病例分布</h3>
|
||||
<p className="text-[11px] text-text-muted mb-2">
|
||||
合计{' '}
|
||||
<span className="data-num text-text-secondary text-[12px]">
|
||||
{totalDistrictCases.toLocaleString()}
|
||||
</span>
|
||||
{selectedDistrict ? (
|
||||
<span className="ml-2 text-primary">· 已选 {selectedDistrict}</span>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<span className="ml-2 text-text-muted">更新中…</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<Segmented<Granularity>
|
||||
testid={TESTIDS.granularityControl}
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'city', label: '全市' },
|
||||
{ value: 'district', label: '区域' },
|
||||
{ value: 'street', label: '街道' },
|
||||
]}
|
||||
value={granularity}
|
||||
onChange={onGranularityChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 max-h-[min(360px,42vh)] overflow-y-auto pr-0.5 -mx-1">
|
||||
<DistrictBreakdown
|
||||
districtCases={districtCases}
|
||||
selectedDistrict={selectedDistrict}
|
||||
onDistrictSelect={onDistrictSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5 mt-3 pt-3 border-t border-border-light">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
|
||||
<span className="w-3 h-2 rounded-sm bg-warning/85" aria-hidden />
|
||||
门诊
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
|
||||
<span className="w-3 h-2 rounded-sm bg-danger/75" aria-hidden />
|
||||
住院
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="w-3 h-3 bg-red-400 rounded-sm" />住院
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -140,71 +92,46 @@ export const OverviewTab = memo(function OverviewTab({
|
||||
interface DistrictBreakdownProps {
|
||||
districtCases: DistrictCaseRow[];
|
||||
selectedDistrict: string | null;
|
||||
// 点击区域条目时上抛——由父组件驱动 URL(粒度真相来源),不在此处 mutate store。
|
||||
onDistrictSelect: (district: string) => void;
|
||||
}
|
||||
|
||||
const DistrictBreakdown = memo(function DistrictBreakdown({
|
||||
districtCases,
|
||||
selectedDistrict,
|
||||
onDistrictSelect,
|
||||
}: DistrictBreakdownProps) {
|
||||
const sortedCases = useMemo(
|
||||
() => [...districtCases].sort((a, b) => b.total - a.total),
|
||||
[districtCases]
|
||||
);
|
||||
const maxTotal = useMemo(
|
||||
() => (sortedCases.length > 0 ? sortedCases[0].total : 1),
|
||||
[sortedCases]
|
||||
);
|
||||
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict, onDistrictSelect }: DistrictBreakdownProps) {
|
||||
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
|
||||
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
|
||||
|
||||
const handleDistrictClick = useCallback(
|
||||
(district: string) => {
|
||||
onDistrictSelect(district);
|
||||
},
|
||||
[onDistrictSelect]
|
||||
);
|
||||
const handleDistrictClick = useCallback((district: string) => {
|
||||
onDistrictSelect(district);
|
||||
}, [onDistrictSelect]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sortedCases.map((d, rank) => {
|
||||
{sortedCases.map((d) => {
|
||||
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
|
||||
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
|
||||
const barWidth = (d.total / maxTotal) * 100;
|
||||
const isActive = selectedDistrict === d.district;
|
||||
return (
|
||||
<div
|
||||
key={d.district}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isActive}
|
||||
className={`district-bar ${isActive ? 'district-bar--active' : ''}`}
|
||||
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
|
||||
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleDistrictClick(d.district)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleDistrictClick(d.district);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-4 text-[10px] font-mono text-text-muted tabular-nums shrink-0 text-right">
|
||||
{rank + 1}
|
||||
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
|
||||
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
|
||||
<div
|
||||
className="bg-orange-400 h-full transition-all"
|
||||
style={{ width: `${barWidth * outPct / 100}%` }}
|
||||
title={`门诊: ${d.outpatient.toLocaleString()}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-red-400 h-full transition-all"
|
||||
style={{ width: `${barWidth * inPct / 100}%` }}
|
||||
title={`住院: ${d.inpatient.toLocaleString()}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[3.25rem] text-[12px] font-medium text-text-secondary shrink-0 truncate">
|
||||
{d.district}
|
||||
</div>
|
||||
<div className="district-bar__track" title={`门诊 ${d.outpatient} · 住院 ${d.inpatient}`}>
|
||||
<div className="district-bar__fill" style={{ width: `${barWidth}%` }}>
|
||||
<div
|
||||
className="h-full bg-warning/85"
|
||||
style={{ width: `${outPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-danger/75"
|
||||
style={{ width: `${inPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[3.75rem] text-right data-num text-[12px] shrink-0">
|
||||
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
|
||||
{d.total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,72 +11,57 @@ export interface AlertSlice {
|
||||
|
||||
interface AlertSeverityDonutProps {
|
||||
data: AlertSlice[];
|
||||
embed?: boolean;
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
function AlertSeverityDonutComponent({ data, embed }: AlertSeverityDonutProps) {
|
||||
function AlertSeverityDonutComponent({ data }: AlertSeverityDonutProps) {
|
||||
const hasData = data.some((d) => d.value > 0);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{embed ? (
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">预警严重度分布</h3>
|
||||
<p className="workbench-panel__sub">P1 紧急 / P2 关注</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||
预警严重度分布
|
||||
</div>
|
||||
{hasData ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: number, name: string) => [value, name]}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
formatter={(value: string) => <span className="text-text-primary">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||
预警严重度分布
|
||||
</div>
|
||||
<EmptyState title="暂无预警数据" />
|
||||
)}
|
||||
<div className={embed ? 'workbench-panel__body' : undefined}>
|
||||
{hasData ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
>
|
||||
{data.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: number, name: string) => [value, name]}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
formatter={(value: string) => <span className="text-text-primary">{value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="暂无预警数据" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embed) return <>{body}</>;
|
||||
return <div className="card p-4">{body}</div>;
|
||||
}
|
||||
|
||||
export const AlertSeverityDonut = memo(AlertSeverityDonutComponent);
|
||||
|
||||
@@ -20,8 +20,6 @@ export interface MergedTrendItem {
|
||||
|
||||
interface CaseAqiTrendProps {
|
||||
data: MergedTrendItem[];
|
||||
/** 嵌入 workbench-panel 时去掉外层 card */
|
||||
embed?: boolean;
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
@@ -32,85 +30,71 @@ function formatDateLabel(dateStr: string): string {
|
||||
const tooltipStyle = {
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
function CaseAqiTrendComponent({ data, embed }: CaseAqiTrendProps) {
|
||||
const body = (
|
||||
<>
|
||||
{embed ? (
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">近 30 日病例与 AQI</h3>
|
||||
<p className="workbench-panel__sub">双轴对照趋势</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||
近30日病例与AQI趋势
|
||||
</div>
|
||||
)}
|
||||
<div className={embed ? 'workbench-panel__body' : undefined}>
|
||||
{data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={embed ? 280 : 200}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={{ stroke: CHART_COLORS.grid }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
|
||||
axisLine={{ stroke: CHART_COLORS.grid }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 10, fill: CHART_COLORS.aqi }}
|
||||
axisLine={{ stroke: CHART_COLORS.grid }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: CHART_COLORS.tooltipText, fontWeight: 600 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="cases"
|
||||
name="病例数"
|
||||
stroke={CHART_COLORS.cases}
|
||||
strokeWidth={2.25}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="aqi"
|
||||
name="AQI"
|
||||
stroke={CHART_COLORS.aqi}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 4"
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="暂无数据" />
|
||||
)}
|
||||
function CaseAqiTrendComponent({ data }: CaseAqiTrendProps) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||
近30日病例与AQI趋势
|
||||
</div>
|
||||
</>
|
||||
{data.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
|
||||
interval="preserveStartEnd"
|
||||
axisLine={{ stroke: CHART_COLORS.grid }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
|
||||
axisLine={{ stroke: CHART_COLORS.grid }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 10, fill: CHART_COLORS.aqi }}
|
||||
axisLine={{ stroke: CHART_COLORS.grid }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
labelStyle={{ color: CHART_COLORS.tooltipText, fontWeight: 600 }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="cases"
|
||||
name="病例数"
|
||||
stroke={CHART_COLORS.cases}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="aqi"
|
||||
name="AQI"
|
||||
stroke={CHART_COLORS.aqi}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
activeDot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="暂无数据" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embed) return <div className="h-full flex flex-col">{body}</div>;
|
||||
return <div className="card p-4">{body}</div>;
|
||||
}
|
||||
|
||||
export const CaseAqiTrend = memo(CaseAqiTrendComponent);
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type MapView from '@geoscene/core/views/MapView';
|
||||
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
|
||||
import Graphic from '@geoscene/core/Graphic';
|
||||
import Polygon from '@geoscene/core/geometry/Polygon';
|
||||
import SimpleFillSymbol from '@geoscene/core/symbols/SimpleFillSymbol';
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { CHART_COLORS } from './chartColors';
|
||||
import { createMapView } from '@/geoscene/createMapView';
|
||||
import { createGraphicsLayer } from '@/geoscene/layers';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
|
||||
interface DistrictChoroplethProps {
|
||||
/** 区名(规范,带「区」) → 当前 metric 标量值 的查表。 */
|
||||
metricLookup: Record<string, number>;
|
||||
/** 当前指标的中文标签,用于 tooltip(如「门诊病例」)。 */
|
||||
metricLabel: string;
|
||||
}
|
||||
|
||||
interface WuhanFeatureProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface WuhanFeature {
|
||||
type: 'Feature';
|
||||
properties: WuhanFeatureProps;
|
||||
geometry: {
|
||||
type: 'Polygon' | 'MultiPolygon';
|
||||
coordinates: number[][][] | number[][][][];
|
||||
};
|
||||
}
|
||||
|
||||
interface WuhanFeatureCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: WuhanFeature[];
|
||||
}
|
||||
const WUHAN_CENTER: [number, number] = [30.59, 114.3];
|
||||
|
||||
/** 把值映射到 7 档顺序色阶;高值 → 深色。 */
|
||||
function colorForValue(value: number, max: number): string {
|
||||
const scale = CHART_COLORS.choropleth;
|
||||
if (max <= 0 || value <= 0) return CHART_COLORS.choroplethEmpty;
|
||||
@@ -40,121 +21,120 @@ function colorForValue(value: number, max: number): string {
|
||||
return scale[idx];
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha = 0.78): number[] {
|
||||
const h = hex.replace('#', '');
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return [r, g, b, alpha];
|
||||
interface WuhanFeatureProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function ringsFromGeometry(geometry: WuhanFeature['geometry']): number[][][] {
|
||||
if (geometry.type === 'Polygon') {
|
||||
return geometry.coordinates as number[][][];
|
||||
}
|
||||
return (geometry.coordinates as number[][][][]).flat();
|
||||
}
|
||||
// @types/geojson 随 @types/leaflet 一并提供 GeoJSON 全局命名空间。
|
||||
type WuhanFeatureCollection = GeoJSON.FeatureCollection;
|
||||
|
||||
function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChoroplethProps) {
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<MapView | null>(null);
|
||||
const layerRef = useRef<GraphicsLayer | null>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const geoLayerRef = useRef<L.GeoJSON | null>(null);
|
||||
const geoDataRef = useRef<WuhanFeatureCollection | null>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
|
||||
const maxValue = useMemo(() => {
|
||||
const vals = Object.values(metricLookup);
|
||||
return vals.length ? Math.max(...vals) : 0;
|
||||
}, [metricLookup]);
|
||||
|
||||
// 创建地图 + 加载 geojson 一次。
|
||||
useEffect(() => {
|
||||
if (!mapDivRef.current || viewRef.current) return;
|
||||
if (!mapDivRef.current || mapRef.current) return;
|
||||
|
||||
const { map, view, destroy } = createMapView({
|
||||
container: mapDivRef.current,
|
||||
const map = L.map(mapDivRef.current, {
|
||||
center: WUHAN_CENTER,
|
||||
zoom: 9,
|
||||
constraints: { minZoom: 8, maxZoom: 14 },
|
||||
zoomControl: true,
|
||||
attributionControl: false,
|
||||
scrollWheelZoom: false,
|
||||
});
|
||||
const layer = createGraphicsLayer('区县填色');
|
||||
map.add(layer);
|
||||
layerRef.current = layer;
|
||||
viewRef.current = view;
|
||||
mapRef.current = map;
|
||||
|
||||
view.ui.remove('zoom');
|
||||
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 18,
|
||||
}).addTo(map);
|
||||
|
||||
let cancelled = false;
|
||||
fetch('/wuhan_districts.geojson')
|
||||
.then((r) => r.json())
|
||||
.then((data: WuhanFeatureCollection) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || !mapRef.current) return;
|
||||
geoDataRef.current = data;
|
||||
renderLayer();
|
||||
try {
|
||||
const tmp = L.geoJSON(data);
|
||||
map.fitBounds(tmp.getBounds(), { padding: [12, 12] });
|
||||
} catch {
|
||||
/* keep default center if bounds fail */
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => {
|
||||
/* network/mock failure — wrapper still renders for tests */
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
layerRef.current = null;
|
||||
viewRef.current = null;
|
||||
destroy();
|
||||
setMapReady(false);
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
}
|
||||
geoLayerRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 当 metric 变化时重绘填色。
|
||||
useEffect(() => {
|
||||
const layer = layerRef.current;
|
||||
const view = viewRef.current;
|
||||
renderLayer();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [metricLookup, maxValue, metricLabel]);
|
||||
|
||||
function renderLayer() {
|
||||
const map = mapRef.current;
|
||||
const data = geoDataRef.current;
|
||||
if (!layer || !view || !mapReady) return;
|
||||
if (!map || !data) return;
|
||||
|
||||
const draw = (fc: WuhanFeatureCollection) => {
|
||||
layer.removeAll();
|
||||
const graphics: Graphic[] = [];
|
||||
for (const feature of fc.features) {
|
||||
const name = feature.properties?.name ?? '未知';
|
||||
const value = metricLookup[name] ?? 0;
|
||||
const rings = ringsFromGeometry(feature.geometry);
|
||||
if (!rings.length) continue;
|
||||
graphics.push(
|
||||
new Graphic({
|
||||
geometry: new Polygon({ rings, spatialReference: { wkid: 4326 } }),
|
||||
symbol: new SimpleFillSymbol({
|
||||
color: hexToRgba(colorForValue(value, maxValue)),
|
||||
outline: { color: hexToRgba(CHART_COLORS.choroplethStroke, 1), width: 1 },
|
||||
}),
|
||||
attributes: { name, value, metricLabel },
|
||||
popupTemplate: {
|
||||
title: '{name}',
|
||||
content: `${metricLabel}:{value}`,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
layer.addMany(graphics);
|
||||
if (graphics.length > 0) {
|
||||
view.goTo(graphics).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
if (data) {
|
||||
draw(data);
|
||||
return;
|
||||
if (geoLayerRef.current) {
|
||||
geoLayerRef.current.remove();
|
||||
geoLayerRef.current = null;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
fetch('/wuhan_districts.geojson')
|
||||
.then((r) => r.json())
|
||||
.then((fc: WuhanFeatureCollection) => {
|
||||
if (cancelled) return;
|
||||
geoDataRef.current = fc;
|
||||
draw(fc);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [metricLookup, maxValue, metricLabel, mapReady]);
|
||||
geoLayerRef.current = L.geoJSON(data, {
|
||||
style: (feature) => {
|
||||
const name = (feature?.properties as WuhanFeatureProps | undefined)?.name ?? '';
|
||||
const value = metricLookup[name] ?? 0;
|
||||
return {
|
||||
fillColor: colorForValue(value, maxValue),
|
||||
fillOpacity: 0.78,
|
||||
color: CHART_COLORS.choroplethStroke,
|
||||
weight: 1.2,
|
||||
};
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
const name = (feature.properties as WuhanFeatureProps).name ?? '未知';
|
||||
const value = metricLookup[name] ?? 0;
|
||||
layer.bindTooltip(
|
||||
`<div style="font-size:12px"><b>${name}</b><br/>${metricLabel}:${value.toLocaleString()}</div>`,
|
||||
{ sticky: true }
|
||||
);
|
||||
layer.on({
|
||||
mouseover: (e) => {
|
||||
(e.target as L.Path).setStyle({ weight: 2.4, color: CHART_COLORS.cases });
|
||||
},
|
||||
mouseout: (e) => {
|
||||
(e.target as L.Path).setStyle({ weight: 1.2, color: CHART_COLORS.choroplethStroke });
|
||||
},
|
||||
click: (e) => {
|
||||
map.fitBounds((e.target as L.GeoJSON).getBounds(), { padding: [40, 40] });
|
||||
},
|
||||
});
|
||||
},
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
// 图例的 5 档分界值。
|
||||
const legendStops = useMemo(() => {
|
||||
const scale = CHART_COLORS.choropleth;
|
||||
return scale.map((color, i) => ({
|
||||
@@ -164,17 +144,8 @@ function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChor
|
||||
}, [maxValue]);
|
||||
|
||||
return (
|
||||
<div data-testid={TESTIDS.choroplethWrapper} className="relative">
|
||||
<div
|
||||
ref={mapDivRef}
|
||||
className="w-full rounded-lg overflow-hidden bg-slate-100"
|
||||
style={{ height: 420 }}
|
||||
/>
|
||||
{!mapReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/70 rounded-lg text-[13px] text-text-muted">
|
||||
地图加载中…
|
||||
</div>
|
||||
)}
|
||||
<div data-testid="choropleth-wrapper" className="relative">
|
||||
<div ref={mapDivRef} className="w-full rounded-lg overflow-hidden" style={{ height: 420 }} />
|
||||
|
||||
<div className="absolute bottom-3 right-3 z-[1000] bg-bg-card/95 px-3 py-2 rounded-lg border border-border shadow-sm">
|
||||
<div className="text-[11px] font-semibold text-text-secondary mb-1.5">{metricLabel}</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TrendingDown,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { StatCard } from '@/components/StatCard';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import { CHART_COLORS } from './chartColors';
|
||||
|
||||
@@ -33,90 +34,49 @@ function changeTrendOf(ratio: number | null | undefined) {
|
||||
|
||||
function KpiRowComponent({ kpi }: KpiRowProps) {
|
||||
const changeTrend = changeTrendOf(kpi?.changeRatio);
|
||||
const trendClass =
|
||||
changeTrend?.direction === 'up'
|
||||
? 'text-danger'
|
||||
: changeTrend?.direction === 'down'
|
||||
? 'text-success'
|
||||
: 'text-text-muted';
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={TESTIDS.kpiRow}
|
||||
className="command-rail stagger-children"
|
||||
role="group"
|
||||
aria-label="综合关键指标"
|
||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3"
|
||||
>
|
||||
<div className="command-rail__cell command-rail__cell--hero">
|
||||
<div className="command-rail__label">
|
||||
<Users className="w-3.5 h-3.5 text-primary" aria-hidden />
|
||||
累计病例总数
|
||||
</div>
|
||||
<div className="command-rail__value">
|
||||
{kpi?.totalCases?.toLocaleString() ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="command-rail__cell">
|
||||
<div className="command-rail__label">
|
||||
<Activity className="w-3.5 h-3.5 text-success" aria-hidden />
|
||||
今日病例
|
||||
</div>
|
||||
<div className="command-rail__value text-[20px]">
|
||||
{kpi?.todayCases?.toLocaleString() ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="command-rail__cell">
|
||||
<div className="command-rail__label">
|
||||
{changeTrend?.direction === 'up' ? (
|
||||
<TrendingUp className="w-3.5 h-3.5 text-danger" aria-hidden />
|
||||
) : changeTrend?.direction === 'down' ? (
|
||||
<TrendingDown className="w-3.5 h-3.5 text-success" aria-hidden />
|
||||
) : (
|
||||
<Activity className="w-3.5 h-3.5 text-text-muted" aria-hidden />
|
||||
)}
|
||||
7日变化率
|
||||
</div>
|
||||
<div className={`command-rail__value text-[20px] ${trendClass}`}>
|
||||
{changeTrend ? changeTrend.value : '--'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="command-rail__cell">
|
||||
<div className="command-rail__label">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-warning" aria-hidden />
|
||||
活跃预警数
|
||||
</div>
|
||||
<div
|
||||
className="command-rail__value text-[20px]"
|
||||
style={
|
||||
kpi && kpi.activeAlerts > 0 ? { color: CHART_COLORS.alertP1 } : undefined
|
||||
}
|
||||
>
|
||||
{kpi?.activeAlerts?.toLocaleString() ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="command-rail__cell">
|
||||
<div className="command-rail__label">
|
||||
<Building2 className="w-3.5 h-3.5 text-danger" aria-hidden />
|
||||
高风险网格
|
||||
</div>
|
||||
<div className="command-rail__value text-[20px]">
|
||||
{kpi?.highRiskGrids?.toLocaleString() ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="command-rail__cell">
|
||||
<div className="command-rail__label">
|
||||
<Droplets className="w-3.5 h-3.5 text-primary-light" aria-hidden />
|
||||
平均 AQI
|
||||
</div>
|
||||
<div className="command-rail__value text-[20px]">
|
||||
{kpi?.avgAQI?.toLocaleString() ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
<StatCard
|
||||
icon={<Users className="w-4 h-4 text-primary" />}
|
||||
label="累计病例总数"
|
||||
value={kpi?.totalCases?.toLocaleString() ?? '--'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="w-4 h-4 text-success" />}
|
||||
label="今日病例"
|
||||
value={kpi?.todayCases?.toLocaleString() ?? '--'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={
|
||||
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-danger" />) ||
|
||||
(changeTrend?.direction === 'down' && (
|
||||
<TrendingDown className="w-4 h-4 text-success" />
|
||||
)) || <Activity className="w-4 h-4 text-text-muted" />
|
||||
}
|
||||
label="7日变化率"
|
||||
value={changeTrend ? changeTrend.value : '--'}
|
||||
trend={changeTrend}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertTriangle className="w-4 h-4 text-warning" />}
|
||||
label="活跃预警数"
|
||||
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
|
||||
color={kpi && kpi.activeAlerts > 0 ? CHART_COLORS.alertP1 : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Building2 className="w-4 h-4 text-danger" />}
|
||||
label="高风险网格"
|
||||
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Droplets className="w-4 h-4 text-primary-light" />}
|
||||
label="平均AQI"
|
||||
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,66 +14,51 @@ import { CHART_COLORS } from './chartColors';
|
||||
|
||||
interface TopDiagnosesBarProps {
|
||||
diagnoses: DiagnosisBreakdown[];
|
||||
embed?: boolean;
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
function TopDiagnosesBarComponent({ diagnoses, embed }: TopDiagnosesBarProps) {
|
||||
const body = (
|
||||
<>
|
||||
{embed ? (
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">Top 5 诊断分布</h3>
|
||||
<p className="workbench-panel__sub">门诊 / 住院堆叠</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||
Top 5 诊断分布
|
||||
</div>
|
||||
)}
|
||||
<div className={embed ? 'workbench-panel__body' : undefined}>
|
||||
{diagnoses.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={[...diagnoses].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="diagnosis"
|
||||
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
|
||||
width={100}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
|
||||
/>
|
||||
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={16} />
|
||||
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={16} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="暂无数据" />
|
||||
)}
|
||||
function TopDiagnosesBarComponent({ diagnoses }: TopDiagnosesBarProps) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||
Top 5 诊断分布
|
||||
</div>
|
||||
</>
|
||||
{diagnoses.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart
|
||||
data={[...diagnoses].reverse()}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="diagnosis"
|
||||
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
|
||||
width={100}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
|
||||
/>
|
||||
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={16} />
|
||||
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={16} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="暂无数据" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embed) return <>{body}</>;
|
||||
return <div className="card p-4">{body}</div>;
|
||||
}
|
||||
|
||||
export const TopDiagnosesBar = memo(TopDiagnosesBarComponent);
|
||||
|
||||
@@ -13,25 +13,21 @@ import { CHART_COLORS } from './chartColors';
|
||||
import { metricValue, type DistrictMetric, type MetricKey } from './districtNormalize';
|
||||
|
||||
interface TopDistrictsBarProps {
|
||||
/** 已归一并聚合到 13 区的指标数据。 */
|
||||
districts: DistrictMetric[];
|
||||
metric: MetricKey;
|
||||
metricLabel: string;
|
||||
embed?: boolean;
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
backgroundColor: '#FFFFFF',
|
||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
function TopDistrictsBarComponent({
|
||||
districts,
|
||||
metric,
|
||||
metricLabel,
|
||||
embed,
|
||||
}: TopDistrictsBarProps) {
|
||||
function TopDistrictsBarComponent({ districts, metric, metricLabel }: TopDistrictsBarProps) {
|
||||
// 按当前 metric 排序取 Top5;横向条形图需 reverse 使最大值在顶部。
|
||||
const top5 = useMemo(() => {
|
||||
return [...districts]
|
||||
.sort((a, b) => metricValue(b, metric) - metricValue(a, metric))
|
||||
@@ -48,62 +44,48 @@ function TopDistrictsBarComponent({
|
||||
const hasData = top5.some((d) => d.value > 0);
|
||||
const showStack = metric === 'all';
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{embed ? (
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h3 className="workbench-panel__title">Top 5 区县{metricLabel}</h3>
|
||||
<p className="workbench-panel__sub">按当前度量排序</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||
Top 5 区县{metricLabel}分布
|
||||
</div>
|
||||
)}
|
||||
<div className={embed ? 'workbench-panel__body' : undefined}>
|
||||
{hasData ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 0, right: 10, left: 30, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="district"
|
||||
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
|
||||
width={64}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
|
||||
/>
|
||||
{showStack ? (
|
||||
<>
|
||||
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={20} />
|
||||
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={20} />
|
||||
</>
|
||||
) : (
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={metric === 'inpatient' ? CHART_COLORS.inpatient : CHART_COLORS.outpatient}
|
||||
name={metricLabel}
|
||||
barSize={20}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="暂无数据" />
|
||||
)}
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||
Top 5 区县{metricLabel}分布
|
||||
</div>
|
||||
</>
|
||||
{hasData ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 0, right: 10, left: 30, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="district"
|
||||
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
|
||||
width={64}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
|
||||
/>
|
||||
{showStack ? (
|
||||
<>
|
||||
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={20} />
|
||||
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={20} />
|
||||
</>
|
||||
) : (
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={metric === 'inpatient' ? CHART_COLORS.inpatient : CHART_COLORS.outpatient}
|
||||
name={metricLabel}
|
||||
barSize={20}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyState title="暂无数据" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embed) return <>{body}</>;
|
||||
return <div className="card p-4">{body}</div>;
|
||||
}
|
||||
|
||||
export const TopDistrictsBar = memo(TopDistrictsBarComponent);
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
/**
|
||||
* 概览大屏图表与地图使用的字面色值集中处。
|
||||
* Recharts / GeoScene 符号需要原始 hex,无法用 Tailwind class,故在此集中定义。
|
||||
* 色系对齐「江雾」青绿 / 雾蓝 / 石板体系。
|
||||
* Recharts / Leaflet 需要原始 hex,无法用 Tailwind class,故在此集中定义,
|
||||
* 避免页面里散落 magic hex。
|
||||
*/
|
||||
export const CHART_COLORS = {
|
||||
outpatient: '#0F766E', // primary teal
|
||||
inpatient: '#C2410C', // danger warm
|
||||
cases: '#0F766E',
|
||||
aqi: '#C27803', // warning
|
||||
grid: '#D4DEE4',
|
||||
axis: '#5A6F7A',
|
||||
axisLabel: '#1A2B33',
|
||||
tooltipBorder: '#D4DEE4',
|
||||
tooltipText: '#1A2B33',
|
||||
alertP1: '#C2410C',
|
||||
alertP2: '#C27803',
|
||||
// choropleth 顺序色阶(浅雾 → 深青),高值高亮
|
||||
choropleth: ['#E6F4F1', '#CCFBF1', '#99F6E4', '#5EEAD4', '#2DD4BF', '#14B8A6', '#0F766E'],
|
||||
choroplethEmpty: '#E8EEF1',
|
||||
outpatient: '#2563EB', // 门诊(primary)
|
||||
inpatient: '#DC2626', // 住院(danger)
|
||||
cases: '#2563EB',
|
||||
aqi: '#D97706', // warning
|
||||
grid: '#E2E8F0', // border
|
||||
axis: '#64748B', // text-secondary
|
||||
axisLabel: '#374151',
|
||||
tooltipBorder: '#E2E8F0',
|
||||
tooltipText: '#1E293B',
|
||||
alertP1: '#DC2626',
|
||||
alertP2: '#D97706',
|
||||
// choropleth 顺序色阶(浅 → 深),高值高亮
|
||||
choropleth: ['#DBEAFE', '#BFDBFE', '#93C5FD', '#60A5FA', '#3B82F6', '#2563EB', '#1D4ED8'],
|
||||
choroplethEmpty: '#F1F5F9', // 无数据区填充
|
||||
choroplethStroke: '#FFFFFF',
|
||||
} as const;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const Card = memo(function Card({
|
||||
<div
|
||||
data-testid={testid}
|
||||
className={[
|
||||
'bg-bg-card rounded-xl border border-border shadow-soft',
|
||||
'bg-bg-card rounded-lg border border-border',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -20,7 +20,7 @@ export const Segmented = memo(function Segmented<T extends string>({
|
||||
return (
|
||||
<div
|
||||
data-testid={testid}
|
||||
className="inline-flex items-center gap-0.5 rounded-lg bg-bg-hover p-0.5 border border-border-light"
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-bg-hover p-0.5"
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isActive = opt.value === value;
|
||||
@@ -31,10 +31,10 @@ export const Segmented = memo(function Segmented<T extends string>({
|
||||
data-testid={testid ? `${testid}-${opt.value}` : undefined}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={[
|
||||
'rounded-md font-medium transition-colors',
|
||||
'rounded-full font-medium transition-colors',
|
||||
sizeClasses,
|
||||
isActive
|
||||
? 'bg-primary text-white shadow-soft'
|
||||
? 'bg-primary text-white shadow-sm'
|
||||
: 'text-text-secondary hover:bg-bg-active',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import Basemap from '@geoscene/core/Basemap';
|
||||
import WebTileLayer from '@geoscene/core/layers/WebTileLayer';
|
||||
import TileInfo from '@geoscene/core/layers/support/TileInfo';
|
||||
import SpatialReference from '@geoscene/core/geometry/SpatialReference';
|
||||
import Map from '@geoscene/core/Map';
|
||||
import MapView from '@geoscene/core/views/MapView';
|
||||
|
||||
/** Wuhan city center — GeoScene / ArcGIS uses [longitude, latitude]. */
|
||||
export const WUHAN_CENTER: [number, number] = [114.31, 30.59];
|
||||
|
||||
const XYZ_TILE_INFO = TileInfo.create({
|
||||
spatialReference: new SpatialReference({ wkid: 3857 }),
|
||||
size: 256,
|
||||
});
|
||||
|
||||
export interface CreateMapViewOptions {
|
||||
container: HTMLDivElement;
|
||||
zoom?: number;
|
||||
/** Custom Basemap, or omit to use resolveDefaultBasemap(). */
|
||||
basemap?: Basemap;
|
||||
center?: [number, number];
|
||||
constraints?: {
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MapHandle {
|
||||
map: Map;
|
||||
view: MapView;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tianditu vector + annotation. Requires a personal key from
|
||||
* https://console.tianditu.gov.cn/
|
||||
* (GeoScene named `tianditu-vector` ships a dead public tk → HTTP 418.)
|
||||
*/
|
||||
export function createTiandituBasemap(tk: string): Basemap {
|
||||
const vec = new WebTileLayer({
|
||||
urlTemplate:
|
||||
`https://t{subDomain}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&VERSION=1.0.0` +
|
||||
`&REQUEST=GetTile&LAYER=vec&STYLE=default&FORMAT=tiles&TILEMATRIXSET=w` +
|
||||
`&TILEMATRIX={level}&TILEROW={row}&TILECOL={col}&tk=${tk}`,
|
||||
subDomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
|
||||
title: '天地图矢量',
|
||||
tileInfo: XYZ_TILE_INFO,
|
||||
});
|
||||
const cva = new WebTileLayer({
|
||||
urlTemplate:
|
||||
`https://t{subDomain}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&VERSION=1.0.0` +
|
||||
`&REQUEST=GetTile&LAYER=cva&STYLE=default&FORMAT=tiles&TILEMATRIXSET=w` +
|
||||
`&TILEMATRIX={level}&TILEROW={row}&TILECOL={col}&tk=${tk}`,
|
||||
subDomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
|
||||
title: '天地图注记',
|
||||
tileInfo: XYZ_TILE_INFO,
|
||||
});
|
||||
return new Basemap({
|
||||
baseLayers: [vec, cva],
|
||||
title: '天地图矢量',
|
||||
id: 'tianditu-custom',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gaode vector via Vite `/basemap-gaode` proxy (avoids CORS).
|
||||
* Production without Tianditu: put the same rewrite behind nginx, or set VITE_TIANDITU_TK.
|
||||
*/
|
||||
export function createGaodeBasemap(): Basemap {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const layer = new WebTileLayer({
|
||||
urlTemplate: `${origin}/basemap-gaode/{level}/{col}/{row}`,
|
||||
title: '高德矢量',
|
||||
copyright: '© 高德地图',
|
||||
tileInfo: XYZ_TILE_INFO,
|
||||
});
|
||||
return new Basemap({
|
||||
baseLayers: [layer],
|
||||
title: '高德矢量',
|
||||
id: 'gaode-vector',
|
||||
});
|
||||
}
|
||||
|
||||
/** One shared basemap — React StrictMode remounts must not abort a fresh #load(). */
|
||||
let sharedDefaultBasemap: Basemap | null = null;
|
||||
|
||||
/**
|
||||
* - VITE_TIANDITU_TK → 天地图
|
||||
* - else → 高德(GeoScene CN 版没有 topo-vector 等 Esri 命名底图)
|
||||
*/
|
||||
export function resolveDefaultBasemap(): Basemap {
|
||||
if (sharedDefaultBasemap) return sharedDefaultBasemap;
|
||||
const tk = import.meta.env.VITE_TIANDITU_TK?.trim();
|
||||
sharedDefaultBasemap = tk ? createTiandituBasemap(tk) : createGaodeBasemap();
|
||||
return sharedDefaultBasemap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Map + MapView pair. Caller owns lifecycle via destroy().
|
||||
* Never pass constraints into the MapView constructor (4.32 Viewport2DMixin crash).
|
||||
*/
|
||||
export function createMapView(options: CreateMapViewOptions): MapHandle {
|
||||
const {
|
||||
container,
|
||||
zoom = 10,
|
||||
basemap = resolveDefaultBasemap(),
|
||||
center = WUHAN_CENTER,
|
||||
constraints,
|
||||
} = options;
|
||||
|
||||
const map = new Map({ basemap });
|
||||
|
||||
const view = new MapView({
|
||||
container,
|
||||
map,
|
||||
center,
|
||||
zoom,
|
||||
});
|
||||
|
||||
if (constraints && (constraints.minZoom != null || constraints.maxZoom != null)) {
|
||||
view
|
||||
.when(() => {
|
||||
view.constraints = {
|
||||
...(constraints.minZoom != null ? { minZoom: constraints.minZoom } : {}),
|
||||
...(constraints.maxZoom != null ? { maxZoom: constraints.maxZoom } : {}),
|
||||
};
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
view.ui.remove('attribution');
|
||||
|
||||
return {
|
||||
map,
|
||||
view,
|
||||
destroy: () => {
|
||||
// Detach shared basemap + destroy operational layers before view teardown,
|
||||
// otherwise StrictMode cleanup aborts Basemap#load() → AbortError spam.
|
||||
const m = view.map;
|
||||
if (m) {
|
||||
const ops = m.layers.toArray();
|
||||
m.removeAll();
|
||||
for (const layer of ops) {
|
||||
layer.destroy();
|
||||
}
|
||||
if (m.basemap === sharedDefaultBasemap) {
|
||||
m.basemap = null as unknown as Basemap;
|
||||
}
|
||||
}
|
||||
view.map = null as unknown as Map;
|
||||
view.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export {
|
||||
createMapView,
|
||||
WUHAN_CENTER,
|
||||
resolveDefaultBasemap,
|
||||
createTiandituBasemap,
|
||||
createGaodeBasemap,
|
||||
} from './createMapView';
|
||||
export type { CreateMapViewOptions, MapHandle } from './createMapView';
|
||||
export {
|
||||
createRiskTileLayer,
|
||||
riskTileUrlTemplate,
|
||||
createGraphicsLayer,
|
||||
createDistrictsGeoJSONLayer,
|
||||
pointGraphic,
|
||||
jitterLonLat,
|
||||
geosceneEnv,
|
||||
} from './layers';
|
||||
@@ -1,100 +0,0 @@
|
||||
import WebTileLayer from '@geoscene/core/layers/WebTileLayer';
|
||||
import GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
|
||||
import GeoJSONLayer from '@geoscene/core/layers/GeoJSONLayer';
|
||||
import Graphic from '@geoscene/core/Graphic';
|
||||
import Point from '@geoscene/core/geometry/Point';
|
||||
import SimpleMarkerSymbol from '@geoscene/core/symbols/SimpleMarkerSymbol';
|
||||
import TileInfo from '@geoscene/core/layers/support/TileInfo';
|
||||
import SpatialReference from '@geoscene/core/geometry/SpatialReference';
|
||||
|
||||
const XYZ_TILE_INFO = TileInfo.create({
|
||||
spatialReference: new SpatialReference({ wkid: 3857 }),
|
||||
size: 256,
|
||||
});
|
||||
|
||||
/**
|
||||
* Absolute FastAPI risk XYZ URL for WebTileLayer.
|
||||
* Relative `/api/...` is resolved to `https://null/...` by the SDK — always use origin.
|
||||
* Placeholders: {level}/{col}/{row} ≡ Leaflet {z}/{x}/{y} for Web Mercator.
|
||||
*/
|
||||
export function riskTileUrlTemplate(day: 1 | 3 | 7, date?: string): string {
|
||||
const apiBase = (import.meta.env.VITE_API_URL || '/api').replace(/\/$/, '');
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const prefix = apiBase.startsWith('http') ? apiBase : `${origin}${apiBase}`;
|
||||
const dateParam = date ? `&date=${date}` : '';
|
||||
return `${prefix}/risk/tiles/{level}/{col}/{row}.png?day=${day}${dateParam}`;
|
||||
}
|
||||
|
||||
export function createRiskTileLayer(day: 1 | 3 | 7, opacity = 0.72): WebTileLayer {
|
||||
return new WebTileLayer({
|
||||
urlTemplate: riskTileUrlTemplate(day),
|
||||
opacity,
|
||||
title: '风险预测',
|
||||
listMode: 'hide',
|
||||
tileInfo: XYZ_TILE_INFO,
|
||||
});
|
||||
}
|
||||
|
||||
export function createGraphicsLayer(title: string): GraphicsLayer {
|
||||
return new GraphicsLayer({ title, listMode: 'hide' });
|
||||
}
|
||||
|
||||
export function createDistrictsGeoJSONLayer(url = '/wuhan_districts.geojson'): GeoJSONLayer {
|
||||
const absolute =
|
||||
url.startsWith('http') || typeof window === 'undefined'
|
||||
? url
|
||||
: `${window.location.origin}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
return new GeoJSONLayer({
|
||||
url: absolute,
|
||||
title: '武汉区县',
|
||||
outFields: ['*'],
|
||||
listMode: 'hide',
|
||||
});
|
||||
}
|
||||
|
||||
export function pointGraphic(
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
color: string,
|
||||
size = 8,
|
||||
attributes?: Record<string, unknown>
|
||||
): Graphic {
|
||||
return new Graphic({
|
||||
geometry: new Point({ longitude, latitude }),
|
||||
symbol: new SimpleMarkerSymbol({
|
||||
style: 'circle',
|
||||
color,
|
||||
size,
|
||||
outline: { color: [255, 255, 255, 0.85], width: 1 },
|
||||
}),
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
|
||||
/** Deterministic ~meter jitter so stacked street/district centroids separate visually. */
|
||||
export function jitterLonLat(
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
seed: string,
|
||||
meters = 55
|
||||
): [number, number] {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
const u = ((h >>> 0) % 1000) / 1000;
|
||||
const v = (((h >>> 10) % 1000) / 1000);
|
||||
const angle = u * Math.PI * 2;
|
||||
const radius = (0.25 + v * 0.75) * meters;
|
||||
const dLat = (radius * Math.cos(angle)) / 111_320;
|
||||
const dLon = (radius * Math.sin(angle)) / (111_320 * Math.cos((latitude * Math.PI) / 180));
|
||||
return [longitude + dLon, latitude + dLat];
|
||||
}
|
||||
|
||||
export const geosceneEnv = {
|
||||
portalUrl: import.meta.env.VITE_GEOSCENE_PORTAL_URL as string | undefined,
|
||||
districtsUrl: import.meta.env.VITE_LAYER_DISTRICTS_URL as string | undefined,
|
||||
riskUrl: import.meta.env.VITE_LAYER_RISK_URL as string | undefined,
|
||||
casesUrl: import.meta.env.VITE_LAYER_CASES_URL as string | undefined,
|
||||
};
|
||||
@@ -3,410 +3,45 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--cbpoa-teal: #0f766e;
|
||||
--cbpoa-mist: #5b8fa8;
|
||||
--cbpoa-slate: #1a2b33;
|
||||
--cbpoa-fog: #f0f4f6;
|
||||
--cbpoa-river: linear-gradient(
|
||||
145deg,
|
||||
#e8f2f6 0%,
|
||||
#f0f4f6 38%,
|
||||
#e6f4f1 72%,
|
||||
#eef3f5 100%
|
||||
);
|
||||
--cbpoa-glass: rgba(250, 252, 253, 0.82);
|
||||
--cbpoa-glass-strong: rgba(255, 255, 255, 0.92);
|
||||
--cbpoa-ink: #1a2b33;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-bg-page text-text-primary font-sans;
|
||||
background-image: var(--cbpoa-river);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-bg-card border border-border rounded-xl shadow-soft;
|
||||
@apply bg-bg-card border border-border rounded-lg;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium
|
||||
hover:bg-primary-deep transition-colors shadow-soft;
|
||||
@apply bg-primary text-white px-4 py-2 rounded-md text-sm font-medium
|
||||
hover:bg-primary-light transition-colors;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-bg-elevated text-text-secondary px-4 py-2 rounded-lg text-sm font-medium
|
||||
@apply bg-bg-page text-text-secondary px-4 py-2 rounded-md text-sm font-medium
|
||||
border border-border hover:border-primary hover:text-primary transition-colors;
|
||||
}
|
||||
|
||||
.page-inset {
|
||||
@apply px-5 py-5;
|
||||
}
|
||||
|
||||
/* —— 指挥台指标带:连续仪表条,非六块同质小卡 —— */
|
||||
.metrics-band {
|
||||
@apply relative border-b border-border;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(240, 244, 246, 0.88) 100%);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.metrics-band::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(15, 118, 110, 0.28) 20%,
|
||||
rgba(91, 143, 168, 0.35) 50%,
|
||||
rgba(15, 118, 110, 0.28) 80%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.command-rail {
|
||||
@apply flex flex-wrap items-stretch w-full min-w-0 rounded-xl overflow-hidden
|
||||
border border-border shadow-soft;
|
||||
background-color: rgba(255, 255, 255, 0.72);
|
||||
background-image:
|
||||
linear-gradient(135deg, rgba(15, 118, 110, 0.04) 0%, transparent 42%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(248, 251, 252, 0.75));
|
||||
}
|
||||
|
||||
.command-rail__cell {
|
||||
@apply relative flex flex-col justify-center gap-1 px-3.5 py-2.5 min-w-0;
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
|
||||
.command-rail__cell + .command-rail__cell::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 18%;
|
||||
bottom: 18%;
|
||||
width: 1px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
rgba(212, 222, 228, 0.95) 30%,
|
||||
rgba(212, 222, 228, 0.95) 70%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.command-rail__cell--hero {
|
||||
flex: 1.35 1 170px;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
rgba(15, 118, 110, 0.08) 0%,
|
||||
rgba(91, 143, 168, 0.04) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.command-rail__label {
|
||||
@apply flex items-center gap-1.5 text-[11px] font-medium text-text-secondary tracking-wide;
|
||||
}
|
||||
|
||||
.command-rail__value {
|
||||
@apply data-num text-[22px] leading-none text-text-primary;
|
||||
}
|
||||
|
||||
.command-rail__cell--hero .command-rail__value {
|
||||
@apply text-[28px] text-primary-deep;
|
||||
}
|
||||
|
||||
.command-rail__meta {
|
||||
@apply flex items-center gap-2 mt-0.5 min-h-[18px];
|
||||
}
|
||||
|
||||
/* 兼容旧 StatCard(总览等仍用) */
|
||||
.stat-card {
|
||||
@apply relative overflow-hidden bg-bg-card border border-border rounded-xl p-4 shadow-soft;
|
||||
transition: box-shadow 200ms ease, transform 200ms ease, border-color 200ms ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, var(--cbpoa-teal), var(--cbpoa-mist));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
@apply shadow-lift border-primary/25;
|
||||
}
|
||||
|
||||
/* —— 地图舞台 / 玻璃翼 —— */
|
||||
.map-stage {
|
||||
@apply relative flex-1 min-h-0 overflow-hidden;
|
||||
background-color: #dce6eb;
|
||||
}
|
||||
|
||||
.map-stage--flush {
|
||||
@apply rounded-none border-0 shadow-none;
|
||||
}
|
||||
|
||||
.map-chrome {
|
||||
@apply pointer-events-none absolute top-0 right-0 z-10
|
||||
flex items-start justify-end gap-2 p-3;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.map-chrome {
|
||||
@apply p-4;
|
||||
}
|
||||
}
|
||||
|
||||
.map-chrome__chip {
|
||||
@apply pointer-events-auto inline-flex items-center gap-2
|
||||
rounded-lg border border-white/50 bg-bg-card/90 backdrop-blur-md
|
||||
px-3 py-1.5 shadow-soft;
|
||||
}
|
||||
|
||||
.glass-wing {
|
||||
@apply relative flex flex-col min-h-0 overflow-hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(240, 244, 246, 0.92) 100%);
|
||||
border-left: 1px solid rgba(212, 222, 228, 0.9);
|
||||
box-shadow: -12px 0 32px rgba(26, 43, 51, 0.04);
|
||||
}
|
||||
|
||||
.glass-wing__section {
|
||||
@apply px-4 pt-4 pb-3 border-b border-border-light;
|
||||
}
|
||||
|
||||
.glass-wing__section:last-child {
|
||||
@apply border-b-0 flex-1 min-h-0 flex flex-col;
|
||||
}
|
||||
|
||||
.glass-wing__title {
|
||||
@apply text-[11px] font-semibold uppercase text-mist-deep mb-3;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
/* 区县条:层次更强 */
|
||||
.district-bar {
|
||||
@apply flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer;
|
||||
transition: background-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.district-bar:hover {
|
||||
@apply bg-mist-light;
|
||||
}
|
||||
|
||||
.district-bar--active {
|
||||
@apply bg-primary-muted ring-1 ring-primary/25;
|
||||
}
|
||||
|
||||
.district-bar__track {
|
||||
@apply relative flex-1 h-6 rounded-md overflow-hidden;
|
||||
background: linear-gradient(90deg, #e8eef1 0%, #f3f6f8 100%);
|
||||
}
|
||||
|
||||
.district-bar__fill {
|
||||
@apply absolute inset-y-0 left-0 flex overflow-hidden rounded-md;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* —— 图表工作区面板 —— */
|
||||
.workbench-panel {
|
||||
@apply relative overflow-hidden rounded-2xl border border-border bg-bg-card shadow-soft;
|
||||
}
|
||||
|
||||
.workbench-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, var(--cbpoa-teal), var(--cbpoa-mist));
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.workbench-panel__head {
|
||||
@apply flex items-end justify-between gap-3 px-5 pt-4 pb-2;
|
||||
}
|
||||
|
||||
.workbench-panel__title {
|
||||
@apply font-display text-[15px] font-semibold text-text-primary tracking-tight;
|
||||
}
|
||||
|
||||
.workbench-panel__sub {
|
||||
@apply text-[12px] text-text-muted mt-0.5;
|
||||
}
|
||||
|
||||
.workbench-panel__body {
|
||||
@apply px-5 pb-5 pt-1;
|
||||
}
|
||||
|
||||
/* 页内分段 tab */
|
||||
.tab-strip {
|
||||
@apply flex items-center gap-0;
|
||||
}
|
||||
|
||||
.tab-strip__item {
|
||||
@apply relative px-4 py-2.5 text-[13px] font-medium text-text-secondary
|
||||
border-b-2 border-transparent -mb-px transition-colors;
|
||||
}
|
||||
|
||||
.tab-strip__item:hover {
|
||||
@apply text-text-primary;
|
||||
}
|
||||
|
||||
.tab-strip__item--active {
|
||||
@apply text-primary border-primary;
|
||||
}
|
||||
|
||||
/* —— 底部时间轴坞 —— */
|
||||
.timeline-dock {
|
||||
@apply fixed right-0 bottom-0 pointer-events-none;
|
||||
z-index: 60;
|
||||
left: 0;
|
||||
padding-left: max(0.75rem, env(safe-area-inset-left));
|
||||
padding-right: max(0.75rem, env(safe-area-inset-right));
|
||||
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.timeline-dock {
|
||||
left: 212px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-dock__inner {
|
||||
@apply pointer-events-auto mx-auto max-w-5xl
|
||||
rounded-2xl border border-border shadow-lift
|
||||
px-3 py-2.5;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.94) 0%,
|
||||
rgba(240, 244, 246, 0.96) 100%
|
||||
);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.timeline-dock__inner {
|
||||
@apply px-4 py-3;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-dock__track {
|
||||
@apply relative h-1.5 rounded-full overflow-hidden bg-bg-hover;
|
||||
}
|
||||
|
||||
.timeline-dock__fill {
|
||||
@apply absolute inset-y-0 left-0 rounded-full;
|
||||
background: linear-gradient(90deg, #0d5c56, #0f766e 55%, #5b8fa8);
|
||||
}
|
||||
|
||||
/* 风险分布条(预警) */
|
||||
.risk-strip {
|
||||
@apply grid grid-cols-2 gap-px rounded-xl overflow-hidden
|
||||
border border-border bg-border shadow-soft;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.risk-strip {
|
||||
@apply grid-cols-4;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-strip__cell {
|
||||
@apply bg-bg-card px-3.5 py-3;
|
||||
}
|
||||
|
||||
.risk-strip__label {
|
||||
@apply text-[11px] text-text-muted mb-1;
|
||||
}
|
||||
|
||||
.risk-strip__value {
|
||||
@apply data-num text-[22px] leading-none;
|
||||
}
|
||||
|
||||
/* 登录页雾感氛围 */
|
||||
.login-atmosphere {
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 15% 20%, rgba(91, 143, 168, 0.22), transparent 55%),
|
||||
radial-gradient(ellipse 70% 50% at 85% 75%, rgba(15, 118, 110, 0.16), transparent 50%),
|
||||
radial-gradient(ellipse 50% 40% at 50% 100%, rgba(232, 242, 246, 0.9), transparent 60%),
|
||||
linear-gradient(160deg, #dce8ee 0%, #eef4f6 45%, #e4f0ed 100%);
|
||||
}
|
||||
|
||||
.login-atmosphere::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
@apply font-display font-bold tracking-tight text-primary;
|
||||
}
|
||||
|
||||
.data-num {
|
||||
@apply font-mono font-semibold tabular-nums tracking-tight text-text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
/* GeoScene MapView — keep below TopNav (z-50) */
|
||||
.esri-view,
|
||||
.geoscene-view {
|
||||
/* Leaflet overrides — keep z-index below TopNav (z-50) and SideNav */
|
||||
.leaflet-container {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.esri-ui,
|
||||
.geoscene-ui {
|
||||
.leaflet-pane {
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
z-index: 5 !important;
|
||||
}
|
||||
|
||||
/* 交错入场 */
|
||||
.stagger-children > * {
|
||||
animation: fade-up 0.45s ease-out both;
|
||||
}
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0.04s; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 0.08s; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 0.12s; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 0.16s; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 0.2s; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 0.24s; }
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
@apply rounded-lg shadow-lg;
|
||||
}
|
||||
|
||||
@keyframes rail-shimmer {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
.leaflet-popup-content {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import '@geoscene/core/assets/geoscene/themes/light/main.css'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useRiskStore } from '@/stores';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useRiskStore, useSessionStore } from '@/stores';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
import type { CellInfo } from '@/components/AlertMap';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
@@ -11,6 +12,15 @@ import { AlertDetailModal, CellInfoPanel } from '@/components/alerts/AlertDetail
|
||||
import type { ExtendedAlert, RiskStats } from '@/components/alerts/types';
|
||||
|
||||
export function AlertsDashboard() {
|
||||
// 视角驱动的两条不变量(D2:纯前端视图预设,非访问控制):
|
||||
// 1. 官员(厅领导)不展示 100m 网格(「对他没意义/太超前」)——强制 showGrid=false 且隐藏网格切换。
|
||||
// 2. 医生(或 ?view=cluster)= 聚类/密度视角:只看聚合栅格密度 + 病种过滤,
|
||||
// 绝不渲染任何个体病例点(隐私不变量)——强制 showAlertMarkers=false 且隐藏「预警标记」切换。
|
||||
const role = useSessionStore((s) => s.role);
|
||||
const [searchParams] = useSearchParams();
|
||||
const view = searchParams.get('view');
|
||||
const isOfficial = role === 'official';
|
||||
const isCluster = role === 'doctor' || view === 'cluster';
|
||||
const alerts = useRiskStore((s) => s.alerts);
|
||||
const isLoading = useRiskStore((s) => s.isLoading);
|
||||
const error = useRiskStore((s) => s.error);
|
||||
@@ -21,32 +31,43 @@ export function AlertsDashboard() {
|
||||
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
|
||||
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
|
||||
const [showMap, setShowMap] = useState(true);
|
||||
const [showAlertMarkers, setShowAlertMarkers] = useState(true);
|
||||
const [showAlertMarkers, setShowAlertMarkers] = useState(false);
|
||||
const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
|
||||
const [riskRange, setRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
||||
const [debouncedRiskRange, setDebouncedRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
||||
const [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
// 官员视角默认隐藏网格(见上);其余角色默认显示。
|
||||
const [showGrid, setShowGrid] = useState(!isOfficial);
|
||||
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
|
||||
|
||||
// 隐私不变量:聚类(医生)视角下,个体病例点标记永远关闭,且无法被打开。
|
||||
// 这里把「用户意图的开关状态」与「实际生效的状态」分开:effectiveShowAlertMarkers
|
||||
// 是唯一传给地图/渲染的真值,cluster 模式恒为 false,与用户点击无关。
|
||||
const effectiveShowAlertMarkers = isCluster ? false : showAlertMarkers;
|
||||
|
||||
// In-page tab strip (no router) — matches existing activePage pattern
|
||||
const [activeTab, setActiveTab] = useState<'list' | 'stats'>('list');
|
||||
|
||||
// Risk-trend data for the 风险统计 tab, fetched on demand
|
||||
const [trendData, setTrendData] = useState<Array<{ date: string; cases: number; risk: number }>>([]);
|
||||
const [trendLoading, setTrendLoading] = useState(false);
|
||||
const [trendError, setTrendError] = useState<string | null>(null);
|
||||
const [trendLoaded, setTrendLoaded] = useState(false);
|
||||
|
||||
// Debounce riskRange for filteredAlerts computation
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedRiskRange(riskRange), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [riskRange]);
|
||||
|
||||
// Fetch grids (for map) and alerts (for side panel) on mount
|
||||
useEffect(() => {
|
||||
fetchRiskMap();
|
||||
fetchAlerts();
|
||||
}, [fetchRiskMap, fetchAlerts]);
|
||||
|
||||
// Fetch real risk-trend data when the 风险统计 tab is first opened
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'stats' || trendLoaded) return;
|
||||
let cancelled = false;
|
||||
@@ -68,9 +89,7 @@ export function AlertsDashboard() {
|
||||
.finally(() => {
|
||||
if (!cancelled) setTrendLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
return () => { cancelled = true; };
|
||||
}, [activeTab, trendLoaded]);
|
||||
|
||||
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
|
||||
@@ -94,8 +113,7 @@ export function AlertsDashboard() {
|
||||
.filter((alert) => {
|
||||
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
|
||||
const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority;
|
||||
const riskMatch =
|
||||
alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
|
||||
const riskMatch = alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
|
||||
return horizonMatch && priorityMatch && riskMatch;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -106,7 +124,9 @@ export function AlertsDashboard() {
|
||||
});
|
||||
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
|
||||
|
||||
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
|
||||
const riskStats: RiskStats = useMemo(() => {
|
||||
// p1/p2 reflect the full (unfiltered) alert set
|
||||
let p1 = 0;
|
||||
let p2 = 0;
|
||||
for (const a of extendedAlerts) {
|
||||
@@ -114,6 +134,7 @@ export function AlertsDashboard() {
|
||||
else if (a.priority === 'P2') p2++;
|
||||
}
|
||||
|
||||
// Single pass over filteredAlerts: counters + sum + district map
|
||||
let high = 0;
|
||||
let mediumHigh = 0;
|
||||
let medium = 0;
|
||||
@@ -131,7 +152,6 @@ export function AlertsDashboard() {
|
||||
const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
|
||||
|
||||
const topDistricts = Object.entries(byDistrict)
|
||||
.filter(([name]) => name && name !== '武汉市' && name !== '未知')
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5);
|
||||
|
||||
@@ -139,22 +159,20 @@ export function AlertsDashboard() {
|
||||
}, [extendedAlerts, filteredAlerts]);
|
||||
|
||||
const selectedAlertData = useMemo(() => {
|
||||
return filteredAlerts.find((a) => a.alert_id === selectedAlert);
|
||||
return filteredAlerts.find(a => a.alert_id === selectedAlert);
|
||||
}, [filteredAlerts, selectedAlert]);
|
||||
|
||||
const selectedGridId = useMemo(() => {
|
||||
if (!selectedAlert) return null;
|
||||
const alert = filteredAlerts.find((a) => a.alert_id === selectedAlert);
|
||||
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
|
||||
return alert?.grid_id ?? null;
|
||||
}, [filteredAlerts, selectedAlert]);
|
||||
|
||||
const filteredAlertsRef = useRef(filteredAlerts);
|
||||
useEffect(() => {
|
||||
filteredAlertsRef.current = filteredAlerts;
|
||||
}, [filteredAlerts]);
|
||||
useEffect(() => { filteredAlertsRef.current = filteredAlerts; }, [filteredAlerts]);
|
||||
|
||||
const handleGridClick = useCallback((gridId: string) => {
|
||||
const alertForGrid = filteredAlertsRef.current.find((a) => a.grid_id === gridId);
|
||||
const alertForGrid = filteredAlertsRef.current.find(a => a.grid_id === gridId);
|
||||
if (alertForGrid) {
|
||||
setSelectedAlert(alertForGrid.alert_id);
|
||||
}
|
||||
@@ -170,42 +188,23 @@ export function AlertsDashboard() {
|
||||
|
||||
const handleCellInfo = useCallback((info: CellInfo) => {
|
||||
setCellInfo(info);
|
||||
setSelectedAlert(null);
|
||||
setSelectedAlert(null); // Close alert modal if open
|
||||
}, []);
|
||||
|
||||
const clearCellInfo = useCallback(() => {
|
||||
setCellInfo(null);
|
||||
}, []);
|
||||
|
||||
// Export utilities
|
||||
const exportToCsv = useCallback(() => {
|
||||
const headers = [
|
||||
'alert_id',
|
||||
'grid_id',
|
||||
'region',
|
||||
'street',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'risk_value',
|
||||
'priority',
|
||||
'forecast_horizon',
|
||||
'reason',
|
||||
'timestamp',
|
||||
];
|
||||
const rows = filteredAlerts.map((a) => [
|
||||
a.alert_id,
|
||||
a.grid_id,
|
||||
a.region,
|
||||
a.street,
|
||||
a.latitude,
|
||||
a.longitude,
|
||||
a.risk_value,
|
||||
a.priority,
|
||||
a.forecast_horizon,
|
||||
`"${a.reason}"`,
|
||||
a.timestamp,
|
||||
const headers = ['alert_id', 'grid_id', 'region', 'street', 'latitude', 'longitude', 'risk_value', 'priority', 'forecast_horizon', 'reason', 'timestamp'];
|
||||
const rows = filteredAlerts.map(a => [
|
||||
a.alert_id, a.grid_id, a.region, a.street,
|
||||
a.latitude, a.longitude, a.risk_value, a.priority,
|
||||
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
|
||||
]);
|
||||
const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -226,22 +225,11 @@ export function AlertsDashboard() {
|
||||
}, [filteredAlerts]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={TESTIDS.pageAlerts}
|
||||
className={
|
||||
isFullscreen
|
||||
? 'fixed inset-0 z-40 bg-bg-page pt-[54px] p-4 overflow-auto'
|
||||
: 'flex flex-col h-full min-h-0 overflow-auto p-4'
|
||||
}
|
||||
>
|
||||
<div data-testid={TESTIDS.pageAlerts} className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
|
||||
{error && (
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
clearError();
|
||||
fetchRiskMap();
|
||||
fetchAlerts();
|
||||
}}
|
||||
onRetry={() => { clearError(); fetchRiskMap(); fetchAlerts(); }}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
)}
|
||||
@@ -284,6 +272,9 @@ export function AlertsDashboard() {
|
||||
onGridClick={handleGridClick}
|
||||
onCellInfo={handleCellInfo}
|
||||
onCardClick={handleAlertCardClick}
|
||||
effectiveShowAlertMarkers={effectiveShowAlertMarkers}
|
||||
isCluster={isCluster}
|
||||
isOfficial={isOfficial}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -296,10 +287,12 @@ export function AlertsDashboard() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cell info panel - shown when clicking grid cell without alert */}
|
||||
{cellInfo && !selectedAlertData && (
|
||||
<CellInfoPanel cellInfo={cellInfo} onClose={clearCellInfo} />
|
||||
)}
|
||||
|
||||
{/* Alert detail modal */}
|
||||
{selectedAlertData && (
|
||||
<AlertDetailModal alert={selectedAlertData} onClose={clearSelectedAlert} />
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from 'react';
|
||||
import { LoadingState } from '@/components/ui';
|
||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||
import { ChatBot } from '@/components/ChatBot';
|
||||
import { caseApi } from '@/services/api';
|
||||
import type { CaseTrendPoint } from '@/types';
|
||||
import {
|
||||
@@ -423,6 +424,8 @@ export function Insights() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatBot />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,92 +32,52 @@ export function Login({ onLogin }: LoginProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={TESTIDS.pageLogin}
|
||||
className="login-atmosphere relative min-h-screen flex items-center justify-center overflow-hidden px-4"
|
||||
>
|
||||
{/* 呼吸感雾层 */}
|
||||
<div
|
||||
className="pointer-events-none absolute -top-24 left-1/4 h-72 w-72 rounded-full bg-mist/30 blur-3xl animate-breath"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 right-1/5 h-64 w-80 rounded-full bg-primary/20 blur-3xl animate-breath"
|
||||
style={{ animationDelay: '2.5s' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div data-testid={TESTIDS.pageLogin} className="min-h-screen bg-bg-page flex items-center justify-center">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm"
|
||||
>
|
||||
<h1 className="text-xl font-semibold text-text-primary mb-6 text-center">
|
||||
CBPOA 登录
|
||||
</h1>
|
||||
|
||||
<div className="relative z-10 w-full max-w-[420px] animate-fade-up">
|
||||
{/* 品牌 hero */}
|
||||
<header className="mb-8 text-center">
|
||||
<p className="brand-mark text-[42px] sm:text-[48px] leading-none mb-3">CBPOA</p>
|
||||
<h1 className="font-display text-[17px] sm:text-[18px] font-semibold text-text-primary tracking-wide mb-2">
|
||||
武汉儿童呼吸疾病风险评估系统
|
||||
</h1>
|
||||
<p className="text-[13px] text-text-secondary leading-relaxed max-w-sm mx-auto">
|
||||
空气质量 · 空间风险 · 儿童健康监测预警
|
||||
</p>
|
||||
</header>
|
||||
{error && (
|
||||
<div className="mb-4 p-2 bg-red-50 text-danger text-sm rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-bg-card/95 backdrop-blur-sm rounded-2xl border border-border shadow-lift p-7 sm:p-8"
|
||||
<label className="block mb-4">
|
||||
<span className="text-text-secondary text-sm">用户名</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block mb-6">
|
||||
<span className="text-text-secondary text-sm">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
data-testid={TESTIDS.loginSubmit}
|
||||
className="w-full py-2 bg-primary text-white rounded text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<h2 className="sr-only">登录</h2>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 px-3 py-2.5 bg-danger-light text-danger text-sm rounded-lg border border-danger/20"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block mb-4">
|
||||
<span className="text-text-secondary text-[13px] font-medium">用户名</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
className="mt-1.5 block w-full rounded-lg border border-border bg-bg-elevated px-3.5 py-2.5 text-sm text-text-primary
|
||||
placeholder:text-text-muted
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-shadow"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block mb-6">
|
||||
<span className="text-text-secondary text-[13px] font-medium">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="mt-1.5 block w-full rounded-lg border border-border bg-bg-elevated px-3.5 py-2.5 text-sm text-text-primary
|
||||
placeholder:text-text-muted
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-shadow"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
data-testid={TESTIDS.loginSubmit}
|
||||
className="w-full py-2.5 bg-primary text-white rounded-lg text-sm font-semibold
|
||||
hover:bg-primary-deep shadow-brand transition-colors
|
||||
disabled:opacity-50 disabled:shadow-none"
|
||||
>
|
||||
{loading ? '登录中…' : '进入系统'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-[11px] text-text-muted tracking-wide">
|
||||
公共健康空间决策 · 武汉
|
||||
</p>
|
||||
</div>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function MonitoringDashboard({
|
||||
defaultStartDate = '2022-12-01',
|
||||
defaultEndDate = '2024-12-30',
|
||||
}: MonitoringDashboardProps) {
|
||||
// In-page tab strip (local state, no router — mirrors the existing activePage pattern)
|
||||
const [activeTab, setActiveTab] = useState<MonitoringTab>('overview');
|
||||
|
||||
const {
|
||||
@@ -45,11 +46,13 @@ export function MonitoringDashboard({
|
||||
const drillDown = useDrilldownStore((s) => s.drillDown);
|
||||
const resetDrillDown = useDrilldownStore((s) => s.resetDrillDown);
|
||||
|
||||
// --- URL 是粒度的真相来源;drilldownStore 由 URL 派生 ---
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const granularity = parseGranularity(searchParams.get('granularity'));
|
||||
const districtParam = searchParams.get('district');
|
||||
const streetParam = searchParams.get('street');
|
||||
|
||||
// 把 URL 写入:粒度控件、面包屑、区域点击都通过它驱动 URL,再由下方 effect 同步 store。
|
||||
const updateUrl = useCallback(
|
||||
(next: { granularity: Granularity; district?: string | null; street?: string | null }) => {
|
||||
setSearchParams(
|
||||
@@ -68,6 +71,7 @@ export function MonitoringDashboard({
|
||||
[setSearchParams]
|
||||
);
|
||||
|
||||
// 粒度控件回调:切换粒度即写 URL(深链可分享)。
|
||||
const handleGranularityChange = useCallback(
|
||||
(g: Granularity) => {
|
||||
if (g === 'city') updateUrl({ granularity: 'city' });
|
||||
@@ -77,6 +81,7 @@ export function MonitoringDashboard({
|
||||
[updateUrl, selectedDistrict, selectedStreet]
|
||||
);
|
||||
|
||||
// 区域 roll-up 点击回调:驱动 URL 而非直接 mutate store(消除命令式 desync)。
|
||||
const handleDistrictSelect = useCallback(
|
||||
(district: string) => {
|
||||
if (selectedDistrict === district) updateUrl({ granularity: 'city' });
|
||||
@@ -85,6 +90,7 @@ export function MonitoringDashboard({
|
||||
[updateUrl, selectedDistrict]
|
||||
);
|
||||
|
||||
// store 从 URL 同步(URL 派生 store,单向)。mount 与 param 变化时执行。
|
||||
useEffect(() => {
|
||||
if (granularity === 'city') {
|
||||
if (selectedDistrict !== null || selectedStreet !== null) resetDrillDown();
|
||||
@@ -95,6 +101,7 @@ export function MonitoringDashboard({
|
||||
else if (!districtParam && selectedDistrict !== null) resetDrillDown();
|
||||
return;
|
||||
}
|
||||
// granularity === 'street'
|
||||
if (districtParam && selectedDistrict !== districtParam) drillDown('district', districtParam);
|
||||
if (streetParam && selectedStreet !== streetParam) drillDown('street', streetParam);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -105,32 +112,22 @@ export function MonitoringDashboard({
|
||||
setCurrentDate(defaultEndDate);
|
||||
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
|
||||
|
||||
// 数据层:图表窗口 + 病例统计/区域统计两个按需 tab 的加载与派生。
|
||||
// 不持有 URL/drilldown 真相来源,只消费 currentDate 与 selectedDistrict。
|
||||
const data = useMonitoringData({ activeTab, currentDate, selectedDistrict });
|
||||
|
||||
const handleDateChange = useCallback(
|
||||
(date: string) => {
|
||||
setCurrentDate(date);
|
||||
},
|
||||
[setCurrentDate]
|
||||
);
|
||||
const handleDateChange = useCallback((date: string) => {
|
||||
setCurrentDate(date);
|
||||
}, [setCurrentDate]);
|
||||
|
||||
const handlePlayPause = useCallback(
|
||||
(playing: boolean) => {
|
||||
setPlaying(playing);
|
||||
},
|
||||
[setPlaying]
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview' as const, label: '概览' },
|
||||
{ key: 'cases' as const, label: '病例统计' },
|
||||
{ key: 'districts' as const, label: '区域统计' },
|
||||
];
|
||||
const handlePlayPause = useCallback((playing: boolean) => {
|
||||
setPlaying(playing);
|
||||
}, [setPlaying]);
|
||||
|
||||
return (
|
||||
<div data-testid="page-monitoring" className="flex flex-col h-full min-h-0">
|
||||
<div data-testid="page-monitoring" className="flex flex-col h-full">
|
||||
{error && (
|
||||
<div className="px-4 pt-3 shrink-0">
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
@@ -141,33 +138,35 @@ export function MonitoringDashboard({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 顶部指标带 + 筛选 + tab */}
|
||||
<div className="metrics-band px-4 pt-3 pb-0 shrink-0">
|
||||
<div className="flex items-stretch justify-between flex-wrap gap-x-4 gap-y-3 mb-3">
|
||||
{/* Top stats bar — standardized with StatCard */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4 shrink-0">
|
||||
<div className="flex items-start justify-between flex-wrap gap-x-4 gap-y-3">
|
||||
<MonitoringStatsBar stats={data.stats} sparkline7d={data.sparkline7d} />
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap self-center">
|
||||
{/* Disease filter */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{data.stats.noData && (
|
||||
<span className="text-xs text-warning bg-warning-light px-2 py-1 rounded-md border border-warning/20">
|
||||
该时段暂无数据
|
||||
</span>
|
||||
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">该时段暂无数据</span>
|
||||
)}
|
||||
<DiseaseFilter onFilterChange={data.debouncedLoadChart} />
|
||||
<AdminBreadcrumb />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-strip border-b border-border" role="tablist" aria-label="监测视图">
|
||||
{tabs.map((tab) => (
|
||||
{/* In-page tab strip */}
|
||||
<div className="flex items-center gap-1 mt-4 border-b border-gray-100 -mb-4">
|
||||
{([
|
||||
{ key: 'overview', label: '概览' },
|
||||
{ key: 'cases', label: '病例统计' },
|
||||
{ key: 'districts', label: '区域统计' },
|
||||
] as const).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`tab-strip__item ${
|
||||
activeTab === tab.key ? 'tab-strip__item--active' : ''
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -176,12 +175,9 @@ export function MonitoringDashboard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容:概览为地图舞台;统计 tab 可滚动(底部留坞空间) */}
|
||||
<div
|
||||
className={`flex-1 min-h-0 ${
|
||||
activeTab === 'overview' ? 'overflow-hidden' : 'overflow-auto p-4 pb-28'
|
||||
}`}
|
||||
>
|
||||
{/* Main content — bottom padding for floating player */}
|
||||
<div className="flex-1 overflow-auto p-6 pb-24">
|
||||
{/* 概览 tab — unchanged Monitoring content */}
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab
|
||||
isLoading={isLoading}
|
||||
@@ -196,6 +192,7 @@ export function MonitoringDashboard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 病例统计 tab */}
|
||||
{activeTab === 'cases' && (
|
||||
<CaseStatsTab
|
||||
loading={data.casesTabLoading}
|
||||
@@ -211,6 +208,7 @@ export function MonitoringDashboard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 区域统计 tab */}
|
||||
{activeTab === 'districts' && (
|
||||
<DistrictStatsTab
|
||||
loading={data.districtTabLoading}
|
||||
@@ -225,6 +223,7 @@ export function MonitoringDashboard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline Player */}
|
||||
<TimelinePlayer
|
||||
startDate={defaultStartDate}
|
||||
endDate={defaultEndDate}
|
||||
|
||||
@@ -52,6 +52,8 @@ export function OverviewDashboard() {
|
||||
const [alertPie, setAlertPie] = useState<AlertSlice[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
// 门诊/住院/全部 — 同时驱动 choropleth 与 Top5 区县条形图。
|
||||
const [metric, setMetric] = useState<MetricKey>('all');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,6 +72,7 @@ export function OverviewDashboard() {
|
||||
start30.setDate(start30.getDate() - 30);
|
||||
const start30Str = start30.toISOString().split('T')[0];
|
||||
|
||||
// KPI sources — Promise.allSettled to survive individual failures.
|
||||
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
|
||||
caseApi.getStats(),
|
||||
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
|
||||
@@ -78,16 +81,18 @@ export function OverviewDashboard() {
|
||||
envApi.getPollutants(7),
|
||||
]);
|
||||
|
||||
// Trend + district sources.
|
||||
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
|
||||
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
|
||||
caseApi.getDistricts(),
|
||||
caseApi.getStats(),
|
||||
caseApi.getStats(), // reuse for top_diagnoses
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const newErrors: string[] = [];
|
||||
|
||||
// --- KPI ---
|
||||
let totalCases = 0;
|
||||
if (statsR.status === 'fulfilled') {
|
||||
const s = statsR.value;
|
||||
@@ -136,6 +141,7 @@ export function OverviewDashboard() {
|
||||
|
||||
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
|
||||
|
||||
// --- Merge case trend + AQI ---
|
||||
if (trend30R.status === 'fulfilled') {
|
||||
const trend30 = trend30R.value.trend || [];
|
||||
const aqiMap: Record<string, number> = {};
|
||||
@@ -147,12 +153,14 @@ export function OverviewDashboard() {
|
||||
newErrors.push('趋势数据加载失败');
|
||||
}
|
||||
|
||||
// --- Districts (feeds choropleth + Top5 via normalize/join) ---
|
||||
if (districtsR.status === 'fulfilled') {
|
||||
setDistricts(districtsR.value.districts || []);
|
||||
} else {
|
||||
newErrors.push('区县数据加载失败');
|
||||
}
|
||||
|
||||
// --- Top 5 Diagnoses ---
|
||||
if (diagStatsR.status === 'fulfilled') {
|
||||
const topDiag = diagStatsR.value.top_diagnoses || [];
|
||||
setTopDiagnoses(
|
||||
@@ -165,6 +173,7 @@ export function OverviewDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Alert severity donut ---
|
||||
const p1 = alertList.filter((a) => a.priority === 'P1').length;
|
||||
const p2 = alertList.filter((a) => a.priority === 'P2').length;
|
||||
setAlertPie([
|
||||
@@ -182,6 +191,7 @@ export function OverviewDashboard() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 归一并聚合到 13 区一次,供 choropleth 与 Top5 共享。
|
||||
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
|
||||
const metricLookup = useMemo(
|
||||
() => buildMetricLookup(joinedDistricts, metric),
|
||||
@@ -195,7 +205,7 @@ export function OverviewDashboard() {
|
||||
return (
|
||||
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
|
||||
{errors.length > 0 && (
|
||||
<div className="px-1 pt-1">
|
||||
<div className="px-6 pt-4">
|
||||
<ErrorBanner
|
||||
error={errors.join(';')}
|
||||
onRetry={() => window.location.reload()}
|
||||
@@ -204,18 +214,18 @@ export function OverviewDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page header + honesty badge + metric toggle */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-[20px] font-semibold mb-1 flex items-center gap-2 text-text-primary">
|
||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-primary" />
|
||||
综合概览
|
||||
<span
|
||||
data-testid={TESTIDS.asofBadge}
|
||||
className="ml-1 inline-flex items-center rounded-md bg-mist-light/80 px-2 py-0.5
|
||||
text-[11px] font-medium text-mist-deep border border-mist/20"
|
||||
className="ml-1 inline-flex items-center rounded-full bg-bg-hover px-2 py-0.5 text-[11px] font-medium text-text-secondary border border-border"
|
||||
>
|
||||
数据截至 2023-12
|
||||
数据截至2023-12
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-[12px] text-text-secondary">病例、环境与预警关键指标总览</p>
|
||||
@@ -228,53 +238,35 @@ export function OverviewDashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section aria-label="关键指标">
|
||||
<KpiRow kpi={kpi} />
|
||||
</section>
|
||||
{/* KPI Row */}
|
||||
<KpiRow kpi={kpi} />
|
||||
|
||||
{/* 地图舞台 + 趋势:双栏构图 */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
|
||||
<section className="workbench-panel xl:col-span-3 overflow-hidden" aria-label="区县分布">
|
||||
<div className="workbench-panel__head">
|
||||
<div>
|
||||
<h2 className="workbench-panel__title">
|
||||
武汉市 13 区{METRIC_LABEL[metric]}分布
|
||||
</h2>
|
||||
<p className="workbench-panel__sub">高值高亮 · 点击图例可对照</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 pb-3 min-h-[340px]">
|
||||
<DistrictChoropleth
|
||||
metricLookup={metricLookup}
|
||||
metricLabel={`${METRIC_LABEL[metric]}数`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="xl:col-span-2 flex flex-col gap-5 min-h-0">
|
||||
<div className="workbench-panel flex-1">
|
||||
<CaseAqiTrend data={mergedTrend} embed />
|
||||
</div>
|
||||
{/* Headline: Wuhan 13-district choropleth */}
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-3">
|
||||
武汉市13区{METRIC_LABEL[metric]}分布(高风险高亮)
|
||||
</div>
|
||||
<DistrictChoropleth
|
||||
metricLookup={metricLookup}
|
||||
metricLabel={`${METRIC_LABEL[metric]}数`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div className="workbench-panel">
|
||||
<TopDistrictsBar
|
||||
districts={joinedDistricts}
|
||||
metric={metric}
|
||||
metricLabel={METRIC_LABEL[metric]}
|
||||
embed
|
||||
/>
|
||||
</div>
|
||||
<div className="workbench-panel">
|
||||
<TopDiagnosesBar diagnoses={topDiagnoses} embed />
|
||||
</div>
|
||||
{/* Case + AQI trend */}
|
||||
<CaseAqiTrend data={mergedTrend} />
|
||||
|
||||
{/* Top districts (metric-driven) + Top diagnoses */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<TopDistrictsBar
|
||||
districts={joinedDistricts}
|
||||
metric={metric}
|
||||
metricLabel={METRIC_LABEL[metric]}
|
||||
/>
|
||||
<TopDiagnosesBar diagnoses={topDiagnoses} />
|
||||
</div>
|
||||
|
||||
<div className="workbench-panel max-w-xl">
|
||||
<AlertSeverityDonut data={alertPie} embed />
|
||||
</div>
|
||||
{/* Alert severity donut */}
|
||||
<AlertSeverityDonut data={alertPie} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, ComponentType } from 'react';
|
||||
import { Navigate, RouteObject } from 'react-router-dom';
|
||||
import { LoadingState } from '@/components/ui/LoadingState';
|
||||
import { RoleRedirect } from '@/components/RoleRedirect';
|
||||
import { TESTIDS } from '@/utils/testids';
|
||||
|
||||
// 按页面懒加载,路由层负责代码分割(原先位于 App.tsx)。
|
||||
@@ -47,7 +48,7 @@ function lazyElement(Page: ComponentType): JSX.Element {
|
||||
|
||||
// AppShell 的子路由表(在 App.tsx 中作为 <Outlet/> 的内容渲染)。
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{ index: true, element: <Navigate to="/monitoring" replace /> },
|
||||
{ index: true, element: <RoleRedirect /> },
|
||||
{ path: 'overview', element: lazyElement(OverviewDashboard) },
|
||||
{ path: 'monitoring', element: lazyElement(MonitoringDashboard) },
|
||||
{ path: 'alerts', element: lazyElement(AlertsDashboard) },
|
||||
|
||||
@@ -178,13 +178,12 @@ export const riskApi = {
|
||||
|
||||
getStats: (): Promise<Stats> => cachedGet('/risk/stats'),
|
||||
|
||||
// Full-Wuhan 100m risk grid XYZ tiles. Absolute URL for GeoScene WebTileLayer.
|
||||
// Full-Wuhan 100m risk grid served as XYZ raster tiles. Returns a Leaflet
|
||||
// URL template (NOT an axios call) — the browser fetches PNGs directly.
|
||||
tileUrlTemplate: (day: 1 | 3 | 7, date?: string): string => {
|
||||
const apiBase = (import.meta.env.VITE_API_URL || '/api').replace(/\/$/, '');
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const prefix = apiBase.startsWith('http') ? apiBase : `${origin}${apiBase}`;
|
||||
const base = import.meta.env.VITE_API_URL || '/api';
|
||||
const dateParam = date ? `&date=${date}` : '';
|
||||
return `${prefix}/risk/tiles/{z}/{x}/{y}.png?day=${day}${dateParam}`;
|
||||
return `${base}/risk/tiles/{z}/{x}/{y}.png?day=${day}${dateParam}`;
|
||||
},
|
||||
|
||||
getCell: (
|
||||
|
||||
@@ -102,6 +102,9 @@ export const useRiskStore = create<RiskState>((set, get) => ({
|
||||
export { useAnalysisStore } from './analysisStore';
|
||||
export { useDrilldownStore } from './drilldownStore';
|
||||
export { useDiseaseStore } from './diseaseStore';
|
||||
export { useSessionStore } from './sessionStore';
|
||||
|
||||
|
||||
interface TimelineState {
|
||||
currentDate: string;
|
||||
startDate: string;
|
||||
@@ -174,7 +177,7 @@ interface MonitoringState {
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useMonitoringStore = create<MonitoringState>((set, get) => ({
|
||||
export const useMonitoringStore = create<MonitoringState>((set) => ({
|
||||
gridFeatures: [],
|
||||
aggregatedData: [],
|
||||
districtCases: [],
|
||||
@@ -218,10 +221,7 @@ export const useMonitoringStore = create<MonitoringState>((set, get) => ({
|
||||
},
|
||||
|
||||
fetchDistrictCases: async (diagnosis?: string, startDate?: string, endDate?: string) => {
|
||||
// 播放时间轴会高频调用:不要拨全局 isLoading,否则 Overview 会卸地图重建底图。
|
||||
const firstLoad = get().districtCases.length === 0;
|
||||
if (firstLoad) set({ isLoading: true, error: null });
|
||||
else set({ error: null });
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (diagnosis) params.diagnosis = diagnosis;
|
||||
|
||||
57
frontend/src/stores/sessionStore.test.ts
Normal file
57
frontend/src/stores/sessionStore.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { getRoleSource, type Role } from './sessionStore';
|
||||
|
||||
const STORAGE_KEY = 'cbpoa_role';
|
||||
const ALL_ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
|
||||
|
||||
describe('sessionStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.resetModules();
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('getRoleSource', () => {
|
||||
it("defaults to 'admin' when localStorage is empty", () => {
|
||||
expect(getRoleSource()).toBe('admin');
|
||||
});
|
||||
|
||||
it("defaults to 'admin' on an invalid stored value", () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'hacker');
|
||||
expect(getRoleSource()).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns each valid stored role', () => {
|
||||
for (const r of ALL_ROLES) {
|
||||
localStorage.setItem(STORAGE_KEY, r);
|
||||
expect(getRoleSource()).toBe(r);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSessionStore', () => {
|
||||
// useSessionStore 在模块加载时从 getRoleSource() 初始化一次,故用
|
||||
// resetModules + 动态 import 来获得一个「按当前 localStorage 初始化」的全新实例。
|
||||
it('initializes role from localStorage', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'doctor');
|
||||
vi.resetModules();
|
||||
const { useSessionStore } = await import('./sessionStore');
|
||||
expect(useSessionStore.getState().role).toBe('doctor');
|
||||
});
|
||||
|
||||
it('setRole updates state and persists to localStorage', async () => {
|
||||
vi.resetModules();
|
||||
const { useSessionStore } = await import('./sessionStore');
|
||||
|
||||
useSessionStore.getState().setRole('community');
|
||||
expect(useSessionStore.getState().role).toBe('community');
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('community');
|
||||
|
||||
useSessionStore.getState().setRole('official');
|
||||
expect(useSessionStore.getState().role).toBe('official');
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('official');
|
||||
});
|
||||
});
|
||||
});
|
||||
59
frontend/src/stores/sessionStore.ts
Normal file
59
frontend/src/stores/sessionStore.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* 视角/perspective 会话存储 —— 纯前端视图预设(D2:不做后端鉴权,不加 JWT role claim)。
|
||||
*
|
||||
* 角色仅决定「默认落地页 + 粒度 + 过滤预设」,不是访问控制。切换器在 UI 上标注为
|
||||
* 「视角」而非「权限」,因此 URL 可编辑不构成可信度陷阱。
|
||||
*
|
||||
* 角色来源被隔离在单一可替换的 getRoleSource() 接缝里:今天读 localStorage,
|
||||
* 将来若需真正 RBAC,只改这一个函数(改读 /api/auth/me),其余代码不变。
|
||||
*/
|
||||
|
||||
export type Role = 'official' | 'community' | 'doctor' | 'admin';
|
||||
|
||||
export const ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
|
||||
|
||||
// 标签与默认落地路径已迁出到纯模块 '@/utils/roleViews'(ROLE_LABELS / roleDefaultPath),
|
||||
// 保持单一来源。本 store 只负责「当前视角是什么」+ 可替换的角色来源接缝。
|
||||
|
||||
const STORAGE_KEY = 'cbpoa_role';
|
||||
const DEFAULT_ROLE: Role = 'admin';
|
||||
|
||||
function isRole(v: unknown): v is Role {
|
||||
return typeof v === 'string' && (ROLES as string[]).includes(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单一可替换接缝:角色来源。今天 = localStorage;将来 = /api/auth/me。
|
||||
* 改 RBAC 只动这一个函数。
|
||||
*/
|
||||
export function getRoleSource(): Role {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return isRole(raw) ? raw : DEFAULT_ROLE;
|
||||
} catch {
|
||||
return DEFAULT_ROLE;
|
||||
}
|
||||
}
|
||||
|
||||
function persistRole(role: Role): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, role);
|
||||
} catch {
|
||||
/* localStorage 不可用时静默降级 */
|
||||
}
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
role: Role;
|
||||
setRole: (role: Role) => void;
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set) => ({
|
||||
role: getRoleSource(),
|
||||
setRole: (role) => {
|
||||
persistRole(role);
|
||||
set({ role });
|
||||
},
|
||||
}));
|
||||
42
frontend/src/utils/roleViews.test.ts
Normal file
42
frontend/src/utils/roleViews.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ROLE_LABELS, roleDefaultPath } from './roleViews';
|
||||
import type { Role } from '@/stores/sessionStore';
|
||||
|
||||
const ALL_ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
|
||||
|
||||
describe('roleViews', () => {
|
||||
describe('ROLE_LABELS', () => {
|
||||
it('has a non-empty label for all 4 roles', () => {
|
||||
for (const r of ALL_ROLES) {
|
||||
expect(ROLE_LABELS[r]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the expected short labels (no 视角 suffix)', () => {
|
||||
expect(ROLE_LABELS).toEqual({
|
||||
official: '厅领导',
|
||||
community: '社区',
|
||||
doctor: '医生',
|
||||
admin: '管理员',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleDefaultPath', () => {
|
||||
it('official → /overview with granularity=district', () => {
|
||||
expect(roleDefaultPath('official')).toBe('/overview?granularity=district');
|
||||
});
|
||||
|
||||
it('community → /monitoring with granularity=street', () => {
|
||||
expect(roleDefaultPath('community')).toBe('/monitoring?granularity=street');
|
||||
});
|
||||
|
||||
it('doctor → /alerts with view=cluster', () => {
|
||||
expect(roleDefaultPath('doctor')).toBe('/alerts?view=cluster');
|
||||
});
|
||||
|
||||
it('admin → /monitoring (legacy full view; keeps user-flows baseline green)', () => {
|
||||
expect(roleDefaultPath('admin')).toBe('/monitoring');
|
||||
});
|
||||
});
|
||||
});
|
||||
42
frontend/src/utils/roleViews.ts
Normal file
42
frontend/src/utils/roleViews.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Role } from '@/stores/sessionStore';
|
||||
|
||||
/**
|
||||
* 视角/perspective 的纯展示元数据 —— 标签 + 默认落地路径。
|
||||
*
|
||||
* 纯模块(无副作用、无 React、无 store 依赖),便于单元测试。
|
||||
* D2:角色只是「前端视图预设」,决定默认落地页 / 粒度 / 过滤预设,不是访问控制。
|
||||
*
|
||||
* wave-2 workers 的契约入口:
|
||||
* import { roleDefaultPath, ROLE_LABELS } from '@/utils/roleViews'
|
||||
*/
|
||||
|
||||
/** 视角中文短标签(switcher 在前面拼接「视角:」前缀,故此处不带「视角」后缀)。 */
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
official: '厅领导',
|
||||
community: '社区',
|
||||
doctor: '医生',
|
||||
admin: '管理员',
|
||||
};
|
||||
|
||||
/**
|
||||
* 每个视角的默认落地 URL。query 参数由 wave-2 workers 消费:
|
||||
* - granularity=district|street → 粒度控件初值(监测/概览)
|
||||
* - view=cluster → 预警页医生聚类视图
|
||||
*/
|
||||
export function roleDefaultPath(role: Role): string {
|
||||
switch (role) {
|
||||
case 'official':
|
||||
return '/overview?granularity=district';
|
||||
case 'community':
|
||||
return '/monitoring?granularity=street';
|
||||
case 'doctor':
|
||||
return '/alerts?view=cluster';
|
||||
case 'admin':
|
||||
default:
|
||||
// admin = 旧「全量」视角,历史落地页即 /monitoring(与改造前 sessionStore 的
|
||||
// ROLE_DEFAULT_PATH 一致)。保持 /monitoring 以兼容既有 user-flows 基线测试
|
||||
// (裸 '/' 无 cbpoa_role ⇒ 默认 admin ⇒ /monitoring)。其余三个视角带查询参数,
|
||||
// 由 wave-2 workers 消费,不受此选择影响。
|
||||
return '/monitoring';
|
||||
}
|
||||
}
|
||||
@@ -44,11 +44,14 @@ export const TESTIDS = {
|
||||
asofBadge: 'asof-badge',
|
||||
outinpatientToggle: 'outinpatient-toggle',
|
||||
|
||||
// 地图图层控件
|
||||
// 视角/perspective + 粒度(Phase 3)
|
||||
perspectiveSwitcher: 'perspective-switcher',
|
||||
perspectiveOption: 'perspective-option', // 配合角色后缀,如 perspective-option-official
|
||||
granularityControl: 'granularity-control',
|
||||
districtRollup: 'district-rollup',
|
||||
gridLayerWrapper: 'grid-layer-wrapper',
|
||||
patientPoint: 'patient-point',
|
||||
clusterView: 'cluster-view',
|
||||
patientPoint: 'patient-point', // 个体病例点标记;医生视角下必须为 0
|
||||
} as const;
|
||||
|
||||
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];
|
||||
|
||||
14
frontend/src/vite-env.d.ts
vendored
14
frontend/src/vite-env.d.ts
vendored
@@ -1,15 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_TIANDITU_TK?: string;
|
||||
readonly VITE_GEOSCENE_PORTAL_URL?: string;
|
||||
readonly VITE_LAYER_DISTRICTS_URL?: string;
|
||||
readonly VITE_LAYER_RISK_URL?: string;
|
||||
readonly VITE_LAYER_CASES_URL?: string;
|
||||
readonly VITE_GEOSCENE_WEBMAP_ID?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -8,73 +8,41 @@ export default {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#0F766E',
|
||||
light: '#14B8A6',
|
||||
muted: '#CCFBF1',
|
||||
deep: '#0D5C56',
|
||||
},
|
||||
mist: {
|
||||
DEFAULT: '#5B8FA8',
|
||||
light: '#E8F2F6',
|
||||
deep: '#3D6B82',
|
||||
DEFAULT: '#2563EB',
|
||||
light: '#3B82F6',
|
||||
muted: '#DBEAFE',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: '#0D9488',
|
||||
light: '#CCFBF1',
|
||||
DEFAULT: '#059669',
|
||||
light: '#D1FAE5',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: '#C27803',
|
||||
DEFAULT: '#D97706',
|
||||
light: '#FEF3C7',
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: '#C2410C',
|
||||
light: '#FFEDD5',
|
||||
DEFAULT: '#DC2626',
|
||||
light: '#FEE2E2',
|
||||
},
|
||||
bg: {
|
||||
page: '#F0F4F6',
|
||||
page: '#F8FAFC',
|
||||
card: '#FFFFFF',
|
||||
hover: '#E8EEF1',
|
||||
active: '#D8E4E9',
|
||||
elevated: '#FAFCFD',
|
||||
hover: '#F1F5F9',
|
||||
active: '#E2E8F0',
|
||||
},
|
||||
text: {
|
||||
primary: '#1A2B33',
|
||||
secondary: '#5A6F7A',
|
||||
muted: '#8A9BA5',
|
||||
primary: '#1E293B',
|
||||
secondary: '#64748B',
|
||||
muted: '#94A3B8',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#D4DEE4',
|
||||
light: '#E8EEF1',
|
||||
DEFAULT: '#E2E8F0',
|
||||
light: '#F1F5F9',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Noto Sans SC"', '"Outfit"', 'sans-serif'],
|
||||
display: ['"Outfit"', '"Noto Sans SC"', 'sans-serif'],
|
||||
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
boxShadow: {
|
||||
soft: '0 1px 2px rgba(26, 43, 51, 0.04), 0 4px 16px rgba(26, 43, 51, 0.05)',
|
||||
lift: '0 2px 8px rgba(15, 118, 110, 0.08), 0 8px 24px rgba(26, 43, 51, 0.06)',
|
||||
brand: '0 8px 32px rgba(15, 118, 110, 0.18)',
|
||||
},
|
||||
keyframes: {
|
||||
'fade-up': {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
'breath': {
|
||||
'0%, 100%': { opacity: '0.35' },
|
||||
'50%': { opacity: '0.55' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-up': 'fade-up 0.5s ease-out both',
|
||||
'fade-in': 'fade-in 0.4s ease-out both',
|
||||
breath: 'breath 8s ease-in-out infinite',
|
||||
sans: ['Inter', 'Noto Sans SC', 'system-ui', 'sans-serif'],
|
||||
display: ['Source Sans Pro', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,9 +9,6 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['@geoscene/core'],
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
allowedHosts: ['alpha.hyh.ink'],
|
||||
@@ -20,17 +17,6 @@ export default defineConfig({
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// Same-origin Gaode tiles — WebTileLayer needs CORS; direct autonavi URLs fail.
|
||||
'/basemap-gaode': {
|
||||
target: 'https://webrd01.is.autonavi.com',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => {
|
||||
const m = p.match(/^\/basemap-gaode\/(\d+)\/(\d+)\/(\d+)/);
|
||||
if (!m) return p;
|
||||
const [, z, x, y] = m;
|
||||
return `/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=${z}&x=${x}&y=${y}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"20231125":0.4461,"20231126":0.4517,"20231127":0.4576,"20231128":0.4638,"20231129":0.4697,"20231130":0.4521,"20231201":0.4581}
|
||||
Reference in New Issue
Block a user