Compare commits
1 Commits
main
...
feature/ge
| Author | SHA1 | Date | |
|---|---|---|---|
| e22b004f9e |
@@ -1,12 +1,12 @@
|
|||||||
# CBPOA — 武汉儿童呼吸疾病风险评估系统
|
# CBPOA — 武汉儿童呼吸疾病风险评估系统
|
||||||
|
|
||||||
FastAPI + React + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
|
FastAPI + React + `@geoscene/core` + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Frontend (pnpm)
|
# Frontend (pnpm)
|
||||||
cd frontend && pnpm dev # localhost:5173 → proxies /api to :8000
|
cd frontend && pnpm dev # localhost:3000 → proxies /api to :8000
|
||||||
|
|
||||||
# Backend (Python venv)
|
# Backend (Python venv)
|
||||||
cd backend && uvicorn main:app --reload # localhost:8000
|
cd backend && uvicorn main:app --reload # localhost:8000
|
||||||
@@ -22,6 +22,7 @@ cd scripts && python train_model.py # PyTorch + MLflow
|
|||||||
| API endpoint | `backend/routers/` |
|
| API endpoint | `backend/routers/` |
|
||||||
| Database / PostGIS | `backend/database.py` |
|
| Database / PostGIS | `backend/database.py` |
|
||||||
| UI component | `frontend/src/components/` |
|
| UI component | `frontend/src/components/` |
|
||||||
|
| GeoScene map helpers | `frontend/src/geoscene/` |
|
||||||
| Page view | `frontend/src/pages/` |
|
| Page view | `frontend/src/pages/` |
|
||||||
| API client / cache | `frontend/src/services/api.ts` |
|
| API client / cache | `frontend/src/services/api.ts` |
|
||||||
| State management | `frontend/src/stores/` |
|
| State management | `frontend/src/stores/` |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ load_dotenv()
|
|||||||
# Paths
|
# Paths
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
PROJECT_ROOT = Path(os.environ.get("CBPOA_ROOT", Path(__file__).parent.parent))
|
||||||
DATA_DIR = PROJECT_ROOT / "outputs" / "daily"
|
DATA_DIR = PROJECT_ROOT / "outputs" / "daily"
|
||||||
REPORTS_DIR = PROJECT_ROOT / "outputs" / "reports"
|
REPORTS_DIR = PROJECT_ROOT / "outputs" / "reports"
|
||||||
WUHAN_BOUNDARY_PATH = PROJECT_ROOT / "Datas" / "武汉市.geojson"
|
WUHAN_BOUNDARY_PATH = PROJECT_ROOT / "Datas" / "武汉市.geojson"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from config import DATA_DIR, ALERT_P1_RISK, ALERT_P2_RISK, WUHAN_BOUNDS, LAT_STE
|
|||||||
from models import Alert, AlertResponse
|
from models import Alert, AlertResponse
|
||||||
from utils.date_helpers import get_latest_date, validate_date_format
|
from utils.date_helpers import get_latest_date, validate_date_format
|
||||||
from utils.risk import risk_value_to_level
|
from utils.risk import risk_value_to_level
|
||||||
|
from utils.district_lookup import district_for_grid
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
||||||
|
|
||||||
@@ -95,13 +96,14 @@ def _generate_alerts_cached(date: str) -> List[Alert]:
|
|||||||
|
|
||||||
lat, lon = grid_id_to_center(grid_id)
|
lat, lon = grid_id_to_center(grid_id)
|
||||||
risk_level = risk_value_to_level(max_risk)
|
risk_level = risk_value_to_level(max_risk)
|
||||||
|
district = district_for_grid(grid_id)
|
||||||
|
|
||||||
alerts.append(
|
alerts.append(
|
||||||
Alert(
|
Alert(
|
||||||
alert_id=f"alert_{date}_{grid_id}",
|
alert_id=f"alert_{date}_{grid_id}",
|
||||||
grid_id=grid_id,
|
grid_id=grid_id,
|
||||||
region="武汉市",
|
region=district,
|
||||||
street=f"Grid {grid_id}",
|
street=grid_id,
|
||||||
latitude=lat,
|
latitude=lat,
|
||||||
longitude=lon,
|
longitude=lon,
|
||||||
risk_value=max_risk,
|
risk_value=max_risk,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from utils.date_helpers import get_latest_date
|
|||||||
from utils.geojson import parse_geojson_file, load_districts
|
from utils.geojson import parse_geojson_file, load_districts
|
||||||
from utils.geo import point_in_polygon
|
from utils.geo import point_in_polygon
|
||||||
from utils.risk import calculate_trend
|
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"])
|
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
|
||||||
|
|
||||||
@@ -83,22 +85,10 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
|
|||||||
for i in range(days):
|
for i in range(days):
|
||||||
date = base_date - timedelta(days=days - 1 - i)
|
date = base_date - timedelta(days=days - 1 - i)
|
||||||
date_str = date.strftime("%Y%m%d")
|
date_str = date.strftime("%Y%m%d")
|
||||||
filepath = DATA_DIR / f"risk_{date_str}.geojson"
|
# Disk+memory cached mean — avoids re-parsing ~45MB GeoJSON every request
|
||||||
|
values.append(daily_avg_risk(date_str))
|
||||||
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"))
|
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)
|
trend_direction = calculate_trend(values)
|
||||||
|
|
||||||
return TrendResponse(
|
return TrendResponse(
|
||||||
@@ -110,15 +100,8 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
|
|||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _grid_district_lookup() -> dict:
|
def _grid_district_lookup() -> dict:
|
||||||
"""Map precomputed r{row}_c{col} grid id -> district name (loaded once)."""
|
"""Backward-compatible alias — prefer utils.district_lookup."""
|
||||||
path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
|
return grid_district_lookup()
|
||||||
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)
|
@lru_cache(maxsize=1)
|
||||||
|
|||||||
81
backend/utils/daily_risk_avg.py
Normal file
81
backend/utils/daily_risk_avg.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Fast daily city-wide mean risk_1d with on-disk cache.
|
||||||
|
|
||||||
|
Avoids re-parsing ~45MB GeoJSON on every /analysis/trend request.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import DATA_DIR, PROJECT_ROOT
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CACHE_PATH = PROJECT_ROOT / "processed" / "daily_avg_risk.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_disk_cache() -> dict[str, float]:
|
||||||
|
if not _CACHE_PATH.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = json.loads(_CACHE_PATH.read_text(encoding="utf-8"))
|
||||||
|
return {str(k): float(v) for k, v in raw.items()}
|
||||||
|
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_disk_cache(cache: dict[str, float]) -> None:
|
||||||
|
try:
|
||||||
|
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_CACHE_PATH.write_text(
|
||||||
|
json.dumps(cache, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning("Failed to persist daily avg risk cache: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_mean_risk_1d(filepath: Path) -> float:
|
||||||
|
"""Parse one risk GeoJSON and return mean risk_1d (0 if empty/missing)."""
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
geojson = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.warning("Failed to parse %s: %s", filepath, e)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
total = 0.0
|
||||||
|
n = 0
|
||||||
|
for feature in geojson.get("features", []):
|
||||||
|
props = feature.get("properties") or {}
|
||||||
|
r = props.get("risk_1d")
|
||||||
|
if r is None:
|
||||||
|
continue
|
||||||
|
total += float(r)
|
||||||
|
n += 1
|
||||||
|
return round(total / n, 4) if n else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=64)
|
||||||
|
def daily_avg_risk(date_yyyymmdd: str) -> float:
|
||||||
|
"""Mean risk_1d for YYYYMMDD. Memory + disk cached."""
|
||||||
|
disk = _read_disk_cache()
|
||||||
|
if date_yyyymmdd in disk:
|
||||||
|
return disk[date_yyyymmdd]
|
||||||
|
|
||||||
|
filepath = DATA_DIR / f"risk_{date_yyyymmdd}.geojson"
|
||||||
|
if not filepath.exists():
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
avg = _compute_mean_risk_1d(filepath)
|
||||||
|
disk[date_yyyymmdd] = avg
|
||||||
|
_write_disk_cache(disk)
|
||||||
|
return avg
|
||||||
|
|
||||||
|
|
||||||
|
def warm_daily_avg_risk(dates: list[str]) -> None:
|
||||||
|
"""Precompute missing dates into the disk cache (blocking)."""
|
||||||
|
for d in dates:
|
||||||
|
daily_avg_risk(d)
|
||||||
21
backend/utils/district_lookup.py
Normal file
21
backend/utils/district_lookup.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Map 100m grid_id (r{row}_c{col}) → Wuhan district name."""
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from config import PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def grid_district_lookup() -> dict[str, str]:
|
||||||
|
"""Loaded once from processed/grid_district_mapping.parquet."""
|
||||||
|
path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
df = pd.read_parquet(path)
|
||||||
|
df = df.dropna(subset=["district_name"])
|
||||||
|
return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str)))
|
||||||
|
|
||||||
|
|
||||||
|
def district_for_grid(grid_id: str, default: str = "其他") -> str:
|
||||||
|
return grid_district_lookup().get(grid_id, default)
|
||||||
@@ -1,32 +1,26 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq-dev \
|
libpq-dev curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN groupadd --gid 1000 appgroup && \
|
RUN groupadd --gid 1000 appgroup && \
|
||||||
useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser
|
useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser
|
||||||
|
|
||||||
WORKDIR /home/appuser
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy requirements and install dependencies
|
|
||||||
COPY --chown=appuser:appgroup requirements.txt .
|
COPY --chown=appuser:appgroup requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt \
|
||||||
|
-i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|
||||||
# Copy backend code
|
|
||||||
COPY --chown=appuser:appgroup . .
|
COPY --chown=appuser:appgroup . .
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
# Expose port
|
ENV CBPOA_ROOT=/data
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Health check
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:8000/docs || exit 1
|
CMD curl -f http://localhost:8000/docs || exit 1
|
||||||
|
|
||||||
# Run uvicorn
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
17
deploy/cbpoa-api.service
Normal file
17
deploy/cbpoa-api.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=CBPOA FastAPI backend
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/cbpoa/backend
|
||||||
|
Environment=PATH=/opt/cbpoa/backend/.venv/bin:/usr/bin
|
||||||
|
EnvironmentFile=-/opt/cbpoa/backend/.env
|
||||||
|
ExecStart=/opt/cbpoa/backend/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 1
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,83 +1,47 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
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:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ../backend
|
context: ../backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: ../deploy/backend/Dockerfile
|
||||||
container_name: wuhan_backend
|
container_name: cbpoa_backend
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://wuhan_user:password@postgres:5432/wuhan_disease
|
CBPOA_ROOT: /data
|
||||||
POSTGRES_HOST: postgres
|
CORS_ORIGINS: "*"
|
||||||
POSTGRES_PORT: 5432
|
volumes:
|
||||||
depends_on:
|
- ../outputs:/data/outputs:ro
|
||||||
postgres:
|
- ../processed:/data/processed:ro
|
||||||
condition: service_healthy
|
- ../Datas:/data/Datas:ro
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:8000/docs || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost:8000/docs || exit 1"]
|
||||||
interval: 10s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 40s
|
||||||
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- wuhan_network
|
- cbpoa_net
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ../frontend
|
context: ../frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: ../deploy/frontend/Dockerfile
|
||||||
container_name: wuhan_frontend
|
container_name: cbpoa_frontend
|
||||||
environment:
|
|
||||||
VITE_API_URL: http://localhost:8000
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "80:80"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"]
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
|
||||||
interval: 10s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- wuhan_network
|
- cbpoa_net
|
||||||
|
|
||||||
# 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:
|
networks:
|
||||||
wuhan_network:
|
cbpoa_net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@@ -5,16 +5,11 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
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
|
RUN npm install -g pnpm && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
ENV VITE_API_URL=/api
|
||||||
# Build the application
|
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -22,15 +17,10 @@ RUN pnpm run build
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM nginx:alpine AS production
|
FROM nginx:alpine AS production
|
||||||
|
|
||||||
# Copy custom nginx config for SPA routing
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
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
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Expose port 80
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Health check for nginx
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
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
|
||||||
37
deploy/nginx-cbpoa.conf
Normal file
37
deploy/nginx-cbpoa.conf
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /var/www/cbpoa;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/geo+json;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gaode basemap proxy (same as Vite /basemap-gaode)
|
||||||
|
location ~ ^/basemap-gaode/(\d+)/(\d+)/(\d+) {
|
||||||
|
proxy_pass https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=$1&x=$2&y=$3;
|
||||||
|
proxy_set_header Host webrd01.is.autonavi.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_hide_header Set-Cookie;
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/.env.example
Normal file
15
frontend/.env.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Frontend env (Vite)
|
||||||
|
|
||||||
|
# API (default: Vite proxy /api → :8000)
|
||||||
|
# VITE_API_URL=/api
|
||||||
|
|
||||||
|
# 天地图个人密钥 https://console.tianditu.gov.cn/
|
||||||
|
# 不设则用高德矢量底图(GeoScene CN 仅有 tianditu-* 命名底图,且内置 tk 会 418)
|
||||||
|
# VITE_TIANDITU_TK=
|
||||||
|
|
||||||
|
# GeoScene Enterprise (optional — unused in POC)
|
||||||
|
# VITE_GEOSCENE_PORTAL_URL=https://cn18:7443/geoscene
|
||||||
|
# VITE_LAYER_DISTRICTS_URL=
|
||||||
|
# VITE_LAYER_RISK_URL=
|
||||||
|
# VITE_LAYER_CASES_URL=
|
||||||
|
# VITE_GEOSCENE_WEBMAP_ID=
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
# Frontend — React + TypeScript + Leaflet
|
# Frontend — React + TypeScript + GeoScene Maps SDK
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- React 18, TypeScript 5, Vite 5
|
- React 18, TypeScript 5, Vite 5
|
||||||
- Tailwind CSS, Recharts, Zustand (state), Axios
|
- Tailwind CSS, Recharts, Zustand (state), Axios
|
||||||
- Leaflet / react-leaflet (maps)
|
- `@geoscene/core` (Maps SDK for JavaScript) — POC uses Tianditu basemap + local GeoJSON / FastAPI risk tiles
|
||||||
- Playwright (e2e tests)
|
- Playwright (e2e tests)
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/src/
|
frontend/src/
|
||||||
main.tsx # Entry point
|
main.tsx # Entry (+ GeoScene theme CSS)
|
||||||
App.tsx # Router setup
|
App.tsx # Router setup
|
||||||
|
geoscene/ # MapView helpers + layer factories
|
||||||
components/ # Reusable UI (maps, charts, nav)
|
components/ # Reusable UI (maps, charts, nav)
|
||||||
|
legacy/ # Unused former Leaflet map experiments
|
||||||
pages/ # Route-level views
|
pages/ # Route-level views
|
||||||
services/api.ts # Axios client with TTL cache + request dedup
|
services/api.ts # Axios client with TTL cache + request dedup
|
||||||
stores/ # Zustand stores
|
stores/ # Zustand stores
|
||||||
@@ -28,15 +30,21 @@ frontend/src/
|
|||||||
## Patterns
|
## Patterns
|
||||||
|
|
||||||
- Components: PascalCase, one per file, default export
|
- Components: PascalCase, one per file, default export
|
||||||
- API calls: use `services/api.ts` wrappers (`riskApi`, `alertApi`, `caseApi`, `gridApi`) — they handle caching and request dedup
|
- Map components: imperative `@geoscene/core` via `createMapView` — do not pass MapView instances between components
|
||||||
- State: Zustand stores in `stores/`, typed with TypeScript interfaces from `types/`
|
- Coordinates: GeoScene uses `[longitude, latitude]`
|
||||||
- Styling: Tailwind utility classes, no CSS modules
|
- API calls: use `services/api.ts` wrappers (`riskApi`, `alertApi`, `caseApi`, `gridApi`)
|
||||||
|
- State: Zustand stores in `stores/`
|
||||||
|
- Styling: Tailwind utility classes
|
||||||
|
|
||||||
|
## Env (optional Enterprise later)
|
||||||
|
|
||||||
|
See `.env.example` for `VITE_GEOSCENE_PORTAL_URL` / `VITE_LAYER_*`. POC runs without them.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
pnpm dev # localhost:5173, proxies /api → localhost:8000
|
pnpm dev # localhost:3000, proxies /api → localhost:8000
|
||||||
pnpm build # tsc + vite build → dist/
|
pnpm build # tsc + vite build → dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,3 +55,4 @@ pnpm build # tsc + vite build → dist/
|
|||||||
- Don't mix data fetching with presentation — fetch in pages, render in components
|
- Don't mix data fetching with presentation — fetch in pages, render in components
|
||||||
- Don't inline styles when Tailwind classes work
|
- Don't inline styles when Tailwind classes work
|
||||||
- Don't create god components (>200 lines) — extract sub-components
|
- Don't create god components (>200 lines) — extract sub-components
|
||||||
|
- Don't reintroduce Leaflet or role/perspective switchers
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
/**
|
|
||||||
* Phase-3 acceptance tests: role-aware 预警 (alerts) view.
|
|
||||||
*
|
|
||||||
* Two view-preset invariants (D2 — frontend presets, NOT access control):
|
|
||||||
*
|
|
||||||
* 1. PRIVACY INVARIANT (doctor / ?view=cluster): the doctor sees ONLY the aggregated
|
|
||||||
* density raster + the disease filter — ZERO individual patient/case point markers.
|
|
||||||
* The page mirrors every individual marker it would actually render into a hidden
|
|
||||||
* data-testid="patient-point" element (the live Leaflet CircleMarkers are canvas/SVG
|
|
||||||
* objects with no testid and can't be counted directly). In cluster mode the page
|
|
||||||
* forces showAlertMarkers=false, so that mirror set is empty → patient-point count 0.
|
|
||||||
*
|
|
||||||
* 2. 官员 (official) GRID-HIDE: the 100m 网格 is meaningless for leadership, so the grid
|
|
||||||
* toggle wrapper (data-testid="grid-layer-wrapper") is not rendered at all.
|
|
||||||
*
|
|
||||||
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically
|
|
||||||
* (no live :8000 backend). Role is seeded via localStorage['cbpoa_role'].
|
|
||||||
*/
|
|
||||||
import { test, expect, Page } from '@playwright/test';
|
|
||||||
import { TESTIDS } from '../src/utils/testids';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed auth token (+ optional role) and mock all /api/** calls before page load.
|
|
||||||
* Response shapes copied from user-flows.spec.ts.
|
|
||||||
*/
|
|
||||||
async function seedAuthAndMockApi(page: Page, role?: string) {
|
|
||||||
await page.addInitScript((r) => {
|
|
||||||
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
|
||||||
if (r) localStorage.setItem('cbpoa_role', r);
|
|
||||||
}, role ?? '');
|
|
||||||
|
|
||||||
await page.route('/api/**', (route) => {
|
|
||||||
const url = route.request().url();
|
|
||||||
|
|
||||||
if (url.includes('/alerts')) {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ alerts: [], total: 0 }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/history/aggregated')) {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/grids')) {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/cases/demographics')) {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
age_distribution: [],
|
|
||||||
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
|
|
||||||
age_diagnosis_matrix: [],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
|
|
||||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/diagnoses') || url.includes('/diagnosis-list')) {
|
|
||||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
|
||||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ data: [], total: 0 }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('role-aware 预警 view (Phase 3)', () => {
|
|
||||||
test.use({ viewport: { width: 1280, height: 800 } });
|
|
||||||
|
|
||||||
test('医生 /alerts?view=cluster: cluster-view mounts, disease filter present, ZERO patient points', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await seedAuthAndMockApi(page, 'doctor');
|
|
||||||
await page.goto('/alerts?view=cluster');
|
|
||||||
|
|
||||||
// Page + the aggregated density (cluster) map both mount.
|
|
||||||
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
|
||||||
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toBeVisible();
|
|
||||||
|
|
||||||
// The disease filter is the doctor's core tool — it must be on the page.
|
|
||||||
await expect(page.getByText('按病种筛选')).toBeVisible();
|
|
||||||
|
|
||||||
// PRIVACY INVARIANT: not a single individual patient/case point may be rendered.
|
|
||||||
// Asserted at the data level (the mirrored DOM set), independent of Leaflet internals.
|
|
||||||
await expect(page.getByTestId(TESTIDS.patientPoint)).toHaveCount(0);
|
|
||||||
|
|
||||||
// The 预警标记 toggle (which would turn individual markers on) must be absent,
|
|
||||||
// so there is no way for the doctor to opt out of the privacy invariant.
|
|
||||||
await expect(page.getByRole('button', { name: '预警标记' })).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('官员 /alerts: 100m grid hidden — grid-layer-wrapper not rendered', async ({ page }) => {
|
|
||||||
await seedAuthAndMockApi(page, 'official');
|
|
||||||
await page.goto('/alerts');
|
|
||||||
|
|
||||||
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
|
||||||
|
|
||||||
// The grid toggle wrapper must be entirely absent for leadership.
|
|
||||||
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin /alerts: full behavior — grid toggle present, no forced cluster view', async ({ page }) => {
|
|
||||||
await seedAuthAndMockApi(page, 'admin');
|
|
||||||
await page.goto('/alerts');
|
|
||||||
|
|
||||||
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
|
||||||
// Admin keeps the grid toggle and is NOT forced into cluster view.
|
|
||||||
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(1);
|
|
||||||
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* Phase-3 acceptance tests: 视角/perspective switcher (D2 — frontend view-presets only).
|
|
||||||
*
|
|
||||||
* Roles are NOT access control: the switcher only changes the default landing page +
|
|
||||||
* granularity/filter presets. This suite verifies the switcher renders, selecting a role
|
|
||||||
* navigates to that role's default landing URL (with its query params), and the choice
|
|
||||||
* survives a reload (persisted to localStorage['cbpoa_role']).
|
|
||||||
*
|
|
||||||
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically.
|
|
||||||
*/
|
|
||||||
import { test, expect, Page } from '@playwright/test';
|
|
||||||
import { TESTIDS } from '../src/utils/testids';
|
|
||||||
|
|
||||||
/** Seed auth token and mock all /api/** calls (shapes copied from user-flows.spec.ts). */
|
|
||||||
async function seedAuthAndMockApi(page: Page) {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route('/api/**', (route) => {
|
|
||||||
const url = route.request().url();
|
|
||||||
|
|
||||||
if (url.includes('/alerts')) {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ alerts: [], total: 0 }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/history/aggregated')) {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/grids')) {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/cases/demographics')) {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
age_distribution: [],
|
|
||||||
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
|
|
||||||
age_diagnosis_matrix: [],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
|
|
||||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
|
||||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ data: [], total: 0 }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('视角/perspective switcher (Phase 3)', () => {
|
|
||||||
// Use a desktop viewport so the top bar renders the switcher inline.
|
|
||||||
test.use({ viewport: { width: 1280, height: 800 } });
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await seedAuthAndMockApi(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('perspective-switcher is visible in the top bar', async ({ page }) => {
|
|
||||||
await page.goto('/monitoring');
|
|
||||||
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selecting 厅领导 (official) navigates to /overview?granularity=district', async ({ page }) => {
|
|
||||||
await page.goto('/monitoring');
|
|
||||||
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
|
|
||||||
await expect(switcher).toBeVisible();
|
|
||||||
|
|
||||||
await switcher.selectOption('official');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/overview/);
|
|
||||||
await expect(page).toHaveURL(/granularity=district/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selecting 医生 (doctor) navigates to /alerts?view=cluster', async ({ page }) => {
|
|
||||||
await page.goto('/monitoring');
|
|
||||||
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
|
|
||||||
await expect(switcher).toBeVisible();
|
|
||||||
|
|
||||||
await switcher.selectOption('doctor');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/alerts/);
|
|
||||||
await expect(page).toHaveURL(/view=cluster/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selected role persists across reload (localStorage cbpoa_role)', async ({ page }) => {
|
|
||||||
await page.goto('/monitoring');
|
|
||||||
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
|
|
||||||
await switcher.selectOption('doctor');
|
|
||||||
await expect(page).toHaveURL(/\/alerts/);
|
|
||||||
|
|
||||||
// localStorage should now hold the chosen role.
|
|
||||||
const stored = await page.evaluate(() => localStorage.getItem('cbpoa_role'));
|
|
||||||
expect(stored).toBe('doctor');
|
|
||||||
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// After reload the switcher reflects the persisted role.
|
|
||||||
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toHaveValue('doctor');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,11 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>武汉儿童呼吸道疾病风险预测平台</title>
|
<title>CBPOA · 武汉儿童呼吸疾病风险评估系统</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<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 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 rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
35
frontend/nginx.conf
Normal file
35
frontend/nginx.conf
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/geo+json;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/basemap-gaode/(\d+)/(\d+)/(\d+) {
|
||||||
|
proxy_pass https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=$1&x=$2&y=$3;
|
||||||
|
proxy_set_header Host webrd01.is.autonavi.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_hide_header Set-Cookie;
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@geoscene/core": "4.32.10",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"lucide-react": "^0.330.0",
|
"lucide-react": "^0.330.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-leaflet": "^4.2.1",
|
|
||||||
"react-router-dom": "^6.30.4",
|
"react-router-dom": "^6.30.4",
|
||||||
"recharts": "^2.12.0",
|
"recharts": "^2.12.0",
|
||||||
"zustand": "^4.5.0"
|
"zustand": "^4.5.0"
|
||||||
@@ -23,7 +22,6 @@
|
|||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^14.3.1",
|
"@testing-library/react": "^14.3.1",
|
||||||
"@types/leaflet": "^1.9.8",
|
|
||||||
"@types/react": "^18.2.55",
|
"@types/react": "^18.2.55",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^18.2.19",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
|||||||
489
frontend/pnpm-lock.yaml
generated
489
frontend/pnpm-lock.yaml
generated
@@ -8,12 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@geoscene/core':
|
||||||
|
specifier: 4.32.10
|
||||||
|
version: 4.32.10
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.6.7
|
specifier: ^1.6.7
|
||||||
version: 1.15.2
|
version: 1.15.2
|
||||||
leaflet:
|
|
||||||
specifier: ^1.9.4
|
|
||||||
version: 1.9.4
|
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.330.0
|
specifier: ^0.330.0
|
||||||
version: 0.330.0(react@18.3.1)
|
version: 0.330.0(react@18.3.1)
|
||||||
@@ -23,9 +23,6 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1(react@18.3.1)
|
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:
|
react-router-dom:
|
||||||
specifier: ^6.30.4
|
specifier: ^6.30.4
|
||||||
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -45,9 +42,6 @@ importers:
|
|||||||
'@testing-library/react':
|
'@testing-library/react':
|
||||||
specifier: ^14.3.1
|
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)
|
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':
|
'@types/react':
|
||||||
specifier: ^18.2.55
|
specifier: ^18.2.55
|
||||||
version: 18.3.28
|
version: 18.3.28
|
||||||
@@ -88,6 +82,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
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':
|
'@asamuzakjp/css-color@3.2.0':
|
||||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||||
|
|
||||||
@@ -344,6 +350,32 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@jest/schemas@29.6.3':
|
||||||
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
@@ -364,6 +396,12 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
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':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -376,17 +414,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@open-wc/dedupe-mixin@1.4.0':
|
||||||
|
resolution: {integrity: sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==}
|
||||||
|
|
||||||
'@playwright/test@1.59.1':
|
'@playwright/test@1.59.1':
|
||||||
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
|
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@react-leaflet/core@2.1.0':
|
'@polymer/polymer@3.5.2':
|
||||||
resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==}
|
resolution: {integrity: sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==}
|
||||||
peerDependencies:
|
|
||||||
leaflet: ^1.9.0
|
|
||||||
react: ^18.0.0
|
|
||||||
react-dom: ^18.0.0
|
|
||||||
|
|
||||||
'@remix-run/router@1.23.3':
|
'@remix-run/router@1.23.3':
|
||||||
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
|
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
|
||||||
@@ -596,12 +633,6 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
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':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
@@ -613,6 +644,55 @@ packages:
|
|||||||
'@types/react@18.3.28':
|
'@types/react@18.3.28':
|
||||||
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
|
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':
|
'@vitejs/plugin-react@4.7.0':
|
||||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
@@ -634,6 +714,13 @@ packages:
|
|||||||
'@vitest/utils@1.6.1':
|
'@vitest/utils@1.6.1':
|
||||||
resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==}
|
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:
|
acorn-walk@8.3.5:
|
||||||
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
|
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
@@ -764,17 +851,41 @@ packages:
|
|||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
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:
|
color-name@1.1.4:
|
||||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
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:
|
combined-stream@1.0.8:
|
||||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
commander@2.20.3:
|
||||||
|
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||||
|
|
||||||
commander@4.1.1:
|
commander@4.1.1:
|
||||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
composed-offset-position@0.0.6:
|
||||||
|
resolution: {integrity: sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@floating-ui/utils': ^0.2.5
|
||||||
|
|
||||||
confbox@0.1.8:
|
confbox@0.1.8:
|
||||||
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
|
||||||
|
|
||||||
@@ -793,6 +904,9 @@ packages:
|
|||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
cssfilter@0.0.10:
|
||||||
|
resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==}
|
||||||
|
|
||||||
cssstyle@4.6.0:
|
cssstyle@4.6.0:
|
||||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -932,6 +1046,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-toolkit@1.49.0:
|
||||||
|
resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==}
|
||||||
|
|
||||||
esbuild@0.21.5:
|
esbuild@0.21.5:
|
||||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -975,6 +1092,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
focus-trap@7.8.0:
|
||||||
|
resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==}
|
||||||
|
|
||||||
follow-redirects@1.16.0:
|
follow-redirects@1.16.0:
|
||||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@@ -1089,6 +1209,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
interactjs@1.10.27:
|
||||||
|
resolution: {integrity: sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==}
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1219,9 +1342,6 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
leaflet@1.9.4:
|
|
||||||
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
|
||||||
|
|
||||||
lilconfig@3.1.3:
|
lilconfig@3.1.3:
|
||||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1229,6 +1349,15 @@ packages:
|
|||||||
lines-and-columns@1.2.4:
|
lines-and-columns@1.2.4:
|
||||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
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:
|
local-pkg@0.5.1:
|
||||||
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
|
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1254,6 +1383,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0
|
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:
|
lz-string@1.5.0:
|
||||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1261,6 +1394,11 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
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:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1500,13 +1638,6 @@ packages:
|
|||||||
react-is@18.3.1:
|
react-is@18.3.1:
|
||||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
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:
|
react-refresh@0.17.0:
|
||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1648,6 +1779,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
sortablejs@1.15.7:
|
||||||
|
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1689,6 +1823,9 @@ packages:
|
|||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
|
tabbable@6.5.0:
|
||||||
|
resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==}
|
||||||
|
|
||||||
tailwindcss@3.4.19:
|
tailwindcss@3.4.19:
|
||||||
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -1701,6 +1838,10 @@ packages:
|
|||||||
thenify@3.3.1:
|
thenify@3.3.1:
|
||||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
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:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
@@ -1734,10 +1875,17 @@ packages:
|
|||||||
ts-interface-checker@0.1.13:
|
ts-interface-checker@0.1.13:
|
||||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
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:
|
type-detect@4.1.0:
|
||||||
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
|
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
type-fest@4.41.0:
|
||||||
|
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -1893,6 +2041,11 @@ packages:
|
|||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
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:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
@@ -1921,6 +2074,17 @@ snapshots:
|
|||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@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':
|
'@asamuzakjp/css-color@3.2.0':
|
||||||
dependencies:
|
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)
|
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||||
@@ -2132,6 +2296,56 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.21.5':
|
'@esbuild/win32-x64@0.21.5':
|
||||||
optional: true
|
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':
|
'@jest/schemas@29.6.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox': 0.27.10
|
'@sinclair/typebox': 0.27.10
|
||||||
@@ -2155,6 +2369,12 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
@@ -2167,15 +2387,15 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.20.1
|
fastq: 1.20.1
|
||||||
|
|
||||||
|
'@open-wc/dedupe-mixin@1.4.0': {}
|
||||||
|
|
||||||
'@playwright/test@1.59.1':
|
'@playwright/test@1.59.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.59.1
|
playwright: 1.59.1
|
||||||
|
|
||||||
'@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@polymer/polymer@3.5.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
leaflet: 1.9.4
|
'@webcomponents/shadycss': 1.11.2
|
||||||
react: 18.3.1
|
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
|
||||||
|
|
||||||
'@remix-run/router@1.23.3': {}
|
'@remix-run/router@1.23.3': {}
|
||||||
|
|
||||||
@@ -2337,12 +2557,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@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/prop-types@15.7.15': {}
|
||||||
|
|
||||||
'@types/react-dom@18.3.7(@types/react@18.3.28)':
|
'@types/react-dom@18.3.7(@types/react@18.3.28)':
|
||||||
@@ -2354,6 +2568,118 @@ snapshots:
|
|||||||
'@types/prop-types': 15.7.15
|
'@types/prop-types': 15.7.15
|
||||||
csstype: 3.2.3
|
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)':
|
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
@@ -2395,6 +2721,10 @@ snapshots:
|
|||||||
loupe: 2.3.7
|
loupe: 2.3.7
|
||||||
pretty-format: 29.7.0
|
pretty-format: 29.7.0
|
||||||
|
|
||||||
|
'@webcomponents/shadycss@1.11.2': {}
|
||||||
|
|
||||||
|
'@zip.js/zip.js@2.7.73': {}
|
||||||
|
|
||||||
acorn-walk@8.3.5:
|
acorn-walk@8.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
@@ -2532,14 +2862,35 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|
||||||
|
color-convert@3.1.3:
|
||||||
|
dependencies:
|
||||||
|
color-name: 2.1.0
|
||||||
|
|
||||||
color-name@1.1.4: {}
|
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:
|
combined-stream@1.0.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
delayed-stream: 1.0.0
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
|
commander@2.20.3: {}
|
||||||
|
|
||||||
commander@4.1.1: {}
|
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: {}
|
confbox@0.1.8: {}
|
||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
@@ -2554,6 +2905,8 @@ snapshots:
|
|||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
|
cssfilter@0.0.10: {}
|
||||||
|
|
||||||
cssstyle@4.6.0:
|
cssstyle@4.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@asamuzakjp/css-color': 3.2.0
|
'@asamuzakjp/css-color': 3.2.0
|
||||||
@@ -2703,6 +3056,8 @@ snapshots:
|
|||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
hasown: 2.0.3
|
hasown: 2.0.3
|
||||||
|
|
||||||
|
es-toolkit@1.49.0: {}
|
||||||
|
|
||||||
esbuild@0.21.5:
|
esbuild@0.21.5:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.21.5
|
'@esbuild/aix-ppc64': 0.21.5
|
||||||
@@ -2771,6 +3126,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
|
focus-trap@7.8.0:
|
||||||
|
dependencies:
|
||||||
|
tabbable: 6.5.0
|
||||||
|
|
||||||
follow-redirects@1.16.0: {}
|
follow-redirects@1.16.0: {}
|
||||||
|
|
||||||
for-each@0.3.5:
|
for-each@0.3.5:
|
||||||
@@ -2875,6 +3234,10 @@ snapshots:
|
|||||||
|
|
||||||
indent-string@4.0.0: {}
|
indent-string@4.0.0: {}
|
||||||
|
|
||||||
|
interactjs@1.10.27:
|
||||||
|
dependencies:
|
||||||
|
'@interactjs/types': 1.10.27
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -3010,12 +3373,26 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
leaflet@1.9.4: {}
|
|
||||||
|
|
||||||
lilconfig@3.1.3: {}
|
lilconfig@3.1.3: {}
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
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:
|
local-pkg@0.5.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mlly: 1.8.2
|
mlly: 1.8.2
|
||||||
@@ -3041,12 +3418,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
|
luxon@3.5.0: {}
|
||||||
|
|
||||||
lz-string@1.5.0: {}
|
lz-string@1.5.0: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@15.0.12: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
merge-stream@2.0.0: {}
|
merge-stream@2.0.0: {}
|
||||||
@@ -3246,13 +3627,6 @@ snapshots:
|
|||||||
|
|
||||||
react-is@18.3.1: {}
|
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-refresh@0.17.0: {}
|
||||||
|
|
||||||
react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
@@ -3449,6 +3823,8 @@ snapshots:
|
|||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
|
sortablejs@1.15.7: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
@@ -3488,6 +3864,8 @@ snapshots:
|
|||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
|
tabbable@6.5.0: {}
|
||||||
|
|
||||||
tailwindcss@3.4.19:
|
tailwindcss@3.4.19:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@alloc/quick-lru': 5.2.0
|
'@alloc/quick-lru': 5.2.0
|
||||||
@@ -3524,6 +3902,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
|
|
||||||
|
timezone-groups@0.10.4: {}
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
@@ -3554,8 +3934,12 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
type-detect@4.1.0: {}
|
type-detect@4.1.0: {}
|
||||||
|
|
||||||
|
type-fest@4.41.0: {}
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
ufo@1.6.4: {}
|
ufo@1.6.4: {}
|
||||||
@@ -3713,6 +4097,11 @@ snapshots:
|
|||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
|
xss@1.0.13:
|
||||||
|
dependencies:
|
||||||
|
commander: 2.20.3
|
||||||
|
cssfilter: 0.0.10
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yocto-queue@1.2.2: {}
|
yocto-queue@1.2.2: {}
|
||||||
|
|||||||
@@ -75,7 +75,12 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BrowserRouter>
|
<BrowserRouter
|
||||||
|
future={{
|
||||||
|
v7_startTransition: true,
|
||||||
|
v7_relativeSplatPath: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{token ? (
|
{token ? (
|
||||||
<AuthedApp onLogout={handleLogout} />
|
<AuthedApp onLogout={handleLogout} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ describe('Component exports', () => {
|
|||||||
expect((mod as any).default || mod.DiseaseFilter).toBeDefined();
|
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 () => {
|
it('TimelinePlayer 可以被导入', async () => {
|
||||||
const mod = await import('@/components/TimelinePlayer');
|
const mod = await import('@/components/TimelinePlayer');
|
||||||
expect((mod as any).default || mod.TimelinePlayer).toBeDefined();
|
expect((mod as any).default || mod.TimelinePlayer).toBeDefined();
|
||||||
@@ -45,11 +40,6 @@ describe('Component exports', () => {
|
|||||||
expect((mod as any).default || mod.StatisticalCharts).toBeDefined();
|
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 () => {
|
it('AlertMap 可以被导入', async () => {
|
||||||
const mod = await import('@/components/AlertMap');
|
const mod = await import('@/components/AlertMap');
|
||||||
expect((mod as any).default || mod.AlertMap).toBeDefined();
|
expect((mod as any).default || mod.AlertMap).toBeDefined();
|
||||||
@@ -60,9 +50,9 @@ describe('Component exports', () => {
|
|||||||
expect((mod as any).default || mod.CaseLocationMap).toBeDefined();
|
expect((mod as any).default || mod.CaseLocationMap).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('CaseMap 可以被导入', async () => {
|
it('geoscene createMapView 可以被导入', async () => {
|
||||||
const mod = await import('@/components/CaseMap');
|
const mod = await import('@/geoscene');
|
||||||
expect((mod as any).default || mod.CaseMap).toBeDefined();
|
expect(mod.createMapView).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('DistributionChart 可以被导入', async () => {
|
it('DistributionChart 可以被导入', async () => {
|
||||||
@@ -75,11 +65,6 @@ describe('Component exports', () => {
|
|||||||
expect((mod as any).default || mod.GridStatsOverlay).toBeDefined();
|
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 () => {
|
it('AdminBreadcrumb 可以被导入', async () => {
|
||||||
const mod = await import('@/components/AdminBreadcrumb');
|
const mod = await import('@/components/AdminBreadcrumb');
|
||||||
expect((mod as any).default || mod.AdminBreadcrumb).toBeDefined();
|
expect((mod as any).default || mod.AdminBreadcrumb).toBeDefined();
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useEffect, useRef, useState, useCallback, memo } from 'react';
|
import { useEffect, useRef, useState, useCallback, memo } from 'react';
|
||||||
import L from 'leaflet';
|
import type MapView from '@geoscene/core/views/MapView';
|
||||||
|
import type WebTileLayer from '@geoscene/core/layers/WebTileLayer';
|
||||||
|
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
|
||||||
|
import Graphic from '@geoscene/core/Graphic';
|
||||||
|
import Point from '@geoscene/core/geometry/Point';
|
||||||
|
import Polygon from '@geoscene/core/geometry/Polygon';
|
||||||
|
import SimpleFillSymbol from '@geoscene/core/symbols/SimpleFillSymbol';
|
||||||
|
import * as reactiveUtils from '@geoscene/core/core/reactiveUtils';
|
||||||
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
|
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
|
||||||
import { riskApi } from '@/services/api';
|
import { riskApi } from '@/services/api';
|
||||||
import type { RiskGridStats } from '@/services/api';
|
import type { RiskGridStats } from '@/services/api';
|
||||||
import type { Alert } from '@/types';
|
import type { Alert } from '@/types';
|
||||||
|
import { createMapView } from '@/geoscene/createMapView';
|
||||||
|
import { createRiskTileLayer, createGraphicsLayer, pointGraphic } from '@/geoscene/layers';
|
||||||
|
|
||||||
export interface CellInfo {
|
export interface CellInfo {
|
||||||
lat: number;
|
lat: number;
|
||||||
@@ -28,10 +37,8 @@ interface AlertMapProps {
|
|||||||
isFullscreen?: boolean;
|
isFullscreen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
|
||||||
const GRID_OPACITY = 0.72;
|
const GRID_OPACITY = 0.72;
|
||||||
|
|
||||||
// Mirrors the server-side colormap in backend/utils/risk_raster.py.
|
|
||||||
const RISK_LEGEND: [number, number, string][] = [
|
const RISK_LEGEND: [number, number, string][] = [
|
||||||
[0.25, 0.4, '#38b000'],
|
[0.25, 0.4, '#38b000'],
|
||||||
[0.4, 0.6, '#facc15'],
|
[0.4, 0.6, '#facc15'],
|
||||||
@@ -57,79 +64,83 @@ function AlertMapComponent({
|
|||||||
isFullscreen = false,
|
isFullscreen = false,
|
||||||
}: AlertMapProps) {
|
}: AlertMapProps) {
|
||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
const viewRef = useRef<MapView | null>(null);
|
||||||
const riskTileRef = useRef<L.TileLayer | null>(null);
|
const destroyRef = useRef<(() => void) | null>(null);
|
||||||
const alertLayerRef = useRef<L.LayerGroup | null>(null);
|
const riskLayerRef = useRef<WebTileLayer | null>(null);
|
||||||
const markerMapRef = useRef<Map<string, L.CircleMarker>>(new Map());
|
const alertLayerRef = useRef<GraphicsLayer | null>(null);
|
||||||
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
|
const selectLayerRef = useRef<GraphicsLayer | null>(null);
|
||||||
const clickHandlerRef = useRef(onGridClick);
|
const clickHandlerRef = useRef(onGridClick);
|
||||||
const cellInfoRef = useRef(onCellInfo);
|
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 inputsRef = useRef({ filteredAlerts, showAlertMarkers, forecastDay });
|
||||||
|
|
||||||
const [gridStats, setGridStats] = useState<RiskGridStats | null>(null);
|
const [gridStats, setGridStats] = useState<RiskGridStats | null>(null);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => { clickHandlerRef.current = onGridClick; }, [onGridClick]);
|
useEffect(() => {
|
||||||
useEffect(() => { cellInfoRef.current = onCellInfo; }, [onCellInfo]);
|
clickHandlerRef.current = onGridClick;
|
||||||
|
}, [onGridClick]);
|
||||||
|
useEffect(() => {
|
||||||
|
cellInfoRef.current = onCellInfo;
|
||||||
|
}, [onCellInfo]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputsRef.current = { filteredAlerts, showAlertMarkers, forecastDay };
|
inputsRef.current = { filteredAlerts, showAlertMarkers, forecastDay };
|
||||||
}, [filteredAlerts, showAlertMarkers, forecastDay]);
|
}, [filteredAlerts, showAlertMarkers, forecastDay]);
|
||||||
|
|
||||||
// --- Initialize map once ---
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current || mapInstanceRef.current) return;
|
if (!mapRef.current || viewRef.current) return;
|
||||||
|
|
||||||
const map = L.map(mapRef.current, {
|
const { map, view, destroy } = createMapView({
|
||||||
center: WUHAN_CENTER,
|
container: mapRef.current,
|
||||||
zoom: 9,
|
zoom: 10,
|
||||||
zoomControl: true,
|
|
||||||
preferCanvas: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
const riskTiles = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0);
|
||||||
maxZoom: 19,
|
const alertLayer = createGraphicsLayer('预警点');
|
||||||
}).addTo(map);
|
const selectLayer = createGraphicsLayer('选中');
|
||||||
|
|
||||||
// Full-Wuhan 100m risk grid as raster tiles. The browser only fetches PNGs
|
map.addMany([riskTiles, alertLayer, selectLayer]);
|
||||||
// (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;
|
|
||||||
|
|
||||||
const alertLayer = L.layerGroup().addTo(map);
|
riskLayerRef.current = riskTiles;
|
||||||
alertLayerRef.current = alertLayer;
|
alertLayerRef.current = alertLayer;
|
||||||
|
selectLayerRef.current = selectLayer;
|
||||||
|
viewRef.current = view;
|
||||||
|
destroyRef.current = destroy;
|
||||||
|
|
||||||
// Click-to-inspect: query the 100m cell under the cursor. Clicks on alert
|
try {
|
||||||
// markers are consumed by the marker handler and never reach this.
|
view.ui.move('zoom', 'bottom-left');
|
||||||
map.on('click', async (e: L.LeafletMouseEvent) => {
|
} catch {
|
||||||
const { lat, lng } = e.latlng;
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
|
||||||
|
|
||||||
|
const clickHandle = view.on('click', async (event) => {
|
||||||
|
if (!event.mapPoint) return;
|
||||||
|
const lat = event.mapPoint.latitude;
|
||||||
|
const lng = event.mapPoint.longitude;
|
||||||
|
if (lat == null || lng == null) return;
|
||||||
const { filteredAlerts: alerts, forecastDay: day } = inputsRef.current;
|
const { filteredAlerts: alerts, forecastDay: day } = inputsRef.current;
|
||||||
try {
|
try {
|
||||||
const cell = await riskApi.getCell(lat, lng, day);
|
const cell = await riskApi.getCell(lat, lng, day);
|
||||||
// Nearest alert (squared degree distance — cheap, no sqrt).
|
|
||||||
let nearestId: string | null = null;
|
let nearestId: string | null = null;
|
||||||
let minSq = Infinity;
|
let minSq = Infinity;
|
||||||
for (const a of alerts) {
|
for (const a of alerts) {
|
||||||
const dx = a.latitude - lat;
|
const dx = a.latitude - lat;
|
||||||
const dy = a.longitude - lng;
|
const dy = a.longitude - lng;
|
||||||
const d = dx * dx + dy * dy;
|
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);
|
const nearestDist = Math.sqrt(minSq);
|
||||||
if (nearestId && nearestDist < 0.01) {
|
if (nearestId && nearestDist < 0.01) {
|
||||||
clickHandlerRef.current(nearestId);
|
clickHandlerRef.current(nearestId);
|
||||||
} else if (cellInfoRef.current) {
|
} else if (cellInfoRef.current) {
|
||||||
cellInfoRef.current({
|
cellInfoRef.current({
|
||||||
lat, lon: lng,
|
lat,
|
||||||
|
lon: lng,
|
||||||
risk: cell.risk_value,
|
risk: cell.risk_value,
|
||||||
grid_id: cell.grid_id,
|
grid_id: cell.grid_id,
|
||||||
risk_1d: cell.risk_1d,
|
risk_1d: cell.risk_1d,
|
||||||
@@ -139,160 +150,199 @@ function AlertMapComponent({
|
|||||||
nearestAlertDist: nearestDist,
|
nearestAlertDist: nearestDist,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch { /* transient fetch error — ignore the click */ }
|
} catch {
|
||||||
|
/* ignore transient click errors */
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mapInstanceRef.current = map;
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
mapInstanceRef.current?.invalidateSize({ animate: false });
|
|
||||||
});
|
|
||||||
resizeObserver.observe(mapRef.current);
|
|
||||||
resizeObserverRef.current = resizeObserver;
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver.disconnect();
|
clickHandle.remove();
|
||||||
resizeObserverRef.current = null;
|
riskLayerRef.current = null;
|
||||||
markerMapRef.current.clear();
|
|
||||||
alertLayerRef.current = null;
|
alertLayerRef.current = null;
|
||||||
riskTileRef.current = null;
|
selectLayerRef.current = null;
|
||||||
map.remove();
|
viewRef.current = null;
|
||||||
mapInstanceRef.current = null;
|
destroy();
|
||||||
|
destroyRef.current = null;
|
||||||
|
setMapReady(false);
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- Update risk tiles + stats when the forecast horizon changes ---
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const layer = riskTileRef.current;
|
const map = viewRef.current?.map;
|
||||||
if (layer) layer.setUrl(riskApi.tileUrlTemplate(forecastDay));
|
if (!map || !mapReady) return;
|
||||||
|
|
||||||
|
if (riskLayerRef.current) {
|
||||||
|
map.remove(riskLayerRef.current);
|
||||||
|
riskLayerRef.current.destroy();
|
||||||
|
}
|
||||||
|
const next = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0);
|
||||||
|
map.add(next);
|
||||||
|
riskLayerRef.current = next;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setStatsLoading(true);
|
setStatsLoading(true);
|
||||||
riskApi.getGridStats(forecastDay)
|
riskApi
|
||||||
.then((s) => { if (!cancelled) setGridStats(s); })
|
.getGridStats(forecastDay)
|
||||||
.catch(() => { if (!cancelled) setGridStats(null); })
|
.then((s) => {
|
||||||
.finally(() => { if (!cancelled) setStatsLoading(false); });
|
if (!cancelled) setGridStats(s);
|
||||||
return () => { cancelled = true; };
|
})
|
||||||
}, [forecastDay]);
|
.catch(() => {
|
||||||
|
if (!cancelled) setGridStats(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setStatsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [forecastDay, mapReady]);
|
||||||
|
|
||||||
// --- Toggle grid visibility without rebuilding tiles ---
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
riskTileRef.current?.setOpacity(showGrid ? GRID_OPACITY : 0);
|
if (riskLayerRef.current) {
|
||||||
|
riskLayerRef.current.opacity = showGrid ? GRID_OPACITY : 0;
|
||||||
|
}
|
||||||
}, [showGrid]);
|
}, [showGrid]);
|
||||||
|
|
||||||
// --- Render alert markers via diffing against a persistent layer group ---
|
|
||||||
const renderAlertMarkers = useCallback(() => {
|
const renderAlertMarkers = useCallback(() => {
|
||||||
const map = mapInstanceRef.current;
|
|
||||||
const layer = alertLayerRef.current;
|
const layer = alertLayerRef.current;
|
||||||
if (!map || !layer) return;
|
const view = viewRef.current;
|
||||||
|
if (!layer || !view) return;
|
||||||
|
|
||||||
|
layer.removeAll();
|
||||||
|
|
||||||
const markerMap = markerMapRef.current;
|
|
||||||
const { filteredAlerts: alerts, showAlertMarkers: showMarkers } = inputsRef.current;
|
const { filteredAlerts: alerts, showAlertMarkers: showMarkers } = inputsRef.current;
|
||||||
|
if (!showMarkers || !alerts?.length) return;
|
||||||
|
|
||||||
if (!showMarkers || !alerts || alerts.length === 0) {
|
const extent = view.extent;
|
||||||
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 maxMarkers = 500;
|
||||||
const step = Math.max(1, Math.floor(alerts.length / maxMarkers));
|
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) {
|
for (let i = 0; i < alerts.length; i += step) {
|
||||||
const alert = alerts[i];
|
const alert = alerts[i];
|
||||||
if (!alert.latitude || !alert.longitude) continue;
|
if (!alert.latitude || !alert.longitude) continue;
|
||||||
if (alert.latitude < south || alert.latitude > north ||
|
if (extent) {
|
||||||
alert.longitude < west || alert.longitude > east) continue;
|
if (
|
||||||
const key = alert.grid_id || `${alert.latitude},${alert.longitude},${i}`;
|
alert.longitude < extent.xmin ||
|
||||||
desired.set(key, alert);
|
alert.longitude > extent.xmax ||
|
||||||
}
|
alert.latitude < extent.ymin ||
|
||||||
|
alert.latitude > extent.ymax
|
||||||
for (const [key, marker] of markerMap) {
|
) {
|
||||||
if (!desired.has(key)) { layer.removeLayer(marker); markerMap.delete(key); }
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for (const [key, alert] of desired) {
|
|
||||||
if (markerMap.has(key)) continue;
|
|
||||||
const isP1 = alert.priority === 'P1';
|
const isP1 = alert.priority === 'P1';
|
||||||
const marker = L.circleMarker([alert.latitude, alert.longitude], {
|
const g = pointGraphic(
|
||||||
radius: isP1 ? 6 : 4,
|
alert.longitude,
|
||||||
fillColor: isP1 ? '#ef4444' : '#f97316',
|
alert.latitude,
|
||||||
fillOpacity: 0.7,
|
isP1 ? '#ef4444' : '#f97316',
|
||||||
color: isP1 ? '#ef4444' : '#f97316',
|
isP1 ? 10 : 7,
|
||||||
weight: 2,
|
{
|
||||||
dashArray: isP1 ? undefined : '4 2',
|
grid_id: alert.grid_id,
|
||||||
});
|
priority: alert.priority,
|
||||||
marker.bindTooltip(
|
risk_value: alert.risk_value,
|
||||||
`<div style="font-size:12px;">
|
region: alert.region,
|
||||||
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
|
street: alert.street,
|
||||||
${alert.region || ''} ${alert.street || ''}
|
}
|
||||||
</div>`,
|
|
||||||
{ direction: 'top', offset: [0, -5] }
|
|
||||||
);
|
);
|
||||||
const gridId = alert.grid_id;
|
g.popupTemplate = {
|
||||||
marker.on('click', () => { if (gridId) clickHandlerRef.current(gridId); });
|
title: '{priority}',
|
||||||
marker.addTo(layer);
|
content: '{region} {street}<br/>风险 {(risk_value * 100).toFixed(0)}%',
|
||||||
markerMap.set(key, marker);
|
};
|
||||||
|
graphics.push(g);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
layer.addMany(graphics);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!mapReady) return;
|
||||||
renderAlertMarkers();
|
renderAlertMarkers();
|
||||||
}, [filteredAlerts, showAlertMarkers, renderAlertMarkers]);
|
}, [filteredAlerts, showAlertMarkers, mapReady, renderAlertMarkers]);
|
||||||
|
|
||||||
// Re-render markers on pan/zoom, throttled, subscribed once per map instance.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapInstanceRef.current;
|
const view = viewRef.current;
|
||||||
if (!map) return;
|
if (!view || !mapReady) return;
|
||||||
let throttle: ReturnType<typeof setTimeout> | null = null;
|
let throttle: ReturnType<typeof setTimeout> | null = null;
|
||||||
const handleMove = () => {
|
const handle = reactiveUtils.watch(
|
||||||
if (throttle) return;
|
() => view.extent,
|
||||||
throttle = setTimeout(() => { throttle = null; renderAlertMarkers(); }, 150);
|
() => {
|
||||||
};
|
if (throttle) return;
|
||||||
map.on('moveend', handleMove);
|
throttle = setTimeout(() => {
|
||||||
|
throttle = null;
|
||||||
|
renderAlertMarkers();
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
map.off('moveend', handleMove);
|
handle.remove();
|
||||||
if (throttle) clearTimeout(throttle);
|
if (throttle) clearTimeout(throttle);
|
||||||
};
|
};
|
||||||
}, [renderAlertMarkers]);
|
}, [mapReady, renderAlertMarkers]);
|
||||||
|
|
||||||
// --- Selected alert highlight (located from the alert list, no grid scan) ---
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapInstanceRef.current;
|
const layer = selectLayerRef.current;
|
||||||
if (!map) return;
|
const view = viewRef.current;
|
||||||
if (selectedMarkerRef.current) {
|
if (!layer || !view) return;
|
||||||
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
|
layer.removeAll();
|
||||||
selectedMarkerRef.current = null;
|
|
||||||
}
|
|
||||||
if (!selectedGridId) return;
|
if (!selectedGridId) return;
|
||||||
const alert = filteredAlerts.find((a) => a.grid_id === selectedGridId);
|
const alert = filteredAlerts.find((a) => a.grid_id === selectedGridId);
|
||||||
if (!alert) return;
|
if (!alert) return;
|
||||||
const latHalf = 0.00045, lonHalf = 0.00052;
|
|
||||||
const rect = L.rectangle(
|
const latHalf = 0.00045;
|
||||||
[[alert.latitude - latHalf, alert.longitude - lonHalf],
|
const lonHalf = 0.00052;
|
||||||
[alert.latitude + latHalf, alert.longitude + lonHalf]],
|
const ring = [
|
||||||
{ fillColor: '#3b82f6', fillOpacity: 0.3, color: '#3b82f6', weight: 3 }
|
[alert.longitude - lonHalf, alert.latitude - latHalf],
|
||||||
).addTo(map);
|
[alert.longitude + lonHalf, alert.latitude - latHalf],
|
||||||
selectedMarkerRef.current = rect;
|
[alert.longitude + lonHalf, alert.latitude + latHalf],
|
||||||
map.flyTo([alert.latitude, alert.longitude], Math.max(map.getZoom(), 13), { duration: 0.5 });
|
[alert.longitude - lonHalf, alert.latitude + latHalf],
|
||||||
|
[alert.longitude - lonHalf, alert.latitude - latHalf],
|
||||||
|
];
|
||||||
|
layer.add(
|
||||||
|
new Graphic({
|
||||||
|
geometry: new Polygon({ rings: [ring], spatialReference: { wkid: 4326 } }),
|
||||||
|
symbol: new SimpleFillSymbol({
|
||||||
|
color: [59, 130, 246, 0.3],
|
||||||
|
outline: { color: [59, 130, 246], width: 2 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
view.goTo(
|
||||||
|
{
|
||||||
|
center: new Point({ longitude: alert.longitude, latitude: alert.latitude }),
|
||||||
|
zoom: Math.max(view.zoom, 13),
|
||||||
|
},
|
||||||
|
{ duration: 500 }
|
||||||
|
).catch(() => undefined);
|
||||||
}, [selectedGridId, filteredAlerts]);
|
}, [selectedGridId, filteredAlerts]);
|
||||||
|
|
||||||
// --- Invalidate size after fullscreen toggle (CSS transition ~200ms) ---
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapInstanceRef.current;
|
// MapView observes container size; force a layout tick after fullscreen CSS settles.
|
||||||
if (!map) return;
|
const timer = setTimeout(() => {
|
||||||
map.invalidateSize({ animate: false });
|
const el = mapRef.current;
|
||||||
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 200);
|
if (el) {
|
||||||
|
el.style.height = el.style.height;
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [isFullscreen]);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
const containerHeight = isFullscreen ? 'calc(100vh - 120px)' : 'calc(100vh - 280px)';
|
// 工作台零内边距后,给地图更多垂直空间(非小卡片)
|
||||||
|
const containerHeight = isFullscreen ? 'calc(100vh - 100px)' : 'calc(100vh - 220px)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<GridStatsOverlay
|
<GridStatsOverlay
|
||||||
count={gridStats?.cell_count ?? 0}
|
count={gridStats?.cell_count ?? 0}
|
||||||
@@ -305,14 +355,16 @@ 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="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="text-[11px] font-semibold text-text-secondary mb-2">风险等级 (100m 网格)</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{RISK_LEGEND.slice().reverse().map(([min, max, color]) => (
|
{RISK_LEGEND.slice()
|
||||||
<div key={color} className="flex items-center gap-2">
|
.reverse()
|
||||||
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
|
.map(([min, max, color]) => (
|
||||||
<span className="text-[11px] text-text-secondary">
|
<div key={color} className="flex items-center gap-2">
|
||||||
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
|
||||||
</span>
|
<span className="text-[11px] text-text-secondary">
|
||||||
</div>
|
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
|
||||||
))}
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-4 rounded border border-border-light bg-transparent" />
|
<div className="w-4 h-4 rounded border border-border-light bg-transparent" />
|
||||||
<span className="text-[11px] text-text-muted"><25% 不显示</span>
|
<span className="text-[11px] text-text-muted"><25% 不显示</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { TopNav } from '@/components/TopNav';
|
import { TopNav } from '@/components/TopNav';
|
||||||
import { SideNav } from '@/components/SideNav';
|
import { SideNav } from '@/components/SideNav';
|
||||||
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
|
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
|
||||||
@@ -10,7 +10,6 @@ interface AppShellProps {
|
|||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。
|
|
||||||
function getFocusable(container: HTMLElement): HTMLElement[] {
|
function getFocusable(container: HTMLElement): HTMLElement[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
container.querySelectorAll<HTMLElement>(
|
container.querySelectorAll<HTMLElement>(
|
||||||
@@ -19,28 +18,29 @@ function getFocusable(container: HTMLElement): HTMLElement[] {
|
|||||||
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
).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) {
|
export function AppShell({ onLogout }: AppShellProps) {
|
||||||
const alerts = useRiskStore((s) => s.alerts);
|
const alerts = useRiskStore((s) => s.alerts);
|
||||||
|
const location = useLocation();
|
||||||
|
const mapWorkbench = isMapWorkbench(location.pathname);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
// 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。
|
|
||||||
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
|
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
|
||||||
const drawerRef = useRef<HTMLElement>(null);
|
const drawerRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||||
|
|
||||||
// 抽屉作为模态:ESC 关闭、锁定 body 滚动、焦点移入并在关闭后归还给汉堡。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!drawerOpen) return;
|
if (!drawerOpen) return;
|
||||||
|
|
||||||
const opener = document.activeElement as HTMLElement | null;
|
const opener = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
// 锁定 body 滚动,关闭时还原原值。
|
|
||||||
const prevOverflow = document.body.style.overflow;
|
const prevOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
// 焦点移入抽屉(优先第一个可聚焦元素,否则聚焦抽屉容器本身)。
|
|
||||||
const drawer = drawerRef.current;
|
const drawer = drawerRef.current;
|
||||||
const focusables = drawer ? getFocusable(drawer) : [];
|
const focusables = drawer ? getFocusable(drawer) : [];
|
||||||
(focusables[0] ?? drawer)?.focus();
|
(focusables[0] ?? drawer)?.focus();
|
||||||
@@ -51,7 +51,6 @@ export function AppShell({ onLogout }: AppShellProps) {
|
|||||||
closeDrawer();
|
closeDrawer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 焦点循环陷阱:Tab 在抽屉内首尾元素之间循环。
|
|
||||||
if (e.key === 'Tab' && drawer) {
|
if (e.key === 'Tab' && drawer) {
|
||||||
const items = getFocusable(drawer);
|
const items = getFocusable(drawer);
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -77,7 +76,6 @@ export function AppShell({ onLogout }: AppShellProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', onKeyDown);
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
document.body.style.overflow = prevOverflow;
|
document.body.style.overflow = prevOverflow;
|
||||||
// 关闭后把焦点还给打开抽屉的元素(汉堡按钮),回退到按 testid 查询。
|
|
||||||
const restoreTarget =
|
const restoreTarget =
|
||||||
opener ??
|
opener ??
|
||||||
document.querySelector<HTMLElement>(`[data-testid="${TESTIDS.hamburger}"]`);
|
document.querySelector<HTMLElement>(`[data-testid="${TESTIDS.hamburger}"]`);
|
||||||
@@ -86,15 +84,22 @@ export function AppShell({ onLogout }: AppShellProps) {
|
|||||||
}, [drawerOpen, closeDrawer]);
|
}, [drawerOpen, closeDrawer]);
|
||||||
|
|
||||||
return (
|
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} />
|
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} isMenuOpen={drawerOpen} />
|
||||||
|
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
{/* lg 及以上:持久侧栏导轨 */}
|
|
||||||
<aside
|
<aside
|
||||||
data-testid={TESTIDS.sidebarRail}
|
data-testid={TESTIDS.sidebarRail}
|
||||||
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
|
className="hidden lg:flex lg:flex-col w-[212px] shrink-0 bg-bg-card/95 border-r border-border backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
|
<div className="px-4 pt-4 pb-2">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
||||||
|
工作台
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<SideNav
|
<SideNav
|
||||||
alertCount={alerts.length}
|
alertCount={alerts.length}
|
||||||
expanded={expandedNav}
|
expanded={expandedNav}
|
||||||
@@ -102,10 +107,9 @@ export function AppShell({ onLogout }: AppShellProps) {
|
|||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* lg 以下:离屏抽屉 + 遮罩 */}
|
|
||||||
{drawerOpen && (
|
{drawerOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
|
className="fixed inset-0 z-40 bg-slate-900/35 lg:hidden"
|
||||||
onClick={closeDrawer}
|
onClick={closeDrawer}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -118,10 +122,14 @@ export function AppShell({ onLogout }: AppShellProps) {
|
|||||||
aria-label="导航菜单"
|
aria-label="导航菜单"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
data-testid={TESTIDS.appDrawer}
|
data-testid={TESTIDS.appDrawer}
|
||||||
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 ${
|
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 ${
|
||||||
drawerOpen ? 'translate-x-0' : '-translate-x-full'
|
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
|
<SideNav
|
||||||
alertCount={alerts.length}
|
alertCount={alerts.length}
|
||||||
onNavigate={closeDrawer}
|
onNavigate={closeDrawer}
|
||||||
@@ -130,7 +138,13 @@ export function AppShell({ onLogout }: AppShellProps) {
|
|||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex-1 min-w-0 overflow-auto p-5">
|
<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'
|
||||||
|
}
|
||||||
|
>
|
||||||
<RouteErrorBoundary>
|
<RouteErrorBoundary>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</RouteErrorBoundary>
|
</RouteErrorBoundary>
|
||||||
|
|||||||
@@ -9,30 +9,30 @@
|
|||||||
|
|
||||||
## Component Types
|
## Component Types
|
||||||
|
|
||||||
**Map components** (`*Map.tsx`) — Leaflet-based maps:
|
**Map components** (`*Map.tsx`, `DistrictChoropleth`) — `@geoscene/core`:
|
||||||
- Use `react-leaflet` / direct Leaflet manipulation via `useRef`
|
- Use `createMapView` / layer factories from `@/geoscene`
|
||||||
- Risk coloring: centralized `RISK_COLORS` and `RISK_LABELS` constants
|
- Coordinate system: `[longitude, latitude]` (GeoScene convention)
|
||||||
- Coordinate system: `[lat, lng]` (Leaflet convention, NOT `[lng, lat]`)
|
- Destroy MapView on unmount
|
||||||
|
|
||||||
**Chart components** (`*Chart*.tsx`) — Recharts:
|
**Chart components** (`*Chart*.tsx`) — Recharts:
|
||||||
- Responsive containers with `width="100%" height={...}`
|
- Responsive containers with `width="100%" height={...}`
|
||||||
|
|
||||||
**Navigation** (`TopNav.tsx`, `SideNav.tsx`):
|
**Navigation** (`TopNav.tsx`, `SideNav.tsx`):
|
||||||
- No data fetching — pure navigation/presentation
|
- No data fetching — pure navigation/presentation
|
||||||
|
- No role/perspective switcher
|
||||||
|
|
||||||
**Overlay/Utility** (`GridStatsOverlay`, `ErrorBanner`, `StatCard`, `TimelinePlayer`):
|
**Overlay/Utility** (`GridStatsOverlay`, `ErrorBanner`, `StatCard`, `TimelinePlayer`):
|
||||||
- Small, focused, reusable across pages
|
- Small, focused, reusable across pages
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
- Components receive data via props, never fetch directly
|
- Components receive data via props, never fetch directly (maps may call tile/cell APIs they own)
|
||||||
- Callbacks passed as props: `onGridSelect`, `onClosePanel`, `onForecastChange`
|
- Callbacks passed as props: `onGridSelect`, `onClosePanel`, `onForecastChange`
|
||||||
- Complex stateful behavior extracted to custom hooks (e.g., `useTimelineStore`)
|
|
||||||
|
|
||||||
## Anti-Patterns
|
## Anti-Patterns
|
||||||
|
|
||||||
- Don't fetch data in components — receive via props or store hooks
|
- Don't fetch page-level data in leaf components — receive via props or store hooks
|
||||||
- Don't create god components (>200 lines) — extract sub-components
|
- Don't create god components (>200 lines) — extract sub-components
|
||||||
- Don't use `any` in prop types — use `unknown` and narrow
|
- Don't use `any` in prop types — use `unknown` and narrow
|
||||||
- Don't pass Leaflet map instances between components — each map manages its own instance
|
- Don't pass MapView instances between components
|
||||||
- Don't use CSS modules or inline styles — Tailwind only
|
- Don't use CSS modules or inline styles — Tailwind only (map symbol colors excepted)
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { useEffect, useRef, useState, memo } from 'react';
|
import { useEffect, useRef, useState, memo } from 'react';
|
||||||
import L from 'leaflet';
|
import type MapView from '@geoscene/core/views/MapView';
|
||||||
|
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
|
||||||
|
import Graphic from '@geoscene/core/Graphic';
|
||||||
import { geocodedApi } from '@/services/api';
|
import { geocodedApi } from '@/services/api';
|
||||||
import { TESTIDS } from '@/utils/testids';
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import type { GeocodedCase } from '@/types';
|
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';
|
type CaseMapMode = 'points' | 'density';
|
||||||
|
|
||||||
interface CaseLocationMapProps {
|
interface CaseLocationMapProps {
|
||||||
@@ -21,7 +18,6 @@ interface CaseLocationMapProps {
|
|||||||
mode?: CaseMapMode;
|
mode?: CaseMapMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聚合中心:按 street(无则 district)分组,取经纬度均值 + 计数。
|
|
||||||
interface DensityCluster {
|
interface DensityCluster {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -58,59 +54,64 @@ function CaseLocationMapComponent({
|
|||||||
mode = 'points',
|
mode = 'points',
|
||||||
}: CaseLocationMapProps) {
|
}: CaseLocationMapProps) {
|
||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
const viewRef = useRef<MapView | null>(null);
|
||||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
const layerRef = useRef<GraphicsLayer | null>(null);
|
||||||
const cancelledRef = useRef(false);
|
const cancelledRef = useRef(false);
|
||||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
const fittedScopeRef = useRef<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [caseCount, setCaseCount] = useState(0);
|
const [caseCount, setCaseCount] = useState(0);
|
||||||
// points 模式下的隐藏 DOM 镜像(每病例一项,供 e2e 对 patient-point 计数)。
|
|
||||||
// density 模式下保持为空数组 —— 隐私不变量:医生视角下 patient-point 必须为 0。
|
|
||||||
const [pointKeys, setPointKeys] = useState<string[]>([]);
|
const [pointKeys, setPointKeys] = useState<string[]>([]);
|
||||||
const [clusterCount, setClusterCount] = useState(0);
|
const [clusterCount, setClusterCount] = useState(0);
|
||||||
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current || mapInstanceRef.current) return;
|
if (!mapRef.current || viewRef.current) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
cancelledRef.current = false;
|
cancelledRef.current = false;
|
||||||
|
const { map, view, destroy } = createMapView({
|
||||||
const map = L.map(mapRef.current, {
|
container: mapRef.current,
|
||||||
center: WUHAN_CENTER,
|
|
||||||
zoom: 11,
|
zoom: 11,
|
||||||
zoomControl: true,
|
|
||||||
});
|
});
|
||||||
|
const layer = createGraphicsLayer('病例');
|
||||||
|
map.add(layer);
|
||||||
|
layerRef.current = layer;
|
||||||
|
viewRef.current = view;
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
// 缩放控件移到左下,避开右上观测日与图例
|
||||||
attribution: '© OpenStreetMap',
|
try {
|
||||||
maxZoom: 18,
|
view.ui.move('zoom', 'bottom-left');
|
||||||
}).addTo(map);
|
} catch {
|
||||||
|
/* zoom widget may be absent */
|
||||||
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;
|
|
||||||
|
|
||||||
// Fetch case locations
|
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
|
||||||
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
|
|
||||||
|
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 })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelledRef.current) return;
|
if (cancelledRef.current) return;
|
||||||
const cases: GeocodedCase[] = data.cases || [];
|
const cases: GeocodedCase[] = data.cases || [];
|
||||||
const layer = layerRef.current;
|
layer.removeAll();
|
||||||
if (!layer) return;
|
|
||||||
|
|
||||||
layer.clearLayers();
|
|
||||||
|
|
||||||
// Deduplicate by case_id to avoid overlapping markers
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
let unique: GeocodedCase[] = [];
|
let unique: GeocodedCase[] = [];
|
||||||
for (const c of cases) {
|
for (const c of cases) {
|
||||||
@@ -119,83 +120,64 @@ function CaseLocationMapComponent({
|
|||||||
unique.push(c);
|
unique.push(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side street filtering
|
|
||||||
if (street) {
|
if (street) {
|
||||||
unique = unique.filter((c) => c.street === street);
|
unique = unique.filter((c) => c.street === street);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelledRef.current) return;
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
if (mode === 'density') {
|
if (mode === 'density') {
|
||||||
// 医生视角:仅渲染聚合密度圆(按街道/区聚合),不渲染任何个体点。
|
|
||||||
const clusters = aggregateClusters(unique);
|
const clusters = aggregateClusters(unique);
|
||||||
const maxCount = clusters.reduce((m, c) => Math.max(m, c.count), 1);
|
const maxCount = clusters.reduce((m, c) => Math.max(m, c.count), 1);
|
||||||
|
const graphics = clusters.map((cl) => {
|
||||||
for (const cl of clusters) {
|
const size = 10 + Math.round((cl.count / maxCount) * 22);
|
||||||
// 半径随计数缩放(8–28px),明确表达「密度」而非个体位置。
|
const g = pointGraphic(cl.longitude, cl.latitude, '#7c3aed', size, {
|
||||||
const radius = 8 + Math.round((cl.count / maxCount) * 20);
|
label: cl.label,
|
||||||
const marker = L.circleMarker([cl.latitude, cl.longitude], {
|
count: cl.count,
|
||||||
radius,
|
|
||||||
fillColor: '#7c3aed',
|
|
||||||
fillOpacity: 0.35,
|
|
||||||
color: '#7c3aed',
|
|
||||||
weight: 1.5,
|
|
||||||
});
|
});
|
||||||
marker.bindTooltip(
|
g.popupTemplate = {
|
||||||
`<div style="font-size:12px"><strong>${cl.label}</strong><br/>病例数: ${cl.count}</div>`,
|
title: '{label}',
|
||||||
{ direction: 'top', offset: [0, -4] }
|
content: '病例数: {count}',
|
||||||
);
|
};
|
||||||
marker.addTo(layer);
|
return g;
|
||||||
}
|
});
|
||||||
|
layer.addMany(graphics);
|
||||||
setClusterCount(clusters.length);
|
setClusterCount(clusters.length);
|
||||||
setCaseCount(unique.length);
|
setCaseCount(unique.length);
|
||||||
setPointKeys([]); // 隐私不变量:density 下无个体点镜像
|
setPointKeys([]);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
if (shouldFit && graphics.length > 0) {
|
||||||
if (clusters.length > 0) {
|
fittedScopeRef.current = scopeKey;
|
||||||
const bounds = L.latLngBounds(clusters.map((c) => [c.latitude, c.longitude]));
|
view.goTo(graphics).catch(() => undefined);
|
||||||
map.fitBounds(bounds, { padding: [30, 30] });
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// points 模式(默认):逐病例渲染个体 circleMarker。
|
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
|
const graphics: Graphic[] = [];
|
||||||
for (const c of unique) {
|
for (const c of unique) {
|
||||||
if (!c.latitude || !c.longitude) continue;
|
if (!c.latitude || !c.longitude) continue;
|
||||||
|
|
||||||
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
|
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
|
||||||
const marker = L.circleMarker([c.latitude, c.longitude], {
|
const [lon, lat] = jitterLonLat(c.longitude, c.latitude, c.case_id, 70);
|
||||||
radius: 3,
|
const g = pointGraphic(lon, lat, color, 6, {
|
||||||
fillColor: color,
|
district: c.district,
|
||||||
fillOpacity: 0.6,
|
street: c.street,
|
||||||
color: color,
|
case_type: c.case_type,
|
||||||
weight: 1,
|
|
||||||
});
|
});
|
||||||
|
g.popupTemplate = {
|
||||||
marker.bindTooltip(
|
title: '{district} {street}',
|
||||||
`<div style="font-size:12px">
|
content: '类型: {case_type}',
|
||||||
<strong>${c.district}</strong> ${c.street}<br/>
|
};
|
||||||
类型: ${c.case_type === 'inpatient' ? '住院' : '门诊'}
|
graphics.push(g);
|
||||||
</div>`,
|
|
||||||
{ direction: 'top', offset: [0, -4] }
|
|
||||||
);
|
|
||||||
|
|
||||||
marker.addTo(layer);
|
|
||||||
keys.push(c.case_id);
|
keys.push(c.case_id);
|
||||||
}
|
}
|
||||||
|
layer.addMany(graphics);
|
||||||
setClusterCount(0);
|
setClusterCount(0);
|
||||||
setCaseCount(unique.length);
|
setCaseCount(unique.length);
|
||||||
setPointKeys(keys); // 隐藏 DOM 镜像供 e2e 计数
|
setPointKeys(keys);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
if (shouldFit && graphics.length > 0) {
|
||||||
// Fit bounds to case locations
|
fittedScopeRef.current = scopeKey;
|
||||||
if (unique.length > 0) {
|
view.goTo(graphics).catch(() => undefined);
|
||||||
const bounds = L.latLngBounds(unique.map((c) => [c.latitude, c.longitude]));
|
|
||||||
map.fitBounds(bounds, { padding: [30, 30] });
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -204,43 +186,69 @@ function CaseLocationMapComponent({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelledRef.current = true;
|
cancelledRef.current = true;
|
||||||
if (resizeObserverRef.current) {
|
|
||||||
resizeObserverRef.current.disconnect();
|
|
||||||
resizeObserverRef.current = null;
|
|
||||||
}
|
|
||||||
map.remove();
|
|
||||||
mapInstanceRef.current = null;
|
|
||||||
};
|
};
|
||||||
}, [district, street, date, mode]);
|
}, [district, street, date, mode, mapReady]);
|
||||||
|
|
||||||
const isDensity = mode === 'density';
|
const isDensity = mode === 'density';
|
||||||
|
|
||||||
|
const fillParent = height === '100%' || height === '100vh';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" data-case-map-mode={mode}>
|
<div
|
||||||
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
|
className={`relative ${fillParent ? 'h-full min-h-0' : ''}`}
|
||||||
{isLoading && (
|
data-case-map-mode={mode}
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
|
>
|
||||||
<div className="text-sm text-gray-500">加载病例位置...</div>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && !isDensity && (
|
{mapReady && isLoading && (
|
||||||
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
<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">
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && isDensity && (
|
{!isLoading && mapReady && (
|
||||||
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
|
<div
|
||||||
<span className="text-purple-600 font-semibold">{clusterCount.toLocaleString()}</span> 个聚合区域
|
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]"
|
||||||
<span className="ml-2 text-gray-500">按区域聚合密度(隐私保护)</span>
|
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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/*
|
|
||||||
隐藏 DOM 镜像:points 模式下每病例输出一个 patient-point 节点,使 e2e 能对
|
|
||||||
Leaflet canvas 之外的真实 DOM 做计数断言。density 模式下 pointKeys 恒为空,
|
|
||||||
因此医生视角下 [data-testid=patient-point] 数量必为 0(隐私不变量)。
|
|
||||||
*/}
|
|
||||||
<div className="hidden" aria-hidden="true">
|
<div className="hidden" aria-hidden="true">
|
||||||
{pointKeys.map((id) => (
|
{pointKeys.map((id) => (
|
||||||
<span key={id} data-testid={TESTIDS.patientPoint} data-case-id={id} />
|
<span key={id} data-testid={TESTIDS.patientPoint} data-case-id={id} />
|
||||||
|
|||||||
@@ -1,377 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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,10 +4,7 @@ import { TESTIDS } from '@/utils/testids';
|
|||||||
|
|
||||||
interface SideNavProps {
|
interface SideNavProps {
|
||||||
alertCount?: number;
|
alertCount?: number;
|
||||||
// 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。
|
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
// 受控的展开手风琴分组:由 AppShell 提供时,导轨与抽屉两份实例保持同步。
|
|
||||||
// 不传则回退到内部 state,向后兼容独立使用。
|
|
||||||
expanded?: string | null;
|
expanded?: string | null;
|
||||||
onExpandedChange?: (moduleId: string | null) => void;
|
onExpandedChange?: (moduleId: string | null) => void;
|
||||||
}
|
}
|
||||||
@@ -69,11 +66,9 @@ export function SideNav({
|
|||||||
}: SideNavProps) {
|
}: SideNavProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// 当前路径命中的模块默认展开。
|
|
||||||
const moduleForPath = (pathname: string) =>
|
const moduleForPath = (pathname: string) =>
|
||||||
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
|
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
|
||||||
|
|
||||||
// 受控/非受控双模式:父级传入 expanded 时由父级管理,否则回退内部 state。
|
|
||||||
const [internalExpanded, setInternalExpanded] = useState<string | null>(() =>
|
const [internalExpanded, setInternalExpanded] = useState<string | null>(() =>
|
||||||
moduleForPath(location.pathname)
|
moduleForPath(location.pathname)
|
||||||
);
|
);
|
||||||
@@ -91,28 +86,41 @@ export function SideNav({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="h-full overflow-y-auto py-4 px-2">
|
<nav className="h-full overflow-y-auto py-2 px-2.5">
|
||||||
{modules.map((module) => (
|
{modules.map((module) => (
|
||||||
<div key={module.id} className="mb-4">
|
<div key={module.id} className="mb-1.5">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setExpanded(expanded === module.id ? null : module.id)}
|
onClick={() => setExpanded(expanded === module.id ? null : module.id)}
|
||||||
className={`w-full flex items-center gap-[10px] px-3 py-[9px] rounded-md text-[14px] font-semibold transition-colors ${
|
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-semibold transition-colors ${
|
||||||
isActiveModule(module.id)
|
isActiveModule(module.id)
|
||||||
? 'bg-primary-muted text-primary'
|
? 'bg-primary-muted text-primary'
|
||||||
: 'text-text-primary hover:bg-bg-hover'
|
: 'text-text-primary hover:bg-bg-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="w-4 h-4 flex items-center justify-center">{module.icon}</span>
|
<span className="w-4 h-4 flex items-center justify-center opacity-90">{module.icon}</span>
|
||||||
<span>{module.label}</span>
|
<span className="flex-1 text-left">{module.label}</span>
|
||||||
{module.id === 'alert' && alertCount > 0 && (
|
{module.id === 'alert' && alertCount > 0 && (
|
||||||
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
|
<span className="bg-danger-light text-danger text-[10px] font-semibold px-1.5 py-0.5 rounded-md tabular-nums">
|
||||||
{alertCount > 99 ? '99+' : alertCount}
|
{alertCount > 99 ? '99+' : alertCount}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
|
|
||||||
{expanded === module.id && (
|
{expanded === module.id && (
|
||||||
<div className="mt-1 pl-7">
|
<div className="mt-0.5 ml-3 pl-3 border-l border-border-light">
|
||||||
{module.items.map((item) => (
|
{module.items.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
@@ -120,7 +128,7 @@ export function SideNav({
|
|||||||
data-testid={item.testid}
|
data-testid={item.testid}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`block w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
|
`block w-full text-left px-2.5 py-1.5 rounded-md text-[12.5px] font-medium transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-bg-active text-primary'
|
? 'bg-bg-active text-primary'
|
||||||
: 'text-text-secondary hover:bg-bg-hover hover:text-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) {
|
}: StatCardProps) {
|
||||||
const trendIndicator = trend ? (
|
const trendIndicator = trend ? (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
|
className={`inline-flex items-center gap-0.5 text-[11px] font-medium ${
|
||||||
trend.direction === 'up'
|
trend.direction === 'up'
|
||||||
? 'text-green-600'
|
? 'text-danger'
|
||||||
: trend.direction === 'down'
|
: trend.direction === 'down'
|
||||||
? 'text-red-600'
|
? 'text-success'
|
||||||
: 'text-gray-500'
|
: 'text-text-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{trend.direction === 'up' && <span aria-hidden>▲</span>}
|
{trend.direction === 'up' && <span aria-hidden>▲</span>}
|
||||||
@@ -42,32 +42,28 @@ export const StatCard = React.memo(function StatCard({
|
|||||||
</span>
|
</span>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const sparklineSvg = sparkline && sparkline.data.length >= 2 ? (
|
const sparklineSvg =
|
||||||
<svg
|
sparkline && sparkline.data.length >= 2 ? (
|
||||||
width="60"
|
<svg width="64" height="26" className="shrink-0" aria-hidden="true">
|
||||||
height="24"
|
<polyline
|
||||||
className="shrink-0"
|
fill="none"
|
||||||
aria-hidden="true"
|
stroke={sparkline.color}
|
||||||
>
|
strokeWidth="1.75"
|
||||||
<polyline
|
strokeLinecap="round"
|
||||||
fill="none"
|
strokeLinejoin="round"
|
||||||
stroke={sparkline.color}
|
points={sparkline.data
|
||||||
strokeWidth="1.5"
|
.map((val, i) => {
|
||||||
strokeLinecap="round"
|
const x = (i / (sparkline.data.length - 1)) * 62 + 1;
|
||||||
strokeLinejoin="round"
|
const max = Math.max(...sparkline.data);
|
||||||
points={sparkline.data
|
const min = Math.min(...sparkline.data);
|
||||||
.map((val, i) => {
|
const range = max - min || 1;
|
||||||
const x = (i / (sparkline.data.length - 1)) * 58 + 1;
|
const y = 24 - ((val - min) / range) * 20 - 1;
|
||||||
const max = Math.max(...sparkline.data);
|
return `${x},${y}`;
|
||||||
const min = Math.min(...sparkline.data);
|
})
|
||||||
const range = max - min || 1;
|
.join(' ')}
|
||||||
const y = 22 - ((val - min) / range) * 20 - 1;
|
/>
|
||||||
return `${x},${y}`;
|
</svg>
|
||||||
})
|
) : null;
|
||||||
.join(' ')}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -84,24 +80,22 @@ export const StatCard = React.memo(function StatCard({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={`bg-white rounded-lg border border-gray-200 p-4 ${
|
className={`stat-card ${onClick ? 'cursor-pointer' : ''}`}
|
||||||
onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
|
<div className="flex items-center gap-2 text-[12px] text-text-secondary mb-1.5 pl-1">
|
||||||
{icon}
|
{icon}
|
||||||
<span>{label}</span>
|
<span className="font-medium tracking-wide">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end justify-between gap-2">
|
<div className="flex items-end justify-between gap-2 pl-1">
|
||||||
<div
|
<div
|
||||||
className="text-2xl font-bold text-gray-900"
|
className="data-num text-[22px] leading-none"
|
||||||
style={color ? { color } : undefined}
|
style={color ? { color } : undefined}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
{sparklineSvg}
|
{sparklineSvg}
|
||||||
</div>
|
</div>
|
||||||
{trendIndicator && <div className="mt-1">{trendIndicator}</div>}
|
{trendIndicator && <div className="mt-1.5 pl-1">{trendIndicator}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ export function TimelinePlayer({
|
|||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const advanceRef = useRef<() => void>(() => {});
|
const advanceRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
// Keep internal play state in sync when the parent/store changes isPlaying.
|
useEffect(() => {
|
||||||
useEffect(() => { setPlaying(isPlaying); }, [isPlaying]);
|
setPlaying(isPlaying);
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
const generateDateRange = useCallback((start: string, end: string) => {
|
const generateDateRange = useCallback((start: string, end: string) => {
|
||||||
const dates: string[] = [];
|
const dates: string[] = [];
|
||||||
@@ -44,15 +45,22 @@ export function TimelinePlayer({
|
|||||||
return dates;
|
return dates;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
|
const dateRange = useMemo(
|
||||||
// Compute index arithmetically from the day difference instead of indexOf.
|
() => generateDateRange(startDate, endDate),
|
||||||
|
[startDate, endDate, generateDateRange]
|
||||||
|
);
|
||||||
|
|
||||||
const currentIndex = useMemo(() => {
|
const currentIndex = useMemo(() => {
|
||||||
if (dateRange.length === 0) return -1;
|
if (dateRange.length === 0) return -1;
|
||||||
const ms = new Date(currentDate).getTime() - new Date(startDate).getTime();
|
const ms = new Date(currentDate).getTime() - new Date(startDate).getTime();
|
||||||
const idx = Math.round(ms / 86400000);
|
const idx = Math.round(ms / 86400000);
|
||||||
return idx >= 0 && idx < dateRange.length ? idx : dateRange.indexOf(currentDate);
|
return idx >= 0 && idx < dateRange.length ? idx : dateRange.indexOf(currentDate);
|
||||||
}, [startDate, currentDate, dateRange]);
|
}, [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(() => {
|
const play = useCallback(() => {
|
||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
@@ -65,11 +73,8 @@ export function TimelinePlayer({
|
|||||||
}, [onPlayPause]);
|
}, [onPlayPause]);
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
if (playing) {
|
if (playing) pause();
|
||||||
pause();
|
else play();
|
||||||
} else {
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToNext = useCallback(() => {
|
const goToNext = useCallback(() => {
|
||||||
@@ -81,33 +86,24 @@ export function TimelinePlayer({
|
|||||||
onDateChange(dateRange[0]);
|
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(() => {
|
useEffect(() => {
|
||||||
advanceRef.current = goToNext;
|
advanceRef.current = goToNext;
|
||||||
}, [goToNext]);
|
}, [goToNext]);
|
||||||
|
|
||||||
// Interval is created once per play/speed change (not per tick).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playing) {
|
if (playing) {
|
||||||
const interval = 1000 / speed;
|
const interval = 1000 / speed;
|
||||||
|
|
||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
advanceRef.current();
|
advanceRef.current();
|
||||||
}, interval);
|
}, interval);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
clearInterval(timerRef.current);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [playing, speed]);
|
}, [playing, speed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentIndex >= dateRange.length - 1) {
|
if (currentIndex >= dateRange.length - 1) pause();
|
||||||
pause();
|
|
||||||
}
|
|
||||||
}, [currentIndex, dateRange.length, pause]);
|
}, [currentIndex, dateRange.length, pause]);
|
||||||
|
|
||||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -116,24 +112,15 @@ export function TimelinePlayer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSpeedChange = () => {
|
const handleSpeedChange = () => {
|
||||||
const currentIndex = SPEEDS.indexOf(speed);
|
const idx = SPEEDS.indexOf(speed);
|
||||||
const nextIndex = (currentIndex + 1) % SPEEDS.length;
|
const nextIndex = (idx + 1) % SPEEDS.length;
|
||||||
onSpeedChange?.(SPEEDS[nextIndex]);
|
onSpeedChange?.(SPEEDS[nextIndex]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatSpeed = (s: number) => {
|
const formatSpeed = (s: number) => (s >= 1 ? `${s}x` : `${s.toFixed(1)}x`);
|
||||||
return s >= 1 ? `${s}x` : `${s.toFixed(1)}x`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
const date = new Date(dateStr);
|
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', {
|
return date.toLocaleDateString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@@ -141,72 +128,82 @@ export function TimelinePlayer({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fmtShort = (d: string) =>
|
||||||
|
new Date(d).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-[9999] w-64">
|
<div className="timeline-dock" role="region" aria-label="时间轴播放器">
|
||||||
<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">
|
<div className="timeline-dock__inner">
|
||||||
{/* Date display */}
|
<div className="flex items-center gap-3 sm:gap-4">
|
||||||
<div className="text-center mb-3">
|
{/* 播放控件 */}
|
||||||
<div className="font-medium text-gray-900 text-sm">{formatDate(currentDate)}</div>
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<div className="text-xs text-gray-400 mt-0.5">
|
<button
|
||||||
第 {currentIndex + 1} / {dateRange.length} 天
|
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>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vertical slider */}
|
{/* 进度轨 */}
|
||||||
<div className="flex justify-center mb-3">
|
<div className="flex-1 min-w-0">
|
||||||
<input
|
<div className="flex items-baseline justify-between gap-2 mb-1.5">
|
||||||
type="range"
|
<div className="font-mono text-[13px] font-semibold tabular-nums text-text-primary">
|
||||||
min="0"
|
{formatDate(currentDate)}
|
||||||
max="100"
|
</div>
|
||||||
value={progress}
|
<div className="text-[11px] text-text-muted tabular-nums font-mono">
|
||||||
onChange={handleSliderChange}
|
{currentIndex + 1} / {dateRange.length}
|
||||||
className="h-1.5 w-full bg-gray-200 rounded-full appearance-none cursor-pointer accent-blue-600"
|
</div>
|
||||||
style={{
|
</div>
|
||||||
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${progress}%, #e5e7eb ${progress}%, #e5e7eb 100%)`,
|
<div className="relative">
|
||||||
}}
|
<div className="timeline-dock__track">
|
||||||
/>
|
<div className="timeline-dock__fill" style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-[10px] text-gray-400 mb-3">
|
<input
|
||||||
<span>{new Date(startDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
|
type="range"
|
||||||
<span>{new Date(endDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
|
min="0"
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
{/* 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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleSpeedChange}
|
onClick={handleSpeedChange}
|
||||||
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"
|
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"
|
||||||
title="调整播放速度"
|
title="调整播放速度"
|
||||||
|
aria-label={`播放速度 ${formatSpeed(speed)}`}
|
||||||
>
|
>
|
||||||
{formatSpeed(speed)}
|
{formatSpeed(speed)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { TESTIDS } from '@/utils/testids';
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import { useSessionStore, ROLES, type Role } from '@/stores/sessionStore';
|
|
||||||
import { ROLE_LABELS, roleDefaultPath } from '@/utils/roleViews';
|
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
// 移动端汉堡按钮:切换侧栏抽屉。
|
|
||||||
onToggleMenu?: () => void;
|
onToggleMenu?: () => void;
|
||||||
// 抽屉是否展开(用于汉堡按钮的 aria-expanded)。
|
|
||||||
isMenuOpen?: boolean;
|
isMenuOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,44 +13,16 @@ function Clock() {
|
|||||||
const id = setInterval(() => setTime(new Date()), 1000);
|
const id = setInterval(() => setTime(new Date()), 1000);
|
||||||
return () => clearInterval(id);
|
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 (
|
return (
|
||||||
<label className="flex items-center gap-1.5 text-[13px] text-text-secondary">
|
<span className="font-mono tabular-nums">
|
||||||
<span className="text-text-muted hidden sm:inline">视角</span>
|
{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||||
<select
|
</span>
|
||||||
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) {
|
export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
|
||||||
return (
|
return (
|
||||||
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
|
<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">
|
||||||
{onToggleMenu && (
|
{onToggleMenu && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -64,7 +31,7 @@ export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavPro
|
|||||||
aria-expanded={isMenuOpen}
|
aria-expanded={isMenuOpen}
|
||||||
aria-controls="app-drawer"
|
aria-controls="app-drawer"
|
||||||
data-testid={TESTIDS.hamburger}
|
data-testid={TESTIDS.hamburger}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
@@ -72,37 +39,38 @@ export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavPro
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
|
<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
|
||||||
|
>
|
||||||
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
||||||
<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" />
|
<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" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-display font-semibold text-[15px] text-text-primary">
|
<div className="min-w-0 leading-tight">
|
||||||
WuhanChildRisk
|
<span className="brand-mark text-[16px] block truncate">CBPOA</span>
|
||||||
</span>
|
<span className="text-[11px] text-text-muted hidden sm:block truncate">
|
||||||
|
武汉儿童呼吸疾病风险评估
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-px h-5 bg-border ml-4 mr-4 hidden sm:block" />
|
<div className="w-px h-6 bg-border ml-4 mr-4 hidden md:block" />
|
||||||
|
|
||||||
<span className="text-[13px] text-text-secondary hidden sm:inline">
|
<span className="text-[12px] text-text-secondary hidden md:inline truncate">
|
||||||
儿童呼吸道疾病风险监测预警平台
|
监测 · 预警 · 空间风险
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-5">
|
<div className="ml-auto flex items-center gap-4">
|
||||||
<span className="text-[12px] text-text-muted hidden sm:inline">
|
<span className="text-[12px] text-text-muted hidden sm:inline">
|
||||||
<Clock />
|
<Clock />
|
||||||
</span>
|
</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 && (
|
{onLogout && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="text-[12px] text-text-muted hover:text-danger transition-colors"
|
className="text-[12px] text-text-muted hover:text-danger transition-colors px-2 py-1 rounded-md hover:bg-danger-light/60"
|
||||||
>
|
>
|
||||||
退出
|
退出
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TESTIDS } from '@/utils/testids';
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import { DiseaseFilter } from '@/components/DiseaseFilter';
|
|
||||||
import { HORIZON_LABELS } from './types';
|
import { HORIZON_LABELS } from './types';
|
||||||
|
|
||||||
interface AlertsFilterBarProps {
|
interface AlertsFilterBarProps {
|
||||||
@@ -18,12 +17,8 @@ interface AlertsFilterBarProps {
|
|||||||
onToggleGrid: () => void;
|
onToggleGrid: () => void;
|
||||||
sortBy: 'risk' | 'time';
|
sortBy: 'risk' | 'time';
|
||||||
onSortByChange: (sortBy: 'risk' | 'time') => void;
|
onSortByChange: (sortBy: 'risk' | 'time') => void;
|
||||||
// 视角驱动的两条不变量(结果由 orchestrator 计算后下传):
|
|
||||||
isCluster: boolean; // 聚类(医生)视角:隐藏「预警标记」切换 + 挂载病种过滤
|
|
||||||
isOfficial: boolean; // 官员视角:隐藏网格切换
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toolbar Row 2: Filters (时效/优先级/风险值/图层切换/排序).
|
|
||||||
export const AlertsFilterBar = React.memo(function AlertsFilterBar({
|
export const AlertsFilterBar = React.memo(function AlertsFilterBar({
|
||||||
selectedHorizon,
|
selectedHorizon,
|
||||||
onHorizonChange,
|
onHorizonChange,
|
||||||
@@ -39,8 +34,6 @@ export const AlertsFilterBar = React.memo(function AlertsFilterBar({
|
|||||||
onToggleGrid,
|
onToggleGrid,
|
||||||
sortBy,
|
sortBy,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
isCluster,
|
|
||||||
isOfficial,
|
|
||||||
}: AlertsFilterBarProps) {
|
}: AlertsFilterBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="card p-3 mb-4">
|
<div className="card p-3 mb-4">
|
||||||
@@ -78,8 +71,8 @@ export const AlertsFilterBar = React.memo(function AlertsFilterBar({
|
|||||||
? priority === 'P1'
|
? priority === 'P1'
|
||||||
? 'bg-danger text-white'
|
? 'bg-danger text-white'
|
||||||
: priority === 'P2'
|
: priority === 'P2'
|
||||||
? 'bg-warning text-white'
|
? 'bg-warning text-white'
|
||||||
: 'bg-primary text-white'
|
: 'bg-primary text-white'
|
||||||
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -129,36 +122,28 @@ export const AlertsFilterBar = React.memo(function AlertsFilterBar({
|
|||||||
>
|
>
|
||||||
地图
|
地图
|
||||||
</button>
|
</button>
|
||||||
{/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */}
|
<button
|
||||||
{!isCluster && (
|
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}>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleAlertMarkers}
|
onClick={onToggleGrid}
|
||||||
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
|
||||||
showAlertMarkers
|
showGrid
|
||||||
? 'bg-primary/10 text-primary border border-primary/30'
|
? 'bg-primary/10 text-primary border border-primary/30'
|
||||||
: 'bg-bg-page text-text-muted border border-border'
|
: 'bg-bg-page text-text-muted border border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
预警标记
|
风险层
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border" />
|
<div className="w-px h-6 bg-border" />
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ interface AlertsHeaderProps {
|
|||||||
onTabChange: (tab: 'list' | 'stats') => void;
|
onTabChange: (tab: 'list' | 'stats') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页头(标题 + 计数)+ 页内 tab 切换条(不走 router)。
|
|
||||||
export const AlertsHeader = React.memo(function AlertsHeader({
|
export const AlertsHeader = React.memo(function AlertsHeader({
|
||||||
total,
|
total,
|
||||||
p1,
|
p1,
|
||||||
@@ -18,34 +17,52 @@ export const AlertsHeader = React.memo(function AlertsHeader({
|
|||||||
}: AlertsHeaderProps) {
|
}: AlertsHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Header */}
|
<div className="flex items-end justify-between mb-4 flex-wrap gap-x-4 gap-y-3">
|
||||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="font-display text-[18px] font-semibold mb-1">风险预警</h1>
|
<h1 className="font-display text-[20px] font-semibold text-text-primary mb-0.5">
|
||||||
|
风险预警
|
||||||
|
</h1>
|
||||||
<p className="text-[12px] text-text-muted truncate">
|
<p className="text-[12px] text-text-muted truncate">
|
||||||
100m网格风险预测 · 多时间尺度预警 · 病例-气象关联分析
|
100m 网格风险预测 · 多时间尺度预警 · 病例–气象关联
|
||||||
</p>
|
</p>
|
||||||
</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>
|
{/* 指挥台式计数,非 badge 堆叠 */}
|
||||||
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {p1}</span>
|
<div
|
||||||
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {p2}</span>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab strip — in-page, no router */}
|
<div className="tab-strip mb-4 border-b border-border" role="tablist" aria-label="预警视图">
|
||||||
<div className="flex gap-1 mb-4 border-b border-border">
|
{(
|
||||||
{([
|
[
|
||||||
{ key: 'list', label: '预警列表' },
|
{ key: 'list' as const, label: '预警列表' },
|
||||||
{ key: 'stats', label: '风险统计' },
|
{ key: 'stats' as const, label: '风险统计' },
|
||||||
] as const).map((tab) => (
|
] as const
|
||||||
|
).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
onClick={() => onTabChange(tab.key)}
|
onClick={() => onTabChange(tab.key)}
|
||||||
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
|
className={`tab-strip__item ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key ? 'tab-strip__item--active' : ''
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-transparent text-text-secondary hover:text-text-primary'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|||||||
@@ -7,39 +7,51 @@ interface RiskDistributionSummaryProps {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预警列表 tab 顶部的风险分布概要(4 卡)。
|
/** 预警列表顶部风险分布 — 连续 risk-strip,非四块同质小卡。 */
|
||||||
export const RiskDistributionSummary = React.memo(function RiskDistributionSummary({
|
export const RiskDistributionSummary = React.memo(function RiskDistributionSummary({
|
||||||
riskStats,
|
riskStats,
|
||||||
total,
|
total,
|
||||||
}: RiskDistributionSummaryProps) {
|
}: RiskDistributionSummaryProps) {
|
||||||
|
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
<div className="risk-strip mb-4" role="group" aria-label="风险分布">
|
||||||
<div className="card p-3">
|
<div className="risk-strip__cell">
|
||||||
<div className="text-[11px] text-text-muted mb-1">高风险 (≥0.8)</div>
|
<div className="risk-strip__label">高风险 (≥0.8)</div>
|
||||||
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
|
<div className="risk-strip__value text-danger">{riskStats.high}</div>
|
||||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
|
||||||
<div className="h-full bg-danger rounded-full" style={{ width: `${total > 0 ? (riskStats.high / total) * 100 : 0}%` }} />
|
<div className="h-full bg-danger rounded-full" style={{ width: `${pct(riskStats.high)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-3">
|
<div className="risk-strip__cell">
|
||||||
<div className="text-[11px] text-text-muted mb-1">中高风险 (0.6-0.8)</div>
|
<div className="risk-strip__label">中高风险 (0.6–0.8)</div>
|
||||||
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
|
<div className="risk-strip__value text-warning">{riskStats.mediumHigh}</div>
|
||||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
|
||||||
<div className="h-full bg-warning rounded-full" style={{ width: `${total > 0 ? (riskStats.mediumHigh / total) * 100 : 0}%` }} />
|
<div
|
||||||
|
className="h-full bg-warning rounded-full"
|
||||||
|
style={{ width: `${pct(riskStats.mediumHigh)}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-3">
|
<div className="risk-strip__cell">
|
||||||
<div className="text-[11px] text-text-muted mb-1">中风险 (0.4-0.6)</div>
|
<div className="risk-strip__label">中风险 (0.4–0.6)</div>
|
||||||
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
|
<div className="risk-strip__value text-primary">{riskStats.medium}</div>
|
||||||
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
|
||||||
<div className="h-full bg-primary rounded-full" style={{ width: `${total > 0 ? (riskStats.medium / total) * 100 : 0}%` }} />
|
<div
|
||||||
|
className="h-full bg-primary rounded-full"
|
||||||
|
style={{ width: `${pct(riskStats.medium)}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card p-3">
|
<div className="risk-strip__cell">
|
||||||
<div className="text-[11px] text-text-muted mb-1">平均风险</div>
|
<div className="risk-strip__label">平均风险</div>
|
||||||
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
|
<div className="risk-strip__value">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
|
||||||
<div className="mt-1.5 text-[10px] text-text-muted">
|
<div className="mt-2 text-[10px] text-text-muted truncate">
|
||||||
高风险区域: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
|
高发区{' '}
|
||||||
|
{riskStats.topDistricts
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(([d, n]) => `${d}(${n})`)
|
||||||
|
.join(' · ') || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,47 +75,54 @@ const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, on
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`card overflow-hidden transition-colors cursor-pointer ${
|
className={`relative overflow-hidden rounded-xl border transition-all cursor-pointer ${
|
||||||
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
|
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'
|
||||||
}`}
|
}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
|
<div
|
||||||
<div className="flex items-center justify-between">
|
className={`absolute left-0 top-0 bottom-0 w-1 ${isP1 ? 'bg-danger' : 'bg-warning'}`}
|
||||||
<div className="flex items-center gap-2">
|
aria-hidden
|
||||||
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
|
/>
|
||||||
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
<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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{alert.priority}
|
{alert.priority}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-text-muted">
|
<span className="text-[10px] text-text-muted truncate">
|
||||||
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
|
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
<span className={`data-num text-[18px] ${isP1 ? 'text-danger' : 'text-warning'}`}>
|
||||||
{riskPercent}%
|
{riskPercent}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="mb-2">
|
||||||
<div className="mb-3">
|
<div className="text-[13px] font-semibold text-text-primary truncate">
|
||||||
<div className="text-[13px] font-semibold mb-1">
|
{alert.region} · {alert.street}
|
||||||
{alert.region} - {alert.street}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-text-muted">
|
|
||||||
网格:{alert.grid_id}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-[11px] text-text-muted font-mono">{alert.grid_id}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
|
<div
|
||||||
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{alert.reason}
|
{alert.reason}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-[11px] text-text-muted">
|
<div className="flex items-center justify-between text-[10px] text-text-muted gap-2">
|
||||||
<span>预测时间:{alert.forecast_time}</span>
|
<span className="truncate">预测 {alert.forecast_time}</span>
|
||||||
<span>生成:{alert.timestamp}</span>
|
<span className="shrink-0">生成 {alert.timestamp}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +137,7 @@ interface AlertsListProps {
|
|||||||
|
|
||||||
export const AlertsList = React.memo(function AlertsList({ filteredAlerts, selectedAlert, onCardClick }: AlertsListProps) {
|
export const AlertsList = React.memo(function AlertsList({ filteredAlerts, selectedAlert, onCardClick }: AlertsListProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
|
<div className="space-y-2 max-h-none overflow-visible">
|
||||||
{filteredAlerts.slice(0, 50).map((alert) => (
|
{filteredAlerts.slice(0, 50).map((alert) => (
|
||||||
<AlertCard
|
<AlertCard
|
||||||
key={alert.alert_id}
|
key={alert.alert_id}
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import { AlertsList, RiskDistributionSummary } from './AlertsList';
|
|||||||
import type { ExtendedAlert, RiskStats } from './types';
|
import type { ExtendedAlert, RiskStats } from './types';
|
||||||
|
|
||||||
interface AlertsListTabProps {
|
interface AlertsListTabProps {
|
||||||
// toolbar
|
|
||||||
forecastDay: 1 | 3 | 7;
|
forecastDay: 1 | 3 | 7;
|
||||||
onForecastDayChange: (day: 1 | 3 | 7) => void;
|
onForecastDayChange: (day: 1 | 3 | 7) => void;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
onExportCsv: () => void;
|
onExportCsv: () => void;
|
||||||
onExportJson: () => void;
|
onExportJson: () => void;
|
||||||
// filter bar
|
|
||||||
selectedHorizon: number | 'all';
|
selectedHorizon: number | 'all';
|
||||||
onHorizonChange: (horizon: number | 'all') => void;
|
onHorizonChange: (horizon: number | 'all') => void;
|
||||||
selectedPriority: 'all' | 'P1' | 'P2';
|
selectedPriority: 'all' | 'P1' | 'P2';
|
||||||
@@ -30,7 +28,6 @@ interface AlertsListTabProps {
|
|||||||
onToggleGrid: () => void;
|
onToggleGrid: () => void;
|
||||||
sortBy: 'risk' | 'time';
|
sortBy: 'risk' | 'time';
|
||||||
onSortByChange: (sortBy: 'risk' | 'time') => void;
|
onSortByChange: (sortBy: 'risk' | 'time') => void;
|
||||||
// data
|
|
||||||
riskStats: RiskStats;
|
riskStats: RiskStats;
|
||||||
filteredAlerts: ExtendedAlert[];
|
filteredAlerts: ExtendedAlert[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -39,21 +36,10 @@ interface AlertsListTabProps {
|
|||||||
onGridClick: (gridId: string) => void;
|
onGridClick: (gridId: string) => void;
|
||||||
onCellInfo: (info: CellInfo) => void;
|
onCellInfo: (info: CellInfo) => void;
|
||||||
onCardClick: (id: string) => 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) {
|
export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsListTabProps) {
|
||||||
const {
|
const { filteredAlerts, isLoading, isFullscreen, showMap, riskStats } = props;
|
||||||
filteredAlerts,
|
|
||||||
isLoading,
|
|
||||||
isCluster,
|
|
||||||
isFullscreen,
|
|
||||||
showMap,
|
|
||||||
riskStats,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -81,46 +67,64 @@ export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsList
|
|||||||
onToggleGrid={props.onToggleGrid}
|
onToggleGrid={props.onToggleGrid}
|
||||||
sortBy={props.sortBy}
|
sortBy={props.sortBy}
|
||||||
onSortByChange={props.onSortByChange}
|
onSortByChange={props.onSortByChange}
|
||||||
isCluster={isCluster}
|
|
||||||
isOfficial={props.isOfficial}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RiskDistributionSummary riskStats={riskStats} total={filteredAlerts.length} />
|
<RiskDistributionSummary riskStats={riskStats} total={filteredAlerts.length} />
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="card p-8">
|
<div className="workbench-panel p-8">
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
) : filteredAlerts.length === 0 && !isCluster ? (
|
) : filteredAlerts.length === 0 && !showMap ? (
|
||||||
// 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
|
<div className="workbench-panel p-8 text-center">
|
||||||
<div className="card p-8 text-center">
|
<svg
|
||||||
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
|
className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<div className="text-text-muted text-[13px]">暂无符合条件的预警</div>
|
<div className="text-text-muted text-[13px]">暂无符合条件的预警</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
|
<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]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{showMap && (
|
{showMap && (
|
||||||
<AlertsMapPanel
|
<div className="map-stage min-h-[420px] relative">
|
||||||
selectedGridId={props.selectedGridId}
|
<div className="map-chrome">
|
||||||
onGridClick={props.onGridClick}
|
<div className="map-chrome__chip">
|
||||||
onCellInfo={props.onCellInfo}
|
<span className="h-1.5 w-1.5 rounded-full bg-danger animate-pulse" aria-hidden />
|
||||||
forecastDay={props.forecastDay}
|
<span className="text-[12px] font-semibold text-text-primary">风险网格地图</span>
|
||||||
effectiveShowAlertMarkers={props.effectiveShowAlertMarkers}
|
</div>
|
||||||
showGrid={props.showGrid}
|
</div>
|
||||||
filteredAlerts={filteredAlerts}
|
<div className="absolute inset-0">
|
||||||
isFullscreen={isFullscreen}
|
<AlertsMapPanel
|
||||||
isCluster={isCluster}
|
selectedGridId={props.selectedGridId}
|
||||||
isOfficial={props.isOfficial}
|
onGridClick={props.onGridClick}
|
||||||
/>
|
onCellInfo={props.onCellInfo}
|
||||||
|
forecastDay={props.forecastDay}
|
||||||
|
showAlertMarkers={props.showAlertMarkers}
|
||||||
|
showGrid={props.showGrid}
|
||||||
|
filteredAlerts={filteredAlerts}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isFullscreen && (
|
{!isFullscreen && (
|
||||||
<AlertsList
|
<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">
|
||||||
filteredAlerts={filteredAlerts}
|
<div className="glass-wing__section !border-b-0 flex-1">
|
||||||
selectedAlert={props.selectedAlert}
|
<h3 className="glass-wing__title">预警列表</h3>
|
||||||
onCardClick={props.onCardClick}
|
<AlertsList
|
||||||
/>
|
filteredAlerts={filteredAlerts}
|
||||||
|
selectedAlert={props.selectedAlert}
|
||||||
|
onCardClick={props.onCardClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TESTIDS } from '@/utils/testids';
|
|
||||||
import { AlertMap } from '@/components/AlertMap';
|
import { AlertMap } from '@/components/AlertMap';
|
||||||
import type { CellInfo } from '@/components/AlertMap';
|
import type { CellInfo } from '@/components/AlertMap';
|
||||||
import type { ExtendedAlert } from './types';
|
import type { ExtendedAlert } from './types';
|
||||||
@@ -9,13 +8,10 @@ interface AlertsMapPanelProps {
|
|||||||
onGridClick: (gridId: string) => void;
|
onGridClick: (gridId: string) => void;
|
||||||
onCellInfo: (info: CellInfo) => void;
|
onCellInfo: (info: CellInfo) => void;
|
||||||
forecastDay: 1 | 3 | 7;
|
forecastDay: 1 | 3 | 7;
|
||||||
// effectiveShowAlertMarkers:唯一真值,cluster 模式恒为 false(隐私不变量),由 orchestrator 计算。
|
showAlertMarkers: boolean;
|
||||||
effectiveShowAlertMarkers: boolean;
|
|
||||||
showGrid: boolean;
|
showGrid: boolean;
|
||||||
filteredAlerts: ExtendedAlert[];
|
filteredAlerts: ExtendedAlert[];
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
isCluster: boolean;
|
|
||||||
isOfficial: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AlertsMapPanel = React.memo(function AlertsMapPanel({
|
export const AlertsMapPanel = React.memo(function AlertsMapPanel({
|
||||||
@@ -23,42 +19,21 @@ export const AlertsMapPanel = React.memo(function AlertsMapPanel({
|
|||||||
onGridClick,
|
onGridClick,
|
||||||
onCellInfo,
|
onCellInfo,
|
||||||
forecastDay,
|
forecastDay,
|
||||||
effectiveShowAlertMarkers,
|
showAlertMarkers,
|
||||||
showGrid,
|
showGrid,
|
||||||
filteredAlerts,
|
filteredAlerts,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
isCluster,
|
|
||||||
isOfficial,
|
|
||||||
}: AlertsMapPanelProps) {
|
}: AlertsMapPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div data-testid={isCluster ? TESTIDS.clusterView : undefined}>
|
<AlertMap
|
||||||
<AlertMap
|
selectedGridId={selectedGridId}
|
||||||
selectedGridId={selectedGridId}
|
onGridClick={onGridClick}
|
||||||
onGridClick={onGridClick}
|
onCellInfo={onCellInfo}
|
||||||
onCellInfo={onCellInfo}
|
forecastDay={forecastDay}
|
||||||
forecastDay={forecastDay}
|
showAlertMarkers={showAlertMarkers}
|
||||||
showAlertMarkers={effectiveShowAlertMarkers}
|
showGrid={showGrid}
|
||||||
showGrid={isOfficial ? false : showGrid}
|
filteredAlerts={filteredAlerts}
|
||||||
filteredAlerts={filteredAlerts}
|
isFullscreen={isFullscreen}
|
||||||
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,6 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { LoadingState } from '@/components/ui';
|
import { LoadingState } from '@/components/ui';
|
||||||
import { StatCard } from '@/components/StatCard';
|
|
||||||
import { StatisticalCharts } from '@/components/StatisticalCharts';
|
import { StatisticalCharts } from '@/components/StatisticalCharts';
|
||||||
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
|
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
import type { RiskStats } from './types';
|
import type { RiskStats } from './types';
|
||||||
@@ -18,112 +17,142 @@ export const AlertsRiskPanel = React.memo(function AlertsRiskPanel({
|
|||||||
trendLoading,
|
trendLoading,
|
||||||
trendError,
|
trendError,
|
||||||
}: AlertsRiskPanelProps) {
|
}: AlertsRiskPanelProps) {
|
||||||
// Severity donut data (P1/P2)
|
const alertPie = useMemo(
|
||||||
const alertPie = useMemo(() => ([
|
() => [
|
||||||
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
|
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#C2410C' },
|
||||||
{ name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
|
{ name: 'P2 (关注)', value: riskStats.p2, color: '#C27803' },
|
||||||
]), [riskStats.p1, riskStats.p2]);
|
],
|
||||||
|
[riskStats.p1, riskStats.p2]
|
||||||
|
);
|
||||||
|
|
||||||
const topDistrictMax = useMemo(
|
const topDistrictMax = useMemo(
|
||||||
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
|
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
|
||||||
[riskStats.topDistricts],
|
[riskStats.topDistricts]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
{/* Risk distribution as StatCards */}
|
{/* 风险分布连续条 */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="risk-strip" role="group" aria-label="风险等级统计">
|
||||||
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
|
<div className="risk-strip__cell">
|
||||||
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
|
<div className="risk-strip__label">高风险 (≥0.8)</div>
|
||||||
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
|
<div className="risk-strip__value text-danger">{riskStats.high}</div>
|
||||||
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Risk trend chart (real data from /api/analysis/trend) */}
|
{/* 趋势图 */}
|
||||||
{trendLoading ? (
|
<section className="workbench-panel" aria-label="风险趋势">
|
||||||
<div className="card p-8"><LoadingState /></div>
|
<div className="workbench-panel__head">
|
||||||
) : trendError ? (
|
<div>
|
||||||
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
|
<h3 className="workbench-panel__title">风险趋势</h3>
|
||||||
) : trendData.length === 0 ? (
|
<p className="workbench-panel__sub">近 14 日风险指数变化</p>
|
||||||
<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-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>
|
||||||
{riskStats.topDistricts.length > 0 ? (
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="workbench-panel__body">
|
||||||
{riskStats.topDistricts.map(([district, count]) => (
|
{trendLoading ? (
|
||||||
<div key={district}>
|
<LoadingState />
|
||||||
<div className="flex items-center justify-between text-[12px] mb-1">
|
) : trendError ? (
|
||||||
<span className="text-text-primary font-medium">{district}</span>
|
<div className="text-center py-8 text-danger text-[13px]">{trendError}</div>
|
||||||
<span className="text-text-muted">{count} 条</span>
|
) : 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
<div className="district-bar__track">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-danger rounded-full"
|
className="district-bar__fill bg-danger/80"
|
||||||
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
|
style={{
|
||||||
|
width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-12 text-right data-num text-[12px]">{count}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="text-center py-8 text-text-muted text-[13px]">暂无区域数据</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>
|
||||||
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
|
</section>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
|
||||||
<PieChart>
|
<section className="workbench-panel" aria-label="预警严重度">
|
||||||
<Pie
|
<div className="workbench-panel__head">
|
||||||
data={alertPie}
|
<div>
|
||||||
cx="50%"
|
<h3 className="workbench-panel__title">预警严重度分布</h3>
|
||||||
cy="50%"
|
<p className="workbench-panel__sub">P1 / P2 占比</p>
|
||||||
innerRadius={50}
|
</div>
|
||||||
outerRadius={80}
|
</div>
|
||||||
paddingAngle={4}
|
<div className="workbench-panel__body">
|
||||||
dataKey="value"
|
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
|
||||||
nameKey="name"
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
>
|
<PieChart>
|
||||||
{alertPie.map((entry) => (
|
<Pie
|
||||||
<Cell key={entry.name} fill={entry.color} />
|
data={alertPie}
|
||||||
))}
|
cx="50%"
|
||||||
</Pie>
|
cy="50%"
|
||||||
<RechartsTooltip
|
innerRadius={50}
|
||||||
contentStyle={{
|
outerRadius={80}
|
||||||
backgroundColor: '#FFFFFF',
|
paddingAngle={4}
|
||||||
border: '1px solid #E2E8F0',
|
dataKey="value"
|
||||||
borderRadius: '8px',
|
nameKey="name"
|
||||||
fontSize: '12px',
|
>
|
||||||
}}
|
{alertPie.map((entry) => (
|
||||||
formatter={(value: number, name: string) => [value, name]}
|
<Cell key={entry.name} fill={entry.color} />
|
||||||
/>
|
))}
|
||||||
<Legend
|
</Pie>
|
||||||
wrapperStyle={{ fontSize: '12px' }}
|
<RechartsTooltip
|
||||||
formatter={(value: string) => <span className="text-text-secondary">{value}</span>}
|
contentStyle={{
|
||||||
/>
|
backgroundColor: '#FFFFFF',
|
||||||
</PieChart>
|
border: '1px solid #D4DEE4',
|
||||||
</ResponsiveContainer>
|
borderRadius: '10px',
|
||||||
) : (
|
fontSize: '12px',
|
||||||
<div className="text-center py-8 text-text-muted text-[13px]">暂无预警数据</div>
|
}}
|
||||||
)}
|
formatter={(value: number, name: string) => [value, name]}
|
||||||
</div>
|
/>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* 住院临床分析页图表字面色值集中处。
|
* 住院临床分析页图表字面色值集中处。
|
||||||
* Recharts 需要原始 hex,无法用 Tailwind class,故在此集中定义,避免散落 magic hex。
|
* 对齐江雾青绿 / 雾蓝体系,避免紫系默认配色。
|
||||||
*/
|
*/
|
||||||
export const CLINICAL_COLORS = {
|
export const CLINICAL_COLORS = {
|
||||||
primary: '#2563EB', // primary
|
primary: '#0F766E',
|
||||||
los: '#2563EB',
|
los: '#0F766E',
|
||||||
box: '#3B82F6', // 箱体填充
|
box: '#14B8A6',
|
||||||
boxMedian: '#1D4ED8', // 中位刻度
|
boxMedian: '#0D5C56',
|
||||||
grid: '#E2E8F0',
|
grid: '#D4DEE4',
|
||||||
axis: '#64748B',
|
axis: '#5A6F7A',
|
||||||
axisLabel: '#374151',
|
axisLabel: '#1A2B33',
|
||||||
tooltipBorder: '#E2E8F0',
|
tooltipBorder: '#D4DEE4',
|
||||||
tooltipText: '#1E293B',
|
tooltipText: '#1A2B33',
|
||||||
// 出院结局按严重程度配色:治愈/好转偏绿,未愈/死亡偏红,其他中性
|
|
||||||
outcome: {
|
outcome: {
|
||||||
治愈: '#16A34A',
|
治愈: '#0D9488',
|
||||||
好转: '#4ADE80',
|
好转: '#5EEAD4',
|
||||||
其他: '#94A3B8',
|
其他: '#8A9BA5',
|
||||||
未愈: '#F97316',
|
未愈: '#C27803',
|
||||||
死亡: '#DC2626',
|
死亡: '#C2410C',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
outcomeFallback: '#94A3B8',
|
outcomeFallback: '#8A9BA5',
|
||||||
// 入院途径 donut 顺序色板
|
routePalette: ['#0F766E', '#5B8FA8', '#14B8A6', '#C27803', '#0D9488', '#C2410C'],
|
||||||
routePalette: ['#2563EB', '#0891B2', '#7C3AED', '#D97706', '#16A34A', '#DC2626'],
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Recharts tooltip 通用样式。 */
|
|
||||||
export const TOOLTIP_STYLE = {
|
export const TOOLTIP_STYLE = {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
|
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '10px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ function formatDateLabel(dateStr: string): string {
|
|||||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
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 {
|
interface CaseStatsTabProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
@@ -33,7 +41,7 @@ interface CaseStatsTabProps {
|
|||||||
onDismissError: () => void;
|
onDismissError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 病例统计 tab —— 诊断分布 / 病例与AQI趋势 / 日历热力图。纯展示,数据由父级按需加载。
|
/** 病例统计 tab — 诊断 / 趋势 / 日历,工作台面板构图。 */
|
||||||
export const CaseStatsTab = memo(function CaseStatsTab({
|
export const CaseStatsTab = memo(function CaseStatsTab({
|
||||||
loading,
|
loading,
|
||||||
loaded,
|
loaded,
|
||||||
@@ -49,97 +57,138 @@ export const CaseStatsTab = memo(function CaseStatsTab({
|
|||||||
if (loading && !loaded) {
|
if (loading && !loaded) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<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 className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5 max-w-[1400px]">
|
||||||
{error && (
|
{error && <ErrorBanner error={error} onRetry={onRetry} onDismiss={onDismissError} />}
|
||||||
<ErrorBanner
|
|
||||||
error={error}
|
|
||||||
onRetry={onRetry}
|
|
||||||
onDismiss={onDismissError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top 5 诊断分布 */}
|
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
{/* Top 5 诊断 — 占 2 列 */}
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 诊断分布</h3>
|
<section className="workbench-panel xl:col-span-2" aria-label="Top 5 诊断分布">
|
||||||
{topDiagnoses.length > 0 ? (
|
<div className="workbench-panel__head">
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<div>
|
||||||
<BarChart
|
<h3 className="workbench-panel__title">Top 5 诊断分布</h3>
|
||||||
data={[...topDiagnoses].reverse()}
|
<p className="workbench-panel__sub">门诊 / 住院堆叠</p>
|
||||||
layout="vertical"
|
</div>
|
||||||
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
|
</div>
|
||||||
>
|
<div className="workbench-panel__body">
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
|
{topDiagnoses.length > 0 ? (
|
||||||
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<YAxis
|
<BarChart
|
||||||
type="category"
|
data={[...topDiagnoses].reverse()}
|
||||||
dataKey="diagnosis"
|
layout="vertical"
|
||||||
tick={{ fontSize: 11, fill: '#374151' }}
|
margin={{ top: 0, right: 12, left: 8, bottom: 0 }}
|
||||||
width={100}
|
>
|
||||||
axisLine={false}
|
<CartesianGrid strokeDasharray="3 3" stroke="#E8EEF1" horizontal={false} />
|
||||||
tickLine={false}
|
<XAxis type="number" tick={{ fontSize: 10, fill: '#8A9BA5' }} />
|
||||||
/>
|
<YAxis
|
||||||
<Tooltip
|
type="category"
|
||||||
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
|
dataKey="diagnosis"
|
||||||
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
tick={{ fontSize: 11, fill: '#5A6F7A' }}
|
||||||
/>
|
width={96}
|
||||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
axisLine={false}
|
||||||
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
|
tickLine={false}
|
||||||
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
|
/>
|
||||||
</BarChart>
|
<Tooltip
|
||||||
</ResponsiveContainer>
|
contentStyle={TOOLTIP}
|
||||||
) : (
|
formatter={(value: number) => [value.toLocaleString(), '病例数']}
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
/>
|
||||||
)}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
|
{/* 日历热力图 — 通栏 */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<section className="workbench-panel" aria-label="每日病例日历">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">病例与AQI趋势</h3>
|
<div className="workbench-panel__head">
|
||||||
<p className="text-xs text-gray-500 mb-4">截至 {currentDate} 的近30日窗口</p>
|
<div>
|
||||||
{caseTrend.length > 0 ? (
|
<h3 className="workbench-panel__title">
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
{heatmapYear ? `${heatmapYear} 年` : ''}每日病例日历
|
||||||
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
</h3>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
<p className="workbench-panel__sub">密度越高表示当日病例越多</p>
|
||||||
<XAxis
|
</div>
|
||||||
dataKey="date"
|
</div>
|
||||||
tickFormatter={formatDateLabel}
|
<div className="workbench-panel__body">
|
||||||
tick={{ fontSize: 10, fill: '#64748B' }}
|
{heatmapYear && heatmapData.length > 0 ? (
|
||||||
interval="preserveStartEnd"
|
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
|
||||||
axisLine={{ stroke: '#E2E8F0' }}
|
) : (
|
||||||
/>
|
<div className="text-center py-12 text-text-muted text-sm">暂无数据</div>
|
||||||
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
)}
|
||||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
|
</div>
|
||||||
<Tooltip
|
</section>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface DistrictStatsTabProps {
|
|||||||
onSort: (col: string) => void;
|
onSort: (col: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 区域统计 tab —— 区域指标热力表。纯展示,排序键由父级持有。
|
/** 区域统计 tab — 热力表工作台面板。 */
|
||||||
export const DistrictStatsTab = memo(function DistrictStatsTab({
|
export const DistrictStatsTab = memo(function DistrictStatsTab({
|
||||||
loading,
|
loading,
|
||||||
loaded,
|
loaded,
|
||||||
@@ -27,40 +27,47 @@ export const DistrictStatsTab = memo(function DistrictStatsTab({
|
|||||||
if (loading && !loaded) {
|
if (loading && !loaded) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<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 className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5 max-w-[1400px]">
|
||||||
{error && (
|
{error && <ErrorBanner error={error} onRetry={onRetry} onDismiss={onDismissError} />}
|
||||||
<ErrorBanner
|
|
||||||
error={error}
|
|
||||||
onRetry={onRetry}
|
|
||||||
onDismiss={onDismissError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<section className="workbench-panel" aria-label="区域指标热力表">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">区域指标热力表</h3>
|
<div className="workbench-panel__head">
|
||||||
<p className="text-xs text-gray-500 mb-4">点击列标题排序</p>
|
<div>
|
||||||
{rows.length > 0 ? (
|
<h3 className="workbench-panel__title">区域指标热力表</h3>
|
||||||
<MetricHeatmapTable
|
<p className="workbench-panel__sub">点击列标题排序 · 颜色深浅反映相对强度</p>
|
||||||
rows={rows}
|
</div>
|
||||||
columns={[
|
{rows.length > 0 && (
|
||||||
{ key: 'total', label: '病例' },
|
<span className="text-[11px] font-mono tabular-nums text-text-muted bg-bg-hover px-2.5 py-1 rounded-md border border-border">
|
||||||
{ key: 'outpatient', label: '门诊' },
|
{rows.length} 个区域
|
||||||
{ key: 'inpatient', label: '住院' },
|
</span>
|
||||||
{ key: 'inpatient_ratio', label: '住院占比%' },
|
)}
|
||||||
]}
|
</div>
|
||||||
data={data}
|
<div className="workbench-panel__body">
|
||||||
onSort={onSort}
|
{rows.length > 0 ? (
|
||||||
/>
|
<div className="rounded-xl border border-border/80 overflow-hidden bg-bg-elevated/50">
|
||||||
) : (
|
<MetricHeatmapTable
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">暂无数据</div>
|
rows={rows}
|
||||||
)}
|
columns={[
|
||||||
</div>
|
{ 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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
|
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
|
||||||
import { StatCard } from '@/components/StatCard';
|
|
||||||
import type { MonitoringStats } from './types';
|
import type { MonitoringStats } from './types';
|
||||||
|
|
||||||
interface MonitoringStatsBarProps {
|
interface MonitoringStatsBarProps {
|
||||||
@@ -8,49 +7,113 @@ interface MonitoringStatsBarProps {
|
|||||||
sparkline7d: number[];
|
sparkline7d: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监测页顶部统计条 —— 纯展示,已自适应(grid-cols-2 sm:grid-cols-3 lg:grid-cols-6)。
|
function MiniSpark({ data, color }: { data: number[]; color: string }) {
|
||||||
export const MonitoringStatsBar = memo(function MonitoringStatsBar({ stats, sparkline7d }: MonitoringStatsBarProps) {
|
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`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
<svg width="74" height="24" className="shrink-0" aria-hidden="true">
|
||||||
<StatCard
|
<polygon fill={`${color}22`} points={area} />
|
||||||
icon={<Calendar className="w-4 h-4 text-blue-600" />}
|
<polyline
|
||||||
label="当日病例"
|
fill="none"
|
||||||
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
|
stroke={color}
|
||||||
/>
|
strokeWidth="1.75"
|
||||||
<StatCard
|
strokeLinecap="round"
|
||||||
icon={<Activity className="w-4 h-4 text-indigo-600" />}
|
strokeLinejoin="round"
|
||||||
label="7日均值"
|
points={points}
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ interface OverviewTabProps {
|
|||||||
onDistrictSelect: (district: string) => void;
|
onDistrictSelect: (district: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 概览 tab —— 病例分布地图 + 统计图表 + 区县 roll-up(粒度真相来源在父级 URL)。
|
/**
|
||||||
|
* 概览 tab — 沉浸式地图舞台 + 玻璃统计翼(趋势 / 区县 roll-up)。
|
||||||
|
* 功能保留:病例地图、AQI/病例趋势、区县分解、粒度切换。
|
||||||
|
* 播放时间轴时地图始终挂载,仅侧翼显示刷新态,避免底图闪烁。
|
||||||
|
*/
|
||||||
export const OverviewTab = memo(function OverviewTab({
|
export const OverviewTab = memo(function OverviewTab({
|
||||||
isLoading,
|
isLoading,
|
||||||
chartData,
|
chartData,
|
||||||
@@ -29,62 +33,106 @@ export const OverviewTab = memo(function OverviewTab({
|
|||||||
onGranularityChange,
|
onGranularityChange,
|
||||||
onDistrictSelect,
|
onDistrictSelect,
|
||||||
}: OverviewTabProps) {
|
}: OverviewTabProps) {
|
||||||
if (isLoading) {
|
const totalDistrictCases = useMemo(
|
||||||
return (
|
() => districtCases.reduce((s, d) => s + d.total, 0),
|
||||||
<div className="flex items-center justify-center h-64">
|
[districtCases]
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
);
|
||||||
</div>
|
const showWingSkeleton = isLoading && districtCases.length === 0;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col xl:flex-row h-full min-h-0 pb-[4.5rem]">
|
||||||
{/* Case Location Map */}
|
{/* 地图舞台 — 全高沉浸;永不因 isLoading 卸载 */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<section className="map-stage min-h-[380px] xl:min-h-0 border-b xl:border-b-0">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">病例分布地图</h3>
|
<div className="map-chrome">
|
||||||
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
|
<div className="map-chrome__chip">
|
||||||
</div>
|
<span className="text-[11px] text-text-muted">观测日</span>
|
||||||
|
<span className="font-mono text-[12px] font-semibold tabular-nums text-text-primary">
|
||||||
{/* Statistical Charts */}
|
{currentDate}
|
||||||
<StatisticalCharts
|
</span>
|
||||||
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>
|
|
||||||
<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="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>
|
||||||
</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>
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -92,46 +140,71 @@ export const OverviewTab = memo(function OverviewTab({
|
|||||||
interface DistrictBreakdownProps {
|
interface DistrictBreakdownProps {
|
||||||
districtCases: DistrictCaseRow[];
|
districtCases: DistrictCaseRow[];
|
||||||
selectedDistrict: string | null;
|
selectedDistrict: string | null;
|
||||||
// 点击区域条目时上抛——由父组件驱动 URL(粒度真相来源),不在此处 mutate store。
|
|
||||||
onDistrictSelect: (district: string) => void;
|
onDistrictSelect: (district: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict, onDistrictSelect }: DistrictBreakdownProps) {
|
const DistrictBreakdown = memo(function DistrictBreakdown({
|
||||||
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
|
districtCases,
|
||||||
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
|
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) => {
|
const handleDistrictClick = useCallback(
|
||||||
onDistrictSelect(district);
|
(district: string) => {
|
||||||
}, [onDistrictSelect]);
|
onDistrictSelect(district);
|
||||||
|
},
|
||||||
|
[onDistrictSelect]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortedCases.map((d) => {
|
{sortedCases.map((d, rank) => {
|
||||||
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
|
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
|
||||||
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
|
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
|
||||||
const barWidth = (d.total / maxTotal) * 100;
|
const barWidth = (d.total / maxTotal) * 100;
|
||||||
|
const isActive = selectedDistrict === d.district;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={d.district}
|
key={d.district}
|
||||||
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
|
role="button"
|
||||||
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
|
tabIndex={0}
|
||||||
}`}
|
aria-pressed={isActive}
|
||||||
|
className={`district-bar ${isActive ? 'district-bar--active' : ''}`}
|
||||||
onClick={() => handleDistrictClick(d.district)}
|
onClick={() => handleDistrictClick(d.district)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDistrictClick(d.district);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
|
<div className="w-4 text-[10px] font-mono text-text-muted tabular-nums shrink-0 text-right">
|
||||||
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
|
{rank + 1}
|
||||||
<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>
|
||||||
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
|
<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">
|
||||||
{d.total.toLocaleString()}
|
{d.total.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,57 +11,72 @@ export interface AlertSlice {
|
|||||||
|
|
||||||
interface AlertSeverityDonutProps {
|
interface AlertSeverityDonutProps {
|
||||||
data: AlertSlice[];
|
data: AlertSlice[];
|
||||||
|
embed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '10px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
};
|
};
|
||||||
|
|
||||||
function AlertSeverityDonutComponent({ data }: AlertSeverityDonutProps) {
|
function AlertSeverityDonutComponent({ data, embed }: AlertSeverityDonutProps) {
|
||||||
const hasData = data.some((d) => d.value > 0);
|
const hasData = data.some((d) => d.value > 0);
|
||||||
|
|
||||||
return (
|
const body = (
|
||||||
<div className="card p-4">
|
<>
|
||||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
{embed ? (
|
||||||
预警严重度分布
|
<div className="workbench-panel__head">
|
||||||
</div>
|
<div>
|
||||||
{hasData ? (
|
<h3 className="workbench-panel__title">预警严重度分布</h3>
|
||||||
<div className="flex items-center justify-center">
|
<p className="workbench-panel__sub">P1 紧急 / P2 关注</p>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
</div>
|
||||||
<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>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="暂无预警数据" />
|
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||||
|
预警严重度分布
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (embed) return <>{body}</>;
|
||||||
|
return <div className="card p-4">{body}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AlertSeverityDonut = memo(AlertSeverityDonutComponent);
|
export const AlertSeverityDonut = memo(AlertSeverityDonutComponent);
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface MergedTrendItem {
|
|||||||
|
|
||||||
interface CaseAqiTrendProps {
|
interface CaseAqiTrendProps {
|
||||||
data: MergedTrendItem[];
|
data: MergedTrendItem[];
|
||||||
|
/** 嵌入 workbench-panel 时去掉外层 card */
|
||||||
|
embed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateLabel(dateStr: string): string {
|
function formatDateLabel(dateStr: string): string {
|
||||||
@@ -30,71 +32,85 @@ function formatDateLabel(dateStr: string): string {
|
|||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '10px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
};
|
};
|
||||||
|
|
||||||
function CaseAqiTrendComponent({ data }: CaseAqiTrendProps) {
|
function CaseAqiTrendComponent({ data, embed }: CaseAqiTrendProps) {
|
||||||
return (
|
const body = (
|
||||||
<div className="card p-4">
|
<>
|
||||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
{embed ? (
|
||||||
近30日病例与AQI趋势
|
<div className="workbench-panel__head">
|
||||||
</div>
|
<div>
|
||||||
{data.length > 0 ? (
|
<h3 className="workbench-panel__title">近 30 日病例与 AQI</h3>
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<p className="workbench-panel__sub">双轴对照趋势</p>
|
||||||
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
</div>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
</div>
|
||||||
<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 className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||||
|
近30日病例与AQI趋势
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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="暂无数据" />
|
||||||
|
)}
|
||||||
|
</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);
|
export const CaseAqiTrend = memo(CaseAqiTrendComponent);
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import type MapView from '@geoscene/core/views/MapView';
|
||||||
import 'leaflet/dist/leaflet.css';
|
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 { CHART_COLORS } from './chartColors';
|
import { CHART_COLORS } from './chartColors';
|
||||||
|
import { createMapView } from '@/geoscene/createMapView';
|
||||||
|
import { createGraphicsLayer } from '@/geoscene/layers';
|
||||||
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
|
||||||
interface DistrictChoroplethProps {
|
interface DistrictChoroplethProps {
|
||||||
/** 区名(规范,带「区」) → 当前 metric 标量值 的查表。 */
|
|
||||||
metricLookup: Record<string, number>;
|
metricLookup: Record<string, number>;
|
||||||
/** 当前指标的中文标签,用于 tooltip(如「门诊病例」)。 */
|
|
||||||
metricLabel: string;
|
metricLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WUHAN_CENTER: [number, number] = [30.59, 114.3];
|
interface WuhanFeatureProps {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WuhanFeature {
|
||||||
|
type: 'Feature';
|
||||||
|
properties: WuhanFeatureProps;
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon' | 'MultiPolygon';
|
||||||
|
coordinates: number[][][] | number[][][][];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WuhanFeatureCollection {
|
||||||
|
type: 'FeatureCollection';
|
||||||
|
features: WuhanFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
/** 把值映射到 7 档顺序色阶;高值 → 深色。 */
|
|
||||||
function colorForValue(value: number, max: number): string {
|
function colorForValue(value: number, max: number): string {
|
||||||
const scale = CHART_COLORS.choropleth;
|
const scale = CHART_COLORS.choropleth;
|
||||||
if (max <= 0 || value <= 0) return CHART_COLORS.choroplethEmpty;
|
if (max <= 0 || value <= 0) return CHART_COLORS.choroplethEmpty;
|
||||||
@@ -21,120 +40,121 @@ function colorForValue(value: number, max: number): string {
|
|||||||
return scale[idx];
|
return scale[idx];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WuhanFeatureProps {
|
function hexToRgba(hex: string, alpha = 0.78): number[] {
|
||||||
name: string;
|
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];
|
||||||
}
|
}
|
||||||
|
|
||||||
// @types/geojson 随 @types/leaflet 一并提供 GeoJSON 全局命名空间。
|
function ringsFromGeometry(geometry: WuhanFeature['geometry']): number[][][] {
|
||||||
type WuhanFeatureCollection = GeoJSON.FeatureCollection;
|
if (geometry.type === 'Polygon') {
|
||||||
|
return geometry.coordinates as number[][][];
|
||||||
|
}
|
||||||
|
return (geometry.coordinates as number[][][][]).flat();
|
||||||
|
}
|
||||||
|
|
||||||
function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChoroplethProps) {
|
function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChoroplethProps) {
|
||||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const viewRef = useRef<MapView | null>(null);
|
||||||
const geoLayerRef = useRef<L.GeoJSON | null>(null);
|
const layerRef = useRef<GraphicsLayer | null>(null);
|
||||||
const geoDataRef = useRef<WuhanFeatureCollection | null>(null);
|
const geoDataRef = useRef<WuhanFeatureCollection | null>(null);
|
||||||
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
|
||||||
const maxValue = useMemo(() => {
|
const maxValue = useMemo(() => {
|
||||||
const vals = Object.values(metricLookup);
|
const vals = Object.values(metricLookup);
|
||||||
return vals.length ? Math.max(...vals) : 0;
|
return vals.length ? Math.max(...vals) : 0;
|
||||||
}, [metricLookup]);
|
}, [metricLookup]);
|
||||||
|
|
||||||
// 创建地图 + 加载 geojson 一次。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapDivRef.current || mapRef.current) return;
|
if (!mapDivRef.current || viewRef.current) return;
|
||||||
|
|
||||||
const map = L.map(mapDivRef.current, {
|
const { map, view, destroy } = createMapView({
|
||||||
center: WUHAN_CENTER,
|
container: mapDivRef.current,
|
||||||
zoom: 9,
|
zoom: 9,
|
||||||
zoomControl: true,
|
constraints: { minZoom: 8, maxZoom: 14 },
|
||||||
attributionControl: false,
|
|
||||||
scrollWheelZoom: false,
|
|
||||||
});
|
});
|
||||||
mapRef.current = map;
|
const layer = createGraphicsLayer('区县填色');
|
||||||
|
map.add(layer);
|
||||||
|
layerRef.current = layer;
|
||||||
|
viewRef.current = view;
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
view.ui.remove('zoom');
|
||||||
maxZoom: 18,
|
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch('/wuhan_districts.geojson')
|
fetch('/wuhan_districts.geojson')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: WuhanFeatureCollection) => {
|
.then((data: WuhanFeatureCollection) => {
|
||||||
if (cancelled || !mapRef.current) return;
|
if (cancelled) return;
|
||||||
geoDataRef.current = data;
|
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(() => {
|
.catch(() => undefined);
|
||||||
/* network/mock failure — wrapper still renders for tests */
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (mapRef.current) {
|
layerRef.current = null;
|
||||||
mapRef.current.remove();
|
viewRef.current = null;
|
||||||
mapRef.current = null;
|
destroy();
|
||||||
}
|
setMapReady(false);
|
||||||
geoLayerRef.current = null;
|
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 当 metric 变化时重绘填色。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
renderLayer();
|
const layer = layerRef.current;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
const view = viewRef.current;
|
||||||
}, [metricLookup, maxValue, metricLabel]);
|
|
||||||
|
|
||||||
function renderLayer() {
|
|
||||||
const map = mapRef.current;
|
|
||||||
const data = geoDataRef.current;
|
const data = geoDataRef.current;
|
||||||
if (!map || !data) return;
|
if (!layer || !view || !mapReady) return;
|
||||||
|
|
||||||
if (geoLayerRef.current) {
|
const draw = (fc: WuhanFeatureCollection) => {
|
||||||
geoLayerRef.current.remove();
|
layer.removeAll();
|
||||||
geoLayerRef.current = null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
geoLayerRef.current = L.geoJSON(data, {
|
let cancelled = false;
|
||||||
style: (feature) => {
|
fetch('/wuhan_districts.geojson')
|
||||||
const name = (feature?.properties as WuhanFeatureProps | undefined)?.name ?? '';
|
.then((r) => r.json())
|
||||||
const value = metricLookup[name] ?? 0;
|
.then((fc: WuhanFeatureCollection) => {
|
||||||
return {
|
if (cancelled) return;
|
||||||
fillColor: colorForValue(value, maxValue),
|
geoDataRef.current = fc;
|
||||||
fillOpacity: 0.78,
|
draw(fc);
|
||||||
color: CHART_COLORS.choroplethStroke,
|
})
|
||||||
weight: 1.2,
|
.catch(() => undefined);
|
||||||
};
|
return () => {
|
||||||
},
|
cancelled = true;
|
||||||
onEachFeature: (feature, layer) => {
|
};
|
||||||
const name = (feature.properties as WuhanFeatureProps).name ?? '未知';
|
}, [metricLookup, maxValue, metricLabel, mapReady]);
|
||||||
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 legendStops = useMemo(() => {
|
||||||
const scale = CHART_COLORS.choropleth;
|
const scale = CHART_COLORS.choropleth;
|
||||||
return scale.map((color, i) => ({
|
return scale.map((color, i) => ({
|
||||||
@@ -144,8 +164,17 @@ function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChor
|
|||||||
}, [maxValue]);
|
}, [maxValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="choropleth-wrapper" className="relative">
|
<div data-testid={TESTIDS.choroplethWrapper} className="relative">
|
||||||
<div ref={mapDivRef} className="w-full rounded-lg overflow-hidden" style={{ height: 420 }} />
|
<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 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="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>
|
<div className="text-[11px] font-semibold text-text-secondary mb-1.5">{metricLabel}</div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
TrendingDown,
|
TrendingDown,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { StatCard } from '@/components/StatCard';
|
|
||||||
import { TESTIDS } from '@/utils/testids';
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import { CHART_COLORS } from './chartColors';
|
import { CHART_COLORS } from './chartColors';
|
||||||
|
|
||||||
@@ -34,49 +33,90 @@ function changeTrendOf(ratio: number | null | undefined) {
|
|||||||
|
|
||||||
function KpiRowComponent({ kpi }: KpiRowProps) {
|
function KpiRowComponent({ kpi }: KpiRowProps) {
|
||||||
const changeTrend = changeTrendOf(kpi?.changeRatio);
|
const changeTrend = changeTrendOf(kpi?.changeRatio);
|
||||||
|
const trendClass =
|
||||||
|
changeTrend?.direction === 'up'
|
||||||
|
? 'text-danger'
|
||||||
|
: changeTrend?.direction === 'down'
|
||||||
|
? 'text-success'
|
||||||
|
: 'text-text-muted';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={TESTIDS.kpiRow}
|
data-testid={TESTIDS.kpiRow}
|
||||||
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3"
|
className="command-rail stagger-children"
|
||||||
|
role="group"
|
||||||
|
aria-label="综合关键指标"
|
||||||
>
|
>
|
||||||
<StatCard
|
<div className="command-rail__cell command-rail__cell--hero">
|
||||||
icon={<Users className="w-4 h-4 text-primary" />}
|
<div className="command-rail__label">
|
||||||
label="累计病例总数"
|
<Users className="w-3.5 h-3.5 text-primary" aria-hidden />
|
||||||
value={kpi?.totalCases?.toLocaleString() ?? '--'}
|
累计病例总数
|
||||||
/>
|
</div>
|
||||||
<StatCard
|
<div className="command-rail__value">
|
||||||
icon={<Activity className="w-4 h-4 text-success" />}
|
{kpi?.totalCases?.toLocaleString() ?? '--'}
|
||||||
label="今日病例"
|
</div>
|
||||||
value={kpi?.todayCases?.toLocaleString() ?? '--'}
|
</div>
|
||||||
/>
|
|
||||||
<StatCard
|
<div className="command-rail__cell">
|
||||||
icon={
|
<div className="command-rail__label">
|
||||||
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-danger" />) ||
|
<Activity className="w-3.5 h-3.5 text-success" aria-hidden />
|
||||||
(changeTrend?.direction === 'down' && (
|
今日病例
|
||||||
<TrendingDown className="w-4 h-4 text-success" />
|
</div>
|
||||||
)) || <Activity className="w-4 h-4 text-text-muted" />
|
<div className="command-rail__value text-[20px]">
|
||||||
}
|
{kpi?.todayCases?.toLocaleString() ?? '--'}
|
||||||
label="7日变化率"
|
</div>
|
||||||
value={changeTrend ? changeTrend.value : '--'}
|
</div>
|
||||||
trend={changeTrend}
|
|
||||||
/>
|
<div className="command-rail__cell">
|
||||||
<StatCard
|
<div className="command-rail__label">
|
||||||
icon={<AlertTriangle className="w-4 h-4 text-warning" />}
|
{changeTrend?.direction === 'up' ? (
|
||||||
label="活跃预警数"
|
<TrendingUp className="w-3.5 h-3.5 text-danger" aria-hidden />
|
||||||
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
|
) : changeTrend?.direction === 'down' ? (
|
||||||
color={kpi && kpi.activeAlerts > 0 ? CHART_COLORS.alertP1 : undefined}
|
<TrendingDown className="w-3.5 h-3.5 text-success" aria-hidden />
|
||||||
/>
|
) : (
|
||||||
<StatCard
|
<Activity className="w-3.5 h-3.5 text-text-muted" aria-hidden />
|
||||||
icon={<Building2 className="w-4 h-4 text-danger" />}
|
)}
|
||||||
label="高风险网格"
|
7日变化率
|
||||||
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
|
</div>
|
||||||
/>
|
<div className={`command-rail__value text-[20px] ${trendClass}`}>
|
||||||
<StatCard
|
{changeTrend ? changeTrend.value : '--'}
|
||||||
icon={<Droplets className="w-4 h-4 text-primary-light" />}
|
</div>
|
||||||
label="平均AQI"
|
</div>
|
||||||
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
|
|
||||||
/>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,51 +14,66 @@ import { CHART_COLORS } from './chartColors';
|
|||||||
|
|
||||||
interface TopDiagnosesBarProps {
|
interface TopDiagnosesBarProps {
|
||||||
diagnoses: DiagnosisBreakdown[];
|
diagnoses: DiagnosisBreakdown[];
|
||||||
|
embed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '10px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
};
|
};
|
||||||
|
|
||||||
function TopDiagnosesBarComponent({ diagnoses }: TopDiagnosesBarProps) {
|
function TopDiagnosesBarComponent({ diagnoses, embed }: TopDiagnosesBarProps) {
|
||||||
return (
|
const body = (
|
||||||
<div className="card p-4">
|
<>
|
||||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
{embed ? (
|
||||||
Top 5 诊断分布
|
<div className="workbench-panel__head">
|
||||||
</div>
|
<div>
|
||||||
{diagnoses.length > 0 ? (
|
<h3 className="workbench-panel__title">Top 5 诊断分布</h3>
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<p className="workbench-panel__sub">门诊 / 住院堆叠</p>
|
||||||
<BarChart
|
</div>
|
||||||
data={[...diagnoses].reverse()}
|
</div>
|
||||||
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 className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||||
|
Top 5 诊断分布
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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="暂无数据" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (embed) return <>{body}</>;
|
||||||
|
return <div className="card p-4">{body}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopDiagnosesBar = memo(TopDiagnosesBarComponent);
|
export const TopDiagnosesBar = memo(TopDiagnosesBarComponent);
|
||||||
|
|||||||
@@ -13,21 +13,25 @@ import { CHART_COLORS } from './chartColors';
|
|||||||
import { metricValue, type DistrictMetric, type MetricKey } from './districtNormalize';
|
import { metricValue, type DistrictMetric, type MetricKey } from './districtNormalize';
|
||||||
|
|
||||||
interface TopDistrictsBarProps {
|
interface TopDistrictsBarProps {
|
||||||
/** 已归一并聚合到 13 区的指标数据。 */
|
|
||||||
districts: DistrictMetric[];
|
districts: DistrictMetric[];
|
||||||
metric: MetricKey;
|
metric: MetricKey;
|
||||||
metricLabel: string;
|
metricLabel: string;
|
||||||
|
embed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '10px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
};
|
};
|
||||||
|
|
||||||
function TopDistrictsBarComponent({ districts, metric, metricLabel }: TopDistrictsBarProps) {
|
function TopDistrictsBarComponent({
|
||||||
// 按当前 metric 排序取 Top5;横向条形图需 reverse 使最大值在顶部。
|
districts,
|
||||||
|
metric,
|
||||||
|
metricLabel,
|
||||||
|
embed,
|
||||||
|
}: TopDistrictsBarProps) {
|
||||||
const top5 = useMemo(() => {
|
const top5 = useMemo(() => {
|
||||||
return [...districts]
|
return [...districts]
|
||||||
.sort((a, b) => metricValue(b, metric) - metricValue(a, metric))
|
.sort((a, b) => metricValue(b, metric) - metricValue(a, metric))
|
||||||
@@ -44,48 +48,62 @@ function TopDistrictsBarComponent({ districts, metric, metricLabel }: TopDistric
|
|||||||
const hasData = top5.some((d) => d.value > 0);
|
const hasData = top5.some((d) => d.value > 0);
|
||||||
const showStack = metric === 'all';
|
const showStack = metric === 'all';
|
||||||
|
|
||||||
return (
|
const body = (
|
||||||
<div className="card p-4">
|
<>
|
||||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
{embed ? (
|
||||||
Top 5 区县{metricLabel}分布
|
<div className="workbench-panel__head">
|
||||||
</div>
|
<div>
|
||||||
{hasData ? (
|
<h3 className="workbench-panel__title">Top 5 区县{metricLabel}</h3>
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<p className="workbench-panel__sub">按当前度量排序</p>
|
||||||
<BarChart data={top5} layout="vertical" margin={{ top: 0, right: 10, left: 30, bottom: 0 }}>
|
</div>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
|
</div>
|
||||||
<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 className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
|
||||||
|
Top 5 区县{metricLabel}分布
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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="暂无数据" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (embed) return <>{body}</>;
|
||||||
|
return <div className="card p-4">{body}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopDistrictsBar = memo(TopDistrictsBarComponent);
|
export const TopDistrictsBar = memo(TopDistrictsBarComponent);
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* 概览大屏图表与地图使用的字面色值集中处。
|
* 概览大屏图表与地图使用的字面色值集中处。
|
||||||
* Recharts / Leaflet 需要原始 hex,无法用 Tailwind class,故在此集中定义,
|
* Recharts / GeoScene 符号需要原始 hex,无法用 Tailwind class,故在此集中定义。
|
||||||
* 避免页面里散落 magic hex。
|
* 色系对齐「江雾」青绿 / 雾蓝 / 石板体系。
|
||||||
*/
|
*/
|
||||||
export const CHART_COLORS = {
|
export const CHART_COLORS = {
|
||||||
outpatient: '#2563EB', // 门诊(primary)
|
outpatient: '#0F766E', // primary teal
|
||||||
inpatient: '#DC2626', // 住院(danger)
|
inpatient: '#C2410C', // danger warm
|
||||||
cases: '#2563EB',
|
cases: '#0F766E',
|
||||||
aqi: '#D97706', // warning
|
aqi: '#C27803', // warning
|
||||||
grid: '#E2E8F0', // border
|
grid: '#D4DEE4',
|
||||||
axis: '#64748B', // text-secondary
|
axis: '#5A6F7A',
|
||||||
axisLabel: '#374151',
|
axisLabel: '#1A2B33',
|
||||||
tooltipBorder: '#E2E8F0',
|
tooltipBorder: '#D4DEE4',
|
||||||
tooltipText: '#1E293B',
|
tooltipText: '#1A2B33',
|
||||||
alertP1: '#DC2626',
|
alertP1: '#C2410C',
|
||||||
alertP2: '#D97706',
|
alertP2: '#C27803',
|
||||||
// choropleth 顺序色阶(浅 → 深),高值高亮
|
// choropleth 顺序色阶(浅雾 → 深青),高值高亮
|
||||||
choropleth: ['#DBEAFE', '#BFDBFE', '#93C5FD', '#60A5FA', '#3B82F6', '#2563EB', '#1D4ED8'],
|
choropleth: ['#E6F4F1', '#CCFBF1', '#99F6E4', '#5EEAD4', '#2DD4BF', '#14B8A6', '#0F766E'],
|
||||||
choroplethEmpty: '#F1F5F9', // 无数据区填充
|
choroplethEmpty: '#E8EEF1',
|
||||||
choroplethStroke: '#FFFFFF',
|
choroplethStroke: '#FFFFFF',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const Card = memo(function Card({
|
|||||||
<div
|
<div
|
||||||
data-testid={testid}
|
data-testid={testid}
|
||||||
className={[
|
className={[
|
||||||
'bg-bg-card rounded-lg border border-border',
|
'bg-bg-card rounded-xl border border-border shadow-soft',
|
||||||
className,
|
className,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const Segmented = memo(function Segmented<T extends string>({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={testid}
|
data-testid={testid}
|
||||||
className="inline-flex items-center gap-0.5 rounded-full bg-bg-hover p-0.5"
|
className="inline-flex items-center gap-0.5 rounded-lg bg-bg-hover p-0.5 border border-border-light"
|
||||||
>
|
>
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const isActive = opt.value === value;
|
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}
|
data-testid={testid ? `${testid}-${opt.value}` : undefined}
|
||||||
onClick={() => onChange(opt.value)}
|
onClick={() => onChange(opt.value)}
|
||||||
className={[
|
className={[
|
||||||
'rounded-full font-medium transition-colors',
|
'rounded-md font-medium transition-colors',
|
||||||
sizeClasses,
|
sizeClasses,
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary text-white shadow-sm'
|
? 'bg-primary text-white shadow-soft'
|
||||||
: 'text-text-secondary hover:bg-bg-active',
|
: 'text-text-secondary hover:bg-bg-active',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
154
frontend/src/geoscene/createMapView.ts
Normal file
154
frontend/src/geoscene/createMapView.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
17
frontend/src/geoscene/index.ts
Normal file
17
frontend/src/geoscene/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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';
|
||||||
100
frontend/src/geoscene/layers.ts
Normal file
100
frontend/src/geoscene/layers.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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,45 +3,410 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@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 {
|
body {
|
||||||
@apply bg-bg-page text-text-primary font-sans;
|
@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 {
|
@layer components {
|
||||||
.card {
|
.card {
|
||||||
@apply bg-bg-card border border-border rounded-lg;
|
@apply bg-bg-card border border-border rounded-xl shadow-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply bg-primary text-white px-4 py-2 rounded-md text-sm font-medium
|
@apply bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium
|
||||||
hover:bg-primary-light transition-colors;
|
hover:bg-primary-deep transition-colors shadow-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply bg-bg-page text-text-secondary px-4 py-2 rounded-md text-sm font-medium
|
@apply bg-bg-elevated text-text-secondary px-4 py-2 rounded-lg text-sm font-medium
|
||||||
border border-border hover:border-primary hover:text-primary transition-colors;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Leaflet overrides — keep z-index below TopNav (z-50) and SideNav */
|
/* GeoScene MapView — keep below TopNav (z-50) */
|
||||||
.leaflet-container {
|
.esri-view,
|
||||||
|
.geoscene-view {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-pane {
|
.esri-ui,
|
||||||
z-index: 1 !important;
|
.geoscene-ui {
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-top,
|
|
||||||
.leaflet-bottom {
|
|
||||||
z-index: 5 !important;
|
z-index: 5 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-popup-content-wrapper {
|
/* 交错入场 */
|
||||||
@apply rounded-lg shadow-lg;
|
.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 {
|
@keyframes rail-shimmer {
|
||||||
@apply m-0;
|
0% { background-position: 0% 50%; }
|
||||||
|
100% { background-position: 100% 50%; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import '@geoscene/core/assets/geoscene/themes/light/main.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useRiskStore } from '@/stores';
|
||||||
import { useRiskStore, useSessionStore } from '@/stores';
|
|
||||||
import { TESTIDS } from '@/utils/testids';
|
import { TESTIDS } from '@/utils/testids';
|
||||||
import type { CellInfo } from '@/components/AlertMap';
|
import type { CellInfo } from '@/components/AlertMap';
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
@@ -12,15 +11,6 @@ import { AlertDetailModal, CellInfoPanel } from '@/components/alerts/AlertDetail
|
|||||||
import type { ExtendedAlert, RiskStats } from '@/components/alerts/types';
|
import type { ExtendedAlert, RiskStats } from '@/components/alerts/types';
|
||||||
|
|
||||||
export function AlertsDashboard() {
|
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 alerts = useRiskStore((s) => s.alerts);
|
||||||
const isLoading = useRiskStore((s) => s.isLoading);
|
const isLoading = useRiskStore((s) => s.isLoading);
|
||||||
const error = useRiskStore((s) => s.error);
|
const error = useRiskStore((s) => s.error);
|
||||||
@@ -31,43 +21,32 @@ export function AlertsDashboard() {
|
|||||||
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
|
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
|
||||||
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
|
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
|
||||||
const [showMap, setShowMap] = useState(true);
|
const [showMap, setShowMap] = useState(true);
|
||||||
const [showAlertMarkers, setShowAlertMarkers] = useState(false);
|
const [showAlertMarkers, setShowAlertMarkers] = useState(true);
|
||||||
const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
|
const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
|
||||||
const [riskRange, setRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
const [riskRange, setRiskRange] = useState<[number, number]>([0.6, 1.0]);
|
||||||
const [debouncedRiskRange, setDebouncedRiskRange] = 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 [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
// 官员视角默认隐藏网格(见上);其余角色默认显示。
|
const [showGrid, setShowGrid] = useState(true);
|
||||||
const [showGrid, setShowGrid] = useState(!isOfficial);
|
|
||||||
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
|
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');
|
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 [trendData, setTrendData] = useState<Array<{ date: string; cases: number; risk: number }>>([]);
|
||||||
const [trendLoading, setTrendLoading] = useState(false);
|
const [trendLoading, setTrendLoading] = useState(false);
|
||||||
const [trendError, setTrendError] = useState<string | null>(null);
|
const [trendError, setTrendError] = useState<string | null>(null);
|
||||||
const [trendLoaded, setTrendLoaded] = useState(false);
|
const [trendLoaded, setTrendLoaded] = useState(false);
|
||||||
|
|
||||||
// Debounce riskRange for filteredAlerts computation
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setDebouncedRiskRange(riskRange), 300);
|
const timer = setTimeout(() => setDebouncedRiskRange(riskRange), 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [riskRange]);
|
}, [riskRange]);
|
||||||
|
|
||||||
// Fetch grids (for map) and alerts (for side panel) on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRiskMap();
|
fetchRiskMap();
|
||||||
fetchAlerts();
|
fetchAlerts();
|
||||||
}, [fetchRiskMap, fetchAlerts]);
|
}, [fetchRiskMap, fetchAlerts]);
|
||||||
|
|
||||||
// Fetch real risk-trend data when the 风险统计 tab is first opened
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== 'stats' || trendLoaded) return;
|
if (activeTab !== 'stats' || trendLoaded) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -89,7 +68,9 @@ export function AlertsDashboard() {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setTrendLoading(false);
|
if (!cancelled) setTrendLoading(false);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [activeTab, trendLoaded]);
|
}, [activeTab, trendLoaded]);
|
||||||
|
|
||||||
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
|
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
|
||||||
@@ -113,7 +94,8 @@ export function AlertsDashboard() {
|
|||||||
.filter((alert) => {
|
.filter((alert) => {
|
||||||
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
|
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
|
||||||
const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority;
|
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;
|
return horizonMatch && priorityMatch && riskMatch;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -124,9 +106,7 @@ export function AlertsDashboard() {
|
|||||||
});
|
});
|
||||||
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
|
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
|
||||||
|
|
||||||
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
|
|
||||||
const riskStats: RiskStats = useMemo(() => {
|
const riskStats: RiskStats = useMemo(() => {
|
||||||
// p1/p2 reflect the full (unfiltered) alert set
|
|
||||||
let p1 = 0;
|
let p1 = 0;
|
||||||
let p2 = 0;
|
let p2 = 0;
|
||||||
for (const a of extendedAlerts) {
|
for (const a of extendedAlerts) {
|
||||||
@@ -134,7 +114,6 @@ export function AlertsDashboard() {
|
|||||||
else if (a.priority === 'P2') p2++;
|
else if (a.priority === 'P2') p2++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single pass over filteredAlerts: counters + sum + district map
|
|
||||||
let high = 0;
|
let high = 0;
|
||||||
let mediumHigh = 0;
|
let mediumHigh = 0;
|
||||||
let medium = 0;
|
let medium = 0;
|
||||||
@@ -152,6 +131,7 @@ export function AlertsDashboard() {
|
|||||||
const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
|
const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
|
||||||
|
|
||||||
const topDistricts = Object.entries(byDistrict)
|
const topDistricts = Object.entries(byDistrict)
|
||||||
|
.filter(([name]) => name && name !== '武汉市' && name !== '未知')
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
|
|
||||||
@@ -159,20 +139,22 @@ export function AlertsDashboard() {
|
|||||||
}, [extendedAlerts, filteredAlerts]);
|
}, [extendedAlerts, filteredAlerts]);
|
||||||
|
|
||||||
const selectedAlertData = useMemo(() => {
|
const selectedAlertData = useMemo(() => {
|
||||||
return filteredAlerts.find(a => a.alert_id === selectedAlert);
|
return filteredAlerts.find((a) => a.alert_id === selectedAlert);
|
||||||
}, [filteredAlerts, selectedAlert]);
|
}, [filteredAlerts, selectedAlert]);
|
||||||
|
|
||||||
const selectedGridId = useMemo(() => {
|
const selectedGridId = useMemo(() => {
|
||||||
if (!selectedAlert) return null;
|
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;
|
return alert?.grid_id ?? null;
|
||||||
}, [filteredAlerts, selectedAlert]);
|
}, [filteredAlerts, selectedAlert]);
|
||||||
|
|
||||||
const filteredAlertsRef = useRef(filteredAlerts);
|
const filteredAlertsRef = useRef(filteredAlerts);
|
||||||
useEffect(() => { filteredAlertsRef.current = filteredAlerts; }, [filteredAlerts]);
|
useEffect(() => {
|
||||||
|
filteredAlertsRef.current = filteredAlerts;
|
||||||
|
}, [filteredAlerts]);
|
||||||
|
|
||||||
const handleGridClick = useCallback((gridId: string) => {
|
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) {
|
if (alertForGrid) {
|
||||||
setSelectedAlert(alertForGrid.alert_id);
|
setSelectedAlert(alertForGrid.alert_id);
|
||||||
}
|
}
|
||||||
@@ -188,23 +170,42 @@ export function AlertsDashboard() {
|
|||||||
|
|
||||||
const handleCellInfo = useCallback((info: CellInfo) => {
|
const handleCellInfo = useCallback((info: CellInfo) => {
|
||||||
setCellInfo(info);
|
setCellInfo(info);
|
||||||
setSelectedAlert(null); // Close alert modal if open
|
setSelectedAlert(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearCellInfo = useCallback(() => {
|
const clearCellInfo = useCallback(() => {
|
||||||
setCellInfo(null);
|
setCellInfo(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Export utilities
|
|
||||||
const exportToCsv = useCallback(() => {
|
const exportToCsv = useCallback(() => {
|
||||||
const headers = ['alert_id', 'grid_id', 'region', 'street', 'latitude', 'longitude', 'risk_value', 'priority', 'forecast_horizon', 'reason', 'timestamp'];
|
const headers = [
|
||||||
const rows = filteredAlerts.map(a => [
|
'alert_id',
|
||||||
a.alert_id, a.grid_id, a.region, a.street,
|
'grid_id',
|
||||||
a.latitude, a.longitude, a.risk_value, a.priority,
|
'region',
|
||||||
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
|
'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 csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
|
||||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -225,11 +226,22 @@ export function AlertsDashboard() {
|
|||||||
}, [filteredAlerts]);
|
}, [filteredAlerts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid={TESTIDS.pageAlerts} className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
|
<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'
|
||||||
|
}
|
||||||
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => { clearError(); fetchRiskMap(); fetchAlerts(); }}
|
onRetry={() => {
|
||||||
|
clearError();
|
||||||
|
fetchRiskMap();
|
||||||
|
fetchAlerts();
|
||||||
|
}}
|
||||||
onDismiss={clearError}
|
onDismiss={clearError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -272,9 +284,6 @@ export function AlertsDashboard() {
|
|||||||
onGridClick={handleGridClick}
|
onGridClick={handleGridClick}
|
||||||
onCellInfo={handleCellInfo}
|
onCellInfo={handleCellInfo}
|
||||||
onCardClick={handleAlertCardClick}
|
onCardClick={handleAlertCardClick}
|
||||||
effectiveShowAlertMarkers={effectiveShowAlertMarkers}
|
|
||||||
isCluster={isCluster}
|
|
||||||
isOfficial={isOfficial}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -287,12 +296,10 @@ export function AlertsDashboard() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cell info panel - shown when clicking grid cell without alert */}
|
|
||||||
{cellInfo && !selectedAlertData && (
|
{cellInfo && !selectedAlertData && (
|
||||||
<CellInfoPanel cellInfo={cellInfo} onClose={clearCellInfo} />
|
<CellInfoPanel cellInfo={cellInfo} onClose={clearCellInfo} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert detail modal */}
|
|
||||||
{selectedAlertData && (
|
{selectedAlertData && (
|
||||||
<AlertDetailModal alert={selectedAlertData} onClose={clearSelectedAlert} />
|
<AlertDetailModal alert={selectedAlertData} onClose={clearSelectedAlert} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useState, useMemo } from 'react';
|
|||||||
import { LoadingState } from '@/components/ui';
|
import { LoadingState } from '@/components/ui';
|
||||||
import { useAnalysisStore } from '@/stores/analysisStore';
|
import { useAnalysisStore } from '@/stores/analysisStore';
|
||||||
import { ErrorBanner } from '@/components/ErrorBanner';
|
import { ErrorBanner } from '@/components/ErrorBanner';
|
||||||
import { ChatBot } from '@/components/ChatBot';
|
|
||||||
import { caseApi } from '@/services/api';
|
import { caseApi } from '@/services/api';
|
||||||
import type { CaseTrendPoint } from '@/types';
|
import type { CaseTrendPoint } from '@/types';
|
||||||
import {
|
import {
|
||||||
@@ -424,8 +423,6 @@ export function Insights() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChatBot />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,52 +32,92 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid={TESTIDS.pageLogin} className="min-h-screen bg-bg-page flex items-center justify-center">
|
<div
|
||||||
<form
|
data-testid={TESTIDS.pageLogin}
|
||||||
onSubmit={handleSubmit}
|
className="login-atmosphere relative min-h-screen flex items-center justify-center overflow-hidden px-4"
|
||||||
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">
|
<div
|
||||||
CBPOA 登录
|
className="pointer-events-none absolute -top-24 left-1/4 h-72 w-72 rounded-full bg-mist/30 blur-3xl animate-breath"
|
||||||
</h1>
|
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
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
<div className="relative z-10 w-full max-w-[420px] animate-fade-up">
|
||||||
<div className="mb-4 p-2 bg-red-50 text-danger text-sm rounded">
|
{/* 品牌 hero */}
|
||||||
{error}
|
<header className="mb-8 text-center">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<label className="block mb-4">
|
<form
|
||||||
<span className="text-text-secondary text-sm">用户名</span>
|
onSubmit={handleSubmit}
|
||||||
<input
|
className="bg-bg-card/95 backdrop-blur-sm rounded-2xl border border-border shadow-lift p-7 sm:p-8"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{loading ? '登录中...' : '登录'}
|
<h2 className="sr-only">登录</h2>
|
||||||
</button>
|
|
||||||
</form>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export function MonitoringDashboard({
|
|||||||
defaultStartDate = '2022-12-01',
|
defaultStartDate = '2022-12-01',
|
||||||
defaultEndDate = '2024-12-30',
|
defaultEndDate = '2024-12-30',
|
||||||
}: MonitoringDashboardProps) {
|
}: MonitoringDashboardProps) {
|
||||||
// In-page tab strip (local state, no router — mirrors the existing activePage pattern)
|
|
||||||
const [activeTab, setActiveTab] = useState<MonitoringTab>('overview');
|
const [activeTab, setActiveTab] = useState<MonitoringTab>('overview');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -46,13 +45,11 @@ export function MonitoringDashboard({
|
|||||||
const drillDown = useDrilldownStore((s) => s.drillDown);
|
const drillDown = useDrilldownStore((s) => s.drillDown);
|
||||||
const resetDrillDown = useDrilldownStore((s) => s.resetDrillDown);
|
const resetDrillDown = useDrilldownStore((s) => s.resetDrillDown);
|
||||||
|
|
||||||
// --- URL 是粒度的真相来源;drilldownStore 由 URL 派生 ---
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const granularity = parseGranularity(searchParams.get('granularity'));
|
const granularity = parseGranularity(searchParams.get('granularity'));
|
||||||
const districtParam = searchParams.get('district');
|
const districtParam = searchParams.get('district');
|
||||||
const streetParam = searchParams.get('street');
|
const streetParam = searchParams.get('street');
|
||||||
|
|
||||||
// 把 URL 写入:粒度控件、面包屑、区域点击都通过它驱动 URL,再由下方 effect 同步 store。
|
|
||||||
const updateUrl = useCallback(
|
const updateUrl = useCallback(
|
||||||
(next: { granularity: Granularity; district?: string | null; street?: string | null }) => {
|
(next: { granularity: Granularity; district?: string | null; street?: string | null }) => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
@@ -71,7 +68,6 @@ export function MonitoringDashboard({
|
|||||||
[setSearchParams]
|
[setSearchParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 粒度控件回调:切换粒度即写 URL(深链可分享)。
|
|
||||||
const handleGranularityChange = useCallback(
|
const handleGranularityChange = useCallback(
|
||||||
(g: Granularity) => {
|
(g: Granularity) => {
|
||||||
if (g === 'city') updateUrl({ granularity: 'city' });
|
if (g === 'city') updateUrl({ granularity: 'city' });
|
||||||
@@ -81,7 +77,6 @@ export function MonitoringDashboard({
|
|||||||
[updateUrl, selectedDistrict, selectedStreet]
|
[updateUrl, selectedDistrict, selectedStreet]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 区域 roll-up 点击回调:驱动 URL 而非直接 mutate store(消除命令式 desync)。
|
|
||||||
const handleDistrictSelect = useCallback(
|
const handleDistrictSelect = useCallback(
|
||||||
(district: string) => {
|
(district: string) => {
|
||||||
if (selectedDistrict === district) updateUrl({ granularity: 'city' });
|
if (selectedDistrict === district) updateUrl({ granularity: 'city' });
|
||||||
@@ -90,7 +85,6 @@ export function MonitoringDashboard({
|
|||||||
[updateUrl, selectedDistrict]
|
[updateUrl, selectedDistrict]
|
||||||
);
|
);
|
||||||
|
|
||||||
// store 从 URL 同步(URL 派生 store,单向)。mount 与 param 变化时执行。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (granularity === 'city') {
|
if (granularity === 'city') {
|
||||||
if (selectedDistrict !== null || selectedStreet !== null) resetDrillDown();
|
if (selectedDistrict !== null || selectedStreet !== null) resetDrillDown();
|
||||||
@@ -101,7 +95,6 @@ export function MonitoringDashboard({
|
|||||||
else if (!districtParam && selectedDistrict !== null) resetDrillDown();
|
else if (!districtParam && selectedDistrict !== null) resetDrillDown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// granularity === 'street'
|
|
||||||
if (districtParam && selectedDistrict !== districtParam) drillDown('district', districtParam);
|
if (districtParam && selectedDistrict !== districtParam) drillDown('district', districtParam);
|
||||||
if (streetParam && selectedStreet !== streetParam) drillDown('street', streetParam);
|
if (streetParam && selectedStreet !== streetParam) drillDown('street', streetParam);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -112,22 +105,32 @@ export function MonitoringDashboard({
|
|||||||
setCurrentDate(defaultEndDate);
|
setCurrentDate(defaultEndDate);
|
||||||
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
|
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
|
||||||
|
|
||||||
// 数据层:图表窗口 + 病例统计/区域统计两个按需 tab 的加载与派生。
|
|
||||||
// 不持有 URL/drilldown 真相来源,只消费 currentDate 与 selectedDistrict。
|
|
||||||
const data = useMonitoringData({ activeTab, currentDate, selectedDistrict });
|
const data = useMonitoringData({ activeTab, currentDate, selectedDistrict });
|
||||||
|
|
||||||
const handleDateChange = useCallback((date: string) => {
|
const handleDateChange = useCallback(
|
||||||
setCurrentDate(date);
|
(date: string) => {
|
||||||
}, [setCurrentDate]);
|
setCurrentDate(date);
|
||||||
|
},
|
||||||
|
[setCurrentDate]
|
||||||
|
);
|
||||||
|
|
||||||
const handlePlayPause = useCallback((playing: boolean) => {
|
const handlePlayPause = useCallback(
|
||||||
setPlaying(playing);
|
(playing: boolean) => {
|
||||||
}, [setPlaying]);
|
setPlaying(playing);
|
||||||
|
},
|
||||||
|
[setPlaying]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'overview' as const, label: '概览' },
|
||||||
|
{ key: 'cases' as const, label: '病例统计' },
|
||||||
|
{ key: 'districts' as const, label: '区域统计' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="page-monitoring" className="flex flex-col h-full">
|
<div data-testid="page-monitoring" className="flex flex-col h-full min-h-0">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-4 pt-3 shrink-0">
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={error}
|
error={error}
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
@@ -138,35 +141,33 @@ export function MonitoringDashboard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Top stats bar — standardized with StatCard */}
|
|
||||||
<div className="bg-white border-b border-gray-200 px-6 py-4 shrink-0">
|
{/* 顶部指标带 + 筛选 + tab */}
|
||||||
<div className="flex items-start justify-between flex-wrap gap-x-4 gap-y-3">
|
<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">
|
||||||
<MonitoringStatsBar stats={data.stats} sparkline7d={data.sparkline7d} />
|
<MonitoringStatsBar stats={data.stats} sparkline7d={data.sparkline7d} />
|
||||||
|
|
||||||
{/* Disease filter */}
|
<div className="flex items-center gap-2 shrink-0 flex-wrap self-center">
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
{data.stats.noData && (
|
{data.stats.noData && (
|
||||||
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">该时段暂无数据</span>
|
<span className="text-xs text-warning bg-warning-light px-2 py-1 rounded-md border border-warning/20">
|
||||||
|
该时段暂无数据
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<DiseaseFilter onFilterChange={data.debouncedLoadChart} />
|
<DiseaseFilter onFilterChange={data.debouncedLoadChart} />
|
||||||
<AdminBreadcrumb />
|
<AdminBreadcrumb />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* In-page tab strip */}
|
<div className="tab-strip border-b border-border" role="tablist" aria-label="监测视图">
|
||||||
<div className="flex items-center gap-1 mt-4 border-b border-gray-100 -mb-4">
|
{tabs.map((tab) => (
|
||||||
{([
|
|
||||||
{ key: 'overview', label: '概览' },
|
|
||||||
{ key: 'cases', label: '病例统计' },
|
|
||||||
{ key: 'districts', label: '区域统计' },
|
|
||||||
] as const).map((tab) => (
|
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
className={`tab-strip__item ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key ? 'tab-strip__item--active' : ''
|
||||||
? 'border-blue-600 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -175,9 +176,12 @@ export function MonitoringDashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content — bottom padding for floating player */}
|
{/* 主内容:概览为地图舞台;统计 tab 可滚动(底部留坞空间) */}
|
||||||
<div className="flex-1 overflow-auto p-6 pb-24">
|
<div
|
||||||
{/* 概览 tab — unchanged Monitoring content */}
|
className={`flex-1 min-h-0 ${
|
||||||
|
activeTab === 'overview' ? 'overflow-hidden' : 'overflow-auto p-4 pb-28'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<OverviewTab
|
<OverviewTab
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -192,7 +196,6 @@ export function MonitoringDashboard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 病例统计 tab */}
|
|
||||||
{activeTab === 'cases' && (
|
{activeTab === 'cases' && (
|
||||||
<CaseStatsTab
|
<CaseStatsTab
|
||||||
loading={data.casesTabLoading}
|
loading={data.casesTabLoading}
|
||||||
@@ -208,7 +211,6 @@ export function MonitoringDashboard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 区域统计 tab */}
|
|
||||||
{activeTab === 'districts' && (
|
{activeTab === 'districts' && (
|
||||||
<DistrictStatsTab
|
<DistrictStatsTab
|
||||||
loading={data.districtTabLoading}
|
loading={data.districtTabLoading}
|
||||||
@@ -223,7 +225,6 @@ export function MonitoringDashboard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline Player */}
|
|
||||||
<TimelinePlayer
|
<TimelinePlayer
|
||||||
startDate={defaultStartDate}
|
startDate={defaultStartDate}
|
||||||
endDate={defaultEndDate}
|
endDate={defaultEndDate}
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ export function OverviewDashboard() {
|
|||||||
const [alertPie, setAlertPie] = useState<AlertSlice[]>([]);
|
const [alertPie, setAlertPie] = useState<AlertSlice[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [errors, setErrors] = useState<string[]>([]);
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
// 门诊/住院/全部 — 同时驱动 choropleth 与 Top5 区县条形图。
|
|
||||||
const [metric, setMetric] = useState<MetricKey>('all');
|
const [metric, setMetric] = useState<MetricKey>('all');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,7 +70,6 @@ export function OverviewDashboard() {
|
|||||||
start30.setDate(start30.getDate() - 30);
|
start30.setDate(start30.getDate() - 30);
|
||||||
const start30Str = start30.toISOString().split('T')[0];
|
const start30Str = start30.toISOString().split('T')[0];
|
||||||
|
|
||||||
// KPI sources — Promise.allSettled to survive individual failures.
|
|
||||||
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
|
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
|
||||||
caseApi.getStats(),
|
caseApi.getStats(),
|
||||||
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
|
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
|
||||||
@@ -81,18 +78,16 @@ export function OverviewDashboard() {
|
|||||||
envApi.getPollutants(7),
|
envApi.getPollutants(7),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Trend + district sources.
|
|
||||||
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
|
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
|
||||||
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
|
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
|
||||||
caseApi.getDistricts(),
|
caseApi.getDistricts(),
|
||||||
caseApi.getStats(), // reuse for top_diagnoses
|
caseApi.getStats(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const newErrors: string[] = [];
|
const newErrors: string[] = [];
|
||||||
|
|
||||||
// --- KPI ---
|
|
||||||
let totalCases = 0;
|
let totalCases = 0;
|
||||||
if (statsR.status === 'fulfilled') {
|
if (statsR.status === 'fulfilled') {
|
||||||
const s = statsR.value;
|
const s = statsR.value;
|
||||||
@@ -141,7 +136,6 @@ export function OverviewDashboard() {
|
|||||||
|
|
||||||
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
|
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
|
||||||
|
|
||||||
// --- Merge case trend + AQI ---
|
|
||||||
if (trend30R.status === 'fulfilled') {
|
if (trend30R.status === 'fulfilled') {
|
||||||
const trend30 = trend30R.value.trend || [];
|
const trend30 = trend30R.value.trend || [];
|
||||||
const aqiMap: Record<string, number> = {};
|
const aqiMap: Record<string, number> = {};
|
||||||
@@ -153,14 +147,12 @@ export function OverviewDashboard() {
|
|||||||
newErrors.push('趋势数据加载失败');
|
newErrors.push('趋势数据加载失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Districts (feeds choropleth + Top5 via normalize/join) ---
|
|
||||||
if (districtsR.status === 'fulfilled') {
|
if (districtsR.status === 'fulfilled') {
|
||||||
setDistricts(districtsR.value.districts || []);
|
setDistricts(districtsR.value.districts || []);
|
||||||
} else {
|
} else {
|
||||||
newErrors.push('区县数据加载失败');
|
newErrors.push('区县数据加载失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Top 5 Diagnoses ---
|
|
||||||
if (diagStatsR.status === 'fulfilled') {
|
if (diagStatsR.status === 'fulfilled') {
|
||||||
const topDiag = diagStatsR.value.top_diagnoses || [];
|
const topDiag = diagStatsR.value.top_diagnoses || [];
|
||||||
setTopDiagnoses(
|
setTopDiagnoses(
|
||||||
@@ -173,7 +165,6 @@ export function OverviewDashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Alert severity donut ---
|
|
||||||
const p1 = alertList.filter((a) => a.priority === 'P1').length;
|
const p1 = alertList.filter((a) => a.priority === 'P1').length;
|
||||||
const p2 = alertList.filter((a) => a.priority === 'P2').length;
|
const p2 = alertList.filter((a) => a.priority === 'P2').length;
|
||||||
setAlertPie([
|
setAlertPie([
|
||||||
@@ -191,7 +182,6 @@ export function OverviewDashboard() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 归一并聚合到 13 区一次,供 choropleth 与 Top5 共享。
|
|
||||||
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
|
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
|
||||||
const metricLookup = useMemo(
|
const metricLookup = useMemo(
|
||||||
() => buildMetricLookup(joinedDistricts, metric),
|
() => buildMetricLookup(joinedDistricts, metric),
|
||||||
@@ -205,7 +195,7 @@ export function OverviewDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
|
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
|
||||||
{errors.length > 0 && (
|
{errors.length > 0 && (
|
||||||
<div className="px-6 pt-4">
|
<div className="px-1 pt-1">
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={errors.join(';')}
|
error={errors.join(';')}
|
||||||
onRetry={() => window.location.reload()}
|
onRetry={() => window.location.reload()}
|
||||||
@@ -214,18 +204,18 @@ export function OverviewDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="space-y-5">
|
||||||
{/* Page header + honesty badge + metric toggle */}
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
|
<h1 className="font-display text-[20px] font-semibold mb-1 flex items-center gap-2 text-text-primary">
|
||||||
<Activity className="w-5 h-5 text-primary" />
|
<Activity className="w-5 h-5 text-primary" />
|
||||||
综合概览
|
综合概览
|
||||||
<span
|
<span
|
||||||
data-testid={TESTIDS.asofBadge}
|
data-testid={TESTIDS.asofBadge}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
数据截至2023-12
|
数据截至 2023-12
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[12px] text-text-secondary">病例、环境与预警关键指标总览</p>
|
<p className="text-[12px] text-text-secondary">病例、环境与预警关键指标总览</p>
|
||||||
@@ -238,35 +228,53 @@ export function OverviewDashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Row */}
|
<section aria-label="关键指标">
|
||||||
<KpiRow kpi={kpi} />
|
<KpiRow kpi={kpi} />
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Headline: Wuhan 13-district choropleth */}
|
{/* 地图舞台 + 趋势:双栏构图 */}
|
||||||
<div className="card p-4">
|
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
|
||||||
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-3">
|
<section className="workbench-panel xl:col-span-3 overflow-hidden" aria-label="区县分布">
|
||||||
武汉市13区{METRIC_LABEL[metric]}分布(高风险高亮)
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<DistrictChoropleth
|
|
||||||
metricLookup={metricLookup}
|
|
||||||
metricLabel={`${METRIC_LABEL[metric]}数`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Case + AQI trend */}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
<CaseAqiTrend data={mergedTrend} />
|
<div className="workbench-panel">
|
||||||
|
<TopDistrictsBar
|
||||||
{/* Top districts (metric-driven) + Top diagnoses */}
|
districts={joinedDistricts}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
metric={metric}
|
||||||
<TopDistrictsBar
|
metricLabel={METRIC_LABEL[metric]}
|
||||||
districts={joinedDistricts}
|
embed
|
||||||
metric={metric}
|
/>
|
||||||
metricLabel={METRIC_LABEL[metric]}
|
</div>
|
||||||
/>
|
<div className="workbench-panel">
|
||||||
<TopDiagnosesBar diagnoses={topDiagnoses} />
|
<TopDiagnosesBar diagnoses={topDiagnoses} embed />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alert severity donut */}
|
<div className="workbench-panel max-w-xl">
|
||||||
<AlertSeverityDonut data={alertPie} />
|
<AlertSeverityDonut data={alertPie} embed />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { lazy, Suspense, ComponentType } from 'react';
|
import { lazy, Suspense, ComponentType } from 'react';
|
||||||
import { Navigate, RouteObject } from 'react-router-dom';
|
import { Navigate, RouteObject } from 'react-router-dom';
|
||||||
import { LoadingState } from '@/components/ui/LoadingState';
|
import { LoadingState } from '@/components/ui/LoadingState';
|
||||||
import { RoleRedirect } from '@/components/RoleRedirect';
|
|
||||||
import { TESTIDS } from '@/utils/testids';
|
import { TESTIDS } from '@/utils/testids';
|
||||||
|
|
||||||
// 按页面懒加载,路由层负责代码分割(原先位于 App.tsx)。
|
// 按页面懒加载,路由层负责代码分割(原先位于 App.tsx)。
|
||||||
@@ -48,7 +47,7 @@ function lazyElement(Page: ComponentType): JSX.Element {
|
|||||||
|
|
||||||
// AppShell 的子路由表(在 App.tsx 中作为 <Outlet/> 的内容渲染)。
|
// AppShell 的子路由表(在 App.tsx 中作为 <Outlet/> 的内容渲染)。
|
||||||
export const appRoutes: RouteObject[] = [
|
export const appRoutes: RouteObject[] = [
|
||||||
{ index: true, element: <RoleRedirect /> },
|
{ index: true, element: <Navigate to="/monitoring" replace /> },
|
||||||
{ path: 'overview', element: lazyElement(OverviewDashboard) },
|
{ path: 'overview', element: lazyElement(OverviewDashboard) },
|
||||||
{ path: 'monitoring', element: lazyElement(MonitoringDashboard) },
|
{ path: 'monitoring', element: lazyElement(MonitoringDashboard) },
|
||||||
{ path: 'alerts', element: lazyElement(AlertsDashboard) },
|
{ path: 'alerts', element: lazyElement(AlertsDashboard) },
|
||||||
|
|||||||
@@ -178,12 +178,13 @@ export const riskApi = {
|
|||||||
|
|
||||||
getStats: (): Promise<Stats> => cachedGet('/risk/stats'),
|
getStats: (): Promise<Stats> => cachedGet('/risk/stats'),
|
||||||
|
|
||||||
// Full-Wuhan 100m risk grid served as XYZ raster tiles. Returns a Leaflet
|
// Full-Wuhan 100m risk grid XYZ tiles. Absolute URL for GeoScene WebTileLayer.
|
||||||
// URL template (NOT an axios call) — the browser fetches PNGs directly.
|
|
||||||
tileUrlTemplate: (day: 1 | 3 | 7, date?: string): string => {
|
tileUrlTemplate: (day: 1 | 3 | 7, date?: string): string => {
|
||||||
const base = import.meta.env.VITE_API_URL || '/api';
|
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}` : '';
|
const dateParam = date ? `&date=${date}` : '';
|
||||||
return `${base}/risk/tiles/{z}/{x}/{y}.png?day=${day}${dateParam}`;
|
return `${prefix}/risk/tiles/{z}/{x}/{y}.png?day=${day}${dateParam}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
getCell: (
|
getCell: (
|
||||||
|
|||||||
@@ -102,9 +102,6 @@ export const useRiskStore = create<RiskState>((set, get) => ({
|
|||||||
export { useAnalysisStore } from './analysisStore';
|
export { useAnalysisStore } from './analysisStore';
|
||||||
export { useDrilldownStore } from './drilldownStore';
|
export { useDrilldownStore } from './drilldownStore';
|
||||||
export { useDiseaseStore } from './diseaseStore';
|
export { useDiseaseStore } from './diseaseStore';
|
||||||
export { useSessionStore } from './sessionStore';
|
|
||||||
|
|
||||||
|
|
||||||
interface TimelineState {
|
interface TimelineState {
|
||||||
currentDate: string;
|
currentDate: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
@@ -177,7 +174,7 @@ interface MonitoringState {
|
|||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMonitoringStore = create<MonitoringState>((set) => ({
|
export const useMonitoringStore = create<MonitoringState>((set, get) => ({
|
||||||
gridFeatures: [],
|
gridFeatures: [],
|
||||||
aggregatedData: [],
|
aggregatedData: [],
|
||||||
districtCases: [],
|
districtCases: [],
|
||||||
@@ -221,7 +218,10 @@ export const useMonitoringStore = create<MonitoringState>((set) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchDistrictCases: async (diagnosis?: string, startDate?: string, endDate?: string) => {
|
fetchDistrictCases: async (diagnosis?: string, startDate?: string, endDate?: string) => {
|
||||||
set({ isLoading: true, error: null });
|
// 播放时间轴会高频调用:不要拨全局 isLoading,否则 Overview 会卸地图重建底图。
|
||||||
|
const firstLoad = get().districtCases.length === 0;
|
||||||
|
if (firstLoad) set({ isLoading: true, error: null });
|
||||||
|
else set({ error: null });
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (diagnosis) params.diagnosis = diagnosis;
|
if (diagnosis) params.diagnosis = diagnosis;
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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 });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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,14 +44,11 @@ export const TESTIDS = {
|
|||||||
asofBadge: 'asof-badge',
|
asofBadge: 'asof-badge',
|
||||||
outinpatientToggle: 'outinpatient-toggle',
|
outinpatientToggle: 'outinpatient-toggle',
|
||||||
|
|
||||||
// 视角/perspective + 粒度(Phase 3)
|
// 地图图层控件
|
||||||
perspectiveSwitcher: 'perspective-switcher',
|
|
||||||
perspectiveOption: 'perspective-option', // 配合角色后缀,如 perspective-option-official
|
|
||||||
granularityControl: 'granularity-control',
|
granularityControl: 'granularity-control',
|
||||||
districtRollup: 'district-rollup',
|
districtRollup: 'district-rollup',
|
||||||
gridLayerWrapper: 'grid-layer-wrapper',
|
gridLayerWrapper: 'grid-layer-wrapper',
|
||||||
clusterView: 'cluster-view',
|
patientPoint: 'patient-point',
|
||||||
patientPoint: 'patient-point', // 个体病例点标记;医生视角下必须为 0
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];
|
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 +1,15 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <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,41 +8,73 @@ export default {
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: '#2563EB',
|
DEFAULT: '#0F766E',
|
||||||
light: '#3B82F6',
|
light: '#14B8A6',
|
||||||
muted: '#DBEAFE',
|
muted: '#CCFBF1',
|
||||||
|
deep: '#0D5C56',
|
||||||
|
},
|
||||||
|
mist: {
|
||||||
|
DEFAULT: '#5B8FA8',
|
||||||
|
light: '#E8F2F6',
|
||||||
|
deep: '#3D6B82',
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
DEFAULT: '#059669',
|
DEFAULT: '#0D9488',
|
||||||
light: '#D1FAE5',
|
light: '#CCFBF1',
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
DEFAULT: '#D97706',
|
DEFAULT: '#C27803',
|
||||||
light: '#FEF3C7',
|
light: '#FEF3C7',
|
||||||
},
|
},
|
||||||
danger: {
|
danger: {
|
||||||
DEFAULT: '#DC2626',
|
DEFAULT: '#C2410C',
|
||||||
light: '#FEE2E2',
|
light: '#FFEDD5',
|
||||||
},
|
},
|
||||||
bg: {
|
bg: {
|
||||||
page: '#F8FAFC',
|
page: '#F0F4F6',
|
||||||
card: '#FFFFFF',
|
card: '#FFFFFF',
|
||||||
hover: '#F1F5F9',
|
hover: '#E8EEF1',
|
||||||
active: '#E2E8F0',
|
active: '#D8E4E9',
|
||||||
|
elevated: '#FAFCFD',
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
primary: '#1E293B',
|
primary: '#1A2B33',
|
||||||
secondary: '#64748B',
|
secondary: '#5A6F7A',
|
||||||
muted: '#94A3B8',
|
muted: '#8A9BA5',
|
||||||
},
|
},
|
||||||
border: {
|
border: {
|
||||||
DEFAULT: '#E2E8F0',
|
DEFAULT: '#D4DEE4',
|
||||||
light: '#F1F5F9',
|
light: '#E8EEF1',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'Noto Sans SC', 'system-ui', 'sans-serif'],
|
sans: ['"Noto Sans SC"', '"Outfit"', 'sans-serif'],
|
||||||
display: ['Source Sans Pro', '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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@geoscene/core'],
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
allowedHosts: ['alpha.hyh.ink'],
|
allowedHosts: ['alpha.hyh.ink'],
|
||||||
@@ -17,6 +20,17 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true,
|
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: {
|
preview: {
|
||||||
|
|||||||
1
processed/daily_avg_risk.json
Normal file
1
processed/daily_avg_risk.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"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