1 Commits

Author SHA1 Message Date
59bb135514 Merge pull request 'feat/ux-modernization' (#1) from feat/ux-modernization into main
Reviewed-on: #1
2026-06-21 09:05:53 -05:00
77 changed files with 3590 additions and 3422 deletions

View File

@@ -1,12 +1,12 @@
# CBPOA — 武汉儿童呼吸疾病风险评估系统
FastAPI + React + `@geoscene/core` + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
FastAPI + React + PyTorch GCN pipeline. 预测空气质量对儿童健康的空间风险。
## Development
```bash
# Frontend (pnpm)
cd frontend && pnpm dev # localhost:3000 → proxies /api to :8000
cd frontend && pnpm dev # localhost:5173 → proxies /api to :8000
# Backend (Python venv)
cd backend && uvicorn main:app --reload # localhost:8000
@@ -22,7 +22,6 @@ cd scripts && python train_model.py # PyTorch + MLflow
| API endpoint | `backend/routers/` |
| Database / PostGIS | `backend/database.py` |
| UI component | `frontend/src/components/` |
| GeoScene map helpers | `frontend/src/geoscene/` |
| Page view | `frontend/src/pages/` |
| API client / cache | `frontend/src/services/api.ts` |
| State management | `frontend/src/stores/` |

View File

@@ -13,7 +13,7 @@ load_dotenv()
# Paths
# ============================================================================
PROJECT_ROOT = Path(os.environ.get("CBPOA_ROOT", Path(__file__).parent.parent))
PROJECT_ROOT = Path(__file__).parent.parent
DATA_DIR = PROJECT_ROOT / "outputs" / "daily"
REPORTS_DIR = PROJECT_ROOT / "outputs" / "reports"
WUHAN_BOUNDARY_PATH = PROJECT_ROOT / "Datas" / "武汉市.geojson"

View File

@@ -13,7 +13,6 @@ from config import DATA_DIR, ALERT_P1_RISK, ALERT_P2_RISK, WUHAN_BOUNDS, LAT_STE
from models import Alert, AlertResponse
from utils.date_helpers import get_latest_date, validate_date_format
from utils.risk import risk_value_to_level
from utils.district_lookup import district_for_grid
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
@@ -96,14 +95,13 @@ def _generate_alerts_cached(date: str) -> List[Alert]:
lat, lon = grid_id_to_center(grid_id)
risk_level = risk_value_to_level(max_risk)
district = district_for_grid(grid_id)
alerts.append(
Alert(
alert_id=f"alert_{date}_{grid_id}",
grid_id=grid_id,
region=district,
street=grid_id,
region="武汉市",
street=f"Grid {grid_id}",
latitude=lat,
longitude=lon,
risk_value=max_risk,

View File

@@ -17,8 +17,6 @@ from utils.date_helpers import get_latest_date
from utils.geojson import parse_geojson_file, load_districts
from utils.geo import point_in_polygon
from utils.risk import calculate_trend
from utils.daily_risk_avg import daily_avg_risk
from utils.district_lookup import grid_district_lookup
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
@@ -85,10 +83,22 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
for i in range(days):
date = base_date - timedelta(days=days - 1 - i)
date_str = date.strftime("%Y%m%d")
# Disk+memory cached mean — avoids re-parsing ~45MB GeoJSON every request
values.append(daily_avg_risk(date_str))
filepath = DATA_DIR / f"risk_{date_str}.geojson"
if filepath.exists():
grids = parse_geojson_file(filepath)
if grids:
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
values.append(round(avg_risk, 4))
else:
values.append(0)
else:
values.append(0)
dates.append(date.strftime("%Y-%m-%d"))
# Preserve the full requested date range: a "7天" request must return 7
# contiguous points. Days with no geojson (or empty grids) stay 0 rather
# than being dropped, which previously produced fewer, non-contiguous points.
trend_direction = calculate_trend(values)
return TrendResponse(
@@ -100,8 +110,15 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
@lru_cache(maxsize=1)
def _grid_district_lookup() -> dict:
"""Backward-compatible alias — prefer utils.district_lookup."""
return grid_district_lookup()
"""Map precomputed r{row}_c{col} grid id -> district name (loaded once)."""
path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
if not path.exists():
return {}
df = pd.read_parquet(path)
# Some grids have a null district_name; drop them so the lookup only ever
# returns valid strings (missing keys fall back to "其他").
df = df.dropna(subset=["district_name"])
return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str)))
@lru_cache(maxsize=1)

View File

@@ -1,81 +0,0 @@
"""Fast daily city-wide mean risk_1d with on-disk cache.
Avoids re-parsing ~45MB GeoJSON on every /analysis/trend request.
"""
from __future__ import annotations
import json
import logging
from functools import lru_cache
from pathlib import Path
from config import DATA_DIR, PROJECT_ROOT
logger = logging.getLogger(__name__)
_CACHE_PATH = PROJECT_ROOT / "processed" / "daily_avg_risk.json"
def _read_disk_cache() -> dict[str, float]:
if not _CACHE_PATH.exists():
return {}
try:
raw = json.loads(_CACHE_PATH.read_text(encoding="utf-8"))
return {str(k): float(v) for k, v in raw.items()}
except (OSError, json.JSONDecodeError, TypeError, ValueError):
return {}
def _write_disk_cache(cache: dict[str, float]) -> None:
try:
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_CACHE_PATH.write_text(
json.dumps(cache, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
except OSError as e:
logger.warning("Failed to persist daily avg risk cache: %s", e)
def _compute_mean_risk_1d(filepath: Path) -> float:
"""Parse one risk GeoJSON and return mean risk_1d (0 if empty/missing)."""
try:
with open(filepath, "r", encoding="utf-8") as f:
geojson = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning("Failed to parse %s: %s", filepath, e)
return 0.0
total = 0.0
n = 0
for feature in geojson.get("features", []):
props = feature.get("properties") or {}
r = props.get("risk_1d")
if r is None:
continue
total += float(r)
n += 1
return round(total / n, 4) if n else 0.0
@lru_cache(maxsize=64)
def daily_avg_risk(date_yyyymmdd: str) -> float:
"""Mean risk_1d for YYYYMMDD. Memory + disk cached."""
disk = _read_disk_cache()
if date_yyyymmdd in disk:
return disk[date_yyyymmdd]
filepath = DATA_DIR / f"risk_{date_yyyymmdd}.geojson"
if not filepath.exists():
return 0.0
avg = _compute_mean_risk_1d(filepath)
disk[date_yyyymmdd] = avg
_write_disk_cache(disk)
return avg
def warm_daily_avg_risk(dates: list[str]) -> None:
"""Precompute missing dates into the disk cache (blocking)."""
for d in dates:
daily_avg_risk(d)

View File

@@ -1,21 +0,0 @@
"""Map 100m grid_id (r{row}_c{col}) → Wuhan district name."""
from functools import lru_cache
import pandas as pd
from config import PROJECT_ROOT
@lru_cache(maxsize=1)
def grid_district_lookup() -> dict[str, str]:
"""Loaded once from processed/grid_district_mapping.parquet."""
path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
if not path.exists():
return {}
df = pd.read_parquet(path)
df = df.dropna(subset=["district_name"])
return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str)))
def district_for_grid(grid_id: str, default: str = "其他") -> str:
return grid_district_lookup().get(grid_id, default)

View File

@@ -1,26 +1,32 @@
FROM python:3.11-slim
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev curl \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd --gid 1000 appgroup && \
useradd --uid 1000 --gid appgroup --shell /bin/bash --create-home appuser
WORKDIR /app
WORKDIR /home/appuser
# Copy requirements and install dependencies
COPY --chown=appuser:appgroup requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
-i https://pypi.tuna.tsinghua.edu.cn/simple
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend code
COPY --chown=appuser:appgroup . .
# Switch to non-root user
USER appuser
ENV CBPOA_ROOT=/data
# Expose port
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/docs || exit 1
# Run uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,17 +0,0 @@
[Unit]
Description=CBPOA FastAPI backend
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/cbpoa/backend
Environment=PATH=/opt/cbpoa/backend/.venv/bin:/usr/bin
EnvironmentFile=-/opt/cbpoa/backend/.env
ExecStart=/opt/cbpoa/backend/.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000 --workers 1
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target

View File

@@ -1,47 +1,83 @@
version: '3.8'
services:
postgres:
image: postgis/postgis:15-3.3
container_name: wuhan_postgres
environment:
POSTGRES_DB: wuhan_disease
POSTGRES_USER: wuhan_user
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wuhan_password}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wuhan_user -d wuhan_disease"]
interval: 5s
timeout: 5s
retries: 5
networks:
- wuhan_network
backend:
build:
context: ../backend
dockerfile: ../deploy/backend/Dockerfile
container_name: cbpoa_backend
dockerfile: Dockerfile
container_name: wuhan_backend
environment:
CBPOA_ROOT: /data
CORS_ORIGINS: "*"
volumes:
- ../outputs:/data/outputs:ro
- ../processed:/data/processed:ro
- ../Datas:/data/Datas:ro
DATABASE_URL: postgresql://wuhan_user:password@postgres:5432/wuhan_disease
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
depends_on:
postgres:
condition: service_healthy
ports:
- "8000:8000"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8000/docs || exit 1"]
interval: 15s
interval: 10s
timeout: 5s
retries: 5
start_period: 40s
restart: unless-stopped
start_period: 30s
networks:
- cbpoa_net
- wuhan_network
frontend:
build:
context: ../frontend
dockerfile: ../deploy/frontend/Dockerfile
container_name: cbpoa_frontend
dockerfile: Dockerfile
container_name: wuhan_frontend
environment:
VITE_API_URL: http://localhost:8000
depends_on:
backend:
condition: service_healthy
- backend
ports:
- "80:80"
- "3000:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
interval: 15s
test: ["CMD-SHELL", "curl -f http://localhost:80 || exit 1"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- cbpoa_net
- wuhan_network
# jupyter:
# image: jupyter/scipy-notebook:latest
# container_name: wuhan_jupyter
# ports:
# - "8888:8888"
# volumes:
# - ../processed:/home/jovyan/processed
# - ../Datas:/home/jovyan/Datas
# networks:
# - wuhan_network
volumes:
postgres_data:
driver: local
networks:
cbpoa_net:
driver: bridge
wuhan_network:
driver: bridge

View File

@@ -3,4 +3,4 @@ node_modules
*.md
tests
.env*
dist
dist

View File

@@ -5,11 +5,16 @@ FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies (using pnpm since lock file is pnpm-lock.yaml)
RUN npm install -g pnpm && pnpm install --frozen-lockfile
# Copy source code
COPY . .
ENV VITE_API_URL=/api
# Build the application
RUN pnpm run build
# =============================================================================
@@ -17,10 +22,15 @@ RUN pnpm run build
# =============================================================================
FROM nginx:alpine AS production
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy custom nginx config for SPA routing
COPY --from=builder /app/nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Health check for nginx
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-redirect --quiet --tries=1 --spider http://localhost/ || exit 1
CMD wget --no-redirect --quiet --tries=1 --spider http://localhost/ || exit 1

View File

@@ -1,37 +0,0 @@
server {
listen 80;
server_name _;
root /var/www/cbpoa;
index index.html;
client_max_body_size 20m;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/geo+json;
gzip_min_length 1000;
location /api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# Gaode basemap proxy (same as Vite /basemap-gaode)
location ~ ^/basemap-gaode/(\d+)/(\d+)/(\d+) {
proxy_pass https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=$1&x=$2&y=$3;
proxy_set_header Host webrd01.is.autonavi.com;
proxy_ssl_server_name on;
proxy_hide_header Set-Cookie;
expires 1d;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,15 +0,0 @@
# Frontend env (Vite)
# API (default: Vite proxy /api → :8000)
# VITE_API_URL=/api
# 天地图个人密钥 https://console.tianditu.gov.cn/
# 不设则用高德矢量底图GeoScene CN 仅有 tianditu-* 命名底图,且内置 tk 会 418
# VITE_TIANDITU_TK=
# GeoScene Enterprise (optional — unused in POC)
# VITE_GEOSCENE_PORTAL_URL=https://cn18:7443/geoscene
# VITE_LAYER_DISTRICTS_URL=
# VITE_LAYER_RISK_URL=
# VITE_LAYER_CASES_URL=
# VITE_GEOSCENE_WEBMAP_ID=

View File

@@ -1,21 +1,19 @@
# Frontend — React + TypeScript + GeoScene Maps SDK
# Frontend — React + TypeScript + Leaflet
## Stack
- React 18, TypeScript 5, Vite 5
- Tailwind CSS, Recharts, Zustand (state), Axios
- `@geoscene/core` (Maps SDK for JavaScript) — POC uses Tianditu basemap + local GeoJSON / FastAPI risk tiles
- Leaflet / react-leaflet (maps)
- Playwright (e2e tests)
## Structure
```
frontend/src/
main.tsx # Entry (+ GeoScene theme CSS)
main.tsx # Entry point
App.tsx # Router setup
geoscene/ # MapView helpers + layer factories
components/ # Reusable UI (maps, charts, nav)
legacy/ # Unused former Leaflet map experiments
pages/ # Route-level views
services/api.ts # Axios client with TTL cache + request dedup
stores/ # Zustand stores
@@ -30,21 +28,15 @@ frontend/src/
## Patterns
- Components: PascalCase, one per file, default export
- Map components: imperative `@geoscene/core` via `createMapView`do not pass MapView instances between components
- Coordinates: GeoScene uses `[longitude, latitude]`
- API calls: use `services/api.ts` wrappers (`riskApi`, `alertApi`, `caseApi`, `gridApi`)
- State: Zustand stores in `stores/`
- Styling: Tailwind utility classes
## Env (optional Enterprise later)
See `.env.example` for `VITE_GEOSCENE_PORTAL_URL` / `VITE_LAYER_*`. POC runs without them.
- API calls: use `services/api.ts` wrappers (`riskApi`, `alertApi`, `caseApi`, `gridApi`)they handle caching and request dedup
- State: Zustand stores in `stores/`, typed with TypeScript interfaces from `types/`
- Styling: Tailwind utility classes, no CSS modules
## Running
```bash
cd frontend
pnpm dev # localhost:3000, proxies /api → localhost:8000
pnpm dev # localhost:5173, proxies /api → localhost:8000
pnpm build # tsc + vite build → dist/
```
@@ -55,4 +47,3 @@ pnpm build # tsc + vite build → dist/
- Don't mix data fetching with presentation — fetch in pages, render in components
- Don't inline styles when Tailwind classes work
- Don't create god components (>200 lines) — extract sub-components
- Don't reintroduce Leaflet or role/perspective switchers

View File

@@ -0,0 +1,143 @@
/**
* Phase-3 acceptance tests: role-aware 预警 (alerts) view.
*
* Two view-preset invariants (D2 — frontend presets, NOT access control):
*
* 1. PRIVACY INVARIANT (doctor / ?view=cluster): the doctor sees ONLY the aggregated
* density raster + the disease filter — ZERO individual patient/case point markers.
* The page mirrors every individual marker it would actually render into a hidden
* data-testid="patient-point" element (the live Leaflet CircleMarkers are canvas/SVG
* objects with no testid and can't be counted directly). In cluster mode the page
* forces showAlertMarkers=false, so that mirror set is empty → patient-point count 0.
*
* 2. 官员 (official) GRID-HIDE: the 100m 网格 is meaningless for leadership, so the grid
* toggle wrapper (data-testid="grid-layer-wrapper") is not rendered at all.
*
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically
* (no live :8000 backend). Role is seeded via localStorage['cbpoa_role'].
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
/**
* Seed auth token (+ optional role) and mock all /api/** calls before page load.
* Response shapes copied from user-flows.spec.ts.
*/
async function seedAuthAndMockApi(page: Page, role?: string) {
await page.addInitScript((r) => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
if (r) localStorage.setItem('cbpoa_role', r);
}, role ?? '');
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/diagnoses') || url.includes('/diagnosis-list')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
test.describe('role-aware 预警 view (Phase 3)', () => {
test.use({ viewport: { width: 1280, height: 800 } });
test('医生 /alerts?view=cluster: cluster-view mounts, disease filter present, ZERO patient points', async ({
page,
}) => {
await seedAuthAndMockApi(page, 'doctor');
await page.goto('/alerts?view=cluster');
// Page + the aggregated density (cluster) map both mount.
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toBeVisible();
// The disease filter is the doctor's core tool — it must be on the page.
await expect(page.getByText('按病种筛选')).toBeVisible();
// PRIVACY INVARIANT: not a single individual patient/case point may be rendered.
// Asserted at the data level (the mirrored DOM set), independent of Leaflet internals.
await expect(page.getByTestId(TESTIDS.patientPoint)).toHaveCount(0);
// The 预警标记 toggle (which would turn individual markers on) must be absent,
// so there is no way for the doctor to opt out of the privacy invariant.
await expect(page.getByRole('button', { name: '预警标记' })).toHaveCount(0);
});
test('官员 /alerts: 100m grid hidden — grid-layer-wrapper not rendered', async ({ page }) => {
await seedAuthAndMockApi(page, 'official');
await page.goto('/alerts');
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
// The grid toggle wrapper must be entirely absent for leadership.
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(0);
});
test('admin /alerts: full behavior — grid toggle present, no forced cluster view', async ({ page }) => {
await seedAuthAndMockApi(page, 'admin');
await page.goto('/alerts');
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
// Admin keeps the grid toggle and is NOT forced into cluster view.
await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(1);
await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toHaveCount(0);
});
});

133
frontend/e2e/roles.spec.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* Phase-3 acceptance tests: 视角/perspective switcher (D2 — frontend view-presets only).
*
* Roles are NOT access control: the switcher only changes the default landing page +
* granularity/filter presets. This suite verifies the switcher renders, selecting a role
* navigates to that role's default landing URL (with its query params), and the choice
* survives a reload (persisted to localStorage['cbpoa_role']).
*
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically.
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
/** Seed auth token and mock all /api/** calls (shapes copied from user-flows.spec.ts). */
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/alerts')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ alerts: [], total: 0 }),
});
return;
}
if (url.includes('/history/aggregated')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ aggregations: [], total_records: 0 }),
});
return;
}
if (url.includes('/grids')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
});
return;
}
if (url.includes('/cases/demographics')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
age_distribution: [],
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
age_diagnosis_matrix: [],
}),
});
return;
}
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/districts') || url.includes('/districts')) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) });
return;
}
if (url.includes('/cases/trend') || url.includes('/cases')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], total: 0 }),
});
return;
}
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], items: [], total: 0 }),
});
});
}
test.describe('视角/perspective switcher (Phase 3)', () => {
// Use a desktop viewport so the top bar renders the switcher inline.
test.use({ viewport: { width: 1280, height: 800 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('perspective-switcher is visible in the top bar', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toBeVisible();
});
test('selecting 厅领导 (official) navigates to /overview?granularity=district', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await expect(switcher).toBeVisible();
await switcher.selectOption('official');
await expect(page).toHaveURL(/\/overview/);
await expect(page).toHaveURL(/granularity=district/);
});
test('selecting 医生 (doctor) navigates to /alerts?view=cluster', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await expect(switcher).toBeVisible();
await switcher.selectOption('doctor');
await expect(page).toHaveURL(/\/alerts/);
await expect(page).toHaveURL(/view=cluster/);
});
test('selected role persists across reload (localStorage cbpoa_role)', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await switcher.selectOption('doctor');
await expect(page).toHaveURL(/\/alerts/);
// localStorage should now hold the chosen role.
const stored = await page.evaluate(() => localStorage.getItem('cbpoa_role'));
expect(stored).toBe('doctor');
await page.reload();
// After reload the switcher reflects the persisted role.
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toHaveValue('doctor');
});
});

View File

@@ -4,10 +4,11 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CBPOA · 武汉儿童呼吸疾病风险评估系统</title>
<title>武汉儿童呼吸疾病风险预测平台</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&family=Noto+Sans+SC:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600&family=Source+Sans+Pro:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
</head>
<body>
<div id="root"></div>

View File

@@ -1,35 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 20m;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/geo+json;
gzip_min_length 1000;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
location ~ ^/basemap-gaode/(\d+)/(\d+)/(\d+) {
proxy_pass https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=$1&x=$2&y=$3;
proxy_set_header Host webrd01.is.autonavi.com;
proxy_ssl_server_name on;
proxy_hide_header Set-Cookie;
expires 1d;
add_header Cache-Control "public";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -9,11 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@geoscene/core": "4.32.10",
"axios": "^1.6.7",
"leaflet": "^1.9.4",
"lucide-react": "^0.330.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.30.4",
"recharts": "^2.12.0",
"zustand": "^4.5.0"
@@ -22,6 +23,7 @@
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^14.3.1",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",

489
frontend/pnpm-lock.yaml generated
View File

@@ -8,12 +8,12 @@ importers:
.:
dependencies:
'@geoscene/core':
specifier: 4.32.10
version: 4.32.10
axios:
specifier: ^1.6.7
version: 1.15.2
leaflet:
specifier: ^1.9.4
version: 1.9.4
lucide-react:
specifier: ^0.330.0
version: 0.330.0(react@18.3.1)
@@ -23,6 +23,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
react-leaflet:
specifier: ^4.2.1
version: 4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom:
specifier: ^6.30.4
version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -42,6 +45,9 @@ importers:
'@testing-library/react':
specifier: ^14.3.1
version: 14.3.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/leaflet':
specifier: ^1.9.8
version: 1.9.21
'@types/react':
specifier: ^18.2.55
version: 18.3.28
@@ -82,18 +88,6 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@arcgis/lumina@4.34.9':
resolution: {integrity: sha512-efqO+SwR+1IYf29AATh1l2FUeypRyRINTBNkaJY+KkaFe+8gqSJ45qOmputhyzF5WTRDb7WhOYgnChjp6VYPpA==}
peerDependencies:
'@lit/context': ^1.1.5
lit: ^3.3.0
peerDependenciesMeta:
'@lit/context':
optional: true
'@arcgis/toolkit@4.34.9':
resolution: {integrity: sha512-wFST+eVnCwmg9NyICVyn9bsBnR+TlWklsGqG3L7xqSTgfXo6TuCThE7wtTb8xWxsTBkGvImqMUgpgLuwQuTQ1g==}
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
@@ -350,32 +344,6 @@ packages:
cpu: [x64]
os: [win32]
'@esri/arcgis-html-sanitizer@4.1.0':
resolution: {integrity: sha512-einEveDJ/k1180NOp78PB/4Hje9eBy3dyOGLLtLn6bSkizpUfCwuYBIXOA7Y3F/k/BsTQXgKqUVwQ0eiscWMdA==}
engines: {node: '>=18.0.0'}
'@esri/calcite-components@3.3.3':
resolution: {integrity: sha512-tw+EfJ3pb+Odj71W6E9GUkm8rMbNxfW1KeiI8GgsKDzhr39hMKwY+zYYFFYuO0FONxWGvAB+B8yqB0NvH7WeHw==}
'@esri/calcite-ui-icons@4.3.0':
resolution: {integrity: sha512-iOOuRurpjFxFVw6+aXW2JpSkRBrdOpBcbdibfPOmSPqMd1aoHBtYmYXetKoH9vfrXoBiPyO2PkDnczhsu/N9IA==}
hasBin: true
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@geoscene/core@4.32.10':
resolution: {integrity: sha512-suiMmwX2dGbAfRlu0KRfAzwQOZrVz6eU/YU0kAsU4VmRSZxCir+iSnvZQzoFAdTmoiivKMRSfnKwSeOqIN4ptg==}
'@interactjs/types@1.10.27':
resolution: {integrity: sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==}
'@jest/schemas@29.6.3':
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -396,12 +364,6 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lit-labs/ssr-dom-shim@1.6.0':
resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -414,16 +376,17 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@open-wc/dedupe-mixin@1.4.0':
resolution: {integrity: sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==}
'@playwright/test@1.59.1':
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
engines: {node: '>=18'}
hasBin: true
'@polymer/polymer@3.5.2':
resolution: {integrity: sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==}
'@react-leaflet/core@2.1.0':
resolution: {integrity: sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==}
peerDependencies:
leaflet: ^1.9.0
react: ^18.0.0
react-dom: ^18.0.0
'@remix-run/router@1.23.3':
resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
@@ -633,6 +596,12 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -644,55 +613,6 @@ packages:
'@types/react@18.3.28':
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
'@types/sortablejs@1.15.9':
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@vaadin/a11y-base@24.6.11':
resolution: {integrity: sha512-yBZ0QGPngbItIJQx3FRIa9IXDW2Ftf6SFFPGhbdAZafJPBlFi6FElP9cVtL3qjJlI5KKBp/UXEcC8ehPK207gw==}
'@vaadin/checkbox@24.6.11':
resolution: {integrity: sha512-Uvd6gZ3xQQrZTtCJL6f4uLbg6mXsAKjiZto7Je39yJwUHz8r5MIQr+4mLF4zc6mYVSH/Ihj/a4n9FOuTwSEuQw==}
'@vaadin/component-base@24.6.11':
resolution: {integrity: sha512-7jR6vcJeCBgY2CNbAPLOcUTsxYspqdkA0slUGk3GwfgsRDD5FLkzqQDSM5+yE6O2+4Wah2Tk+kG/GsKGtlUlwg==}
'@vaadin/field-base@24.6.11':
resolution: {integrity: sha512-dRjxKzbW3xQAau1xuO8uZepVWaImS2wEyKDK9Oh+y8iiu4smYEmo9e4aqMqQN/sOHU6OSa4YtbyJZlvD1sBXrA==}
'@vaadin/grid@24.6.11':
resolution: {integrity: sha512-10ra384y81iIPwrVCsJwZda4vrdVeDk7SaZSXHe+pM8dVNAvBfmCNomdc9XdC6Q289GHt1AHn/3SaN+G3Wr7FQ==}
'@vaadin/icon@24.6.11':
resolution: {integrity: sha512-CKOh+I84+GZRfMHrhtATtrw3bSW5eUArgGT4cKsOY3asoCZXUdTObPD/PqKfP4e2uAA1bgLl27kOc+W8dmibJA==}
'@vaadin/input-container@24.6.11':
resolution: {integrity: sha512-fT1DK1QDp6VNKaHKkHxuuF3OlbNWbZOtK9IcLs3Q78/9jzhs8gg/nhIbQbyvhIhvjjHncIhzp0vPiw1l7Xxl+Q==}
'@vaadin/lit-renderer@24.6.11':
resolution: {integrity: sha512-JugFumbBQP4r28+HcbdDUVVGs5VRsqanLsifjkVrz/xb4saWv460lEYco5ES+StH+xZ2IuJZmEjEFUBSrVR/tA==}
'@vaadin/text-field@24.6.11':
resolution: {integrity: sha512-pqDPTf5AGwz5CcMfyFmF2215WzwWpjfudKlCje6u2qOcA/9kqBYCTQolemVYCtMDwn0yHXFSp4dU8UasxMCUJA==}
'@vaadin/vaadin-development-mode-detector@2.0.7':
resolution: {integrity: sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==}
'@vaadin/vaadin-lumo-styles@24.6.11':
resolution: {integrity: sha512-WRluczao8lZgImdtl66v09YjFULb1iLAhcU48aiR9igAT7h6aLeHYBvRH3AA/gBlUNwHd4xlBSl89p4HP2GGog==}
'@vaadin/vaadin-material-styles@24.6.11':
resolution: {integrity: sha512-tDumwlaDp/s9u++MPi64I1o2ls/drWOZf4xVPhztUjt3NwYJUeVXtwu39q0wBRIeRM7UBrs06kug2CVT72U4qQ==}
'@vaadin/vaadin-themable-mixin@24.6.11':
resolution: {integrity: sha512-xCmn3X+2C7nI9LQn2OqLLkLw7VeJOCo99DlHwnxeLZpJJ/s8bjDXcIWflS+IOChzHixgEFkDSoLcNYoCR1RvYg==}
'@vaadin/vaadin-usage-statistics@2.1.3':
resolution: {integrity: sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -714,13 +634,6 @@ packages:
'@vitest/utils@1.6.1':
resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==}
'@webcomponents/shadycss@1.11.2':
resolution: {integrity: sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==}
'@zip.js/zip.js@2.7.73':
resolution: {integrity: sha512-I2UP8/rdQE5hTtVVL08B7P8XuwXiKuuMUPjNuFOVL/9b+8IsExR9S5jz2H58u0rJjU4M1BikLgqEMG8gZJZVBw==}
engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=16.5.0'}
acorn-walk@8.3.5:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
@@ -851,41 +764,17 @@ packages:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-convert@3.1.3:
resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==}
engines: {node: '>=14.6'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-name@2.1.0:
resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==}
engines: {node: '>=12.20'}
color-string@2.1.4:
resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==}
engines: {node: '>=18'}
color@5.0.3:
resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==}
engines: {node: '>=18'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
composed-offset-position@0.0.6:
resolution: {integrity: sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==}
peerDependencies:
'@floating-ui/utils': ^0.2.5
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@@ -904,9 +793,6 @@ packages:
engines: {node: '>=4'}
hasBin: true
cssfilter@0.0.10:
resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==}
cssstyle@4.6.0:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'}
@@ -1046,9 +932,6 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.49.0:
resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -1092,9 +975,6 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
focus-trap@7.8.0:
resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
@@ -1209,9 +1089,6 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
interactjs@1.10.27:
resolution: {integrity: sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==}
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -1342,6 +1219,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -1349,15 +1229,6 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
lit-element@4.2.2:
resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==}
lit-html@3.3.3:
resolution: {integrity: sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==}
lit@3.3.3:
resolution: {integrity: sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==}
local-pkg@0.5.1:
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
engines: {node: '>=14'}
@@ -1383,10 +1254,6 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0
luxon@3.5.0:
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -1394,11 +1261,6 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@15.0.12:
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
engines: {node: '>= 18'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1638,6 +1500,13 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-leaflet@4.2.1:
resolution: {integrity: sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==}
peerDependencies:
leaflet: ^1.9.0
react: ^18.0.0
react-dom: ^18.0.0
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -1779,9 +1648,6 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sortablejs@1.15.7:
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1823,9 +1689,6 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tabbable@6.5.0:
resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==}
tailwindcss@3.4.19:
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
engines: {node: '>=14.0.0'}
@@ -1838,10 +1701,6 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
timezone-groups@0.10.4:
resolution: {integrity: sha512-AnkJYrbb7uPkDCEqGeVJiawZNiwVlSkkeX4jZg1gTEguClhyX+/Ezn07KB6DT29tG3UN418ldmS/W6KqGOTDjg==}
engines: {node: '>=18.12.0'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -1875,17 +1734,10 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-detect@4.1.0:
resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==}
engines: {node: '>=4'}
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -2041,11 +1893,6 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xss@1.0.13:
resolution: {integrity: sha512-clu7dxTm1e8Mo5fz3n/oW3UCXBfV89xZ72jM8yzo1vR/pIS0w3sgB3XV2H8Vm6zfGnHL0FzvLJPJEBhd86/z4Q==}
engines: {node: '>= 0.10.0'}
hasBin: true
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -2074,17 +1921,6 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@arcgis/lumina@4.34.9(lit@3.3.3)':
dependencies:
'@arcgis/toolkit': 4.34.9
csstype: 3.2.3
lit: 3.3.3
tslib: 2.8.1
'@arcgis/toolkit@4.34.9':
dependencies:
tslib: 2.8.1
'@asamuzakjp/css-color@3.2.0':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -2296,56 +2132,6 @@ snapshots:
'@esbuild/win32-x64@0.21.5':
optional: true
'@esri/arcgis-html-sanitizer@4.1.0':
dependencies:
xss: 1.0.13
'@esri/calcite-components@3.3.3':
dependencies:
'@arcgis/lumina': 4.34.9(lit@3.3.3)
'@arcgis/toolkit': 4.34.9
'@esri/calcite-ui-icons': 4.3.0
'@floating-ui/dom': 1.8.0
'@floating-ui/utils': 0.2.12
'@types/sortablejs': 1.15.9
color: 5.0.3
composed-offset-position: 0.0.6(@floating-ui/utils@0.2.12)
es-toolkit: 1.49.0
focus-trap: 7.8.0
interactjs: 1.10.27
lit: 3.3.3
sortablejs: 1.15.7
timezone-groups: 0.10.4
type-fest: 4.41.0
transitivePeerDependencies:
- '@lit/context'
'@esri/calcite-ui-icons@4.3.0': {}
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/utils@0.2.12': {}
'@geoscene/core@4.32.10':
dependencies:
'@esri/arcgis-html-sanitizer': 4.1.0
'@esri/calcite-components': 3.3.3
'@vaadin/grid': 24.6.11
'@zip.js/zip.js': 2.7.73
luxon: 3.5.0
marked: 15.0.12
transitivePeerDependencies:
- '@lit/context'
'@interactjs/types@1.10.27': {}
'@jest/schemas@29.6.3':
dependencies:
'@sinclair/typebox': 0.27.10
@@ -2369,12 +2155,6 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lit-labs/ssr-dom-shim@1.6.0': {}
'@lit/reactive-element@2.1.2':
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2387,15 +2167,15 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
'@open-wc/dedupe-mixin@1.4.0': {}
'@playwright/test@1.59.1':
dependencies:
playwright: 1.59.1
'@polymer/polymer@3.5.2':
'@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@webcomponents/shadycss': 1.11.2
leaflet: 1.9.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@remix-run/router@1.23.3': {}
@@ -2557,6 +2337,12 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/geojson@7946.0.16': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.7(@types/react@18.3.28)':
@@ -2568,118 +2354,6 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/sortablejs@1.15.9': {}
'@types/trusted-types@2.0.7': {}
'@vaadin/a11y-base@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
lit: 3.3.3
'@vaadin/checkbox@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/component-base': 24.6.11
'@vaadin/field-base': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/component-base@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/vaadin-development-mode-detector': 2.0.7
'@vaadin/vaadin-usage-statistics': 2.1.3
lit: 3.3.3
'@vaadin/field-base@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/component-base': 24.6.11
lit: 3.3.3
'@vaadin/grid@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/checkbox': 24.6.11
'@vaadin/component-base': 24.6.11
'@vaadin/lit-renderer': 24.6.11
'@vaadin/text-field': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/icon@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/input-container@24.6.11':
dependencies:
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/lit-renderer@24.6.11':
dependencies:
lit: 3.3.3
'@vaadin/text-field@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
'@polymer/polymer': 3.5.2
'@vaadin/a11y-base': 24.6.11
'@vaadin/component-base': 24.6.11
'@vaadin/field-base': 24.6.11
'@vaadin/input-container': 24.6.11
'@vaadin/vaadin-lumo-styles': 24.6.11
'@vaadin/vaadin-material-styles': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
lit: 3.3.3
'@vaadin/vaadin-development-mode-detector@2.0.7': {}
'@vaadin/vaadin-lumo-styles@24.6.11':
dependencies:
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/icon': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
'@vaadin/vaadin-material-styles@24.6.11':
dependencies:
'@polymer/polymer': 3.5.2
'@vaadin/component-base': 24.6.11
'@vaadin/vaadin-themable-mixin': 24.6.11
'@vaadin/vaadin-themable-mixin@24.6.11':
dependencies:
'@open-wc/dedupe-mixin': 1.4.0
lit: 3.3.3
'@vaadin/vaadin-usage-statistics@2.1.3':
dependencies:
'@vaadin/vaadin-development-mode-detector': 2.0.7
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
dependencies:
'@babel/core': 7.29.0
@@ -2721,10 +2395,6 @@ snapshots:
loupe: 2.3.7
pretty-format: 29.7.0
'@webcomponents/shadycss@1.11.2': {}
'@zip.js/zip.js@2.7.73': {}
acorn-walk@8.3.5:
dependencies:
acorn: 8.16.0
@@ -2862,35 +2532,14 @@ snapshots:
dependencies:
color-name: 1.1.4
color-convert@3.1.3:
dependencies:
color-name: 2.1.0
color-name@1.1.4: {}
color-name@2.1.0: {}
color-string@2.1.4:
dependencies:
color-name: 2.1.0
color@5.0.3:
dependencies:
color-convert: 3.1.3
color-string: 2.1.4
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@2.20.3: {}
commander@4.1.1: {}
composed-offset-position@0.0.6(@floating-ui/utils@0.2.12):
dependencies:
'@floating-ui/utils': 0.2.12
confbox@0.1.8: {}
convert-source-map@2.0.0: {}
@@ -2905,8 +2554,6 @@ snapshots:
cssesc@3.0.0: {}
cssfilter@0.0.10: {}
cssstyle@4.6.0:
dependencies:
'@asamuzakjp/css-color': 3.2.0
@@ -3056,8 +2703,6 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.3
es-toolkit@1.49.0: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -3126,10 +2771,6 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
focus-trap@7.8.0:
dependencies:
tabbable: 6.5.0
follow-redirects@1.16.0: {}
for-each@0.3.5:
@@ -3234,10 +2875,6 @@ snapshots:
indent-string@4.0.0: {}
interactjs@1.10.27:
dependencies:
'@interactjs/types': 1.10.27
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -3373,26 +3010,12 @@ snapshots:
json5@2.2.3: {}
leaflet@1.9.4: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
lit-element@4.2.2:
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@lit/reactive-element': 2.1.2
lit-html: 3.3.3
lit-html@3.3.3:
dependencies:
'@types/trusted-types': 2.0.7
lit@3.3.3:
dependencies:
'@lit/reactive-element': 2.1.2
lit-element: 4.2.2
lit-html: 3.3.3
local-pkg@0.5.1:
dependencies:
mlly: 1.8.2
@@ -3418,16 +3041,12 @@ snapshots:
dependencies:
react: 18.3.1
luxon@3.5.0: {}
lz-string@1.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@15.0.12: {}
math-intrinsics@1.1.0: {}
merge-stream@2.0.0: {}
@@ -3627,6 +3246,13 @@ snapshots:
react-is@18.3.1: {}
react-leaflet@4.2.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@react-leaflet/core': 2.1.0(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
leaflet: 1.9.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-refresh@0.17.0: {}
react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -3823,8 +3449,6 @@ snapshots:
signal-exit@4.1.0: {}
sortablejs@1.15.7: {}
source-map-js@1.2.1: {}
stackback@0.0.2: {}
@@ -3864,8 +3488,6 @@ snapshots:
symbol-tree@3.2.4: {}
tabbable@6.5.0: {}
tailwindcss@3.4.19:
dependencies:
'@alloc/quick-lru': 5.2.0
@@ -3902,8 +3524,6 @@ snapshots:
dependencies:
any-promise: 1.3.0
timezone-groups@0.10.4: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@@ -3934,12 +3554,8 @@ snapshots:
ts-interface-checker@0.1.13: {}
tslib@2.8.1: {}
type-detect@4.1.0: {}
type-fest@4.41.0: {}
typescript@5.9.3: {}
ufo@1.6.4: {}
@@ -4097,11 +3713,6 @@ snapshots:
xmlchars@2.2.0: {}
xss@1.0.13:
dependencies:
commander: 2.20.3
cssfilter: 0.0.10
yallist@3.1.1: {}
yocto-queue@1.2.2: {}

View File

@@ -75,12 +75,7 @@ function App() {
return (
<ErrorBoundary>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<BrowserRouter>
{token ? (
<AuthedApp onLogout={handleLogout} />
) : (

View File

@@ -30,6 +30,11 @@ describe('Component exports', () => {
expect((mod as any).default || mod.DiseaseFilter).toBeDefined();
});
it('ChatBot 可以被导入', async () => {
const mod = await import('@/components/ChatBot');
expect((mod as any).default || mod.ChatBot).toBeDefined();
});
it('TimelinePlayer 可以被导入', async () => {
const mod = await import('@/components/TimelinePlayer');
expect((mod as any).default || mod.TimelinePlayer).toBeDefined();
@@ -40,6 +45,11 @@ describe('Component exports', () => {
expect((mod as any).default || mod.StatisticalCharts).toBeDefined();
});
it('RiskMap 可以被导入', async () => {
const mod = await import('@/components/RiskMap');
expect((mod as any).default || mod.RiskMap).toBeDefined();
});
it('AlertMap 可以被导入', async () => {
const mod = await import('@/components/AlertMap');
expect((mod as any).default || mod.AlertMap).toBeDefined();
@@ -50,9 +60,9 @@ describe('Component exports', () => {
expect((mod as any).default || mod.CaseLocationMap).toBeDefined();
});
it('geoscene createMapView 可以被导入', async () => {
const mod = await import('@/geoscene');
expect(mod.createMapView).toBeDefined();
it('CaseMap 可以被导入', async () => {
const mod = await import('@/components/CaseMap');
expect((mod as any).default || mod.CaseMap).toBeDefined();
});
it('DistributionChart 可以被导入', async () => {
@@ -65,6 +75,11 @@ describe('Component exports', () => {
expect((mod as any).default || mod.GridStatsOverlay).toBeDefined();
});
it('LodGridLayer 可以被导入', async () => {
const mod = await import('@/components/LodGridLayer');
expect((mod as any).default || mod.LodGridLayer).toBeDefined();
});
it('AdminBreadcrumb 可以被导入', async () => {
const mod = await import('@/components/AdminBreadcrumb');
expect((mod as any).default || mod.AdminBreadcrumb).toBeDefined();

View File

@@ -1,18 +1,9 @@
import { useEffect, useRef, useState, useCallback, memo } from 'react';
import type MapView from '@geoscene/core/views/MapView';
import type WebTileLayer from '@geoscene/core/layers/WebTileLayer';
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import Graphic from '@geoscene/core/Graphic';
import Point from '@geoscene/core/geometry/Point';
import Polygon from '@geoscene/core/geometry/Polygon';
import SimpleFillSymbol from '@geoscene/core/symbols/SimpleFillSymbol';
import * as reactiveUtils from '@geoscene/core/core/reactiveUtils';
import L from 'leaflet';
import { GridStatsOverlay } from '@/components/GridStatsOverlay';
import { riskApi } from '@/services/api';
import type { RiskGridStats } from '@/services/api';
import type { Alert } from '@/types';
import { createMapView } from '@/geoscene/createMapView';
import { createRiskTileLayer, createGraphicsLayer, pointGraphic } from '@/geoscene/layers';
export interface CellInfo {
lat: number;
@@ -37,8 +28,10 @@ interface AlertMapProps {
isFullscreen?: boolean;
}
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
const GRID_OPACITY = 0.72;
// Mirrors the server-side colormap in backend/utils/risk_raster.py.
const RISK_LEGEND: [number, number, string][] = [
[0.25, 0.4, '#38b000'],
[0.4, 0.6, '#facc15'],
@@ -64,83 +57,79 @@ function AlertMapComponent({
isFullscreen = false,
}: AlertMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<MapView | null>(null);
const destroyRef = useRef<(() => void) | null>(null);
const riskLayerRef = useRef<WebTileLayer | null>(null);
const alertLayerRef = useRef<GraphicsLayer | null>(null);
const selectLayerRef = useRef<GraphicsLayer | null>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const riskTileRef = useRef<L.TileLayer | null>(null);
const alertLayerRef = useRef<L.LayerGroup | null>(null);
const markerMapRef = useRef<Map<string, L.CircleMarker>>(new Map());
const selectedMarkerRef = useRef<L.Rectangle | null>(null);
const clickHandlerRef = useRef(onGridClick);
const cellInfoRef = useRef(onCellInfo);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
// Latest inputs the once-subscribed map handlers read, so we never have to
// re-subscribe (and tear down listeners) when prop/callback identities change.
const inputsRef = useRef({ filteredAlerts, showAlertMarkers, forecastDay });
const [gridStats, setGridStats] = useState<RiskGridStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [mapReady, setMapReady] = useState(false);
useEffect(() => {
clickHandlerRef.current = onGridClick;
}, [onGridClick]);
useEffect(() => {
cellInfoRef.current = onCellInfo;
}, [onCellInfo]);
useEffect(() => { clickHandlerRef.current = onGridClick; }, [onGridClick]);
useEffect(() => { cellInfoRef.current = onCellInfo; }, [onCellInfo]);
useEffect(() => {
inputsRef.current = { filteredAlerts, showAlertMarkers, forecastDay };
}, [filteredAlerts, showAlertMarkers, forecastDay]);
// --- Initialize map once ---
useEffect(() => {
if (!mapRef.current || viewRef.current) return;
if (!mapRef.current || mapInstanceRef.current) return;
const { map, view, destroy } = createMapView({
container: mapRef.current,
zoom: 10,
const map = L.map(mapRef.current, {
center: WUHAN_CENTER,
zoom: 9,
zoomControl: true,
preferCanvas: true,
});
const riskTiles = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0);
const alertLayer = createGraphicsLayer('预警点');
const selectLayer = createGraphicsLayer('选中');
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
map.addMany([riskTiles, alertLayer, selectLayer]);
// Full-Wuhan 100m risk grid as raster tiles. The browser only fetches PNGs
// (Leaflet caches them per z/x/y); LOD is inherent in the tile pyramid.
const riskTiles = L.tileLayer(riskApi.tileUrlTemplate(forecastDay), {
opacity: showGrid ? GRID_OPACITY : 0,
maxNativeZoom: 16,
maxZoom: 19,
updateWhenZooming: false,
keepBuffer: 2,
zIndex: 200,
}).addTo(map);
riskTileRef.current = riskTiles;
riskLayerRef.current = riskTiles;
const alertLayer = L.layerGroup().addTo(map);
alertLayerRef.current = alertLayer;
selectLayerRef.current = selectLayer;
viewRef.current = view;
destroyRef.current = destroy;
try {
view.ui.move('zoom', 'bottom-left');
} catch {
/* ignore */
}
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
const clickHandle = view.on('click', async (event) => {
if (!event.mapPoint) return;
const lat = event.mapPoint.latitude;
const lng = event.mapPoint.longitude;
if (lat == null || lng == null) return;
// Click-to-inspect: query the 100m cell under the cursor. Clicks on alert
// markers are consumed by the marker handler and never reach this.
map.on('click', async (e: L.LeafletMouseEvent) => {
const { lat, lng } = e.latlng;
const { filteredAlerts: alerts, forecastDay: day } = inputsRef.current;
try {
const cell = await riskApi.getCell(lat, lng, day);
// Nearest alert (squared degree distance — cheap, no sqrt).
let nearestId: string | null = null;
let minSq = Infinity;
for (const a of alerts) {
const dx = a.latitude - lat;
const dy = a.longitude - lng;
const d = dx * dx + dy * dy;
if (d < minSq) {
minSq = d;
nearestId = a.grid_id;
}
if (d < minSq) { minSq = d; nearestId = a.grid_id; }
}
const nearestDist = Math.sqrt(minSq);
if (nearestId && nearestDist < 0.01) {
clickHandlerRef.current(nearestId);
} else if (cellInfoRef.current) {
cellInfoRef.current({
lat,
lon: lng,
lat, lon: lng,
risk: cell.risk_value,
grid_id: cell.grid_id,
risk_1d: cell.risk_1d,
@@ -150,199 +139,160 @@ function AlertMapComponent({
nearestAlertDist: nearestDist,
});
}
} catch {
/* ignore transient click errors */
}
} catch { /* transient fetch error — ignore the click */ }
});
mapInstanceRef.current = map;
const resizeObserver = new ResizeObserver(() => {
mapInstanceRef.current?.invalidateSize({ animate: false });
});
resizeObserver.observe(mapRef.current);
resizeObserverRef.current = resizeObserver;
return () => {
clickHandle.remove();
riskLayerRef.current = null;
resizeObserver.disconnect();
resizeObserverRef.current = null;
markerMapRef.current.clear();
alertLayerRef.current = null;
selectLayerRef.current = null;
viewRef.current = null;
destroy();
destroyRef.current = null;
setMapReady(false);
riskTileRef.current = null;
map.remove();
mapInstanceRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// --- Update risk tiles + stats when the forecast horizon changes ---
useEffect(() => {
const map = viewRef.current?.map;
if (!map || !mapReady) return;
if (riskLayerRef.current) {
map.remove(riskLayerRef.current);
riskLayerRef.current.destroy();
}
const next = createRiskTileLayer(forecastDay, showGrid ? GRID_OPACITY : 0);
map.add(next);
riskLayerRef.current = next;
const layer = riskTileRef.current;
if (layer) layer.setUrl(riskApi.tileUrlTemplate(forecastDay));
let cancelled = false;
setStatsLoading(true);
riskApi
.getGridStats(forecastDay)
.then((s) => {
if (!cancelled) setGridStats(s);
})
.catch(() => {
if (!cancelled) setGridStats(null);
})
.finally(() => {
if (!cancelled) setStatsLoading(false);
});
return () => {
cancelled = true;
};
}, [forecastDay, mapReady]);
riskApi.getGridStats(forecastDay)
.then((s) => { if (!cancelled) setGridStats(s); })
.catch(() => { if (!cancelled) setGridStats(null); })
.finally(() => { if (!cancelled) setStatsLoading(false); });
return () => { cancelled = true; };
}, [forecastDay]);
// --- Toggle grid visibility without rebuilding tiles ---
useEffect(() => {
if (riskLayerRef.current) {
riskLayerRef.current.opacity = showGrid ? GRID_OPACITY : 0;
}
riskTileRef.current?.setOpacity(showGrid ? GRID_OPACITY : 0);
}, [showGrid]);
// --- Render alert markers via diffing against a persistent layer group ---
const renderAlertMarkers = useCallback(() => {
const map = mapInstanceRef.current;
const layer = alertLayerRef.current;
const view = viewRef.current;
if (!layer || !view) return;
layer.removeAll();
if (!map || !layer) return;
const markerMap = markerMapRef.current;
const { filteredAlerts: alerts, showAlertMarkers: showMarkers } = inputsRef.current;
if (!showMarkers || !alerts?.length) return;
const extent = view.extent;
if (!showMarkers || !alerts || alerts.length === 0) {
if (markerMap.size > 0) { layer.clearLayers(); markerMap.clear(); }
return;
}
const b = map.getBounds();
const south = b.getSouth(), north = b.getNorth(), west = b.getWest(), east = b.getEast();
const maxMarkers = 500;
const step = Math.max(1, Math.floor(alerts.length / maxMarkers));
const graphics: Graphic[] = [];
const desired = new Map<string, Alert>();
for (let i = 0; i < alerts.length; i += step) {
const alert = alerts[i];
if (!alert.latitude || !alert.longitude) continue;
if (extent) {
if (
alert.longitude < extent.xmin ||
alert.longitude > extent.xmax ||
alert.latitude < extent.ymin ||
alert.latitude > extent.ymax
) {
continue;
}
}
const isP1 = alert.priority === 'P1';
const g = pointGraphic(
alert.longitude,
alert.latitude,
isP1 ? '#ef4444' : '#f97316',
isP1 ? 10 : 7,
{
grid_id: alert.grid_id,
priority: alert.priority,
risk_value: alert.risk_value,
region: alert.region,
street: alert.street,
}
);
g.popupTemplate = {
title: '{priority}',
content: '{region} {street}<br/>风险 {(risk_value * 100).toFixed(0)}%',
};
graphics.push(g);
if (alert.latitude < south || alert.latitude > north ||
alert.longitude < west || alert.longitude > east) continue;
const key = alert.grid_id || `${alert.latitude},${alert.longitude},${i}`;
desired.set(key, alert);
}
layer.addMany(graphics);
for (const [key, marker] of markerMap) {
if (!desired.has(key)) { layer.removeLayer(marker); markerMap.delete(key); }
}
for (const [key, alert] of desired) {
if (markerMap.has(key)) continue;
const isP1 = alert.priority === 'P1';
const marker = L.circleMarker([alert.latitude, alert.longitude], {
radius: isP1 ? 6 : 4,
fillColor: isP1 ? '#ef4444' : '#f97316',
fillOpacity: 0.7,
color: isP1 ? '#ef4444' : '#f97316',
weight: 2,
dashArray: isP1 ? undefined : '4 2',
});
marker.bindTooltip(
`<div style="font-size:12px;">
<strong>${alert.priority}</strong> · ${(alert.risk_value * 100).toFixed(0)}%<br/>
${alert.region || ''} ${alert.street || ''}
</div>`,
{ direction: 'top', offset: [0, -5] }
);
const gridId = alert.grid_id;
marker.on('click', () => { if (gridId) clickHandlerRef.current(gridId); });
marker.addTo(layer);
markerMap.set(key, marker);
}
}, []);
useEffect(() => {
if (!mapReady) return;
renderAlertMarkers();
}, [filteredAlerts, showAlertMarkers, mapReady, renderAlertMarkers]);
}, [filteredAlerts, showAlertMarkers, renderAlertMarkers]);
// Re-render markers on pan/zoom, throttled, subscribed once per map instance.
useEffect(() => {
const view = viewRef.current;
if (!view || !mapReady) return;
const map = mapInstanceRef.current;
if (!map) return;
let throttle: ReturnType<typeof setTimeout> | null = null;
const handle = reactiveUtils.watch(
() => view.extent,
() => {
if (throttle) return;
throttle = setTimeout(() => {
throttle = null;
renderAlertMarkers();
}, 150);
}
);
const handleMove = () => {
if (throttle) return;
throttle = setTimeout(() => { throttle = null; renderAlertMarkers(); }, 150);
};
map.on('moveend', handleMove);
return () => {
handle.remove();
map.off('moveend', handleMove);
if (throttle) clearTimeout(throttle);
};
}, [mapReady, renderAlertMarkers]);
}, [renderAlertMarkers]);
// --- Selected alert highlight (located from the alert list, no grid scan) ---
useEffect(() => {
const layer = selectLayerRef.current;
const view = viewRef.current;
if (!layer || !view) return;
layer.removeAll();
const map = mapInstanceRef.current;
if (!map) return;
if (selectedMarkerRef.current) {
try { map.removeLayer(selectedMarkerRef.current); } catch { /* ok */ }
selectedMarkerRef.current = null;
}
if (!selectedGridId) return;
const alert = filteredAlerts.find((a) => a.grid_id === selectedGridId);
if (!alert) return;
const latHalf = 0.00045;
const lonHalf = 0.00052;
const ring = [
[alert.longitude - lonHalf, alert.latitude - latHalf],
[alert.longitude + lonHalf, alert.latitude - latHalf],
[alert.longitude + lonHalf, alert.latitude + latHalf],
[alert.longitude - lonHalf, alert.latitude + latHalf],
[alert.longitude - lonHalf, alert.latitude - latHalf],
];
layer.add(
new Graphic({
geometry: new Polygon({ rings: [ring], spatialReference: { wkid: 4326 } }),
symbol: new SimpleFillSymbol({
color: [59, 130, 246, 0.3],
outline: { color: [59, 130, 246], width: 2 },
}),
})
);
view.goTo(
{
center: new Point({ longitude: alert.longitude, latitude: alert.latitude }),
zoom: Math.max(view.zoom, 13),
},
{ duration: 500 }
).catch(() => undefined);
const latHalf = 0.00045, lonHalf = 0.00052;
const rect = L.rectangle(
[[alert.latitude - latHalf, alert.longitude - lonHalf],
[alert.latitude + latHalf, alert.longitude + lonHalf]],
{ fillColor: '#3b82f6', fillOpacity: 0.3, color: '#3b82f6', weight: 3 }
).addTo(map);
selectedMarkerRef.current = rect;
map.flyTo([alert.latitude, alert.longitude], Math.max(map.getZoom(), 13), { duration: 0.5 });
}, [selectedGridId, filteredAlerts]);
// --- Invalidate size after fullscreen toggle (CSS transition ~200ms) ---
useEffect(() => {
// MapView observes container size; force a layout tick after fullscreen CSS settles.
const timer = setTimeout(() => {
const el = mapRef.current;
if (el) {
el.style.height = el.style.height;
}
}, 200);
const map = mapInstanceRef.current;
if (!map) return;
map.invalidateSize({ animate: false });
const timer = setTimeout(() => map.invalidateSize({ animate: true }), 200);
return () => clearTimeout(timer);
}, [isFullscreen]);
// 工作台零内边距后,给地图更多垂直空间(非小卡片)
const containerHeight = isFullscreen ? 'calc(100vh - 100px)' : 'calc(100vh - 220px)';
const containerHeight = isFullscreen ? 'calc(100vh - 120px)' : 'calc(100vh - 280px)';
return (
<div className="relative">
<div
ref={mapRef}
className="w-full rounded-lg overflow-hidden bg-slate-100"
style={{ height: containerHeight }}
/>
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/70 rounded-lg text-[13px] text-text-muted">
</div>
)}
<div ref={mapRef} className="w-full rounded-lg overflow-hidden" style={{ height: containerHeight }} />
<GridStatsOverlay
count={gridStats?.cell_count ?? 0}
@@ -355,16 +305,14 @@ function AlertMapComponent({
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
<div className="text-[11px] font-semibold text-text-secondary mb-2"> (100m )</div>
<div className="space-y-1.5">
{RISK_LEGEND.slice()
.reverse()
.map(([min, max, color]) => (
<div key={color} className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
<span className="text-[11px] text-text-secondary">
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
</span>
</div>
))}
{RISK_LEGEND.slice().reverse().map(([min, max, color]) => (
<div key={color} className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: color }} />
<span className="text-[11px] text-text-secondary">
{getRiskLabel((min + max) / 2)} ({(min * 100).toFixed(0)}-{(max * 100).toFixed(0)}%)
</span>
</div>
))}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border border-border-light bg-transparent" />
<span className="text-[11px] text-text-muted">&lt;25% </span>

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
import { TopNav } from '@/components/TopNav';
import { SideNav } from '@/components/SideNav';
import { RouteErrorBoundary } from '@/components/RouteErrorBoundary';
@@ -10,6 +10,7 @@ interface AppShellProps {
onLogout?: () => void;
}
// 收集容器内当前可聚焦的元素,供初始聚焦与焦点循环陷阱使用。
function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(
@@ -18,29 +19,28 @@ function getFocusable(container: HTMLElement): HTMLElement[] {
).filter((el) => el.offsetParent !== null || el === document.activeElement);
}
/** 监测 / 预警:地图工作台,主区零内边距、禁止外层滚动,把高度留给地图。 */
function isMapWorkbench(pathname: string): boolean {
return pathname.startsWith('/monitoring') || pathname.startsWith('/alerts');
}
// 仅负责布局骨架(顶栏 / 侧栏 / 内容区),不涉及路由匹配与鉴权。
export function AppShell({ onLogout }: AppShellProps) {
const alerts = useRiskStore((s) => s.alerts);
const location = useLocation();
const mapWorkbench = isMapWorkbench(location.pathname);
const [drawerOpen, setDrawerOpen] = useState(false);
// 提升手风琴展开态:导轨与抽屉两份 SideNav 共享,保持同步。
const [expandedNav, setExpandedNav] = useState<string | null>('monitoring');
const drawerRef = useRef<HTMLElement>(null);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
// 抽屉作为模态ESC 关闭、锁定 body 滚动、焦点移入并在关闭后归还给汉堡。
useEffect(() => {
if (!drawerOpen) return;
const opener = document.activeElement as HTMLElement | null;
// 锁定 body 滚动,关闭时还原原值。
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// 焦点移入抽屉(优先第一个可聚焦元素,否则聚焦抽屉容器本身)。
const drawer = drawerRef.current;
const focusables = drawer ? getFocusable(drawer) : [];
(focusables[0] ?? drawer)?.focus();
@@ -51,6 +51,7 @@ export function AppShell({ onLogout }: AppShellProps) {
closeDrawer();
return;
}
// 焦点循环陷阱Tab 在抽屉内首尾元素之间循环。
if (e.key === 'Tab' && drawer) {
const items = getFocusable(drawer);
if (items.length === 0) {
@@ -76,6 +77,7 @@ export function AppShell({ onLogout }: AppShellProps) {
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = prevOverflow;
// 关闭后把焦点还给打开抽屉的元素(汉堡按钮),回退到按 testid 查询。
const restoreTarget =
opener ??
document.querySelector<HTMLElement>(`[data-testid="${TESTIDS.hamburger}"]`);
@@ -84,22 +86,15 @@ export function AppShell({ onLogout }: AppShellProps) {
}, [drawerOpen, closeDrawer]);
return (
<div
data-testid={TESTIDS.appShell}
className="h-screen bg-bg-page flex flex-col overflow-hidden"
>
<div data-testid={TESTIDS.appShell} className="h-screen bg-bg-page flex flex-col overflow-hidden">
<TopNav onLogout={onLogout} onToggleMenu={openDrawer} isMenuOpen={drawerOpen} />
<div className="flex flex-1 min-h-0">
{/* lg 及以上:持久侧栏导轨 */}
<aside
data-testid={TESTIDS.sidebarRail}
className="hidden lg:flex lg:flex-col w-[212px] shrink-0 bg-bg-card/95 border-r border-border backdrop-blur-sm"
className="hidden lg:block w-[200px] shrink-0 bg-bg-card border-r border-border"
>
<div className="px-4 pt-4 pb-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted">
</p>
</div>
<SideNav
alertCount={alerts.length}
expanded={expandedNav}
@@ -107,9 +102,10 @@ export function AppShell({ onLogout }: AppShellProps) {
/>
</aside>
{/* lg 以下:离屏抽屉 + 遮罩 */}
{drawerOpen && (
<div
className="fixed inset-0 z-40 bg-slate-900/35 lg:hidden"
className="fixed inset-0 z-40 bg-black/40 lg:hidden"
onClick={closeDrawer}
aria-hidden="true"
/>
@@ -122,14 +118,10 @@ export function AppShell({ onLogout }: AppShellProps) {
aria-label="导航菜单"
tabIndex={-1}
data-testid={TESTIDS.appDrawer}
className={`fixed top-0 left-0 bottom-0 z-50 w-[280px] max-w-[85vw] bg-bg-card border-r border-border shadow-lift transition-transform duration-200 lg:hidden ${
className={`fixed top-0 left-0 bottom-0 z-50 w-[260px] max-w-[80vw] bg-bg-card border-r border-border shadow-xl transition-transform duration-200 lg:hidden ${
drawerOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="px-4 pt-5 pb-2 border-b border-border-light">
<p className="brand-mark text-xl leading-none">CBPOA</p>
<p className="mt-1 text-[11px] text-text-muted"></p>
</div>
<SideNav
alertCount={alerts.length}
onNavigate={closeDrawer}
@@ -138,13 +130,7 @@ export function AppShell({ onLogout }: AppShellProps) {
/>
</aside>
<main
className={
mapWorkbench
? 'flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col'
: 'flex-1 min-w-0 overflow-auto p-5'
}
>
<main className="flex-1 min-w-0 overflow-auto p-5">
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>

View File

@@ -9,30 +9,30 @@
## Component Types
**Map components** (`*Map.tsx`, `DistrictChoropleth`) — `@geoscene/core`:
- Use `createMapView` / layer factories from `@/geoscene`
- Coordinate system: `[longitude, latitude]` (GeoScene convention)
- Destroy MapView on unmount
**Map components** (`*Map.tsx`) — Leaflet-based maps:
- Use `react-leaflet` / direct Leaflet manipulation via `useRef`
- Risk coloring: centralized `RISK_COLORS` and `RISK_LABELS` constants
- Coordinate system: `[lat, lng]` (Leaflet convention, NOT `[lng, lat]`)
**Chart components** (`*Chart*.tsx`) — Recharts:
- Responsive containers with `width="100%" height={...}`
**Navigation** (`TopNav.tsx`, `SideNav.tsx`):
- No data fetching — pure navigation/presentation
- No role/perspective switcher
**Overlay/Utility** (`GridStatsOverlay`, `ErrorBanner`, `StatCard`, `TimelinePlayer`):
- Small, focused, reusable across pages
## Data Flow
- Components receive data via props, never fetch directly (maps may call tile/cell APIs they own)
- Components receive data via props, never fetch directly
- Callbacks passed as props: `onGridSelect`, `onClosePanel`, `onForecastChange`
- Complex stateful behavior extracted to custom hooks (e.g., `useTimelineStore`)
## Anti-Patterns
- Don't fetch page-level data in leaf components — receive via props or store hooks
- Don't fetch data in components — receive via props or store hooks
- Don't create god components (>200 lines) — extract sub-components
- Don't use `any` in prop types — use `unknown` and narrow
- Don't pass MapView instances between components
- Don't use CSS modules or inline styles — Tailwind only (map symbol colors excepted)
- Don't pass Leaflet map instances between components — each map manages its own instance
- Don't use CSS modules or inline styles — Tailwind only

View File

@@ -1,13 +1,16 @@
import { useEffect, useRef, useState, memo } from 'react';
import type MapView from '@geoscene/core/views/MapView';
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import Graphic from '@geoscene/core/Graphic';
import L from 'leaflet';
import { geocodedApi } from '@/services/api';
import { TESTIDS } from '@/utils/testids';
import type { GeocodedCase } from '@/types';
import { createMapView } from '@/geoscene/createMapView';
import { createGraphicsLayer, pointGraphic, jitterLonLat } from '@/geoscene/layers';
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
// 视图模式:
// 'points' —— 个体病例点(默认,非医生视角)。逐病例渲染 circleMarker
// 并在 DOM 中输出隐藏的 patient-point 镜像供 e2e 计数。
// 'density' —— 聚合密度(医生视角,隐私不变量)。仅按行政区/街道聚合的密度圆,
// 不渲染任何个体点patient-point 数量必须为 0。
type CaseMapMode = 'points' | 'density';
interface CaseLocationMapProps {
@@ -18,6 +21,7 @@ interface CaseLocationMapProps {
mode?: CaseMapMode;
}
// 聚合中心:按 street无则 district分组取经纬度均值 + 计数。
interface DensityCluster {
key: string;
label: string;
@@ -54,64 +58,59 @@ function CaseLocationMapComponent({
mode = 'points',
}: CaseLocationMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<MapView | null>(null);
const layerRef = useRef<GraphicsLayer | null>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const cancelledRef = useRef(false);
const fittedScopeRef = useRef<string | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [caseCount, setCaseCount] = useState(0);
// points 模式下的隐藏 DOM 镜像(每病例一项,供 e2e 对 patient-point 计数)。
// density 模式下保持为空数组 —— 隐私不变量:医生视角下 patient-point 必须为 0。
const [pointKeys, setPointKeys] = useState<string[]>([]);
const [clusterCount, setClusterCount] = useState(0);
const [mapReady, setMapReady] = useState(false);
useEffect(() => {
if (!mapRef.current || viewRef.current) return;
if (!mapRef.current || mapInstanceRef.current) return;
setIsLoading(true);
cancelledRef.current = false;
const { map, view, destroy } = createMapView({
container: mapRef.current,
const map = L.map(mapRef.current, {
center: WUHAN_CENTER,
zoom: 11,
zoomControl: true,
});
const layer = createGraphicsLayer('病例');
map.add(layer);
layerRef.current = layer;
viewRef.current = view;
// 缩放控件移到左下,避开右上观测日与图例
try {
view.ui.move('zoom', 'bottom-left');
} catch {
/* zoom widget may be absent */
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 18,
}).addTo(map);
mapInstanceRef.current = map;
layerRef.current = L.layerGroup().addTo(map);
// ResizeObserver: auto-invalidate when container size changes (window resize, layout shifts)
const resizeObserver = new ResizeObserver(() => {
if (mapInstanceRef.current) {
mapInstanceRef.current.invalidateSize({ animate: false });
}
});
if (mapRef.current) {
resizeObserver.observe(mapRef.current);
}
resizeObserverRef.current = resizeObserver;
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
return () => {
cancelledRef.current = true;
layerRef.current = null;
viewRef.current = null;
fittedScopeRef.current = null;
destroy();
setMapReady(false);
};
}, []);
useEffect(() => {
if (!mapReady || !viewRef.current || !layerRef.current) return;
cancelledRef.current = false;
const layer = layerRef.current;
const view = viewRef.current;
const scopeKey = `${district ?? ''}|${street ?? ''}|${mode}`;
const shouldFit = fittedScopeRef.current !== scopeKey;
geocodedApi
.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
// Fetch case locations
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined, date: date || undefined })
.then((data) => {
if (cancelledRef.current) return;
const cases: GeocodedCase[] = data.cases || [];
layer.removeAll();
const layer = layerRef.current;
if (!layer) return;
layer.clearLayers();
// Deduplicate by case_id to avoid overlapping markers
const seen = new Set<string>();
let unique: GeocodedCase[] = [];
for (const c of cases) {
@@ -120,64 +119,83 @@ function CaseLocationMapComponent({
unique.push(c);
}
}
// Client-side street filtering
if (street) {
unique = unique.filter((c) => c.street === street);
}
if (cancelledRef.current) return;
if (mode === 'density') {
// 医生视角:仅渲染聚合密度圆(按街道/区聚合),不渲染任何个体点。
const clusters = aggregateClusters(unique);
const maxCount = clusters.reduce((m, c) => Math.max(m, c.count), 1);
const graphics = clusters.map((cl) => {
const size = 10 + Math.round((cl.count / maxCount) * 22);
const g = pointGraphic(cl.longitude, cl.latitude, '#7c3aed', size, {
label: cl.label,
count: cl.count,
for (const cl of clusters) {
// 半径随计数缩放828px明确表达「密度」而非个体位置。
const radius = 8 + Math.round((cl.count / maxCount) * 20);
const marker = L.circleMarker([cl.latitude, cl.longitude], {
radius,
fillColor: '#7c3aed',
fillOpacity: 0.35,
color: '#7c3aed',
weight: 1.5,
});
g.popupTemplate = {
title: '{label}',
content: '病例数: {count}',
};
return g;
});
layer.addMany(graphics);
marker.bindTooltip(
`<div style="font-size:12px"><strong>${cl.label}</strong><br/>病例数: ${cl.count}</div>`,
{ direction: 'top', offset: [0, -4] }
);
marker.addTo(layer);
}
setClusterCount(clusters.length);
setCaseCount(unique.length);
setPointKeys([]);
setPointKeys([]); // 隐私不变量density 下无个体点镜像
setIsLoading(false);
if (shouldFit && graphics.length > 0) {
fittedScopeRef.current = scopeKey;
view.goTo(graphics).catch(() => undefined);
if (clusters.length > 0) {
const bounds = L.latLngBounds(clusters.map((c) => [c.latitude, c.longitude]));
map.fitBounds(bounds, { padding: [30, 30] });
}
return;
}
// points 模式(默认):逐病例渲染个体 circleMarker。
const keys: string[] = [];
const graphics: Graphic[] = [];
for (const c of unique) {
if (!c.latitude || !c.longitude) continue;
const color = c.case_type === 'inpatient' ? '#ef4444' : '#3b82f6';
const [lon, lat] = jitterLonLat(c.longitude, c.latitude, c.case_id, 70);
const g = pointGraphic(lon, lat, color, 6, {
district: c.district,
street: c.street,
case_type: c.case_type,
const marker = L.circleMarker([c.latitude, c.longitude], {
radius: 3,
fillColor: color,
fillOpacity: 0.6,
color: color,
weight: 1,
});
g.popupTemplate = {
title: '{district} {street}',
content: '类型: {case_type}',
};
graphics.push(g);
marker.bindTooltip(
`<div style="font-size:12px">
<strong>${c.district}</strong> ${c.street}<br/>
类型: ${c.case_type === 'inpatient' ? '住院' : '门诊'}
</div>`,
{ direction: 'top', offset: [0, -4] }
);
marker.addTo(layer);
keys.push(c.case_id);
}
layer.addMany(graphics);
setClusterCount(0);
setCaseCount(unique.length);
setPointKeys(keys);
setPointKeys(keys); // 隐藏 DOM 镜像供 e2e 计数
setIsLoading(false);
if (shouldFit && graphics.length > 0) {
fittedScopeRef.current = scopeKey;
view.goTo(graphics).catch(() => undefined);
// Fit bounds to case locations
if (unique.length > 0) {
const bounds = L.latLngBounds(unique.map((c) => [c.latitude, c.longitude]));
map.fitBounds(bounds, { padding: [30, 30] });
}
})
.catch(() => {
@@ -186,69 +204,43 @@ function CaseLocationMapComponent({
return () => {
cancelledRef.current = true;
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
map.remove();
mapInstanceRef.current = null;
};
}, [district, street, date, mode, mapReady]);
}, [district, street, date, mode]);
const isDensity = mode === 'density';
const fillParent = height === '100%' || height === '100vh';
return (
<div
className={`relative ${fillParent ? 'h-full min-h-0' : ''}`}
data-case-map-mode={mode}
>
<div
ref={mapRef}
className={`w-full overflow-hidden bg-bg-hover ${fillParent ? 'h-full rounded-none' : 'rounded-xl'}`}
style={{ height: fillParent ? '100%' : height, width: '100%' }}
/>
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/80">
<div className="text-[13px] text-text-muted"></div>
<div className="relative" data-case-map-mode={mode}>
<div ref={mapRef} style={{ height, width: '100%', borderRadius: '8px' }} />
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 rounded-lg">
<div className="text-sm text-gray-500">...</div>
</div>
)}
{mapReady && isLoading && (
<div className="absolute top-14 right-3 z-[1000] bg-bg-card/95 px-2.5 py-1 rounded-md border border-border shadow-soft text-[11px] text-text-muted">
{!isLoading && !isDensity && (
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
<span className="text-blue-600 font-semibold">{caseCount.toLocaleString()}</span>
<span className="ml-2 text-red-500"> </span>
<span className="ml-1 text-blue-500"> </span>
</div>
)}
{!isLoading && mapReady && (
<div
className="absolute bottom-3 left-14 z-[1000] bg-bg-card/95 px-3 py-2 rounded-lg border border-border shadow-soft text-[12px] max-w-[220px]"
aria-label="病例图例"
>
<div className="text-[11px] font-semibold text-text-secondary mb-1.5 tracking-wide">
</div>
{!isDensity ? (
<>
<div className="font-mono tabular-nums text-text-primary mb-1.5">
<span className="text-primary font-semibold">{caseCount.toLocaleString()}</span>
<span className="text-text-muted ml-1"></span>
</div>
<div className="flex items-center gap-3 text-[11px] text-text-muted">
<span className="inline-flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-[#3b82f6]" aria-hidden />
</span>
<span className="inline-flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded-full bg-[#ef4444]" aria-hidden />
</span>
</div>
</>
) : (
<>
<div className="font-mono tabular-nums text-text-primary mb-1">
<span className="font-semibold text-mist-deep">{clusterCount.toLocaleString()}</span>
<span className="text-text-muted ml-1"></span>
</div>
<div className="text-[11px] text-text-muted">/ · </div>
</>
)}
{!isLoading && isDensity && (
<div className="absolute top-2 right-2 bg-white/90 px-3 py-1.5 rounded shadow text-xs">
<span className="text-purple-600 font-semibold">{clusterCount.toLocaleString()}</span>
<span className="ml-2 text-gray-500"></span>
</div>
)}
{/*
隐藏 DOM 镜像points 模式下每病例输出一个 patient-point 节点,使 e2e 能对
Leaflet canvas 之外的真实 DOM 做计数断言。density 模式下 pointKeys 恒为空,
因此医生视角下 [data-testid=patient-point] 数量必为 0隐私不变量
*/}
<div className="hidden" aria-hidden="true">
{pointKeys.map((id) => (
<span key={id} data-testid={TESTIDS.patientPoint} data-case-id={id} />

View File

@@ -0,0 +1,377 @@
import { memo, useEffect, useRef, useState, useCallback } from 'react';
import { Skeleton } from '@/components/ui';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { geocodedApi } from '@/services/api';
import type { CaseGrid, GeocodedCase } from '@/types';
interface CaseMapProps {
height?: string;
}
type ViewMode = 'grid' | 'point';
// Grid is 100m x 100m at Wuhan latitude (~30.5°N)
const GRID_HALF_SIZE_LAT = 0.00045; // ~50m in degrees
const GRID_HALF_SIZE_LON = 0.00052; // ~50m in degrees
function getGridBounds(g: { latitude: number; longitude: number }) {
if (typeof g.latitude !== 'number' || typeof g.longitude !== 'number') {
return null;
}
return {
lat_min: g.latitude - GRID_HALF_SIZE_LAT,
lat_max: g.latitude + GRID_HALF_SIZE_LAT,
lon_min: g.longitude - GRID_HALF_SIZE_LON,
lon_max: g.longitude + GRID_HALF_SIZE_LON,
};
}
const RISK_COLORS = {
high: '#ff4444',
medium: '#ffaa44',
low: '#44bb44',
};
function getRiskColor(riskIndex: number): string {
if (riskIndex >= 0.67) return RISK_COLORS.high;
if (riskIndex >= 0.33) return RISK_COLORS.medium;
return RISK_COLORS.low;
}
function getRiskLabel(riskIndex: number): string {
if (riskIndex >= 0.67) return '高风险';
if (riskIndex >= 0.33) return '中风险';
return '低风险';
}
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
function CaseMapComponent({ height = '480px' }: CaseMapProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const gridLayerRef = useRef<any>(null);
const pointLayerRef = useRef<any>(null);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [grids, setGrids] = useState<CaseGrid[]>([]);
const [cases, setCases] = useState<GeocodedCase[]>([]);
const [totalCases, setTotalCases] = useState(0);
const [gridCount, setGridCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setIsLoading(true);
setError(null);
try {
const [gridRes, geoRes] = await Promise.all([
geocodedApi.getGrid(),
geocodedApi.getGeocoded({ limit: 5000 }),
]);
if (cancelled) return;
setGrids(gridRes.grids || []);
setGridCount(gridRes.total_count || 0);
setTotalCases(gridRes.total_cases || 0);
setCases(geoRes.cases || []);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : '加载失败');
} finally {
if (!cancelled) setIsLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!mapDivRef.current || mapRef.current) return;
const map = L.map(mapDivRef.current, {
center: [30.59, 114.31],
zoom: 11,
zoomControl: true,
preferCanvas: false,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
mapRef.current = map;
const handleZoom = debounce(() => renderLayers(), 150);
const handleMove = debounce(() => renderLayers(), 150);
map.on('zoomend', handleZoom);
map.on('moveend', handleMove);
return () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
gridLayerRef.current = null;
pointLayerRef.current = null;
}
};
}, []);
useEffect(() => {
if (!mapRef.current) return;
renderLayers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [grids, cases, viewMode]);
const renderLayers = useCallback(() => {
if (!mapRef.current) return;
const map = mapRef.current;
if (gridLayerRef.current) {
try { map.removeLayer(gridLayerRef.current); } catch { /* silent */ }
gridLayerRef.current = null;
}
if (pointLayerRef.current) {
try { map.removeLayer(pointLayerRef.current); } catch { /* silent */ }
pointLayerRef.current = null;
}
const zoom = map.getZoom();
if (viewMode === 'grid') {
const gridLayer = L.layerGroup();
const bounds = map.getBounds();
let rendered = 0;
const maxRender = 5000;
for (const g of grids) {
if (rendered >= maxRender) break;
const gBounds = getGridBounds(g);
if (!gBounds) continue;
if (
gBounds.lat_max < bounds.getSouth() ||
gBounds.lat_min > bounds.getNorth() ||
gBounds.lon_max < bounds.getWest() ||
gBounds.lon_min > bounds.getEast()
) {
continue;
}
const color = getRiskColor(g.risk_index);
const opacity = 0.5 + g.risk_index * 0.35;
const rect = L.rectangle(
[[gBounds.lat_min, gBounds.lon_min], [gBounds.lat_max, gBounds.lon_max]],
{
fillColor: color,
fillOpacity: opacity,
color: color,
weight: zoom >= 14 ? 1 : 0,
opacity: 0.3,
}
);
rect.bindTooltip(
`<div style="font-size: 12px;">
<strong>网格 ${g.grid_id}</strong><br/>
病例数: ${g.total_cases.toLocaleString()}<br/>
风险指数: ${(g.risk_index * 100).toFixed(1)}%<br/>
<span style="color: ${color}; font-weight: 600;">${getRiskLabel(g.risk_index)}</span>
</div>`,
{ direction: 'top', offset: [0, -5] }
);
rect.addTo(gridLayer);
rendered++;
}
gridLayer.addTo(map);
gridLayerRef.current = gridLayer;
} else {
const pointLayer = L.layerGroup();
const bounds = map.getBounds();
const caseColor = (c: GeocodedCase) =>
c.case_type === 'inpatient' ? '#DC2626' : '#2563EB';
// Viewport culling + maxRender to avoid Leaflet canvas intersects bug
const maxRender = 500;
let rendered = 0;
for (const c of cases) {
if (rendered >= maxRender) break;
if (typeof c.latitude !== 'number' || typeof c.longitude !== 'number') continue;
// Viewport culling - skip points outside visible area
if (
c.latitude < bounds.getSouth() ||
c.latitude > bounds.getNorth() ||
c.longitude < bounds.getWest() ||
c.longitude > bounds.getEast()
) {
continue;
}
// Use tiny rectangles instead of circleMarker to avoid Leaflet 1.9.4 intersects bug
const size = zoom >= 14 ? 0.00005 : zoom >= 12 ? 0.00003 : 0.00002;
const rect = L.rectangle(
[[c.latitude - size, c.longitude - size], [c.latitude + size, c.longitude + size]],
{
fillColor: caseColor(c),
fillOpacity: 0.8,
color: '#FFFFFF',
weight: 0.5,
}
);
rect.bindTooltip(
`<div style="font-size: 12px;">
<strong>${c.case_type === 'inpatient' ? '住院' : '门诊'}病例</strong><br/>
坐标:${c.latitude.toFixed(5)}, ${c.longitude.toFixed(5)}
</div>`,
{ direction: 'top', offset: [0, -5] }
);
rect.addTo(pointLayer);
rendered++;
}
pointLayer.addTo(map);
pointLayerRef.current = pointLayer;
}
}, [grids, cases, viewMode]);
const handleToggle = useCallback((mode: ViewMode) => {
setViewMode(mode);
}, []);
return (
<div className="card">
<div className="flex items-center justify-between px-4 py-3 border-b border-border-light">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
</svg>
<span className="font-medium text-[14px]"></span>
</div>
<div className="flex items-center gap-3">
<div className="flex gap-0.5 bg-bg-page p-0.5 rounded">
<button
onClick={() => handleToggle('grid')}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
viewMode === 'grid'
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
<button
onClick={() => handleToggle('point')}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
viewMode === 'point'
? 'bg-bg-card text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
</button>
</div>
<div className="text-[11px] text-text-muted">
{viewMode === 'grid' ? '100×100m 网格' : '个体病例定位'}
</div>
</div>
</div>
<div className="relative" style={{ height }}>
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
<div className="absolute bottom-4 right-4 bg-bg-card/90 backdrop-blur rounded-lg border border-border-light shadow-sm z-[1000] px-4 py-3">
{viewMode === 'grid' ? (
<>
<div className="text-[11px] font-semibold text-text-secondary mb-2"></div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS.high }} />
<span className="text-[11px] text-text-secondary"> (&gt;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"> (&lt;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);

View File

@@ -0,0 +1,185 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { MessageSquare, X, Send, RefreshCw, Loader2 } from 'lucide-react';
import { chatApi } from '@/services/api';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export function ChatBot() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, isLoading, scrollToBottom]);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
const handleSend = useCallback(async () => {
const trimmed = input.trim();
if (!trimmed || isLoading) return;
const userMessage: Message = { role: 'user', content: trimmed };
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput('');
setError(null);
setIsLoading(true);
try {
const data = await chatApi.sendMessage(
updatedMessages.map((m) => ({ role: m.role, content: m.content }))
);
setMessages((prev) => [...prev, { role: 'assistant', content: data.reply }]);
} catch (err: any) {
const errMsg = err?.response?.data?.detail || err?.message || '请求失败,请稍后重试';
setError(errMsg);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
const handleRetry = useCallback(() => {
setError(null);
handleSend();
}, [handleSend]);
return (
<>
{/* Float toggle button */}
<button
onClick={() => setIsOpen((prev) => !prev)}
className={`fixed bottom-5 right-5 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary shadow-lg transition-all hover:bg-primary-light ${
isOpen ? 'scale-0 opacity-0' : 'scale-100 opacity-100'
}`}
aria-label={isOpen ? '关闭聊天' : '打开聊天'}
>
<MessageSquare className="h-5 w-5 text-white" />
</button>
{/* Chat panel */}
{isOpen && (
<div className="fixed bottom-20 right-5 z-50 flex w-[380px] flex-col rounded-xl border border-border bg-bg-card shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between rounded-t-xl bg-primary p-3 text-white">
<h3 className="text-[14px] font-semibold">AI </h3>
<button
onClick={() => setIsOpen(false)}
className="rounded p-1 transition-colors hover:bg-white/20"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Messages area */}
<div
ref={scrollRef}
className="flex flex-col gap-3 overflow-y-auto p-4"
style={{ height: '420px' }}
>
{messages.length === 0 && !error && (
<div className="flex flex-1 flex-col items-center justify-center py-12 text-center">
<MessageSquare className="mb-3 h-10 w-10 text-text-muted" />
<p className="text-[13px] text-text-muted">
</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 text-[13px] leading-relaxed ${
msg.role === 'user'
? 'rounded-br-sm bg-primary text-white'
: 'rounded-bl-sm bg-bg-page text-text-primary'
}`}
>
{msg.content}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="flex items-center gap-2 rounded-2xl rounded-bl-sm bg-bg-page px-4 py-3">
<Loader2 className="h-4 w-4 animate-spin text-text-muted" />
<span className="text-[12px] text-text-muted">...</span>
</div>
</div>
)}
{error && (
<div className="flex flex-col items-start gap-2 rounded-lg border border-danger/20 bg-danger-light px-4 py-3">
<span className="text-[13px] text-danger">{error}</span>
<button
onClick={handleRetry}
className="flex items-center gap-1 rounded px-2.5 py-1 text-[12px] font-medium text-danger transition-colors hover:bg-danger/10"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* Input area */}
<div className="flex gap-2 border-t border-border p-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入您的问题..."
disabled={isLoading}
className="flex-1 rounded-lg border border-border bg-bg-page px-3 py-2 text-[13px] text-text-primary placeholder-text-muted outline-none transition-colors focus:border-primary disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={isLoading || !input.trim()}
className="flex items-center justify-center rounded-lg bg-primary px-4 text-[13px] font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,375 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import { useLodGrid, type MapBounds } from '@/hooks/useLodGrid';
const RISK_COLORS: [number, number, string][] = [
[0.0, 0.2, '#22c55e'],
[0.2, 0.4, '#3b82f6'],
[0.4, 0.6, '#eab308'],
[0.6, 0.8, '#f97316'],
[0.8, 1.0, '#ef4444'],
];
// Pre-computed color buckets for fillStyle caching
const COLOR_BUCKETS: Record<string, { full: string; dim: string }> = {};
for (const [, , color] of RISK_COLORS) {
COLOR_BUCKETS[color] = { full: color, dim: color + '14' };
}
function getRiskColor(value: number): string {
for (const [min, max, color] of RISK_COLORS) {
if (value >= min && value < max) return color;
}
return '#ef4444';
}
// 100m grid step in degrees
const LAT_STEP = 0.0009;
const LON_STEP = 0.001046;
// Mercator helpers (avoid per-cell latLngToContainerPoint)
function latToMercY(lat: number): number {
return 128 - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / (2 * Math.PI);
}
function lonToMercX(lon: number): number {
return ((lon + 180) / 360) * 256;
}
interface LodGridLayerProps {
map: L.Map | null;
forecastDay: 1 | 3 | 7;
visible?: boolean;
riskRange?: [number, number];
onCellClick?: (lat: number, lon: number, risk: number) => void;
}
export function LodGridLayer({
map,
forecastDay,
visible = true,
riskRange,
onCellClick,
}: LodGridLayerProps) {
const [zoom, setZoom] = useState(map?.getZoom() ?? 10);
const [mapBounds, setMapBounds] = useState<MapBounds | undefined>();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const paneRef = useRef<HTMLElement | null>(null);
const animFrameRef = useRef<number>(0);
const clickCallbackRef = useRef(onCellClick);
const gridsRef = useRef<number[][]>([]);
const forecastDayRef = useRef(forecastDay);
const riskRangeRef = useRef(riskRange);
const visibleRef = useRef(visible);
const drawnOriginRef = useRef<{ x: number; y: number } | null>(null);
// Keep refs in sync
useEffect(() => { clickCallbackRef.current = onCellClick; }, [onCellClick]);
useEffect(() => { forecastDayRef.current = forecastDay; }, [forecastDay]);
useEffect(() => { riskRangeRef.current = riskRange; }, [riskRange]);
useEffect(() => { visibleRef.current = visible; }, [visible]);
// Track map bounds and zoom
useEffect(() => {
if (!map) return;
const update = () => {
const b = map.getBounds();
setMapBounds({
min_lat: b.getSouth(),
max_lat: b.getNorth(),
min_lon: b.getWest(),
max_lon: b.getEast(),
});
setZoom(map.getZoom());
};
update();
map.on('moveend', update);
map.on('zoomend', update);
return () => {
map.off('moveend', update);
map.off('zoomend', update);
};
}, [map]);
const { grids } = useLodGrid(zoom, forecastDay, mapBounds);
// Update gridsRef only when we have actual data (preserve stale data during loading)
useEffect(() => {
if (grids.length > 0) {
gridsRef.current = grids;
}
}, [grids]);
// Create canvas overlay pane and attach to map
useEffect(() => {
if (!map) return;
const pane = map.createPane('lod-grid-pane');
pane.style.zIndex = '450';
pane.style.pointerEvents = 'none';
paneRef.current = pane;
const canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
canvas.style.display = visibleRef.current ? '' : 'none';
pane.appendChild(canvas);
canvasRef.current = canvas;
// Handle map clicks for grid cell selection
const handleMapClick = (e: L.LeafletMouseEvent) => {
if (!clickCallbackRef.current) return;
const currentGrids = gridsRef.current;
if (!currentGrids || currentGrids.length === 0) return;
const { lat, lng } = e.latlng;
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
let nearestDist = Infinity;
let nearestRisk = 0;
let nearestLat = 0;
let nearestLon = 0;
for (const g of currentGrids) {
const d = Math.sqrt((g[0] - lat) ** 2 + (g[1] - lng) ** 2);
if (d < nearestDist) {
nearestDist = d;
nearestRisk = g[riskIdx] ?? 0;
nearestLat = g[0];
nearestLon = g[1];
}
}
if (nearestDist < 0.01) {
clickCallbackRef.current(nearestLat, nearestLon, nearestRisk);
}
};
map.on('click', handleMapClick);
// Full redraw function
const redraw = () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = requestAnimationFrame(() => {
const container = map.getContainer();
const w = container.clientWidth;
const h = container.clientHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Reset drift transform after redraw
canvas.style.transform = '';
drawnOriginRef.current = null;
// Visibility is controlled via canvas CSS display (see visible effect),
// so we still draw pixels even when hidden to keep them ready on re-show.
const currentGrids = gridsRef.current;
if (!currentGrids || currentGrids.length === 0) return;
const z = map.getZoom();
const riskIdx = forecastDayRef.current === 1 ? 2 : forecastDayRef.current === 3 ? 3 : 4;
const range = riskRangeRef.current;
const mapBounds = map.getBounds();
const south = mapBounds.getSouth();
const north = mapBounds.getNorth();
const west = mapBounds.getWest();
const east = mapBounds.getEast();
// Use Mercator math for pixel conversion (avoids per-cell latLngToContainerPoint)
const scale = 2 ** z;
const origin = map.getPixelOrigin();
drawnOriginRef.current = { x: origin.x, y: origin.y };
// Pre-compute Mercator Y steps for cell size at this zoom
const halfLat = LAT_STEP / 2;
const halfLon = LON_STEP / 2;
// Group cells by color to minimize fillStyle changes
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
// Viewport culling margin in degrees
const margin = 0.02;
const isHighZoom = z >= 12;
const isMedZoom = z >= 10;
for (const g of currentGrids) {
const lat = g[0];
const lon = g[1];
const risk = g[riskIdx] ?? 0;
// Pre-filter: skip zero-risk cells (majority of cells at most zooms)
if (risk === 0) continue;
// Viewport culling
if (lat < south - margin || lat > north + margin ||
lon < west - margin || lon > east + margin) {
continue;
}
// Risk range filter
let alpha = 0.85;
if (range) {
if (risk < range[0]) {
alpha = 0.08;
} else if (risk > range[1]) {
alpha = 0.3;
}
}
const color = getRiskColor(risk);
if (isHighZoom) {
// Compute cell rectangle using Mercator math
const lx = lonToMercX(lon - halfLon) * scale - origin.x;
const rx = lonToMercX(lon + halfLon) * scale - origin.x;
const ty = latToMercY(lat + halfLat) * scale - origin.y;
const by = latToMercY(lat - halfLat) * scale - origin.y;
const cellW = rx - lx;
const cellH = by - ty;
if (cellW < 0.5 || cellH < 0.5) continue;
// Group by color+alpha for batch rendering
const key = alpha < 1 ? `${color}_${alpha}` : color;
if (!colorGroups[key]) colorGroups[key] = [];
colorGroups[key].push({ x: lx, y: ty, w: cellW, h: cellH });
} else {
// Medium/low zoom: compute center pixel
const cx = lonToMercX(lon) * scale - origin.x;
const cy = latToMercY(lat) * scale - origin.y;
const key = alpha < 1 ? `${color}_${alpha}` : color;
if (!colorGroups[key]) colorGroups[key] = [];
colorGroups[key].push({ x: cx, y: cy, w: 0, h: 0 });
}
}
// Render grouped cells
for (const [key, cells] of Object.entries(colorGroups)) {
const parts = key.split('_');
const color = parts[0];
const alpha = parts.length > 1 ? parseFloat(parts[1]) : 1;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
if (isHighZoom) {
for (const c of cells) {
ctx.fillRect(c.x, c.y, c.w, c.h);
}
// Stroke only at high enough cell sizes
ctx.globalAlpha = 0.4;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 0.5;
for (const c of cells) {
if (c.w > 2 && c.h > 2) {
ctx.strokeRect(c.x, c.y, c.w, c.h);
}
}
} else if (isMedZoom) {
const size = Math.max(2, Math.min(6, z - 7));
const halfSize = size / 2;
for (const c of cells) {
ctx.fillRect(c.x - halfSize, c.y - halfSize, size, size);
}
} else {
const radius = Math.max(1, Math.min(3, z - 5));
for (const c of cells) {
ctx.beginPath();
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
}
ctx.globalAlpha = 1;
});
};
// Debounced full redraw (avoid thrashing during rapid pan/zoom)
let redrawTimer: ReturnType<typeof setTimeout> | null = null;
const debouncedRedraw = () => {
if (redrawTimer) clearTimeout(redrawTimer);
redrawTimer = setTimeout(redraw, 300);
};
// During pan: apply CSS transform to track tile movement (fixes drift)
const onMove = () => {
const drawn = drawnOriginRef.current;
if (!drawn) {
// No previous draw yet, just request a redraw
redraw();
return;
}
const current = map.getPixelOrigin();
const dx = drawn.x - current.x;
const dy = drawn.y - current.y;
canvas.style.transform = `translate(${dx}px, ${dy}px)`;
};
// On moveend/zoomend: reset transform and do debounced full redraw
const onMoveEnd = () => {
canvas.style.transform = '';
drawnOriginRef.current = null;
debouncedRedraw();
};
const onResize = () => redraw();
map.on('move', onMove);
map.on('moveend', onMoveEnd);
map.on('zoomend', onMoveEnd);
map.on('resize', onResize);
// Store redraw reference for external triggers
(canvas as any).__lodRedraw = redraw;
// Initial draw
redraw();
return () => {
map.off('move', onMove);
map.off('moveend', onMoveEnd);
map.off('zoomend', onMoveEnd);
map.off('resize', onResize);
map.off('click', handleMapClick);
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
if (redrawTimer) clearTimeout(redrawTimer);
pane.removeChild(canvas);
if (pane.parentNode) pane.parentNode.removeChild(pane);
canvasRef.current = null;
paneRef.current = null;
};
}, [map]);
// Trigger redraw when data/geometry-affecting inputs change.
useEffect(() => {
const canvas = canvasRef.current;
if (canvas && (canvas as any).__lodRedraw) {
(canvas as any).__lodRedraw();
}
}, [grids, forecastDay, riskRange]);
// Visibility toggle: hide/show via CSS instead of a full geometry redraw.
useEffect(() => {
const canvas = canvasRef.current;
if (canvas) {
canvas.style.display = visible ? '' : 'none';
}
}, [visible]);
return null;
}

View File

@@ -0,0 +1,407 @@
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { GridRisk, GridDetail, ForecastDay } from '@/types';
interface RiskMapProps {
grids: GridRisk[];
selectedGridId: string | null;
selectedGrid: GridDetail | null;
forecastDay: ForecastDay;
onGridSelect: (gridId: string) => void;
onClosePanel: () => void;
onFullscreen: () => void;
onForecastChange: (day: ForecastDay) => void;
isFullscreen?: boolean;
}
const RISK_COLORS: Record<string, string> = {
low: '#22c55e',
medium_low: '#3b82f6',
medium: '#eab308',
medium_high: '#f97316',
high: '#ef4444',
};
const RISK_LABELS: Record<string, string> = {
low: '低风险',
medium_low: '中低',
medium: '中风险',
medium_high: '中高',
high: '高风险',
};
const WUHAN_BOUNDS = {
minLat: 29.97,
maxLat: 31.37,
minLon: 113.69,
maxLon: 115.07,
};
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// Mercator helpers (avoid per-cell latLngToContainerPoint) — mirrors LodGridLayer.
function latToMercY(lat: number): number {
return 128 - (256 * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / (2 * Math.PI);
}
function lonToMercX(lon: number): number {
return ((lon + 180) / 360) * 256;
}
function riskColorForValue(riskValue: number): string {
if (riskValue >= 0.7) return RISK_COLORS.high;
if (riskValue >= 0.5) return RISK_COLORS.medium_high;
if (riskValue >= 0.3) return RISK_COLORS.medium_low;
return RISK_COLORS.low;
}
function RiskMapComponent(props: RiskMapProps) {
const {
grids,
selectedGrid,
forecastDay,
onGridSelect,
onClosePanel,
onFullscreen,
onForecastChange,
isFullscreen,
} = props;
const mapDivRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<any>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const paneRef = useRef<HTMLElement | null>(null);
const animFrameRef = useRef<number>(0);
const redrawRef = useRef<() => void>(() => {});
const zoomRef = useRef(9);
const callbacksRef = useRef({ onGridSelect, onClosePanel, onFullscreen, onForecastChange });
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const gridMapRef = useRef<Map<string, GridRisk>>(new Map());
useEffect(() => {
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
}, [onGridSelect, onClosePanel, onFullscreen, onForecastChange]);
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';
// Invalidate map size when container size changes (window resize, fullscreen, layout shifts)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
// Fullscreen transition: wait for CSS transition to complete
const timer = setTimeout(() => {
map.invalidateSize({ animate: true });
}, 150);
return () => clearTimeout(timer);
}, [isFullscreen, containerHeight]);
const gridMap = useMemo(() => {
const map = new Map<string, GridRisk>();
grids.forEach((g) => {
const key = `${g.latitude.toFixed(4)}-${g.longitude.toFixed(4)}`;
map.set(key, g);
});
return map;
}, [grids]);
// Keep gridMap accessible to the canvas render fn (read via ref, no re-init).
useEffect(() => {
gridMapRef.current = gridMap;
redrawRef.current();
}, [gridMap]);
// Create the map + tile layer + canvas overlay + handlers ONCE.
useEffect(() => {
if (!mapDivRef.current || mapRef.current) return;
const map = L.map(mapDivRef.current, {
center: [(WUHAN_BOUNDS.minLat + WUHAN_BOUNDS.maxLat) / 2, (WUHAN_BOUNDS.minLon + WUHAN_BOUNDS.maxLon) / 2],
zoom: 9,
zoomControl: true,
preferCanvas: true,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
mapRef.current = map;
// Canvas overlay pane for batched grid rendering (replaces per-cell rectangles).
const pane = map.createPane('risk-grid-pane');
pane.style.zIndex = '450';
pane.style.pointerEvents = 'none';
paneRef.current = pane;
const canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.pointerEvents = 'none';
pane.appendChild(canvas);
canvasRef.current = canvas;
// ResizeObserver: auto-invalidate map size when container changes
const resizeObserver = new ResizeObserver(
debounce(() => {
if (mapRef.current) {
mapRef.current.invalidateSize({ animate: false });
}
}, 100)
);
if (mapDivRef.current) {
resizeObserver.observe(mapDivRef.current);
}
resizeObserverRef.current = resizeObserver;
// Batched canvas render: group cells by color and fillRect on one canvas.
function renderGridLayer() {
if (!mapRef.current || !canvasRef.current) return;
const map = mapRef.current;
const canvas = canvasRef.current;
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = requestAnimationFrame(() => {
const container = map.getContainer();
const w = container.clientWidth;
const h = container.clientHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const zoom = map.getZoom();
let cellSize: number;
let step: number;
if (zoom <= 8) { cellSize = 0.1; step = 10; }
else if (zoom <= 10) { cellSize = 0.025; step = 4; }
else if (zoom <= 12) { cellSize = 0.01; step = 2; }
else { cellSize = 0.005; step = 2; }
const bounds = map.getBounds();
const minLat = Math.max(bounds.getSouth(), WUHAN_BOUNDS.minLat);
const maxLat = Math.min(bounds.getNorth(), WUHAN_BOUNDS.maxLat);
const minLon = Math.max(bounds.getWest(), WUHAN_BOUNDS.minLon);
const maxLon = Math.min(bounds.getEast(), WUHAN_BOUNDS.maxLon);
const latStart = Math.floor((minLat - WUHAN_BOUNDS.minLat) / cellSize) * cellSize + WUHAN_BOUNDS.minLat;
const lonStart = Math.floor((minLon - WUHAN_BOUNDS.minLon) / cellSize) * cellSize + WUHAN_BOUNDS.minLon;
const currentGridMap = gridMapRef.current;
if (currentGridMap.size > 5000) {
console.warn(`[RiskMap] Data too dense: ${currentGridMap.size} grid cells, rendering may be slow`);
}
const scale = 2 ** zoom;
const origin = map.getPixelOrigin();
const cellDeg = cellSize * step;
// Group cells by color to minimize fillStyle changes.
const colorGroups: Record<string, { x: number; y: number; w: number; h: number }[]> = {};
let count = 0;
const maxCount = 1500;
for (let lat = latStart; lat < maxLat && count < maxCount; lat += cellDeg) {
for (let lon = lonStart; lon < maxLon && count < maxCount; lon += cellDeg) {
const key = `${lat.toFixed(4)}-${lon.toFixed(4)}`;
const grid = currentGridMap.get(key);
const riskValue = grid?.risk_value ?? 0.5;
const color = riskColorForValue(riskValue);
const lx = lonToMercX(lon) * scale - origin.x;
const rx = lonToMercX(lon + cellDeg) * scale - origin.x;
const ty = latToMercY(lat + cellDeg) * scale - origin.y;
const by = latToMercY(lat) * scale - origin.y;
if (!colorGroups[color]) colorGroups[color] = [];
colorGroups[color].push({ x: lx, y: ty, w: rx - lx, h: by - ty });
count++;
}
}
ctx.globalAlpha = 0.6;
for (const [color, cells] of Object.entries(colorGroups)) {
ctx.fillStyle = color;
for (const c of cells) {
ctx.fillRect(c.x, c.y, c.w, c.h);
}
}
ctx.globalAlpha = 1;
});
}
redrawRef.current = renderGridLayer;
// Single map-level click handler: nearest-cell lookup (replaces 1500 handlers).
const handleMapClick = (e: L.LeafletMouseEvent) => {
const currentGridMap = gridMapRef.current;
if (currentGridMap.size === 0) return;
const { lat, lng } = e.latlng;
let nearestDist = Infinity;
let nearestGrid: GridRisk | null = null;
for (const grid of currentGridMap.values()) {
const d = (grid.latitude - lat) ** 2 + (grid.longitude - lng) ** 2;
if (d < nearestDist) {
nearestDist = d;
nearestGrid = grid;
}
}
if (nearestGrid && nearestDist < 0.01 * 0.01) {
callbacksRef.current.onGridSelect(nearestGrid.grid_id);
}
};
const handleZoom = debounce(() => {
zoomRef.current = map.getZoom();
renderGridLayer();
}, 150);
const handleMove = debounce(() => {
renderGridLayer();
}, 150);
map.on('zoomend', handleZoom);
map.on('moveend', handleMove);
map.on('resize', renderGridLayer);
map.on('click', handleMapClick);
// Initial render
renderGridLayer();
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
resizeObserverRef.current = null;
}
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
redrawRef.current = () => {};
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
canvasRef.current = null;
paneRef.current = null;
};
}, []);
const handleForecastChange = useCallback((d: ForecastDay) => {
callbacksRef.current.onForecastChange(d);
}, []);
const handleFullscreen = useCallback(() => {
callbacksRef.current.onFullscreen();
}, []);
const handleClosePanel = useCallback(() => {
callbacksRef.current.onClosePanel();
}, []);
return (
<div className="card">
<div className="flex items-center justify-between px-5 py-3.5 border-b border-gray-100">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 3l-.16.03L15 5.1 9 3 3.36 4.9c-.21.07-.36.25-.36.48V20.5c0 .28.22.5.5.5l.16-.03L9 18.9l6 2.1 5.64-1.9c.21-.07.36-.25.36-.48V3.5c0-.28-.22-.5-.5-.5zM15 19l-6-2.11V5l6 2.11V19z"/>
</svg>
<span className="font-medium text-[14px]"></span>
</div>
<div className="flex gap-0.5 bg-gray-100 p-0.5 rounded">
{([0, 1, 3, 7] as ForecastDay[]).map((d) => (
<button
key={d}
onClick={() => handleForecastChange(d)}
className={`px-3 py-1 text-[12px] font-medium rounded transition-colors ${
forecastDay === d ? 'bg-blue-500 text-white' : 'text-gray-600 hover:text-blue-500'
}`}
>
{d === 0 ? '今日' : d + '天后'}
</button>
))}
</div>
<button
onClick={handleFullscreen}
className="px-3 py-1.5 text-[12px] text-gray-600 bg-gray-100 border border-gray-200 rounded hover:border-blue-400 transition-colors"
>
</button>
</div>
<div className="relative" style={{ height: containerHeight }}>
<div ref={mapDivRef} className="w-full h-full overflow-hidden rounded-lg" />
<div className="absolute bottom-4 right-4 bg-white px-4 py-3 rounded-lg border border-gray-200 shadow-sm z-[1000]">
<div className="text-[11px] font-semibold text-gray-600 mb-2"></div>
<div className="flex flex-wrap gap-3">
{Object.entries(RISK_LABELS).map(([level, label]) => (
<div key={level} className="flex items-center gap-1.5 text-[11px] text-gray-600">
<div className="w-4 h-4 rounded" style={{ backgroundColor: RISK_COLORS[level] }} />
<span>{label}</span>
</div>
))}
</div>
</div>
<div className="absolute top-4 left-4 bg-white px-3 py-2 rounded-lg border border-gray-200 shadow-sm z-[1000]">
<div className="text-[11px] text-gray-600">
<span className="font-semibold text-gray-900">{grids.length.toLocaleString()}</span>
<span className="mx-2 text-gray-300">|</span>
{forecastDay === 0 ? '实时监测' : forecastDay + '天预报'}
</div>
</div>
{selectedGrid && (
<div className="absolute top-4 right-4 w-[280px] bg-white border border-gray-200 rounded-lg shadow-lg z-[1001]">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<span className="text-[13px] font-semibold"></span>
<button onClick={handleClosePanel} className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100">
<svg className="w-3.5 h-3.5 fill-gray-400" viewBox="0 0 24 24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
<div className="p-4">
<div className={`rounded-md p-3 mb-4 ${selectedGrid.risk_value >= 0.7 ? 'bg-red-50' : 'bg-yellow-50'}`}>
<div className="text-[12px] text-gray-500 mb-1"></div>
<div className={`text-[24px] font-bold ${selectedGrid.risk_value >= 0.7 ? 'text-red-600' : 'text-yellow-600'}`}>
{Math.round(selectedGrid.risk_value * 100)}%
</div>
</div>
<div className="space-y-2 text-[12px]">
<div className="flex justify-between py-1.5 border-b border-gray-100">
<span className="text-gray-400"></span>
<span className="font-medium">{selectedGrid.region || '--'}</span>
</div>
<div className="flex justify-between py-1.5 border-b border-gray-100">
<span className="text-gray-400"></span>
<span className="font-medium">{selectedGrid.street || '--'}</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export const RiskMap = memo(RiskMapComponent);

View File

@@ -0,0 +1,13 @@
import { Navigate } from 'react-router-dom';
import { useSessionStore } from '@/stores';
import { roleDefaultPath } from '@/utils/roleViews';
/**
* 根据当前视角role把裸路径 `/` 重定向到该视角的默认落地页。
* D2纯前端视图预设 —— role 只决定默认落地页,不是访问控制。
* 角色来源被隔离在 sessionStore 的 getRoleSource() 接缝里。
*/
export function RoleRedirect(): JSX.Element {
const role = useSessionStore((s) => s.role);
return <Navigate to={roleDefaultPath(role)} replace />;
}

View File

@@ -4,7 +4,10 @@ import { TESTIDS } from '@/utils/testids';
interface SideNavProps {
alertCount?: number;
// 抽屉模式下点击导航项后关闭抽屉(持久侧栏可不传)。
onNavigate?: () => void;
// 受控的展开手风琴分组:由 AppShell 提供时,导轨与抽屉两份实例保持同步。
// 不传则回退到内部 state向后兼容独立使用。
expanded?: string | null;
onExpandedChange?: (moduleId: string | null) => void;
}
@@ -66,9 +69,11 @@ export function SideNav({
}: SideNavProps) {
const location = useLocation();
// 当前路径命中的模块默认展开。
const moduleForPath = (pathname: string) =>
modules.find((m) => m.items.some((item) => pathname.startsWith(item.to)))?.id ?? 'monitoring';
// 受控/非受控双模式:父级传入 expanded 时由父级管理,否则回退内部 state。
const [internalExpanded, setInternalExpanded] = useState<string | null>(() =>
moduleForPath(location.pathname)
);
@@ -86,41 +91,28 @@ export function SideNav({
};
return (
<nav className="h-full overflow-y-auto py-2 px-2.5">
<nav className="h-full overflow-y-auto py-4 px-2">
{modules.map((module) => (
<div key={module.id} className="mb-1.5">
<div key={module.id} className="mb-4">
<button
type="button"
onClick={() => setExpanded(expanded === module.id ? null : module.id)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-semibold transition-colors ${
className={`w-full flex items-center gap-[10px] px-3 py-[9px] rounded-md text-[14px] font-semibold transition-colors ${
isActiveModule(module.id)
? 'bg-primary-muted text-primary'
: 'text-text-primary hover:bg-bg-hover'
}`}
>
<span className="w-4 h-4 flex items-center justify-center opacity-90">{module.icon}</span>
<span className="flex-1 text-left">{module.label}</span>
<span className="w-4 h-4 flex items-center justify-center">{module.icon}</span>
<span>{module.label}</span>
{module.id === 'alert' && alertCount > 0 && (
<span className="bg-danger-light text-danger text-[10px] font-semibold px-1.5 py-0.5 rounded-md tabular-nums">
<span className="ml-auto bg-danger-light text-danger text-[10px] font-semibold px-[5px] py-[2px] rounded">
{alertCount > 99 ? '99+' : alertCount}
</span>
)}
<svg
className={`w-3.5 h-3.5 text-text-muted shrink-0 transition-transform ${
expanded === module.id ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded === module.id && (
<div className="mt-0.5 ml-3 pl-3 border-l border-border-light">
<div className="mt-1 pl-7">
{module.items.map((item) => (
<NavLink
key={item.to}
@@ -128,7 +120,7 @@ export function SideNav({
data-testid={item.testid}
onClick={onNavigate}
className={({ isActive }) =>
`block w-full text-left px-2.5 py-1.5 rounded-md text-[12.5px] font-medium transition-colors ${
`block w-full text-left px-3 py-[7px] rounded text-[13px] font-medium transition-colors ${
isActive
? 'bg-bg-active text-primary'
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'

View File

@@ -27,12 +27,12 @@ export const StatCard = React.memo(function StatCard({
}: StatCardProps) {
const trendIndicator = trend ? (
<span
className={`inline-flex items-center gap-0.5 text-[11px] font-medium ${
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
trend.direction === 'up'
? 'text-danger'
? 'text-green-600'
: trend.direction === 'down'
? 'text-success'
: 'text-text-muted'
? 'text-red-600'
: 'text-gray-500'
}`}
>
{trend.direction === 'up' && <span aria-hidden>&#9650;</span>}
@@ -42,28 +42,32 @@ export const StatCard = React.memo(function StatCard({
</span>
) : null;
const sparklineSvg =
sparkline && sparkline.data.length >= 2 ? (
<svg width="64" height="26" className="shrink-0" aria-hidden="true">
<polyline
fill="none"
stroke={sparkline.color}
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
points={sparkline.data
.map((val, i) => {
const x = (i / (sparkline.data.length - 1)) * 62 + 1;
const max = Math.max(...sparkline.data);
const min = Math.min(...sparkline.data);
const range = max - min || 1;
const y = 24 - ((val - min) / range) * 20 - 1;
return `${x},${y}`;
})
.join(' ')}
/>
</svg>
) : null;
const sparklineSvg = sparkline && sparkline.data.length >= 2 ? (
<svg
width="60"
height="24"
className="shrink-0"
aria-hidden="true"
>
<polyline
fill="none"
stroke={sparkline.color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
points={sparkline.data
.map((val, i) => {
const x = (i / (sparkline.data.length - 1)) * 58 + 1;
const max = Math.max(...sparkline.data);
const min = Math.min(...sparkline.data);
const range = max - min || 1;
const y = 22 - ((val - min) / range) * 20 - 1;
return `${x},${y}`;
})
.join(' ')}
/>
</svg>
) : null;
return (
<div
@@ -80,22 +84,24 @@ export const StatCard = React.memo(function StatCard({
}
: undefined
}
className={`stat-card ${onClick ? 'cursor-pointer' : ''}`}
className={`bg-white rounded-lg border border-gray-200 p-4 ${
onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''
}`}
>
<div className="flex items-center gap-2 text-[12px] text-text-secondary mb-1.5 pl-1">
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
{icon}
<span className="font-medium tracking-wide">{label}</span>
<span>{label}</span>
</div>
<div className="flex items-end justify-between gap-2 pl-1">
<div className="flex items-end justify-between gap-2">
<div
className="data-num text-[22px] leading-none"
className="text-2xl font-bold text-gray-900"
style={color ? { color } : undefined}
>
{value}
</div>
{sparklineSvg}
</div>
{trendIndicator && <div className="mt-1.5 pl-1">{trendIndicator}</div>}
{trendIndicator && <div className="mt-1">{trendIndicator}</div>}
</div>
);
});

View File

@@ -28,9 +28,8 @@ export function TimelinePlayer({
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const advanceRef = useRef<() => void>(() => {});
useEffect(() => {
setPlaying(isPlaying);
}, [isPlaying]);
// Keep internal play state in sync when the parent/store changes isPlaying.
useEffect(() => { setPlaying(isPlaying); }, [isPlaying]);
const generateDateRange = useCallback((start: string, end: string) => {
const dates: string[] = [];
@@ -45,22 +44,15 @@ export function TimelinePlayer({
return dates;
}, []);
const dateRange = useMemo(
() => generateDateRange(startDate, endDate),
[startDate, endDate, generateDateRange]
);
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
// Compute index arithmetically from the day difference instead of indexOf.
const currentIndex = useMemo(() => {
if (dateRange.length === 0) return -1;
const ms = new Date(currentDate).getTime() - new Date(startDate).getTime();
const idx = Math.round(ms / 86400000);
return idx >= 0 && idx < dateRange.length ? idx : dateRange.indexOf(currentDate);
}, [startDate, currentDate, dateRange]);
const progress = useMemo(
() => ((currentIndex + 1) / dateRange.length) * 100,
[currentIndex, dateRange.length]
);
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
const play = useCallback(() => {
setPlaying(true);
@@ -73,8 +65,11 @@ export function TimelinePlayer({
}, [onPlayPause]);
const togglePlay = () => {
if (playing) pause();
else play();
if (playing) {
pause();
} else {
play();
}
};
const goToNext = useCallback(() => {
@@ -86,24 +81,33 @@ export function TimelinePlayer({
onDateChange(dateRange[0]);
};
// Keep the advance logic in a ref so the interval doesn't get recreated each
// tick when goToNext's identity changes.
useEffect(() => {
advanceRef.current = goToNext;
}, [goToNext]);
// Interval is created once per play/speed change (not per tick).
useEffect(() => {
if (playing) {
const interval = 1000 / speed;
timerRef.current = setInterval(() => {
advanceRef.current();
}, interval);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}
}, [playing, speed]);
useEffect(() => {
if (currentIndex >= dateRange.length - 1) pause();
if (currentIndex >= dateRange.length - 1) {
pause();
}
}, [currentIndex, dateRange.length, pause]);
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -112,15 +116,24 @@ export function TimelinePlayer({
};
const handleSpeedChange = () => {
const idx = SPEEDS.indexOf(speed);
const nextIndex = (idx + 1) % SPEEDS.length;
const currentIndex = SPEEDS.indexOf(speed);
const nextIndex = (currentIndex + 1) % SPEEDS.length;
onSpeedChange?.(SPEEDS[nextIndex]);
};
const formatSpeed = (s: number) => (s >= 1 ? `${s}x` : `${s.toFixed(1)}x`);
const formatSpeed = (s: number) => {
return s >= 1 ? `${s}x` : `${s.toFixed(1)}x`;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
if (isToday) {
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
}
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
@@ -128,82 +141,72 @@ export function TimelinePlayer({
});
};
const fmtShort = (d: string) =>
new Date(d).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
return (
<div className="timeline-dock" role="region" aria-label="时间轴播放器">
<div className="timeline-dock__inner">
<div className="flex items-center gap-3 sm:gap-4">
{/* 播放控件 */}
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={goToStart}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"
title="跳到开始"
aria-label="跳到开始"
>
<SkipBack className="w-4 h-4" />
</button>
<button
type="button"
onClick={togglePlay}
className="p-2 bg-primary text-white rounded-xl hover:bg-primary-deep transition-colors shadow-brand"
aria-label={playing ? '暂停' : '播放'}
>
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
</button>
<button
type="button"
onClick={goToNext}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"
title="跳到下一天"
aria-label="跳到下一天"
>
<SkipForward className="w-4 h-4" />
</button>
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-[9999] w-64">
<div className="bg-white/95 backdrop-blur-xl border border-gray-200/80 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] px-4 py-3">
{/* Date display */}
<div className="text-center mb-3">
<div className="font-medium text-gray-900 text-sm">{formatDate(currentDate)}</div>
<div className="text-xs text-gray-400 mt-0.5">
{currentIndex + 1} / {dateRange.length}
</div>
</div>
{/* 进度轨 */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2 mb-1.5">
<div className="font-mono text-[13px] font-semibold tabular-nums text-text-primary">
{formatDate(currentDate)}
</div>
<div className="text-[11px] text-text-muted tabular-nums font-mono">
{currentIndex + 1} / {dateRange.length}
</div>
</div>
<div className="relative">
<div className="timeline-dock__track">
<div className="timeline-dock__fill" style={{ width: `${progress}%` }} />
</div>
<input
type="range"
min="0"
max="100"
value={progress}
onChange={handleSliderChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
aria-label="时间进度"
/>
</div>
<div className="flex justify-between text-[10px] text-text-muted mt-1">
<span>{fmtShort(startDate)}</span>
<span>{fmtShort(endDate)}</span>
</div>
</div>
{/* Vertical slider */}
<div className="flex justify-center mb-3">
<input
type="range"
min="0"
max="100"
value={progress}
onChange={handleSliderChange}
className="h-1.5 w-full bg-gray-200 rounded-full appearance-none cursor-pointer accent-blue-600"
style={{
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${progress}%, #e5e7eb ${progress}%, #e5e7eb 100%)`,
}}
/>
</div>
<div className="flex justify-between text-[10px] text-gray-400 mb-3">
<span>{new Date(startDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
<span>{new Date(endDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
</div>
{/* 倍速 */}
{/* Transport controls */}
<div className="flex items-center justify-center gap-2">
<button
onClick={goToStart}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="跳到开始"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={togglePlay}
className="p-2.5 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors shadow-md"
>
{playing ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
<button
onClick={goToNext}
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
title="跳到下一天"
>
<SkipForward className="w-4 h-4" />
</button>
</div>
{/* Speed */}
<div className="flex items-center justify-center gap-2 mt-2">
<button
type="button"
onClick={handleSpeedChange}
className="shrink-0 px-2.5 py-1 text-[11px] font-mono font-semibold text-text-secondary
bg-bg-hover border border-border rounded-lg hover:border-primary hover:text-primary
transition-colors"
className="px-2 py-0.5 text-xs font-medium text-gray-600 bg-gray-100/80 rounded-full hover:bg-gray-200 transition-colors"
title="调整播放速度"
aria-label={`播放速度 ${formatSpeed(speed)}`}
>
{formatSpeed(speed)}
</button>

View File

@@ -1,9 +1,14 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { TESTIDS } from '@/utils/testids';
import { useSessionStore, ROLES, type Role } from '@/stores/sessionStore';
import { ROLE_LABELS, roleDefaultPath } from '@/utils/roleViews';
interface TopNavProps {
onLogout?: () => void;
// 移动端汉堡按钮:切换侧栏抽屉。
onToggleMenu?: () => void;
// 抽屉是否展开(用于汉堡按钮的 aria-expanded
isMenuOpen?: boolean;
}
@@ -13,16 +18,44 @@ function Clock() {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
}
// 视角切换器纯前端视图预设D2。刻意标注「视角」而非「权限」——不是访问控制。
// 切换时持久化角色并跳转到该视角的默认落地页。
function PerspectiveSwitcher() {
const role = useSessionStore((s) => s.role);
const setRole = useSessionStore((s) => s.setRole);
const navigate = useNavigate();
const handleChange = (next: Role) => {
setRole(next);
navigate(roleDefaultPath(next));
};
return (
<span className="font-mono tabular-nums">
{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
</span>
<label className="flex items-center gap-1.5 text-[13px] text-text-secondary">
<span className="text-text-muted hidden sm:inline"></span>
<select
data-testid={TESTIDS.perspectiveSwitcher}
value={role}
onChange={(e) => handleChange(e.target.value as Role)}
aria-label="切换视角"
className="bg-bg-card border border-border rounded-md px-2 py-1 text-[13px] text-text-primary hover:bg-bg-hover focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
>
{ROLES.map((r) => (
<option key={r} value={r} data-testid={`${TESTIDS.perspectiveOption}-${r}`}>
{ROLE_LABELS[r]}
</option>
))}
</select>
</label>
);
}
export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavProps) {
return (
<nav className="h-[54px] shrink-0 bg-bg-card/95 border-b border-border flex items-center px-4 sm:px-5 z-50 backdrop-blur-md">
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 z-50">
{onToggleMenu && (
<button
type="button"
@@ -31,7 +64,7 @@ export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavPro
aria-expanded={isMenuOpen}
aria-controls="app-drawer"
data-testid={TESTIDS.hamburger}
className="lg:hidden mr-2.5 -ml-0.5 w-9 h-9 flex items-center justify-center rounded-lg text-text-secondary hover:bg-bg-hover transition-colors"
className="lg:hidden mr-3 -ml-1 w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-bg-hover transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
@@ -39,38 +72,37 @@ export function TopNav({ onLogout, onToggleMenu, isMenuOpen = false }: TopNavPro
</button>
)}
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-mist flex items-center justify-center shadow-soft shrink-0"
aria-hidden
>
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-primary rounded-md flex items-center justify-center">
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
<path d="M12 3c-1.2 2.4-3.5 4-6 4 .6 3.4 2.8 6.2 6 7.5 3.2-1.3 5.4-4.1 6-7.5-2.5 0-4.8-1.6-6-4zm0 14.5c-2.2-.9-4-2.5-5.2-4.5C5.5 15.2 4 18 4 21h16c0-3-1.5-5.8-2.8-8-1.2 2-3 3.6-5.2 4.5z" />
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 3c1.93 0 3.5 1.57 3.5 3.5S13.93 13 12 13s-3.5-1.57-3.5-3.5S10.07 6 12 6zm7 13H5v-.23c0-.62.28-1.2.76-1.58C7.47 15.82 9.64 15 12 15s4.53.82 6.24 2.19c.48.38.76.97.76 1.58V19z" />
</svg>
</div>
<div className="min-w-0 leading-tight">
<span className="brand-mark text-[16px] block truncate">CBPOA</span>
<span className="text-[11px] text-text-muted hidden sm:block truncate">
</span>
</div>
<span className="font-display font-semibold text-[15px] text-text-primary">
WuhanChildRisk
</span>
</div>
<div className="w-px h-6 bg-border ml-4 mr-4 hidden md:block" />
<div className="w-px h-5 bg-border ml-4 mr-4 hidden sm:block" />
<span className="text-[12px] text-text-secondary hidden md:inline truncate">
· ·
<span className="text-[13px] text-text-secondary hidden sm:inline">
</span>
<div className="ml-auto flex items-center gap-4">
<div className="ml-auto flex items-center gap-5">
<span className="text-[12px] text-text-muted hidden sm:inline">
<Clock />
</span>
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
</svg>
<PerspectiveSwitcher />
</div>
{onLogout && (
<button
type="button"
onClick={onLogout}
className="text-[12px] text-text-muted hover:text-danger transition-colors px-2 py-1 rounded-md hover:bg-danger-light/60"
className="text-[12px] text-text-muted hover:text-danger transition-colors"
>
退
</button>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { TESTIDS } from '@/utils/testids';
import { DiseaseFilter } from '@/components/DiseaseFilter';
import { HORIZON_LABELS } from './types';
interface AlertsFilterBarProps {
@@ -17,8 +18,12 @@ interface AlertsFilterBarProps {
onToggleGrid: () => void;
sortBy: 'risk' | 'time';
onSortByChange: (sortBy: 'risk' | 'time') => void;
// 视角驱动的两条不变量(结果由 orchestrator 计算后下传):
isCluster: boolean; // 聚类(医生)视角:隐藏「预警标记」切换 + 挂载病种过滤
isOfficial: boolean; // 官员视角:隐藏网格切换
}
// Toolbar Row 2: Filters (时效/优先级/风险值/图层切换/排序).
export const AlertsFilterBar = React.memo(function AlertsFilterBar({
selectedHorizon,
onHorizonChange,
@@ -34,6 +39,8 @@ export const AlertsFilterBar = React.memo(function AlertsFilterBar({
onToggleGrid,
sortBy,
onSortByChange,
isCluster,
isOfficial,
}: AlertsFilterBarProps) {
return (
<div className="card p-3 mb-4">
@@ -71,8 +78,8 @@ export const AlertsFilterBar = React.memo(function AlertsFilterBar({
? priority === 'P1'
? 'bg-danger text-white'
: priority === 'P2'
? 'bg-warning text-white'
: 'bg-primary text-white'
? 'bg-warning text-white'
: 'bg-primary text-white'
: 'bg-bg-page text-text-secondary border border-border hover:border-primary'
}`}
>
@@ -122,28 +129,36 @@ export const AlertsFilterBar = React.memo(function AlertsFilterBar({
>
</button>
<button
onClick={onToggleAlertMarkers}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showAlertMarkers
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
<div data-testid={TESTIDS.gridLayerWrapper}>
{/* 预警标记切换:聚类(医生)视角隐藏整块——个体病例点不可开启(隐私不变量)。 */}
{!isCluster && (
<button
onClick={onToggleGrid}
onClick={onToggleAlertMarkers}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showGrid
showAlertMarkers
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
</div>
)}
{/* 网格切换官员视角隐藏整块100m 网格对其无意义/太超前)。 */}
{!isOfficial && (
<div data-testid={TESTIDS.gridLayerWrapper}>
<button
onClick={onToggleGrid}
className={`px-2.5 py-1.5 text-[12px] font-medium rounded transition-colors ${
showGrid
? 'bg-primary/10 text-primary border border-primary/30'
: 'bg-bg-page text-text-muted border border-border'
}`}
>
</button>
</div>
)}
{/* 聚类(医生)视角:病种过滤是其核心工具,挂载于此。 */}
{isCluster && <DiseaseFilter />}
</div>
<div className="w-px h-6 bg-border" />

View File

@@ -8,6 +8,7 @@ interface AlertsHeaderProps {
onTabChange: (tab: 'list' | 'stats') => void;
}
// 页头(标题 + 计数)+ 页内 tab 切换条(不走 router
export const AlertsHeader = React.memo(function AlertsHeader({
total,
p1,
@@ -17,52 +18,34 @@ export const AlertsHeader = React.memo(function AlertsHeader({
}: AlertsHeaderProps) {
return (
<>
<div className="flex items-end justify-between mb-4 flex-wrap gap-x-4 gap-y-3">
{/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-x-4 gap-y-2">
<div className="min-w-0">
<h1 className="font-display text-[20px] font-semibold text-text-primary mb-0.5">
</h1>
<h1 className="font-display text-[18px] font-semibold mb-1"></h1>
<p className="text-[12px] text-text-muted truncate">
100m · ·
100m网格风险预 · · -
</p>
</div>
{/* 指挥台式计数,非 badge 堆叠 */}
<div
className="command-rail !flex-none stagger-children"
role="group"
aria-label="预警计数"
>
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
<div className="command-rail__label"></div>
<div className="command-rail__value text-[20px]">{total}</div>
</div>
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
<div className="command-rail__label text-danger">P1 </div>
<div className="command-rail__value text-[20px] text-danger">{p1}</div>
</div>
<div className="command-rail__cell !py-2 !px-4" style={{ flex: '0 0 auto' }}>
<div className="command-rail__label text-warning">P2 </div>
<div className="command-rail__value text-[20px] text-warning">{p2}</div>
</div>
<div className="flex items-center gap-3 text-[11px] shrink-0 flex-wrap">
<span className="text-text-muted"> <span className="font-semibold text-text-primary">{total}</span> </span>
<span className="px-2 py-1 bg-danger/10 border border-danger/20 rounded text-danger font-semibold">P1: {p1}</span>
<span className="px-2 py-1 bg-warning/10 border border-warning/20 rounded text-warning font-semibold">P2: {p2}</span>
</div>
</div>
<div className="tab-strip mb-4 border-b border-border" role="tablist" aria-label="预警视图">
{(
[
{ key: 'list' as const, label: '预警列表' },
{ key: 'stats' as const, label: '风险统计' },
] as const
).map((tab) => (
{/* Tab strip — in-page, no router */}
<div className="flex gap-1 mb-4 border-b border-border">
{([
{ key: 'list', label: '预警列表' },
{ key: 'stats', label: '风险统计' },
] as const).map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={activeTab === tab.key}
onClick={() => onTabChange(tab.key)}
className={`tab-strip__item ${
activeTab === tab.key ? 'tab-strip__item--active' : ''
className={`px-4 py-2 text-[13px] font-medium -mb-px border-b-2 transition-colors ${
activeTab === tab.key
? 'border-primary text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}

View File

@@ -7,51 +7,39 @@ interface RiskDistributionSummaryProps {
total: number;
}
/** 预警列表顶部风险分布 — 连续 risk-strip非四块同质小卡。 */
// 预警列表 tab 顶部风险分布概要4 卡)。
export const RiskDistributionSummary = React.memo(function RiskDistributionSummary({
riskStats,
total,
}: RiskDistributionSummaryProps) {
const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0);
return (
<div className="risk-strip mb-4" role="group" aria-label="风险分布">
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.8)</div>
<div className="risk-strip__value text-danger">{riskStats.high}</div>
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
<div className="h-full bg-danger rounded-full" style={{ width: `${pct(riskStats.high)}%` }} />
<div className="grid grid-cols-4 gap-3 mb-4">
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.8)</div>
<div className="text-xl font-bold text-danger">{riskStats.high}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-danger rounded-full" style={{ width: `${total > 0 ? (riskStats.high / total) * 100 : 0}%` }} />
</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.60.8)</div>
<div className="risk-strip__value text-warning">{riskStats.mediumHigh}</div>
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
<div
className="h-full bg-warning rounded-full"
style={{ width: `${pct(riskStats.mediumHigh)}%` }}
/>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.6-0.8)</div>
<div className="text-xl font-bold text-warning">{riskStats.mediumHigh}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-warning rounded-full" style={{ width: `${total > 0 ? (riskStats.mediumHigh / total) * 100 : 0}%` }} />
</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.40.6)</div>
<div className="risk-strip__value text-primary">{riskStats.medium}</div>
<div className="mt-2 h-1 bg-bg-hover rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: `${pct(riskStats.medium)}%` }}
/>
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"> (0.4-0.6)</div>
<div className="text-xl font-bold text-primary">{riskStats.medium}</div>
<div className="mt-1.5 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${total > 0 ? (riskStats.medium / total) * 100 : 0}%` }} />
</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"></div>
<div className="risk-strip__value">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
<div className="mt-2 text-[10px] text-text-muted truncate">
{' '}
{riskStats.topDistricts
.slice(0, 2)
.map(([d, n]) => `${d}(${n})`)
.join(' · ') || '—'}
<div className="card p-3">
<div className="text-[11px] text-text-muted mb-1"></div>
<div className="text-xl font-bold text-text-primary">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
<div className="mt-1.5 text-[10px] text-text-muted">
: {riskStats.topDistricts.slice(0, 2).map(([d, n]) => `${d}(${n})`).join(', ')}
</div>
</div>
</div>
@@ -75,54 +63,47 @@ const AlertCard = React.memo(function AlertCard({ alert, isSelected, alertId, on
return (
<div
className={`relative overflow-hidden rounded-xl border transition-all cursor-pointer ${
isSelected
? 'border-primary bg-primary-muted/40 shadow-soft ring-1 ring-primary/30'
: 'border-border/80 bg-bg-card/90 hover:border-primary/40 hover:shadow-soft'
className={`card overflow-hidden transition-colors cursor-pointer ${
isSelected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary'
}`}
onClick={handleClick}
>
<div
className={`absolute left-0 top-0 bottom-0 w-1 ${isP1 ? 'bg-danger' : 'bg-warning'}`}
aria-hidden
/>
<div className="pl-4 pr-3.5 py-3">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<span
className={`text-[10px] font-bold tracking-wide px-1.5 py-0.5 rounded ${
isP1 ? 'bg-danger-light text-danger' : 'bg-warning-light text-warning'
}`}
>
<div className={`px-4 py-3 border-b ${isP1 ? 'bg-danger/5 border-danger/20' : 'bg-warning/5 border-warning/20'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${isP1 ? 'bg-danger' : 'bg-warning'}`} />
<span className={`text-[11px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{alert.priority}
</span>
<span className="text-[10px] text-text-muted truncate">
<span className="text-[10px] text-text-muted">
{HORIZON_LABELS[alert.forecast_horizon] || '未知'}
</span>
</div>
<span className={`data-num text-[18px] ${isP1 ? 'text-danger' : 'text-warning'}`}>
<span className={`text-[18px] font-bold ${isP1 ? 'text-danger' : 'text-warning'}`}>
{riskPercent}%
</span>
</div>
</div>
<div className="mb-2">
<div className="text-[13px] font-semibold text-text-primary truncate">
{alert.region} · {alert.street}
<div className="p-4">
<div className="mb-3">
<div className="text-[13px] font-semibold mb-1">
{alert.region} - {alert.street}
</div>
<div className="text-[11px] text-text-muted">
{alert.grid_id}
</div>
<div className="text-[11px] text-text-muted font-mono">{alert.grid_id}</div>
</div>
<div
className={`text-[12px] px-2.5 py-1.5 rounded-lg mb-2 line-clamp-2 ${
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
}`}
>
<div className={`text-[12px] px-3 py-2 rounded mb-3 ${
isP1 ? 'bg-danger/10 text-danger' : 'bg-warning/10 text-warning'
}`}>
{alert.reason}
</div>
<div className="flex items-center justify-between text-[10px] text-text-muted gap-2">
<span className="truncate"> {alert.forecast_time}</span>
<span className="shrink-0"> {alert.timestamp}</span>
<div className="flex items-center justify-between text-[11px] text-text-muted">
<span>{alert.forecast_time}</span>
<span>{alert.timestamp}</span>
</div>
</div>
</div>
@@ -137,7 +118,7 @@ interface AlertsListProps {
export const AlertsList = React.memo(function AlertsList({ filteredAlerts, selectedAlert, onCardClick }: AlertsListProps) {
return (
<div className="space-y-2 max-h-none overflow-visible">
<div className="space-y-3 max-h-[calc(100vh-280px)] overflow-y-auto">
{filteredAlerts.slice(0, 50).map((alert) => (
<AlertCard
key={alert.alert_id}

View File

@@ -8,12 +8,14 @@ import { AlertsList, RiskDistributionSummary } from './AlertsList';
import type { ExtendedAlert, RiskStats } from './types';
interface AlertsListTabProps {
// toolbar
forecastDay: 1 | 3 | 7;
onForecastDayChange: (day: 1 | 3 | 7) => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onExportCsv: () => void;
onExportJson: () => void;
// filter bar
selectedHorizon: number | 'all';
onHorizonChange: (horizon: number | 'all') => void;
selectedPriority: 'all' | 'P1' | 'P2';
@@ -28,6 +30,7 @@ interface AlertsListTabProps {
onToggleGrid: () => void;
sortBy: 'risk' | 'time';
onSortByChange: (sortBy: 'risk' | 'time') => void;
// data
riskStats: RiskStats;
filteredAlerts: ExtendedAlert[];
isLoading: boolean;
@@ -36,10 +39,21 @@ interface AlertsListTabProps {
onGridClick: (gridId: string) => void;
onCellInfo: (info: CellInfo) => void;
onCardClick: (id: string) => void;
// privacy/role results (computed by orchestrator)
effectiveShowAlertMarkers: boolean;
isCluster: boolean;
isOfficial: boolean;
}
export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsListTabProps) {
const { filteredAlerts, isLoading, isFullscreen, showMap, riskStats } = props;
const {
filteredAlerts,
isLoading,
isCluster,
isFullscreen,
showMap,
riskStats,
} = props;
return (
<>
@@ -67,64 +81,46 @@ export const AlertsListTab = React.memo(function AlertsListTab(props: AlertsList
onToggleGrid={props.onToggleGrid}
sortBy={props.sortBy}
onSortByChange={props.onSortByChange}
isCluster={isCluster}
isOfficial={props.isOfficial}
/>
<RiskDistributionSummary riskStats={riskStats} total={filteredAlerts.length} />
{isLoading ? (
<div className="workbench-panel p-8">
<div className="card p-8">
<LoadingState />
</div>
) : filteredAlerts.length === 0 && !showMap ? (
<div className="workbench-panel p-8 text-center">
<svg
className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
) : filteredAlerts.length === 0 && !isCluster ? (
// 聚类(医生)视角即使没有个体预警,也要展示聚合密度栅格——故不走空状态分支。
<div className="card p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-3 text-text-muted opacity-50" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
</svg>
<div className="text-text-muted text-[13px]"></div>
</div>
) : (
<div
className={`grid gap-0 overflow-hidden rounded-2xl border border-border shadow-soft ${
isFullscreen ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-[1fr_380px]'
}`}
>
<div className={`grid gap-4 ${isFullscreen ? 'grid-cols-1' : 'grid-cols-[1fr_400px]'}`}>
{showMap && (
<div className="map-stage min-h-[420px] relative">
<div className="map-chrome">
<div className="map-chrome__chip">
<span className="h-1.5 w-1.5 rounded-full bg-danger animate-pulse" aria-hidden />
<span className="text-[12px] font-semibold text-text-primary"></span>
</div>
</div>
<div className="absolute inset-0">
<AlertsMapPanel
selectedGridId={props.selectedGridId}
onGridClick={props.onGridClick}
onCellInfo={props.onCellInfo}
forecastDay={props.forecastDay}
showAlertMarkers={props.showAlertMarkers}
showGrid={props.showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
</div>
</div>
<AlertsMapPanel
selectedGridId={props.selectedGridId}
onGridClick={props.onGridClick}
onCellInfo={props.onCellInfo}
forecastDay={props.forecastDay}
effectiveShowAlertMarkers={props.effectiveShowAlertMarkers}
showGrid={props.showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
isCluster={isCluster}
isOfficial={props.isOfficial}
/>
)}
{!isFullscreen && (
<aside className="glass-wing max-h-[min(720px,70vh)] overflow-auto border-l-0 lg:border-l border-t lg:border-t-0 border-border">
<div className="glass-wing__section !border-b-0 flex-1">
<h3 className="glass-wing__title"></h3>
<AlertsList
filteredAlerts={filteredAlerts}
selectedAlert={props.selectedAlert}
onCardClick={props.onCardClick}
/>
</div>
</aside>
<AlertsList
filteredAlerts={filteredAlerts}
selectedAlert={props.selectedAlert}
onCardClick={props.onCardClick}
/>
)}
</div>
)}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { TESTIDS } from '@/utils/testids';
import { AlertMap } from '@/components/AlertMap';
import type { CellInfo } from '@/components/AlertMap';
import type { ExtendedAlert } from './types';
@@ -8,10 +9,13 @@ interface AlertsMapPanelProps {
onGridClick: (gridId: string) => void;
onCellInfo: (info: CellInfo) => void;
forecastDay: 1 | 3 | 7;
showAlertMarkers: boolean;
// effectiveShowAlertMarkers唯一真值cluster 模式恒为 false隐私不变量由 orchestrator 计算。
effectiveShowAlertMarkers: boolean;
showGrid: boolean;
filteredAlerts: ExtendedAlert[];
isFullscreen: boolean;
isCluster: boolean;
isOfficial: boolean;
}
export const AlertsMapPanel = React.memo(function AlertsMapPanel({
@@ -19,21 +23,42 @@ export const AlertsMapPanel = React.memo(function AlertsMapPanel({
onGridClick,
onCellInfo,
forecastDay,
showAlertMarkers,
effectiveShowAlertMarkers,
showGrid,
filteredAlerts,
isFullscreen,
isCluster,
isOfficial,
}: AlertsMapPanelProps) {
return (
<AlertMap
selectedGridId={selectedGridId}
onGridClick={onGridClick}
onCellInfo={onCellInfo}
forecastDay={forecastDay}
showAlertMarkers={showAlertMarkers}
showGrid={showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
<div data-testid={isCluster ? TESTIDS.clusterView : undefined}>
<AlertMap
selectedGridId={selectedGridId}
onGridClick={onGridClick}
onCellInfo={onCellInfo}
forecastDay={forecastDay}
showAlertMarkers={effectiveShowAlertMarkers}
showGrid={isOfficial ? false : showGrid}
filteredAlerts={filteredAlerts}
isFullscreen={isFullscreen}
/>
{/*
隐私不变量的「数据级」可断言点:每渲染一个个体病例点标记,就在此输出一个
data-testid="patient-point" 的隐藏标记。Leaflet 的 CircleMarker 是 canvas/SVG
内部对象、不带 testid无法被 e2e 直接计数;这里把「实际会显示的个体点集合」
镜像成 DOM使测试可断言医生/聚类视角下 patient-point 计数恒为 0
而无需窥探 Leaflet 内部。effectiveShowAlertMarkers 在 cluster 模式恒为 false
故该集合为空。
*/}
{effectiveShowAlertMarkers &&
filteredAlerts.map((a) => (
<span
key={a.alert_id}
data-testid={TESTIDS.patientPoint}
className="hidden"
aria-hidden
/>
))}
</div>
);
});

View File

@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { LoadingState } from '@/components/ui';
import { StatCard } from '@/components/StatCard';
import { StatisticalCharts } from '@/components/StatisticalCharts';
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer } from 'recharts';
import type { RiskStats } from './types';
@@ -17,142 +18,112 @@ export const AlertsRiskPanel = React.memo(function AlertsRiskPanel({
trendLoading,
trendError,
}: AlertsRiskPanelProps) {
const alertPie = useMemo(
() => [
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#C2410C' },
{ name: 'P2 (关注)', value: riskStats.p2, color: '#C27803' },
],
[riskStats.p1, riskStats.p2]
);
// Severity donut data (P1/P2)
const alertPie = useMemo(() => ([
{ name: 'P1 (紧急)', value: riskStats.p1, color: '#ef4444' },
{ name: 'P2 (关注)', value: riskStats.p2, color: '#f59e0b' },
]), [riskStats.p1, riskStats.p2]);
const topDistrictMax = useMemo(
() => riskStats.topDistricts.reduce((m, [, n]) => Math.max(m, n), 0),
[riskStats.topDistricts]
[riskStats.topDistricts],
);
return (
<div className="space-y-5">
{/* 风险分布连续条 */}
<div className="risk-strip" role="group" aria-label="风险等级统计">
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.8)</div>
<div className="risk-strip__value text-danger">{riskStats.high}</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.60.8)</div>
<div className="risk-strip__value text-warning">{riskStats.mediumHigh}</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"> (0.40.6)</div>
<div className="risk-strip__value text-primary">{riskStats.medium}</div>
</div>
<div className="risk-strip__cell">
<div className="risk-strip__label"></div>
<div className="risk-strip__value">{(riskStats.avgRisk * 100).toFixed(1)}%</div>
</div>
<div className="space-y-4">
{/* Risk distribution as StatCards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="高风险 (≥0.8)" value={riskStats.high} color="#ef4444" />
<StatCard label="中高风险 (0.6-0.8)" value={riskStats.mediumHigh} color="#f59e0b" />
<StatCard label="中风险 (0.4-0.6)" value={riskStats.medium} color="#3b82f6" />
<StatCard label="平均风险" value={`${(riskStats.avgRisk * 100).toFixed(1)}%`} />
</div>
{/* 趋势图 */}
<section className="workbench-panel" aria-label="风险趋势">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub"> 14 </p>
</div>
</div>
<div className="workbench-panel__body">
{trendLoading ? (
<LoadingState />
) : trendError ? (
<div className="text-center py-8 text-danger text-[13px]">{trendError}</div>
) : trendData.length === 0 ? (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
) : (
<StatisticalCharts data={trendData} showCases={false} showRisk height={280} />
)}
</div>
</section>
{/* Risk trend chart (real data from /api/analysis/trend) */}
{trendLoading ? (
<div className="card p-8"><LoadingState /></div>
) : trendError ? (
<div className="card p-8 text-center text-danger text-[13px]">{trendError}</div>
) : trendData.length === 0 ? (
<div className="card p-8 text-center text-text-muted text-[13px]"></div>
) : (
<StatisticalCharts
data={trendData}
showCases={false}
showRisk
height={280}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<section className="workbench-panel" aria-label="高风险区域">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"> Top 5</h3>
<p className="workbench-panel__sub"></p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Top high-risk districts bar */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
Top 5
</div>
<div className="workbench-panel__body space-y-3">
{riskStats.topDistricts.length > 0 ? (
riskStats.topDistricts.map(([district, count], i) => (
<div key={district} className="district-bar !cursor-default hover:!bg-transparent !px-0">
<div className="w-4 text-[10px] font-mono text-text-muted tabular-nums">{i + 1}</div>
<div className="w-16 text-[12px] font-medium text-text-secondary shrink-0 truncate">
{district}
{riskStats.topDistricts.length > 0 ? (
<div className="space-y-3">
{riskStats.topDistricts.map(([district, count]) => (
<div key={district}>
<div className="flex items-center justify-between text-[12px] mb-1">
<span className="text-text-primary font-medium">{district}</span>
<span className="text-text-muted">{count} </span>
</div>
<div className="district-bar__track">
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="district-bar__fill bg-danger/80"
style={{
width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%`,
}}
className="h-full bg-danger rounded-full"
style={{ width: `${topDistrictMax > 0 ? (count / topDistrictMax) * 100 : 0}%` }}
/>
</div>
<div className="w-12 text-right data-num text-[12px]">{count}</div>
</div>
))
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</section>
<section className="workbench-panel" aria-label="预警严重度">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub">P1 / P2 </p>
))}
</div>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
{/* Alert severity donut (P1/P2) */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-muted uppercase tracking-wide mb-4">
</div>
<div className="workbench-panel__body">
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={alertPie}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{alertPie.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #D4DEE4',
borderRadius: '10px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => (
<span className="text-text-secondary">{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</section>
{riskStats.p1 > 0 || riskStats.p2 > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={alertPie}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{alertPie.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{
backgroundColor: '#FFFFFF',
border: '1px solid #E2E8F0',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => <span className="text-text-secondary">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-text-muted text-[13px]"></div>
)}
</div>
</div>
</div>
);

View File

@@ -1,31 +1,34 @@
/**
* 住院临床分析页图表字面色值集中处。
* 对齐江雾青绿 / 雾蓝体系,避免紫系默认配色
* Recharts 需要原始 hex无法用 Tailwind class故在此集中定义避免散落 magic hex
*/
export const CLINICAL_COLORS = {
primary: '#0F766E',
los: '#0F766E',
box: '#14B8A6',
boxMedian: '#0D5C56',
grid: '#D4DEE4',
axis: '#5A6F7A',
axisLabel: '#1A2B33',
tooltipBorder: '#D4DEE4',
tooltipText: '#1A2B33',
primary: '#2563EB', // primary
los: '#2563EB',
box: '#3B82F6', // 箱体填充
boxMedian: '#1D4ED8', // 中位刻度
grid: '#E2E8F0',
axis: '#64748B',
axisLabel: '#374151',
tooltipBorder: '#E2E8F0',
tooltipText: '#1E293B',
// 出院结局按严重程度配色:治愈/好转偏绿,未愈/死亡偏红,其他中性
outcome: {
: '#0D9488',
: '#5EEAD4',
: '#8A9BA5',
: '#C27803',
: '#C2410C',
: '#16A34A',
: '#4ADE80',
: '#94A3B8',
: '#F97316',
: '#DC2626',
} as Record<string, string>,
outcomeFallback: '#8A9BA5',
routePalette: ['#0F766E', '#5B8FA8', '#14B8A6', '#C27803', '#0D9488', '#C2410C'],
outcomeFallback: '#94A3B8',
// 入院途径 donut 顺序色板
routePalette: ['#2563EB', '#0891B2', '#7C3AED', '#D97706', '#16A34A', '#DC2626'],
} as const;
/** Recharts tooltip 通用样式。 */
export const TOOLTIP_STYLE = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CLINICAL_COLORS.tooltipBorder}`,
borderRadius: '10px',
borderRadius: '8px',
fontSize: '12px',
} as const;

View File

@@ -20,14 +20,6 @@ function formatDateLabel(dateStr: string): string {
return `${d.getMonth() + 1}/${d.getDate()}`;
}
const TOOLTIP = {
backgroundColor: '#FFFFFF',
border: '1px solid #D4DEE4',
borderRadius: '10px',
fontSize: '12px',
boxShadow: '0 4px 16px rgba(26,43,51,0.08)',
};
interface CaseStatsTabProps {
loading: boolean;
loaded: boolean;
@@ -41,7 +33,7 @@ interface CaseStatsTabProps {
onDismissError: () => void;
}
/** 病例统计 tab — 诊断 / 趋势 / 日历,工作台面板构图。 */
// 病例统计 tab — 诊断分布 / 病例与AQI趋势 / 日历热力图。纯展示,数据由父级按需加载。
export const CaseStatsTab = memo(function CaseStatsTab({
loading,
loaded,
@@ -57,138 +49,97 @@ export const CaseStatsTab = memo(function CaseStatsTab({
if (loading && !loaded) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-5 max-w-[1400px]">
{error && <ErrorBanner error={error} onRetry={onRetry} onDismiss={onDismissError} />}
<div className="space-y-6">
{error && (
<ErrorBanner
error={error}
onRetry={onRetry}
onDismiss={onDismissError}
/>
)}
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
{/* Top 5 诊断 — 占 2 列 */}
<section className="workbench-panel xl:col-span-2" aria-label="Top 5 诊断分布">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">Top 5 </h3>
<p className="workbench-panel__sub"> / </p>
</div>
</div>
<div className="workbench-panel__body">
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 12, left: 8, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E8EEF1" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#8A9BA5' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#5A6F7A' }}
width={96}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={TOOLTIP}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="outpatient" stackId="a" fill="#C27803" name="门诊" barSize={18} radius={[0, 0, 0, 0]} />
<Bar dataKey="inpatient" stackId="a" fill="#C2410C" name="住院" barSize={18} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
{/* 病例与 AQI 趋势 — 占 3 列 */}
<section className="workbench-panel xl:col-span-3" aria-label="病例与AQI趋势">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"> AQI </h3>
<p className="workbench-panel__sub"> {currentDate} 30 </p>
</div>
</div>
<div className="workbench-panel__body">
{caseTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E8EEF1" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#8A9BA5' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#D4DEE4' }}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 10, fill: '#8A9BA5' }}
axisLine={{ stroke: '#D4DEE4' }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 10, fill: '#C27803' }}
axisLine={{ stroke: '#D4DEE4' }}
/>
<Tooltip contentStyle={TOOLTIP} labelStyle={{ color: '#1A2B33', fontWeight: 600 }} />
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line
yAxisId="left"
type="monotone"
dataKey="cases"
name="病例数"
stroke="#0F766E"
strokeWidth={2.25}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="aqi"
name="AQI"
stroke="#C27803"
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
{/* Top 5 诊断分布 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top 5 </h3>
{topDiagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart
data={[...topDiagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: '#64748B' }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: '#374151' }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
formatter={(value: number) => [value.toLocaleString(), '病例数']}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Bar dataKey="outpatient" stackId="a" fill="#3B82F6" name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill="#EF4444" name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 日历热力图 — 通栏 */}
<section className="workbench-panel" aria-label="每日病例日历">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">
{heatmapYear ? `${heatmapYear}` : ''}
</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
<div className="workbench-panel__body">
{heatmapYear && heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
{/* 近30日病例与AQI趋势 (driven off Monitoring timeline currentDate) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1">AQI趋势</h3>
<p className="text-xs text-gray-500 mb-4"> {currentDate} 30</p>
{caseTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={caseTrend} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: '#64748B' }}
interval="preserveStartEnd"
axisLine={{ stroke: '#E2E8F0' }}
/>
<YAxis yAxisId="left" tick={{ fontSize: 10, fill: '#64748B' }} axisLine={{ stroke: '#E2E8F0' }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 10, fill: '#F59E0B' }} axisLine={{ stroke: '#E2E8F0' }} />
<Tooltip
contentStyle={{ backgroundColor: '#FFFFFF', border: '1px solid #E2E8F0', borderRadius: '8px', fontSize: '12px' }}
labelStyle={{ color: '#1E293B', fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line yAxisId="left" type="monotone" dataKey="cases" name="病例数" stroke="#3B82F6" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
<Line yAxisId="right" type="monotone" dataKey="aqi" name="AQI" stroke="#F59E0B" strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
{/* 日历热力图 (year derived from data) */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{heatmapYear ? `${heatmapYear}` : ''}
</h3>
{heatmapYear && heatmapData.length > 0 ? (
<CalendarHeatmap data={heatmapData} year={heatmapYear} />
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
);
});

View File

@@ -13,7 +13,7 @@ interface DistrictStatsTabProps {
onSort: (col: string) => void;
}
/** 区域统计 tab — 热力表工作台面板。 */
// 区域统计 tab —— 区域指标热力表。纯展示,排序键由父级持有。
export const DistrictStatsTab = memo(function DistrictStatsTab({
loading,
loaded,
@@ -27,47 +27,40 @@ export const DistrictStatsTab = memo(function DistrictStatsTab({
if (loading && !loaded) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="space-y-5 max-w-[1400px]">
{error && <ErrorBanner error={error} onRetry={onRetry} onDismiss={onDismissError} />}
<div className="space-y-6">
{error && (
<ErrorBanner
error={error}
onRetry={onRetry}
onDismiss={onDismissError}
/>
)}
<section className="workbench-panel" aria-label="区域指标热力表">
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub"> · </p>
</div>
{rows.length > 0 && (
<span className="text-[11px] font-mono tabular-nums text-text-muted bg-bg-hover px-2.5 py-1 rounded-md border border-border">
{rows.length}
</span>
)}
</div>
<div className="workbench-panel__body">
{rows.length > 0 ? (
<div className="rounded-xl border border-border/80 overflow-hidden bg-bg-elevated/50">
<MetricHeatmapTable
rows={rows}
columns={[
{ key: 'total', label: '病例' },
{ key: 'outpatient', label: '门诊' },
{ key: 'inpatient', label: '住院' },
{ key: 'inpatient_ratio', label: '住院占比%' },
]}
data={data}
onSort={onSort}
/>
</div>
) : (
<div className="text-center py-12 text-text-muted text-sm"></div>
)}
</div>
</section>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1"></h3>
<p className="text-xs text-gray-500 mb-4"></p>
{rows.length > 0 ? (
<MetricHeatmapTable
rows={rows}
columns={[
{ key: 'total', label: '病例' },
{ key: 'outpatient', label: '门诊' },
{ key: 'inpatient', label: '住院' },
{ key: 'inpatient_ratio', label: '住院占比%' },
]}
data={data}
onSort={onSort}
/>
) : (
<div className="text-center py-8 text-gray-400 text-sm"></div>
)}
</div>
</div>
);
});

View File

@@ -1,5 +1,6 @@
import { memo } from 'react';
import { Calendar, Activity, TrendingUp, TrendingDown, Stethoscope, Zap, BarChart3 } from 'lucide-react';
import { StatCard } from '@/components/StatCard';
import type { MonitoringStats } from './types';
interface MonitoringStatsBarProps {
@@ -7,113 +8,49 @@ interface MonitoringStatsBarProps {
sparkline7d: number[];
}
function MiniSpark({ data, color }: { data: number[]; color: string }) {
if (data.length < 2) return null;
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
const points = data
.map((val, i) => {
const x = (i / (data.length - 1)) * 72 + 1;
const y = 22 - ((val - min) / range) * 18 - 1;
return `${x},${y}`;
})
.join(' ');
const area = `1,23 ${points} 73,23`;
// 监测页顶部统计条 —— 纯展示已自适应grid-cols-2 sm:grid-cols-3 lg:grid-cols-6
export const MonitoringStatsBar = memo(function MonitoringStatsBar({ stats, sparkline7d }: MonitoringStatsBarProps) {
return (
<svg width="74" height="24" className="shrink-0" aria-hidden="true">
<polygon fill={`${color}22`} points={area} />
<polyline
fill="none"
stroke={color}
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
<div className="flex-1 min-w-0 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Calendar className="w-4 h-4 text-blue-600" />}
label="当日病例"
value={stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-indigo-600" />}
label="7日均值"
value={stats.avg7d.toLocaleString()}
sparkline={sparkline7d.length >= 2 ? { data: sparkline7d, color: '#6366F1' } : undefined}
/>
<StatCard
icon={
stats.trend === 'up' ? <TrendingUp className="w-4 h-4 text-red-500" /> :
stats.trend === 'down' ? <TrendingDown className="w-4 h-4 text-green-500" /> :
<Activity className="w-4 h-4 text-gray-400" />
}
label="趋势"
value={stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳'}
trend={{
direction: stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'stable',
value: stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳',
}}
/>
<StatCard
icon={<Zap className="w-4 h-4 text-amber-500" />}
label="峰值日"
value={`${stats.maxDay.cases.toLocaleString()} (${stats.maxDay.date.slice(5)})`}
/>
<StatCard
icon={<BarChart3 className="w-4 h-4 text-purple-500" />}
label="标准差"
value={stats.stdDev.toLocaleString()}
/>
<StatCard
icon={<Stethoscope className="w-4 h-4 text-orange-500" />}
label="门诊 / 住院"
value={`${stats.totalOutpatient.toLocaleString()} / ${stats.totalInpatient.toLocaleString()}`}
/>
</svg>
);
}
/** 监测页指挥台指标带 — 6 KPI + sparkline连续仪表条构图。 */
export const MonitoringStatsBar = memo(function MonitoringStatsBar({
stats,
sparkline7d,
}: MonitoringStatsBarProps) {
const trendLabel = stats.trend === 'up' ? '上升' : stats.trend === 'down' ? '下降' : '平稳';
const trendClass =
stats.trend === 'up' ? 'text-danger' : stats.trend === 'down' ? 'text-success' : 'text-text-muted';
return (
<div className="command-rail flex-1 min-w-0 stagger-children" role="group" aria-label="监测关键指标">
<div className="command-rail__cell command-rail__cell--hero">
<div className="command-rail__label">
<Calendar className="w-3.5 h-3.5 text-primary" aria-hidden />
</div>
<div className="command-rail__value">
{stats.todayCases !== null ? stats.todayCases.toLocaleString() : '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Activity className="w-3.5 h-3.5 text-mist" aria-hidden />
7
</div>
<div className="flex items-end justify-between gap-2">
<div className="command-rail__value text-[20px]">{stats.avg7d.toLocaleString()}</div>
{sparkline7d.length >= 2 && <MiniSpark data={sparkline7d} color="#0F766E" />}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
{stats.trend === 'up' ? (
<TrendingUp className="w-3.5 h-3.5 text-danger" aria-hidden />
) : stats.trend === 'down' ? (
<TrendingDown className="w-3.5 h-3.5 text-success" aria-hidden />
) : (
<Activity className="w-3.5 h-3.5 text-text-muted" aria-hidden />
)}
</div>
<div className={`command-rail__value text-[20px] ${trendClass}`}>{trendLabel}</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Zap className="w-3.5 h-3.5 text-warning" aria-hidden />
</div>
<div className="command-rail__value text-[18px]">
{stats.maxDay.cases.toLocaleString()}
<span className="ml-1.5 text-[12px] font-medium text-text-muted font-sans tracking-normal">
{stats.maxDay.date.slice(5)}
</span>
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<BarChart3 className="w-3.5 h-3.5 text-mist-deep" aria-hidden />
</div>
<div className="command-rail__value text-[20px]">{stats.stdDev.toLocaleString()}</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Stethoscope className="w-3.5 h-3.5 text-primary-light" aria-hidden />
/
</div>
<div className="command-rail__value text-[17px]">
<span className="text-warning">{stats.totalOutpatient.toLocaleString()}</span>
<span className="mx-1 text-text-muted font-sans font-normal">/</span>
<span className="text-danger">{stats.totalInpatient.toLocaleString()}</span>
</div>
</div>
</div>
);
});

View File

@@ -17,11 +17,7 @@ interface OverviewTabProps {
onDistrictSelect: (district: string) => void;
}
/**
* 概览 tab — 沉浸式地图舞台 + 玻璃统计翼(趋势 / 区县 roll-up
* 功能保留病例地图、AQI/病例趋势、区县分解、粒度切换。
* 播放时间轴时地图始终挂载,仅侧翼显示刷新态,避免底图闪烁。
*/
// 概览 tab —— 病例分布地图 + 统计图表 + 区县 roll-up粒度真相来源在父级 URL
export const OverviewTab = memo(function OverviewTab({
isLoading,
chartData,
@@ -33,106 +29,62 @@ export const OverviewTab = memo(function OverviewTab({
onGranularityChange,
onDistrictSelect,
}: OverviewTabProps) {
const totalDistrictCases = useMemo(
() => districtCases.reduce((s, d) => s + d.total, 0),
[districtCases]
);
const showWingSkeleton = isLoading && districtCases.length === 0;
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="flex flex-col xl:flex-row h-full min-h-0 pb-[4.5rem]">
{/* 地图舞台 — 全高沉浸;永不因 isLoading 卸载 */}
<section className="map-stage min-h-[380px] xl:min-h-0 border-b xl:border-b-0">
<div className="map-chrome">
<div className="map-chrome__chip">
<span className="text-[11px] text-text-muted"></span>
<span className="font-mono text-[12px] font-semibold tabular-nums text-text-primary">
{currentDate}
</span>
</div>
</div>
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-[6] h-16
bg-gradient-to-t from-[#dce6eb]/70 to-transparent"
aria-hidden
/>
<div className="absolute inset-0">
<CaseLocationMap
height="100%"
district={selectedDistrict}
street={selectedStreet}
date={currentDate}
<div className="space-y-6">
{/* Case Location Map */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<CaseLocationMap height="400px" district={selectedDistrict} street={selectedStreet} date={currentDate} />
</div>
{/* Statistical Charts */}
<StatisticalCharts
data={chartData}
height={350}
showCases={true}
showAQI={true}
/>
{/* District breakdown — 区域 roll-upURL 粒度真相来源) */}
<div data-testid={TESTIDS.districtRollup} className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<Segmented<Granularity>
testid={TESTIDS.granularityControl}
size="sm"
options={[
{ value: 'city', label: '全市' },
{ value: 'district', label: '区域' },
{ value: 'street', label: '街道' },
]}
value={granularity}
onChange={onGranularityChange}
/>
</div>
</section>
{/* 玻璃统计翼 */}
<aside className="glass-wing xl:w-[400px] 2xl:w-[440px] shrink-0 max-h-[48vh] xl:max-h-none overflow-auto">
{showWingSkeleton ? (
<div className="flex items-center justify-center min-h-[280px]">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
<div className="space-y-2">
<DistrictBreakdown
districtCases={districtCases}
selectedDistrict={selectedDistrict}
onDistrictSelect={onDistrictSelect}
/>
</div>
<div className="flex items-center gap-4 mt-3 pt-2 border-t border-gray-100">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-orange-400 rounded-sm" />
</div>
) : (
<>
<div className="glass-wing__section">
<h3 className="glass-wing__title"> AQI </h3>
<div className="rounded-xl border border-border/70 bg-bg-card/80 p-1.5 shadow-soft">
<StatisticalCharts data={chartData} height={240} showCases={true} showAQI={true} />
</div>
</div>
<div data-testid={TESTIDS.districtRollup} className="glass-wing__section !pb-4">
<div className="flex items-center justify-between mb-1 gap-2 flex-wrap">
<div>
<h3 className="glass-wing__title !mb-1"></h3>
<p className="text-[11px] text-text-muted mb-2">
{' '}
<span className="data-num text-text-secondary text-[12px]">
{totalDistrictCases.toLocaleString()}
</span>
{selectedDistrict ? (
<span className="ml-2 text-primary">· {selectedDistrict}</span>
) : null}
{isLoading ? (
<span className="ml-2 text-text-muted"></span>
) : null}
</p>
</div>
<Segmented<Granularity>
testid={TESTIDS.granularityControl}
size="sm"
options={[
{ value: 'city', label: '全市' },
{ value: 'district', label: '区域' },
{ value: 'street', label: '街道' },
]}
value={granularity}
onChange={onGranularityChange}
/>
</div>
<div className="space-y-0.5 max-h-[min(360px,42vh)] overflow-y-auto pr-0.5 -mx-1">
<DistrictBreakdown
districtCases={districtCases}
selectedDistrict={selectedDistrict}
onDistrictSelect={onDistrictSelect}
/>
</div>
<div className="flex items-center gap-5 mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
<span className="w-3 h-2 rounded-sm bg-warning/85" aria-hidden />
</div>
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
<span className="w-3 h-2 rounded-sm bg-danger/75" aria-hidden />
</div>
</div>
</div>
</>
)}
</aside>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span className="w-3 h-3 bg-red-400 rounded-sm" />
</div>
</div>
</div>
</div>
);
});
@@ -140,71 +92,46 @@ export const OverviewTab = memo(function OverviewTab({
interface DistrictBreakdownProps {
districtCases: DistrictCaseRow[];
selectedDistrict: string | null;
// 点击区域条目时上抛——由父组件驱动 URL粒度真相来源不在此处 mutate store。
onDistrictSelect: (district: string) => void;
}
const DistrictBreakdown = memo(function DistrictBreakdown({
districtCases,
selectedDistrict,
onDistrictSelect,
}: DistrictBreakdownProps) {
const sortedCases = useMemo(
() => [...districtCases].sort((a, b) => b.total - a.total),
[districtCases]
);
const maxTotal = useMemo(
() => (sortedCases.length > 0 ? sortedCases[0].total : 1),
[sortedCases]
);
const DistrictBreakdown = memo(function DistrictBreakdown({ districtCases, selectedDistrict, onDistrictSelect }: DistrictBreakdownProps) {
const sortedCases = useMemo(() => [...districtCases].sort((a, b) => b.total - a.total), [districtCases]);
const maxTotal = useMemo(() => sortedCases.length > 0 ? sortedCases[0].total : 1, [sortedCases]);
const handleDistrictClick = useCallback(
(district: string) => {
onDistrictSelect(district);
},
[onDistrictSelect]
);
const handleDistrictClick = useCallback((district: string) => {
onDistrictSelect(district);
}, [onDistrictSelect]);
return (
<>
{sortedCases.map((d, rank) => {
{sortedCases.map((d) => {
const outPct = d.total > 0 ? (d.outpatient / d.total) * 100 : 0;
const inPct = d.total > 0 ? (d.inpatient / d.total) * 100 : 0;
const barWidth = (d.total / maxTotal) * 100;
const isActive = selectedDistrict === d.district;
return (
<div
key={d.district}
role="button"
tabIndex={0}
aria-pressed={isActive}
className={`district-bar ${isActive ? 'district-bar--active' : ''}`}
className={`flex items-center gap-3 p-2 rounded cursor-pointer transition-colors ${
selectedDistrict === d.district ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
onClick={() => handleDistrictClick(d.district)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDistrictClick(d.district);
}
}}
>
<div className="w-4 text-[10px] font-mono text-text-muted tabular-nums shrink-0 text-right">
{rank + 1}
<div className="w-16 text-sm text-gray-700 text-right shrink-0">{d.district}</div>
<div className="flex-1 h-6 bg-gray-100 rounded overflow-hidden flex">
<div
className="bg-orange-400 h-full transition-all"
style={{ width: `${barWidth * outPct / 100}%` }}
title={`门诊: ${d.outpatient.toLocaleString()}`}
/>
<div
className="bg-red-400 h-full transition-all"
style={{ width: `${barWidth * inPct / 100}%` }}
title={`住院: ${d.inpatient.toLocaleString()}`}
/>
</div>
<div className="w-[3.25rem] text-[12px] font-medium text-text-secondary shrink-0 truncate">
{d.district}
</div>
<div className="district-bar__track" title={`门诊 ${d.outpatient} · 住院 ${d.inpatient}`}>
<div className="district-bar__fill" style={{ width: `${barWidth}%` }}>
<div
className="h-full bg-warning/85"
style={{ width: `${outPct}%` }}
/>
<div
className="h-full bg-danger/75"
style={{ width: `${inPct}%` }}
/>
</div>
</div>
<div className="w-[3.75rem] text-right data-num text-[12px] shrink-0">
<div className="w-20 text-right text-sm font-medium text-gray-900 shrink-0">
{d.total.toLocaleString()}
</div>
</div>

View File

@@ -11,72 +11,57 @@ export interface AlertSlice {
interface AlertSeverityDonutProps {
data: AlertSlice[];
embed?: boolean;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
borderRadius: '8px',
fontSize: '12px',
};
function AlertSeverityDonutComponent({ data, embed }: AlertSeverityDonutProps) {
function AlertSeverityDonutComponent({ data }: AlertSeverityDonutProps) {
const hasData = data.some((d) => d.value > 0);
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"></h3>
<p className="workbench-panel__sub">P1 / P2 </p>
</div>
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
</div>
{hasData ? (
<div className="flex items-center justify-center">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => <span className="text-text-primary">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
</div>
<EmptyState title="暂无预警数据" />
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{hasData ? (
<div className="flex items-center justify-center">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={4}
dataKey="value"
nameKey="name"
>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value, name]}
/>
<Legend
wrapperStyle={{ fontSize: '12px' }}
formatter={(value: string) => <span className="text-text-primary">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<EmptyState title="暂无预警数据" />
)}
</div>
</>
</div>
);
if (embed) return <>{body}</>;
return <div className="card p-4">{body}</div>;
}
export const AlertSeverityDonut = memo(AlertSeverityDonutComponent);

View File

@@ -20,8 +20,6 @@ export interface MergedTrendItem {
interface CaseAqiTrendProps {
data: MergedTrendItem[];
/** 嵌入 workbench-panel 时去掉外层 card */
embed?: boolean;
}
function formatDateLabel(dateStr: string): string {
@@ -32,85 +30,71 @@ function formatDateLabel(dateStr: string): string {
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
borderRadius: '8px',
fontSize: '12px',
};
function CaseAqiTrendComponent({ data, embed }: CaseAqiTrendProps) {
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title"> 30 AQI</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
30AQI趋势
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{data.length > 0 ? (
<ResponsiveContainer width="100%" height={embed ? 280 : 200}>
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
interval="preserveStartEnd"
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 10, fill: CHART_COLORS.aqi }}
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: CHART_COLORS.tooltipText, fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line
yAxisId="left"
type="monotone"
dataKey="cases"
name="病例数"
stroke={CHART_COLORS.cases}
strokeWidth={2.25}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="aqi"
name="AQI"
stroke={CHART_COLORS.aqi}
strokeWidth={2}
strokeDasharray="5 4"
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
function CaseAqiTrendComponent({ data }: CaseAqiTrendProps) {
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
30AQI趋势
</div>
</>
{data.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
interval="preserveStartEnd"
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 10, fill: CHART_COLORS.axis }}
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 10, fill: CHART_COLORS.aqi }}
axisLine={{ stroke: CHART_COLORS.grid }}
/>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: CHART_COLORS.tooltipText, fontWeight: 600 }}
/>
<Legend wrapperStyle={{ fontSize: '11px' }} />
<Line
yAxisId="left"
type="monotone"
dataKey="cases"
name="病例数"
stroke={CHART_COLORS.cases}
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="aqi"
name="AQI"
stroke={CHART_COLORS.aqi}
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
activeDot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
</div>
);
if (embed) return <div className="h-full flex flex-col">{body}</div>;
return <div className="card p-4">{body}</div>;
}
export const CaseAqiTrend = memo(CaseAqiTrendComponent);

View File

@@ -1,37 +1,18 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import type MapView from '@geoscene/core/views/MapView';
import type GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import Graphic from '@geoscene/core/Graphic';
import Polygon from '@geoscene/core/geometry/Polygon';
import SimpleFillSymbol from '@geoscene/core/symbols/SimpleFillSymbol';
import { memo, useEffect, useMemo, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { CHART_COLORS } from './chartColors';
import { createMapView } from '@/geoscene/createMapView';
import { createGraphicsLayer } from '@/geoscene/layers';
import { TESTIDS } from '@/utils/testids';
interface DistrictChoroplethProps {
/** 区名(规范,带「区」) → 当前 metric 标量值 的查表。 */
metricLookup: Record<string, number>;
/** 当前指标的中文标签,用于 tooltip如「门诊病例」。 */
metricLabel: string;
}
interface WuhanFeatureProps {
name: string;
}
interface WuhanFeature {
type: 'Feature';
properties: WuhanFeatureProps;
geometry: {
type: 'Polygon' | 'MultiPolygon';
coordinates: number[][][] | number[][][][];
};
}
interface WuhanFeatureCollection {
type: 'FeatureCollection';
features: WuhanFeature[];
}
const WUHAN_CENTER: [number, number] = [30.59, 114.3];
/** 把值映射到 7 档顺序色阶;高值 → 深色。 */
function colorForValue(value: number, max: number): string {
const scale = CHART_COLORS.choropleth;
if (max <= 0 || value <= 0) return CHART_COLORS.choroplethEmpty;
@@ -40,121 +21,120 @@ function colorForValue(value: number, max: number): string {
return scale[idx];
}
function hexToRgba(hex: string, alpha = 0.78): number[] {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return [r, g, b, alpha];
interface WuhanFeatureProps {
name: string;
}
function ringsFromGeometry(geometry: WuhanFeature['geometry']): number[][][] {
if (geometry.type === 'Polygon') {
return geometry.coordinates as number[][][];
}
return (geometry.coordinates as number[][][][]).flat();
}
// @types/geojson 随 @types/leaflet 一并提供 GeoJSON 全局命名空间。
type WuhanFeatureCollection = GeoJSON.FeatureCollection;
function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChoroplethProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<MapView | null>(null);
const layerRef = useRef<GraphicsLayer | null>(null);
const mapRef = useRef<L.Map | null>(null);
const geoLayerRef = useRef<L.GeoJSON | null>(null);
const geoDataRef = useRef<WuhanFeatureCollection | null>(null);
const [mapReady, setMapReady] = useState(false);
const maxValue = useMemo(() => {
const vals = Object.values(metricLookup);
return vals.length ? Math.max(...vals) : 0;
}, [metricLookup]);
// 创建地图 + 加载 geojson 一次。
useEffect(() => {
if (!mapDivRef.current || viewRef.current) return;
if (!mapDivRef.current || mapRef.current) return;
const { map, view, destroy } = createMapView({
container: mapDivRef.current,
const map = L.map(mapDivRef.current, {
center: WUHAN_CENTER,
zoom: 9,
constraints: { minZoom: 8, maxZoom: 14 },
zoomControl: true,
attributionControl: false,
scrollWheelZoom: false,
});
const layer = createGraphicsLayer('区县填色');
map.add(layer);
layerRef.current = layer;
viewRef.current = view;
mapRef.current = map;
view.ui.remove('zoom');
view.when(() => setMapReady(true)).catch(() => setMapReady(true));
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
maxZoom: 18,
}).addTo(map);
let cancelled = false;
fetch('/wuhan_districts.geojson')
.then((r) => r.json())
.then((data: WuhanFeatureCollection) => {
if (cancelled) return;
if (cancelled || !mapRef.current) return;
geoDataRef.current = data;
renderLayer();
try {
const tmp = L.geoJSON(data);
map.fitBounds(tmp.getBounds(), { padding: [12, 12] });
} catch {
/* keep default center if bounds fail */
}
})
.catch(() => undefined);
.catch(() => {
/* network/mock failure — wrapper still renders for tests */
});
return () => {
cancelled = true;
layerRef.current = null;
viewRef.current = null;
destroy();
setMapReady(false);
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
geoLayerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 当 metric 变化时重绘填色。
useEffect(() => {
const layer = layerRef.current;
const view = viewRef.current;
renderLayer();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metricLookup, maxValue, metricLabel]);
function renderLayer() {
const map = mapRef.current;
const data = geoDataRef.current;
if (!layer || !view || !mapReady) return;
if (!map || !data) return;
const draw = (fc: WuhanFeatureCollection) => {
layer.removeAll();
const graphics: Graphic[] = [];
for (const feature of fc.features) {
const name = feature.properties?.name ?? '未知';
const value = metricLookup[name] ?? 0;
const rings = ringsFromGeometry(feature.geometry);
if (!rings.length) continue;
graphics.push(
new Graphic({
geometry: new Polygon({ rings, spatialReference: { wkid: 4326 } }),
symbol: new SimpleFillSymbol({
color: hexToRgba(colorForValue(value, maxValue)),
outline: { color: hexToRgba(CHART_COLORS.choroplethStroke, 1), width: 1 },
}),
attributes: { name, value, metricLabel },
popupTemplate: {
title: '{name}',
content: `${metricLabel}{value}`,
},
})
);
}
layer.addMany(graphics);
if (graphics.length > 0) {
view.goTo(graphics).catch(() => undefined);
}
};
if (data) {
draw(data);
return;
if (geoLayerRef.current) {
geoLayerRef.current.remove();
geoLayerRef.current = null;
}
let cancelled = false;
fetch('/wuhan_districts.geojson')
.then((r) => r.json())
.then((fc: WuhanFeatureCollection) => {
if (cancelled) return;
geoDataRef.current = fc;
draw(fc);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [metricLookup, maxValue, metricLabel, mapReady]);
geoLayerRef.current = L.geoJSON(data, {
style: (feature) => {
const name = (feature?.properties as WuhanFeatureProps | undefined)?.name ?? '';
const value = metricLookup[name] ?? 0;
return {
fillColor: colorForValue(value, maxValue),
fillOpacity: 0.78,
color: CHART_COLORS.choroplethStroke,
weight: 1.2,
};
},
onEachFeature: (feature, layer) => {
const name = (feature.properties as WuhanFeatureProps).name ?? '未知';
const value = metricLookup[name] ?? 0;
layer.bindTooltip(
`<div style="font-size:12px"><b>${name}</b><br/>${metricLabel}${value.toLocaleString()}</div>`,
{ sticky: true }
);
layer.on({
mouseover: (e) => {
(e.target as L.Path).setStyle({ weight: 2.4, color: CHART_COLORS.cases });
},
mouseout: (e) => {
(e.target as L.Path).setStyle({ weight: 1.2, color: CHART_COLORS.choroplethStroke });
},
click: (e) => {
map.fitBounds((e.target as L.GeoJSON).getBounds(), { padding: [40, 40] });
},
});
},
}).addTo(map);
}
// 图例的 5 档分界值。
const legendStops = useMemo(() => {
const scale = CHART_COLORS.choropleth;
return scale.map((color, i) => ({
@@ -164,17 +144,8 @@ function DistrictChoroplethComponent({ metricLookup, metricLabel }: DistrictChor
}, [maxValue]);
return (
<div data-testid={TESTIDS.choroplethWrapper} className="relative">
<div
ref={mapDivRef}
className="w-full rounded-lg overflow-hidden bg-slate-100"
style={{ height: 420 }}
/>
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-bg-card/70 rounded-lg text-[13px] text-text-muted">
</div>
)}
<div data-testid="choropleth-wrapper" className="relative">
<div ref={mapDivRef} className="w-full rounded-lg overflow-hidden" style={{ height: 420 }} />
<div className="absolute bottom-3 right-3 z-[1000] bg-bg-card/95 px-3 py-2 rounded-lg border border-border shadow-sm">
<div className="text-[11px] font-semibold text-text-secondary mb-1.5">{metricLabel}</div>

View File

@@ -8,6 +8,7 @@ import {
TrendingDown,
Users,
} from 'lucide-react';
import { StatCard } from '@/components/StatCard';
import { TESTIDS } from '@/utils/testids';
import { CHART_COLORS } from './chartColors';
@@ -33,90 +34,49 @@ function changeTrendOf(ratio: number | null | undefined) {
function KpiRowComponent({ kpi }: KpiRowProps) {
const changeTrend = changeTrendOf(kpi?.changeRatio);
const trendClass =
changeTrend?.direction === 'up'
? 'text-danger'
: changeTrend?.direction === 'down'
? 'text-success'
: 'text-text-muted';
return (
<div
data-testid={TESTIDS.kpiRow}
className="command-rail stagger-children"
role="group"
aria-label="综合关键指标"
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3"
>
<div className="command-rail__cell command-rail__cell--hero">
<div className="command-rail__label">
<Users className="w-3.5 h-3.5 text-primary" aria-hidden />
</div>
<div className="command-rail__value">
{kpi?.totalCases?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Activity className="w-3.5 h-3.5 text-success" aria-hidden />
</div>
<div className="command-rail__value text-[20px]">
{kpi?.todayCases?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
{changeTrend?.direction === 'up' ? (
<TrendingUp className="w-3.5 h-3.5 text-danger" aria-hidden />
) : changeTrend?.direction === 'down' ? (
<TrendingDown className="w-3.5 h-3.5 text-success" aria-hidden />
) : (
<Activity className="w-3.5 h-3.5 text-text-muted" aria-hidden />
)}
7
</div>
<div className={`command-rail__value text-[20px] ${trendClass}`}>
{changeTrend ? changeTrend.value : '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<AlertTriangle className="w-3.5 h-3.5 text-warning" aria-hidden />
</div>
<div
className="command-rail__value text-[20px]"
style={
kpi && kpi.activeAlerts > 0 ? { color: CHART_COLORS.alertP1 } : undefined
}
>
{kpi?.activeAlerts?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Building2 className="w-3.5 h-3.5 text-danger" aria-hidden />
</div>
<div className="command-rail__value text-[20px]">
{kpi?.highRiskGrids?.toLocaleString() ?? '--'}
</div>
</div>
<div className="command-rail__cell">
<div className="command-rail__label">
<Droplets className="w-3.5 h-3.5 text-primary-light" aria-hidden />
AQI
</div>
<div className="command-rail__value text-[20px]">
{kpi?.avgAQI?.toLocaleString() ?? '--'}
</div>
</div>
<StatCard
icon={<Users className="w-4 h-4 text-primary" />}
label="累计病例总数"
value={kpi?.totalCases?.toLocaleString() ?? '--'}
/>
<StatCard
icon={<Activity className="w-4 h-4 text-success" />}
label="今日病例"
value={kpi?.todayCases?.toLocaleString() ?? '--'}
/>
<StatCard
icon={
(changeTrend?.direction === 'up' && <TrendingUp className="w-4 h-4 text-danger" />) ||
(changeTrend?.direction === 'down' && (
<TrendingDown className="w-4 h-4 text-success" />
)) || <Activity className="w-4 h-4 text-text-muted" />
}
label="7日变化率"
value={changeTrend ? changeTrend.value : '--'}
trend={changeTrend}
/>
<StatCard
icon={<AlertTriangle className="w-4 h-4 text-warning" />}
label="活跃预警数"
value={kpi?.activeAlerts?.toLocaleString() ?? '--'}
color={kpi && kpi.activeAlerts > 0 ? CHART_COLORS.alertP1 : undefined}
/>
<StatCard
icon={<Building2 className="w-4 h-4 text-danger" />}
label="高风险网格"
value={kpi?.highRiskGrids?.toLocaleString() ?? '--'}
/>
<StatCard
icon={<Droplets className="w-4 h-4 text-primary-light" />}
label="平均AQI"
value={kpi?.avgAQI?.toLocaleString() ?? '--'}
/>
</div>
);
}

View File

@@ -14,66 +14,51 @@ import { CHART_COLORS } from './chartColors';
interface TopDiagnosesBarProps {
diagnoses: DiagnosisBreakdown[];
embed?: boolean;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
borderRadius: '8px',
fontSize: '12px',
};
function TopDiagnosesBarComponent({ diagnoses, embed }: TopDiagnosesBarProps) {
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">Top 5 </h3>
<p className="workbench-panel__sub"> / </p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
Top 5
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{diagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={[...diagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
function TopDiagnosesBarComponent({ diagnoses }: TopDiagnosesBarProps) {
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
Top 5
</div>
</>
{diagnoses.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={[...diagnoses].reverse()}
layout="vertical"
margin={{ top: 0, right: 10, left: 60, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
<YAxis
type="category"
dataKey="diagnosis"
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
width={100}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={16} />
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={16} />
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
</div>
);
if (embed) return <>{body}</>;
return <div className="card p-4">{body}</div>;
}
export const TopDiagnosesBar = memo(TopDiagnosesBarComponent);

View File

@@ -13,25 +13,21 @@ import { CHART_COLORS } from './chartColors';
import { metricValue, type DistrictMetric, type MetricKey } from './districtNormalize';
interface TopDistrictsBarProps {
/** 已归一并聚合到 13 区的指标数据。 */
districts: DistrictMetric[];
metric: MetricKey;
metricLabel: string;
embed?: boolean;
}
const tooltipStyle = {
backgroundColor: '#FFFFFF',
border: `1px solid ${CHART_COLORS.tooltipBorder}`,
borderRadius: '10px',
borderRadius: '8px',
fontSize: '12px',
};
function TopDistrictsBarComponent({
districts,
metric,
metricLabel,
embed,
}: TopDistrictsBarProps) {
function TopDistrictsBarComponent({ districts, metric, metricLabel }: TopDistrictsBarProps) {
// 按当前 metric 排序取 Top5横向条形图需 reverse 使最大值在顶部。
const top5 = useMemo(() => {
return [...districts]
.sort((a, b) => metricValue(b, metric) - metricValue(a, metric))
@@ -48,62 +44,48 @@ function TopDistrictsBarComponent({
const hasData = top5.some((d) => d.value > 0);
const showStack = metric === 'all';
const body = (
<>
{embed ? (
<div className="workbench-panel__head">
<div>
<h3 className="workbench-panel__title">Top 5 {metricLabel}</h3>
<p className="workbench-panel__sub"></p>
</div>
</div>
) : (
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
Top 5 {metricLabel}
</div>
)}
<div className={embed ? 'workbench-panel__body' : undefined}>
{hasData ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={top5} layout="vertical" margin={{ top: 0, right: 10, left: 30, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
width={64}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
{showStack ? (
<>
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={20} />
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={20} />
</>
) : (
<Bar
dataKey="value"
fill={metric === 'inpatient' ? CHART_COLORS.inpatient : CHART_COLORS.outpatient}
name={metricLabel}
barSize={20}
/>
)}
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
return (
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-4">
Top 5 {metricLabel}
</div>
</>
{hasData ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={top5} layout="vertical" margin={{ top: 0, right: 10, left: 30, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 10, fill: CHART_COLORS.axis }} />
<YAxis
type="category"
dataKey="district"
tick={{ fontSize: 11, fill: CHART_COLORS.axisLabel }}
width={64}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
{showStack ? (
<>
<Bar dataKey="outpatient" stackId="a" fill={CHART_COLORS.outpatient} name="门诊" barSize={20} />
<Bar dataKey="inpatient" stackId="a" fill={CHART_COLORS.inpatient} name="住院" barSize={20} />
</>
) : (
<Bar
dataKey="value"
fill={metric === 'inpatient' ? CHART_COLORS.inpatient : CHART_COLORS.outpatient}
name={metricLabel}
barSize={20}
/>
)}
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState title="暂无数据" />
)}
</div>
);
if (embed) return <>{body}</>;
return <div className="card p-4">{body}</div>;
}
export const TopDistrictsBar = memo(TopDistrictsBarComponent);

View File

@@ -1,22 +1,22 @@
/**
* 概览大屏图表与地图使用的字面色值集中处。
* Recharts / GeoScene 符号需要原始 hex无法用 Tailwind class故在此集中定义
* 色系对齐「江雾」青绿 / 雾蓝 / 石板体系
* Recharts / Leaflet 需要原始 hex无法用 Tailwind class故在此集中定义
* 避免页面里散落 magic hex
*/
export const CHART_COLORS = {
outpatient: '#0F766E', // primary teal
inpatient: '#C2410C', // danger warm
cases: '#0F766E',
aqi: '#C27803', // warning
grid: '#D4DEE4',
axis: '#5A6F7A',
axisLabel: '#1A2B33',
tooltipBorder: '#D4DEE4',
tooltipText: '#1A2B33',
alertP1: '#C2410C',
alertP2: '#C27803',
// choropleth 顺序色阶(浅 → 深),高值高亮
choropleth: ['#E6F4F1', '#CCFBF1', '#99F6E4', '#5EEAD4', '#2DD4BF', '#14B8A6', '#0F766E'],
choroplethEmpty: '#E8EEF1',
outpatient: '#2563EB', // 门诊(primary
inpatient: '#DC2626', // 住院(danger
cases: '#2563EB',
aqi: '#D97706', // warning
grid: '#E2E8F0', // border
axis: '#64748B', // text-secondary
axisLabel: '#374151',
tooltipBorder: '#E2E8F0',
tooltipText: '#1E293B',
alertP1: '#DC2626',
alertP2: '#D97706',
// choropleth 顺序色阶(浅 → 深),高值高亮
choropleth: ['#DBEAFE', '#BFDBFE', '#93C5FD', '#60A5FA', '#3B82F6', '#2563EB', '#1D4ED8'],
choroplethEmpty: '#F1F5F9', // 无数据区填充
choroplethStroke: '#FFFFFF',
} as const;

View File

@@ -19,7 +19,7 @@ export const Card = memo(function Card({
<div
data-testid={testid}
className={[
'bg-bg-card rounded-xl border border-border shadow-soft',
'bg-bg-card rounded-lg border border-border',
className,
]
.filter(Boolean)

View File

@@ -20,7 +20,7 @@ export const Segmented = memo(function Segmented<T extends string>({
return (
<div
data-testid={testid}
className="inline-flex items-center gap-0.5 rounded-lg bg-bg-hover p-0.5 border border-border-light"
className="inline-flex items-center gap-0.5 rounded-full bg-bg-hover p-0.5"
>
{options.map((opt) => {
const isActive = opt.value === value;
@@ -31,10 +31,10 @@ export const Segmented = memo(function Segmented<T extends string>({
data-testid={testid ? `${testid}-${opt.value}` : undefined}
onClick={() => onChange(opt.value)}
className={[
'rounded-md font-medium transition-colors',
'rounded-full font-medium transition-colors',
sizeClasses,
isActive
? 'bg-primary text-white shadow-soft'
? 'bg-primary text-white shadow-sm'
: 'text-text-secondary hover:bg-bg-active',
].join(' ')}
>

View File

@@ -1,154 +0,0 @@
import Basemap from '@geoscene/core/Basemap';
import WebTileLayer from '@geoscene/core/layers/WebTileLayer';
import TileInfo from '@geoscene/core/layers/support/TileInfo';
import SpatialReference from '@geoscene/core/geometry/SpatialReference';
import Map from '@geoscene/core/Map';
import MapView from '@geoscene/core/views/MapView';
/** Wuhan city center — GeoScene / ArcGIS uses [longitude, latitude]. */
export const WUHAN_CENTER: [number, number] = [114.31, 30.59];
const XYZ_TILE_INFO = TileInfo.create({
spatialReference: new SpatialReference({ wkid: 3857 }),
size: 256,
});
export interface CreateMapViewOptions {
container: HTMLDivElement;
zoom?: number;
/** Custom Basemap, or omit to use resolveDefaultBasemap(). */
basemap?: Basemap;
center?: [number, number];
constraints?: {
minZoom?: number;
maxZoom?: number;
};
}
export interface MapHandle {
map: Map;
view: MapView;
destroy: () => void;
}
/**
* Tianditu vector + annotation. Requires a personal key from
* https://console.tianditu.gov.cn/
* (GeoScene named `tianditu-vector` ships a dead public tk → HTTP 418.)
*/
export function createTiandituBasemap(tk: string): Basemap {
const vec = new WebTileLayer({
urlTemplate:
`https://t{subDomain}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&VERSION=1.0.0` +
`&REQUEST=GetTile&LAYER=vec&STYLE=default&FORMAT=tiles&TILEMATRIXSET=w` +
`&TILEMATRIX={level}&TILEROW={row}&TILECOL={col}&tk=${tk}`,
subDomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
title: '天地图矢量',
tileInfo: XYZ_TILE_INFO,
});
const cva = new WebTileLayer({
urlTemplate:
`https://t{subDomain}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&VERSION=1.0.0` +
`&REQUEST=GetTile&LAYER=cva&STYLE=default&FORMAT=tiles&TILEMATRIXSET=w` +
`&TILEMATRIX={level}&TILEROW={row}&TILECOL={col}&tk=${tk}`,
subDomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
title: '天地图注记',
tileInfo: XYZ_TILE_INFO,
});
return new Basemap({
baseLayers: [vec, cva],
title: '天地图矢量',
id: 'tianditu-custom',
});
}
/**
* Gaode vector via Vite `/basemap-gaode` proxy (avoids CORS).
* Production without Tianditu: put the same rewrite behind nginx, or set VITE_TIANDITU_TK.
*/
export function createGaodeBasemap(): Basemap {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const layer = new WebTileLayer({
urlTemplate: `${origin}/basemap-gaode/{level}/{col}/{row}`,
title: '高德矢量',
copyright: '© 高德地图',
tileInfo: XYZ_TILE_INFO,
});
return new Basemap({
baseLayers: [layer],
title: '高德矢量',
id: 'gaode-vector',
});
}
/** One shared basemap — React StrictMode remounts must not abort a fresh #load(). */
let sharedDefaultBasemap: Basemap | null = null;
/**
* - VITE_TIANDITU_TK → 天地图
* - else → 高德GeoScene CN 版没有 topo-vector 等 Esri 命名底图)
*/
export function resolveDefaultBasemap(): Basemap {
if (sharedDefaultBasemap) return sharedDefaultBasemap;
const tk = import.meta.env.VITE_TIANDITU_TK?.trim();
sharedDefaultBasemap = tk ? createTiandituBasemap(tk) : createGaodeBasemap();
return sharedDefaultBasemap;
}
/**
* Create a Map + MapView pair. Caller owns lifecycle via destroy().
* Never pass constraints into the MapView constructor (4.32 Viewport2DMixin crash).
*/
export function createMapView(options: CreateMapViewOptions): MapHandle {
const {
container,
zoom = 10,
basemap = resolveDefaultBasemap(),
center = WUHAN_CENTER,
constraints,
} = options;
const map = new Map({ basemap });
const view = new MapView({
container,
map,
center,
zoom,
});
if (constraints && (constraints.minZoom != null || constraints.maxZoom != null)) {
view
.when(() => {
view.constraints = {
...(constraints.minZoom != null ? { minZoom: constraints.minZoom } : {}),
...(constraints.maxZoom != null ? { maxZoom: constraints.maxZoom } : {}),
};
})
.catch(() => undefined);
}
view.ui.remove('attribution');
return {
map,
view,
destroy: () => {
// Detach shared basemap + destroy operational layers before view teardown,
// otherwise StrictMode cleanup aborts Basemap#load() → AbortError spam.
const m = view.map;
if (m) {
const ops = m.layers.toArray();
m.removeAll();
for (const layer of ops) {
layer.destroy();
}
if (m.basemap === sharedDefaultBasemap) {
m.basemap = null as unknown as Basemap;
}
}
view.map = null as unknown as Map;
view.destroy();
},
};
}

View File

@@ -1,17 +0,0 @@
export {
createMapView,
WUHAN_CENTER,
resolveDefaultBasemap,
createTiandituBasemap,
createGaodeBasemap,
} from './createMapView';
export type { CreateMapViewOptions, MapHandle } from './createMapView';
export {
createRiskTileLayer,
riskTileUrlTemplate,
createGraphicsLayer,
createDistrictsGeoJSONLayer,
pointGraphic,
jitterLonLat,
geosceneEnv,
} from './layers';

View File

@@ -1,100 +0,0 @@
import WebTileLayer from '@geoscene/core/layers/WebTileLayer';
import GraphicsLayer from '@geoscene/core/layers/GraphicsLayer';
import GeoJSONLayer from '@geoscene/core/layers/GeoJSONLayer';
import Graphic from '@geoscene/core/Graphic';
import Point from '@geoscene/core/geometry/Point';
import SimpleMarkerSymbol from '@geoscene/core/symbols/SimpleMarkerSymbol';
import TileInfo from '@geoscene/core/layers/support/TileInfo';
import SpatialReference from '@geoscene/core/geometry/SpatialReference';
const XYZ_TILE_INFO = TileInfo.create({
spatialReference: new SpatialReference({ wkid: 3857 }),
size: 256,
});
/**
* Absolute FastAPI risk XYZ URL for WebTileLayer.
* Relative `/api/...` is resolved to `https://null/...` by the SDK — always use origin.
* Placeholders: {level}/{col}/{row} ≡ Leaflet {z}/{x}/{y} for Web Mercator.
*/
export function riskTileUrlTemplate(day: 1 | 3 | 7, date?: string): string {
const apiBase = (import.meta.env.VITE_API_URL || '/api').replace(/\/$/, '');
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const prefix = apiBase.startsWith('http') ? apiBase : `${origin}${apiBase}`;
const dateParam = date ? `&date=${date}` : '';
return `${prefix}/risk/tiles/{level}/{col}/{row}.png?day=${day}${dateParam}`;
}
export function createRiskTileLayer(day: 1 | 3 | 7, opacity = 0.72): WebTileLayer {
return new WebTileLayer({
urlTemplate: riskTileUrlTemplate(day),
opacity,
title: '风险预测',
listMode: 'hide',
tileInfo: XYZ_TILE_INFO,
});
}
export function createGraphicsLayer(title: string): GraphicsLayer {
return new GraphicsLayer({ title, listMode: 'hide' });
}
export function createDistrictsGeoJSONLayer(url = '/wuhan_districts.geojson'): GeoJSONLayer {
const absolute =
url.startsWith('http') || typeof window === 'undefined'
? url
: `${window.location.origin}${url.startsWith('/') ? '' : '/'}${url}`;
return new GeoJSONLayer({
url: absolute,
title: '武汉区县',
outFields: ['*'],
listMode: 'hide',
});
}
export function pointGraphic(
longitude: number,
latitude: number,
color: string,
size = 8,
attributes?: Record<string, unknown>
): Graphic {
return new Graphic({
geometry: new Point({ longitude, latitude }),
symbol: new SimpleMarkerSymbol({
style: 'circle',
color,
size,
outline: { color: [255, 255, 255, 0.85], width: 1 },
}),
attributes,
});
}
/** Deterministic ~meter jitter so stacked street/district centroids separate visually. */
export function jitterLonLat(
longitude: number,
latitude: number,
seed: string,
meters = 55
): [number, number] {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
const u = ((h >>> 0) % 1000) / 1000;
const v = (((h >>> 10) % 1000) / 1000);
const angle = u * Math.PI * 2;
const radius = (0.25 + v * 0.75) * meters;
const dLat = (radius * Math.cos(angle)) / 111_320;
const dLon = (radius * Math.sin(angle)) / (111_320 * Math.cos((latitude * Math.PI) / 180));
return [longitude + dLon, latitude + dLat];
}
export const geosceneEnv = {
portalUrl: import.meta.env.VITE_GEOSCENE_PORTAL_URL as string | undefined,
districtsUrl: import.meta.env.VITE_LAYER_DISTRICTS_URL as string | undefined,
riskUrl: import.meta.env.VITE_LAYER_RISK_URL as string | undefined,
casesUrl: import.meta.env.VITE_LAYER_CASES_URL as string | undefined,
};

View File

@@ -3,410 +3,45 @@
@tailwind utilities;
@layer base {
:root {
--cbpoa-teal: #0f766e;
--cbpoa-mist: #5b8fa8;
--cbpoa-slate: #1a2b33;
--cbpoa-fog: #f0f4f6;
--cbpoa-river: linear-gradient(
145deg,
#e8f2f6 0%,
#f0f4f6 38%,
#e6f4f1 72%,
#eef3f5 100%
);
--cbpoa-glass: rgba(250, 252, 253, 0.82);
--cbpoa-glass-strong: rgba(255, 255, 255, 0.92);
--cbpoa-ink: #1a2b33;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-bg-page text-text-primary font-sans;
background-image: var(--cbpoa-river);
background-attachment: fixed;
}
::selection {
background: rgba(15, 118, 110, 0.18);
}
}
@layer components {
.card {
@apply bg-bg-card border border-border rounded-xl shadow-soft;
@apply bg-bg-card border border-border rounded-lg;
}
.btn-primary {
@apply bg-primary text-white px-4 py-2 rounded-lg text-sm font-medium
hover:bg-primary-deep transition-colors shadow-soft;
@apply bg-primary text-white px-4 py-2 rounded-md text-sm font-medium
hover:bg-primary-light transition-colors;
}
.btn-secondary {
@apply bg-bg-elevated text-text-secondary px-4 py-2 rounded-lg text-sm font-medium
@apply bg-bg-page text-text-secondary px-4 py-2 rounded-md text-sm font-medium
border border-border hover:border-primary hover:text-primary transition-colors;
}
.page-inset {
@apply px-5 py-5;
}
/* —— 指挥台指标带:连续仪表条,非六块同质小卡 —— */
.metrics-band {
@apply relative border-b border-border;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(240, 244, 246, 0.88) 100%);
backdrop-filter: blur(10px);
}
.metrics-band::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(15, 118, 110, 0.28) 20%,
rgba(91, 143, 168, 0.35) 50%,
rgba(15, 118, 110, 0.28) 80%,
transparent 100%
);
}
.command-rail {
@apply flex flex-wrap items-stretch w-full min-w-0 rounded-xl overflow-hidden
border border-border shadow-soft;
background-color: rgba(255, 255, 255, 0.72);
background-image:
linear-gradient(135deg, rgba(15, 118, 110, 0.04) 0%, transparent 42%),
linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(248, 251, 252, 0.75));
}
.command-rail__cell {
@apply relative flex flex-col justify-center gap-1 px-3.5 py-2.5 min-w-0;
flex: 1 1 140px;
}
.command-rail__cell + .command-rail__cell::before {
content: '';
position: absolute;
left: 0;
top: 18%;
bottom: 18%;
width: 1px;
background: linear-gradient(
180deg,
transparent,
rgba(212, 222, 228, 0.95) 30%,
rgba(212, 222, 228, 0.95) 70%,
transparent
);
}
.command-rail__cell--hero {
flex: 1.35 1 170px;
background: linear-gradient(
120deg,
rgba(15, 118, 110, 0.08) 0%,
rgba(91, 143, 168, 0.04) 55%,
transparent 100%
);
}
.command-rail__label {
@apply flex items-center gap-1.5 text-[11px] font-medium text-text-secondary tracking-wide;
}
.command-rail__value {
@apply data-num text-[22px] leading-none text-text-primary;
}
.command-rail__cell--hero .command-rail__value {
@apply text-[28px] text-primary-deep;
}
.command-rail__meta {
@apply flex items-center gap-2 mt-0.5 min-h-[18px];
}
/* 兼容旧 StatCard总览等仍用 */
.stat-card {
@apply relative overflow-hidden bg-bg-card border border-border rounded-xl p-4 shadow-soft;
transition: box-shadow 200ms ease, transform 200ms ease, border-color 200ms ease;
}
.stat-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, var(--cbpoa-teal), var(--cbpoa-mist));
opacity: 0.85;
}
.stat-card:hover {
@apply shadow-lift border-primary/25;
}
/* —— 地图舞台 / 玻璃翼 —— */
.map-stage {
@apply relative flex-1 min-h-0 overflow-hidden;
background-color: #dce6eb;
}
.map-stage--flush {
@apply rounded-none border-0 shadow-none;
}
.map-chrome {
@apply pointer-events-none absolute top-0 right-0 z-10
flex items-start justify-end gap-2 p-3;
}
@media (min-width: 640px) {
.map-chrome {
@apply p-4;
}
}
.map-chrome__chip {
@apply pointer-events-auto inline-flex items-center gap-2
rounded-lg border border-white/50 bg-bg-card/90 backdrop-blur-md
px-3 py-1.5 shadow-soft;
}
.glass-wing {
@apply relative flex flex-col min-h-0 overflow-hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(240, 244, 246, 0.92) 100%);
border-left: 1px solid rgba(212, 222, 228, 0.9);
box-shadow: -12px 0 32px rgba(26, 43, 51, 0.04);
}
.glass-wing__section {
@apply px-4 pt-4 pb-3 border-b border-border-light;
}
.glass-wing__section:last-child {
@apply border-b-0 flex-1 min-h-0 flex flex-col;
}
.glass-wing__title {
@apply text-[11px] font-semibold uppercase text-mist-deep mb-3;
letter-spacing: 0.12em;
}
/* 区县条:层次更强 */
.district-bar {
@apply flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer;
transition: background-color 150ms ease, box-shadow 150ms ease;
}
.district-bar:hover {
@apply bg-mist-light;
}
.district-bar--active {
@apply bg-primary-muted ring-1 ring-primary/25;
}
.district-bar__track {
@apply relative flex-1 h-6 rounded-md overflow-hidden;
background: linear-gradient(90deg, #e8eef1 0%, #f3f6f8 100%);
}
.district-bar__fill {
@apply absolute inset-y-0 left-0 flex overflow-hidden rounded-md;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
}
/* —— 图表工作区面板 —— */
.workbench-panel {
@apply relative overflow-hidden rounded-2xl border border-border bg-bg-card shadow-soft;
}
.workbench-panel::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, var(--cbpoa-teal), var(--cbpoa-mist));
opacity: 0.75;
}
.workbench-panel__head {
@apply flex items-end justify-between gap-3 px-5 pt-4 pb-2;
}
.workbench-panel__title {
@apply font-display text-[15px] font-semibold text-text-primary tracking-tight;
}
.workbench-panel__sub {
@apply text-[12px] text-text-muted mt-0.5;
}
.workbench-panel__body {
@apply px-5 pb-5 pt-1;
}
/* 页内分段 tab */
.tab-strip {
@apply flex items-center gap-0;
}
.tab-strip__item {
@apply relative px-4 py-2.5 text-[13px] font-medium text-text-secondary
border-b-2 border-transparent -mb-px transition-colors;
}
.tab-strip__item:hover {
@apply text-text-primary;
}
.tab-strip__item--active {
@apply text-primary border-primary;
}
/* —— 底部时间轴坞 —— */
.timeline-dock {
@apply fixed right-0 bottom-0 pointer-events-none;
z-index: 60;
left: 0;
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.75rem, env(safe-area-inset-right));
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
@media (min-width: 1024px) {
.timeline-dock {
left: 212px;
}
}
.timeline-dock__inner {
@apply pointer-events-auto mx-auto max-w-5xl
rounded-2xl border border-border shadow-lift
px-3 py-2.5;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.94) 0%,
rgba(240, 244, 246, 0.96) 100%
);
backdrop-filter: blur(14px);
}
@media (min-width: 640px) {
.timeline-dock__inner {
@apply px-4 py-3;
}
}
.timeline-dock__track {
@apply relative h-1.5 rounded-full overflow-hidden bg-bg-hover;
}
.timeline-dock__fill {
@apply absolute inset-y-0 left-0 rounded-full;
background: linear-gradient(90deg, #0d5c56, #0f766e 55%, #5b8fa8);
}
/* 风险分布条(预警) */
.risk-strip {
@apply grid grid-cols-2 gap-px rounded-xl overflow-hidden
border border-border bg-border shadow-soft;
}
@media (min-width: 640px) {
.risk-strip {
@apply grid-cols-4;
}
}
.risk-strip__cell {
@apply bg-bg-card px-3.5 py-3;
}
.risk-strip__label {
@apply text-[11px] text-text-muted mb-1;
}
.risk-strip__value {
@apply data-num text-[22px] leading-none;
}
/* 登录页雾感氛围 */
.login-atmosphere {
background:
radial-gradient(ellipse 80% 60% at 15% 20%, rgba(91, 143, 168, 0.22), transparent 55%),
radial-gradient(ellipse 70% 50% at 85% 75%, rgba(15, 118, 110, 0.16), transparent 50%),
radial-gradient(ellipse 50% 40% at 50% 100%, rgba(232, 242, 246, 0.9), transparent 60%),
linear-gradient(160deg, #dce8ee 0%, #eef4f6 45%, #e4f0ed 100%);
}
.login-atmosphere::after {
content: '';
position: absolute;
inset: 0;
opacity: 0.04;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.brand-mark {
@apply font-display font-bold tracking-tight text-primary;
}
.data-num {
@apply font-mono font-semibold tabular-nums tracking-tight text-text-primary;
}
}
/* GeoScene MapView — keep below TopNav (z-50) */
.esri-view,
.geoscene-view {
/* Leaflet overrides — keep z-index below TopNav (z-50) and SideNav */
.leaflet-container {
font-family: inherit;
}
.esri-ui,
.geoscene-ui {
.leaflet-pane {
z-index: 1 !important;
}
.leaflet-top,
.leaflet-bottom {
z-index: 5 !important;
}
/* 交错入场 */
.stagger-children > * {
animation: fade-up 0.45s ease-out both;
}
.stagger-children > *:nth-child(1) { animation-delay: 0.04s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.08s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.12s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.16s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.2s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.24s; }
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
.leaflet-popup-content-wrapper {
@apply rounded-lg shadow-lg;
}
@keyframes rail-shimmer {
0% { background-position: 0% 50%; }
100% { background-position: 100% 50%; }
.leaflet-popup-content {
@apply m-0;
}

View File

@@ -1,7 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import '@geoscene/core/assets/geoscene/themes/light/main.css'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(

View File

@@ -1,5 +1,6 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useRiskStore } from '@/stores';
import { useSearchParams } from 'react-router-dom';
import { useRiskStore, useSessionStore } from '@/stores';
import { TESTIDS } from '@/utils/testids';
import type { CellInfo } from '@/components/AlertMap';
import { ErrorBanner } from '@/components/ErrorBanner';
@@ -11,6 +12,15 @@ import { AlertDetailModal, CellInfoPanel } from '@/components/alerts/AlertDetail
import type { ExtendedAlert, RiskStats } from '@/components/alerts/types';
export function AlertsDashboard() {
// 视角驱动的两条不变量D2纯前端视图预设非访问控制
// 1. 官员(厅领导)不展示 100m 网格(「对他没意义/太超前」)——强制 showGrid=false 且隐藏网格切换。
// 2. 医生(或 ?view=cluster= 聚类/密度视角:只看聚合栅格密度 + 病种过滤,
// 绝不渲染任何个体病例点(隐私不变量)——强制 showAlertMarkers=false 且隐藏「预警标记」切换。
const role = useSessionStore((s) => s.role);
const [searchParams] = useSearchParams();
const view = searchParams.get('view');
const isOfficial = role === 'official';
const isCluster = role === 'doctor' || view === 'cluster';
const alerts = useRiskStore((s) => s.alerts);
const isLoading = useRiskStore((s) => s.isLoading);
const error = useRiskStore((s) => s.error);
@@ -21,32 +31,43 @@ export function AlertsDashboard() {
const [selectedPriority, setSelectedPriority] = useState<'all' | 'P1' | 'P2'>('all');
const [sortBy, setSortBy] = useState<'risk' | 'time'>('risk');
const [showMap, setShowMap] = useState(true);
const [showAlertMarkers, setShowAlertMarkers] = useState(true);
const [showAlertMarkers, setShowAlertMarkers] = useState(false);
const [selectedAlert, setSelectedAlert] = useState<string | null>(null);
const [riskRange, setRiskRange] = useState<[number, number]>([0.6, 1.0]);
const [debouncedRiskRange, setDebouncedRiskRange] = useState<[number, number]>([0.6, 1.0]);
const [forecastDay, setForecastDay] = useState<1 | 3 | 7>(1);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showGrid, setShowGrid] = useState(true);
// 官员视角默认隐藏网格(见上);其余角色默认显示。
const [showGrid, setShowGrid] = useState(!isOfficial);
const [cellInfo, setCellInfo] = useState<CellInfo | null>(null);
// 隐私不变量:聚类(医生)视角下,个体病例点标记永远关闭,且无法被打开。
// 这里把「用户意图的开关状态」与「实际生效的状态」分开effectiveShowAlertMarkers
// 是唯一传给地图/渲染的真值cluster 模式恒为 false与用户点击无关。
const effectiveShowAlertMarkers = isCluster ? false : showAlertMarkers;
// In-page tab strip (no router) — matches existing activePage pattern
const [activeTab, setActiveTab] = useState<'list' | 'stats'>('list');
// Risk-trend data for the 风险统计 tab, fetched on demand
const [trendData, setTrendData] = useState<Array<{ date: string; cases: number; risk: number }>>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [trendError, setTrendError] = useState<string | null>(null);
const [trendLoaded, setTrendLoaded] = useState(false);
// Debounce riskRange for filteredAlerts computation
useEffect(() => {
const timer = setTimeout(() => setDebouncedRiskRange(riskRange), 300);
return () => clearTimeout(timer);
}, [riskRange]);
// Fetch grids (for map) and alerts (for side panel) on mount
useEffect(() => {
fetchRiskMap();
fetchAlerts();
}, [fetchRiskMap, fetchAlerts]);
// Fetch real risk-trend data when the 风险统计 tab is first opened
useEffect(() => {
if (activeTab !== 'stats' || trendLoaded) return;
let cancelled = false;
@@ -68,9 +89,7 @@ export function AlertsDashboard() {
.finally(() => {
if (!cancelled) setTrendLoading(false);
});
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, [activeTab, trendLoaded]);
const extendedAlerts: ExtendedAlert[] = useMemo(() => {
@@ -94,8 +113,7 @@ export function AlertsDashboard() {
.filter((alert) => {
const horizonMatch = selectedHorizon === 'all' || alert.forecast_horizon === selectedHorizon;
const priorityMatch = selectedPriority === 'all' || alert.priority === selectedPriority;
const riskMatch =
alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
const riskMatch = alert.risk_value >= debouncedRiskRange[0] && alert.risk_value <= debouncedRiskRange[1];
return horizonMatch && priorityMatch && riskMatch;
})
.sort((a, b) => {
@@ -106,7 +124,9 @@ export function AlertsDashboard() {
});
}, [extendedAlerts, selectedHorizon, selectedPriority, sortBy, debouncedRiskRange]);
// Risk distribution stats (includes p1/p2 counts) — single pass over each array
const riskStats: RiskStats = useMemo(() => {
// p1/p2 reflect the full (unfiltered) alert set
let p1 = 0;
let p2 = 0;
for (const a of extendedAlerts) {
@@ -114,6 +134,7 @@ export function AlertsDashboard() {
else if (a.priority === 'P2') p2++;
}
// Single pass over filteredAlerts: counters + sum + district map
let high = 0;
let mediumHigh = 0;
let medium = 0;
@@ -131,7 +152,6 @@ export function AlertsDashboard() {
const avgRisk = filteredAlerts.length > 0 ? sum / filteredAlerts.length : 0;
const topDistricts = Object.entries(byDistrict)
.filter(([name]) => name && name !== '武汉市' && name !== '未知')
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
@@ -139,22 +159,20 @@ export function AlertsDashboard() {
}, [extendedAlerts, filteredAlerts]);
const selectedAlertData = useMemo(() => {
return filteredAlerts.find((a) => a.alert_id === selectedAlert);
return filteredAlerts.find(a => a.alert_id === selectedAlert);
}, [filteredAlerts, selectedAlert]);
const selectedGridId = useMemo(() => {
if (!selectedAlert) return null;
const alert = filteredAlerts.find((a) => a.alert_id === selectedAlert);
const alert = filteredAlerts.find(a => a.alert_id === selectedAlert);
return alert?.grid_id ?? null;
}, [filteredAlerts, selectedAlert]);
const filteredAlertsRef = useRef(filteredAlerts);
useEffect(() => {
filteredAlertsRef.current = filteredAlerts;
}, [filteredAlerts]);
useEffect(() => { filteredAlertsRef.current = filteredAlerts; }, [filteredAlerts]);
const handleGridClick = useCallback((gridId: string) => {
const alertForGrid = filteredAlertsRef.current.find((a) => a.grid_id === gridId);
const alertForGrid = filteredAlertsRef.current.find(a => a.grid_id === gridId);
if (alertForGrid) {
setSelectedAlert(alertForGrid.alert_id);
}
@@ -170,42 +188,23 @@ export function AlertsDashboard() {
const handleCellInfo = useCallback((info: CellInfo) => {
setCellInfo(info);
setSelectedAlert(null);
setSelectedAlert(null); // Close alert modal if open
}, []);
const clearCellInfo = useCallback(() => {
setCellInfo(null);
}, []);
// Export utilities
const exportToCsv = useCallback(() => {
const headers = [
'alert_id',
'grid_id',
'region',
'street',
'latitude',
'longitude',
'risk_value',
'priority',
'forecast_horizon',
'reason',
'timestamp',
];
const rows = filteredAlerts.map((a) => [
a.alert_id,
a.grid_id,
a.region,
a.street,
a.latitude,
a.longitude,
a.risk_value,
a.priority,
a.forecast_horizon,
`"${a.reason}"`,
a.timestamp,
const headers = ['alert_id', 'grid_id', 'region', 'street', 'latitude', 'longitude', 'risk_value', 'priority', 'forecast_horizon', 'reason', 'timestamp'];
const rows = filteredAlerts.map(a => [
a.alert_id, a.grid_id, a.region, a.street,
a.latitude, a.longitude, a.risk_value, a.priority,
a.forecast_horizon, `"${a.reason}"`, a.timestamp,
]);
const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -226,22 +225,11 @@ export function AlertsDashboard() {
}, [filteredAlerts]);
return (
<div
data-testid={TESTIDS.pageAlerts}
className={
isFullscreen
? 'fixed inset-0 z-40 bg-bg-page pt-[54px] p-4 overflow-auto'
: 'flex flex-col h-full min-h-0 overflow-auto p-4'
}
>
<div data-testid={TESTIDS.pageAlerts} className={isFullscreen ? 'fixed inset-0 z-40 bg-bg-page pt-[52px] p-5' : 'p-5'}>
{error && (
<ErrorBanner
error={error}
onRetry={() => {
clearError();
fetchRiskMap();
fetchAlerts();
}}
onRetry={() => { clearError(); fetchRiskMap(); fetchAlerts(); }}
onDismiss={clearError}
/>
)}
@@ -284,6 +272,9 @@ export function AlertsDashboard() {
onGridClick={handleGridClick}
onCellInfo={handleCellInfo}
onCardClick={handleAlertCardClick}
effectiveShowAlertMarkers={effectiveShowAlertMarkers}
isCluster={isCluster}
isOfficial={isOfficial}
/>
)}
@@ -296,10 +287,12 @@ export function AlertsDashboard() {
/>
)}
{/* Cell info panel - shown when clicking grid cell without alert */}
{cellInfo && !selectedAlertData && (
<CellInfoPanel cellInfo={cellInfo} onClose={clearCellInfo} />
)}
{/* Alert detail modal */}
{selectedAlertData && (
<AlertDetailModal alert={selectedAlertData} onClose={clearSelectedAlert} />
)}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from 'react';
import { LoadingState } from '@/components/ui';
import { useAnalysisStore } from '@/stores/analysisStore';
import { ErrorBanner } from '@/components/ErrorBanner';
import { ChatBot } from '@/components/ChatBot';
import { caseApi } from '@/services/api';
import type { CaseTrendPoint } from '@/types';
import {
@@ -423,6 +424,8 @@ export function Insights() {
</>
)}
</div>
<ChatBot />
</div>
);
}

View File

@@ -32,92 +32,52 @@ export function Login({ onLogin }: LoginProps) {
};
return (
<div
data-testid={TESTIDS.pageLogin}
className="login-atmosphere relative min-h-screen flex items-center justify-center overflow-hidden px-4"
>
{/* 呼吸感雾层 */}
<div
className="pointer-events-none absolute -top-24 left-1/4 h-72 w-72 rounded-full bg-mist/30 blur-3xl animate-breath"
aria-hidden
/>
<div
className="pointer-events-none absolute bottom-0 right-1/5 h-64 w-80 rounded-full bg-primary/20 blur-3xl animate-breath"
style={{ animationDelay: '2.5s' }}
aria-hidden
/>
<div data-testid={TESTIDS.pageLogin} className="min-h-screen bg-bg-page flex items-center justify-center">
<form
onSubmit={handleSubmit}
className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm"
>
<h1 className="text-xl font-semibold text-text-primary mb-6 text-center">
CBPOA
</h1>
<div className="relative z-10 w-full max-w-[420px] animate-fade-up">
{/* 品牌 hero */}
<header className="mb-8 text-center">
<p className="brand-mark text-[42px] sm:text-[48px] leading-none mb-3">CBPOA</p>
<h1 className="font-display text-[17px] sm:text-[18px] font-semibold text-text-primary tracking-wide mb-2">
</h1>
<p className="text-[13px] text-text-secondary leading-relaxed max-w-sm mx-auto">
· ·
</p>
</header>
{error && (
<div className="mb-4 p-2 bg-red-50 text-danger text-sm rounded">
{error}
</div>
)}
<form
onSubmit={handleSubmit}
className="bg-bg-card/95 backdrop-blur-sm rounded-2xl border border-border shadow-lift p-7 sm:p-8"
<label className="block mb-4">
<span className="text-text-secondary text-sm"></span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</label>
<label className="block mb-6">
<span className="text-text-secondary text-sm"></span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</label>
<button
type="submit"
disabled={loading}
data-testid={TESTIDS.loginSubmit}
className="w-full py-2 bg-primary text-white rounded text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
<h2 className="sr-only"></h2>
{error && (
<div
role="alert"
className="mb-4 px-3 py-2.5 bg-danger-light text-danger text-sm rounded-lg border border-danger/20"
>
{error}
</div>
)}
<label className="block mb-4">
<span className="text-text-secondary text-[13px] font-medium"></span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
className="mt-1.5 block w-full rounded-lg border border-border bg-bg-elevated px-3.5 py-2.5 text-sm text-text-primary
placeholder:text-text-muted
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-shadow"
required
/>
</label>
<label className="block mb-6">
<span className="text-text-secondary text-[13px] font-medium"></span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="mt-1.5 block w-full rounded-lg border border-border bg-bg-elevated px-3.5 py-2.5 text-sm text-text-primary
placeholder:text-text-muted
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-shadow"
required
/>
</label>
<button
type="submit"
disabled={loading}
data-testid={TESTIDS.loginSubmit}
className="w-full py-2.5 bg-primary text-white rounded-lg text-sm font-semibold
hover:bg-primary-deep shadow-brand transition-colors
disabled:opacity-50 disabled:shadow-none"
>
{loading ? '登录中…' : '进入系统'}
</button>
</form>
<p className="mt-6 text-center text-[11px] text-text-muted tracking-wide">
·
</p>
</div>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
);
}

View File

@@ -25,6 +25,7 @@ export function MonitoringDashboard({
defaultStartDate = '2022-12-01',
defaultEndDate = '2024-12-30',
}: MonitoringDashboardProps) {
// In-page tab strip (local state, no router — mirrors the existing activePage pattern)
const [activeTab, setActiveTab] = useState<MonitoringTab>('overview');
const {
@@ -45,11 +46,13 @@ export function MonitoringDashboard({
const drillDown = useDrilldownStore((s) => s.drillDown);
const resetDrillDown = useDrilldownStore((s) => s.resetDrillDown);
// --- URL 是粒度的真相来源drilldownStore 由 URL 派生 ---
const [searchParams, setSearchParams] = useSearchParams();
const granularity = parseGranularity(searchParams.get('granularity'));
const districtParam = searchParams.get('district');
const streetParam = searchParams.get('street');
// 把 URL 写入:粒度控件、面包屑、区域点击都通过它驱动 URL再由下方 effect 同步 store。
const updateUrl = useCallback(
(next: { granularity: Granularity; district?: string | null; street?: string | null }) => {
setSearchParams(
@@ -68,6 +71,7 @@ export function MonitoringDashboard({
[setSearchParams]
);
// 粒度控件回调:切换粒度即写 URL深链可分享
const handleGranularityChange = useCallback(
(g: Granularity) => {
if (g === 'city') updateUrl({ granularity: 'city' });
@@ -77,6 +81,7 @@ export function MonitoringDashboard({
[updateUrl, selectedDistrict, selectedStreet]
);
// 区域 roll-up 点击回调:驱动 URL 而非直接 mutate store消除命令式 desync
const handleDistrictSelect = useCallback(
(district: string) => {
if (selectedDistrict === district) updateUrl({ granularity: 'city' });
@@ -85,6 +90,7 @@ export function MonitoringDashboard({
[updateUrl, selectedDistrict]
);
// store 从 URL 同步URL 派生 store单向。mount 与 param 变化时执行。
useEffect(() => {
if (granularity === 'city') {
if (selectedDistrict !== null || selectedStreet !== null) resetDrillDown();
@@ -95,6 +101,7 @@ export function MonitoringDashboard({
else if (!districtParam && selectedDistrict !== null) resetDrillDown();
return;
}
// granularity === 'street'
if (districtParam && selectedDistrict !== districtParam) drillDown('district', districtParam);
if (streetParam && selectedStreet !== streetParam) drillDown('street', streetParam);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -105,32 +112,22 @@ export function MonitoringDashboard({
setCurrentDate(defaultEndDate);
}, [defaultStartDate, defaultEndDate, setDateRange, setCurrentDate]);
// 数据层:图表窗口 + 病例统计/区域统计两个按需 tab 的加载与派生。
// 不持有 URL/drilldown 真相来源,只消费 currentDate 与 selectedDistrict。
const data = useMonitoringData({ activeTab, currentDate, selectedDistrict });
const handleDateChange = useCallback(
(date: string) => {
setCurrentDate(date);
},
[setCurrentDate]
);
const handleDateChange = useCallback((date: string) => {
setCurrentDate(date);
}, [setCurrentDate]);
const handlePlayPause = useCallback(
(playing: boolean) => {
setPlaying(playing);
},
[setPlaying]
);
const tabs = [
{ key: 'overview' as const, label: '概览' },
{ key: 'cases' as const, label: '病例统计' },
{ key: 'districts' as const, label: '区域统计' },
];
const handlePlayPause = useCallback((playing: boolean) => {
setPlaying(playing);
}, [setPlaying]);
return (
<div data-testid="page-monitoring" className="flex flex-col h-full min-h-0">
<div data-testid="page-monitoring" className="flex flex-col h-full">
{error && (
<div className="px-4 pt-3 shrink-0">
<div className="px-6 pt-4">
<ErrorBanner
error={error}
onRetry={() => {
@@ -141,33 +138,35 @@ export function MonitoringDashboard({
/>
</div>
)}
{/* 顶部指标带 + 筛选 + tab */}
<div className="metrics-band px-4 pt-3 pb-0 shrink-0">
<div className="flex items-stretch justify-between flex-wrap gap-x-4 gap-y-3 mb-3">
{/* Top stats bar — standardized with StatCard */}
<div className="bg-white border-b border-gray-200 px-6 py-4 shrink-0">
<div className="flex items-start justify-between flex-wrap gap-x-4 gap-y-3">
<MonitoringStatsBar stats={data.stats} sparkline7d={data.sparkline7d} />
<div className="flex items-center gap-2 shrink-0 flex-wrap self-center">
{/* Disease filter */}
<div className="flex items-center gap-2 shrink-0">
{data.stats.noData && (
<span className="text-xs text-warning bg-warning-light px-2 py-1 rounded-md border border-warning/20">
</span>
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded"></span>
)}
<DiseaseFilter onFilterChange={data.debouncedLoadChart} />
<AdminBreadcrumb />
</div>
</div>
<div className="tab-strip border-b border-border" role="tablist" aria-label="监测视图">
{tabs.map((tab) => (
{/* In-page tab strip */}
<div className="flex items-center gap-1 mt-4 border-b border-gray-100 -mb-4">
{([
{ key: 'overview', label: '概览' },
{ key: 'cases', label: '病例统计' },
{ key: 'districts', label: '区域统计' },
] as const).map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={activeTab === tab.key}
onClick={() => setActiveTab(tab.key)}
className={`tab-strip__item ${
activeTab === tab.key ? 'tab-strip__item--active' : ''
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
@@ -176,12 +175,9 @@ export function MonitoringDashboard({
</div>
</div>
{/* 主内容:概览为地图舞台;统计 tab 可滚动(底部留坞空间) */}
<div
className={`flex-1 min-h-0 ${
activeTab === 'overview' ? 'overflow-hidden' : 'overflow-auto p-4 pb-28'
}`}
>
{/* Main content — bottom padding for floating player */}
<div className="flex-1 overflow-auto p-6 pb-24">
{/* 概览 tab — unchanged Monitoring content */}
{activeTab === 'overview' && (
<OverviewTab
isLoading={isLoading}
@@ -196,6 +192,7 @@ export function MonitoringDashboard({
/>
)}
{/* 病例统计 tab */}
{activeTab === 'cases' && (
<CaseStatsTab
loading={data.casesTabLoading}
@@ -211,6 +208,7 @@ export function MonitoringDashboard({
/>
)}
{/* 区域统计 tab */}
{activeTab === 'districts' && (
<DistrictStatsTab
loading={data.districtTabLoading}
@@ -225,6 +223,7 @@ export function MonitoringDashboard({
)}
</div>
{/* Timeline Player */}
<TimelinePlayer
startDate={defaultStartDate}
endDate={defaultEndDate}

View File

@@ -52,6 +52,8 @@ export function OverviewDashboard() {
const [alertPie, setAlertPie] = useState<AlertSlice[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState<string[]>([]);
// 门诊/住院/全部 — 同时驱动 choropleth 与 Top5 区县条形图。
const [metric, setMetric] = useState<MetricKey>('all');
useEffect(() => {
@@ -70,6 +72,7 @@ export function OverviewDashboard() {
start30.setDate(start30.getDate() - 30);
const start30Str = start30.toISOString().split('T')[0];
// KPI sources — Promise.allSettled to survive individual failures.
const [statsR, trend14R, alertsR, riskStatsR, pollutantsR] = await Promise.allSettled([
caseApi.getStats(),
caseApi.getTrend({ start_date: start14Str, end_date: endStr, group_by: 'day' }),
@@ -78,16 +81,18 @@ export function OverviewDashboard() {
envApi.getPollutants(7),
]);
// Trend + district sources.
const [trend30R, districtsR, diagStatsR] = await Promise.allSettled([
caseApi.getTrend({ start_date: start30Str, end_date: endStr, group_by: 'day' }),
caseApi.getDistricts(),
caseApi.getStats(),
caseApi.getStats(), // reuse for top_diagnoses
]);
if (cancelled) return;
const newErrors: string[] = [];
// --- KPI ---
let totalCases = 0;
if (statsR.status === 'fulfilled') {
const s = statsR.value;
@@ -136,6 +141,7 @@ export function OverviewDashboard() {
setKpi({ totalCases, todayCases, changeRatio, activeAlerts, highRiskGrids, avgAQI });
// --- Merge case trend + AQI ---
if (trend30R.status === 'fulfilled') {
const trend30 = trend30R.value.trend || [];
const aqiMap: Record<string, number> = {};
@@ -147,12 +153,14 @@ export function OverviewDashboard() {
newErrors.push('趋势数据加载失败');
}
// --- Districts (feeds choropleth + Top5 via normalize/join) ---
if (districtsR.status === 'fulfilled') {
setDistricts(districtsR.value.districts || []);
} else {
newErrors.push('区县数据加载失败');
}
// --- Top 5 Diagnoses ---
if (diagStatsR.status === 'fulfilled') {
const topDiag = diagStatsR.value.top_diagnoses || [];
setTopDiagnoses(
@@ -165,6 +173,7 @@ export function OverviewDashboard() {
);
}
// --- Alert severity donut ---
const p1 = alertList.filter((a) => a.priority === 'P1').length;
const p2 = alertList.filter((a) => a.priority === 'P2').length;
setAlertPie([
@@ -182,6 +191,7 @@ export function OverviewDashboard() {
};
}, []);
// 归一并聚合到 13 区一次,供 choropleth 与 Top5 共享。
const joinedDistricts = useMemo(() => joinDistrictCases(districts), [districts]);
const metricLookup = useMemo(
() => buildMetricLookup(joinedDistricts, metric),
@@ -195,7 +205,7 @@ export function OverviewDashboard() {
return (
<div data-testid={TESTIDS.pageOverview} className="flex flex-col h-full overflow-auto">
{errors.length > 0 && (
<div className="px-1 pt-1">
<div className="px-6 pt-4">
<ErrorBanner
error={errors.join('')}
onRetry={() => window.location.reload()}
@@ -204,18 +214,18 @@ export function OverviewDashboard() {
</div>
)}
<div className="space-y-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="p-6 space-y-6">
{/* Page header + honesty badge + metric toggle */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="font-display text-[20px] font-semibold mb-1 flex items-center gap-2 text-text-primary">
<h1 className="font-display text-[18px] font-semibold mb-1 flex items-center gap-2">
<Activity className="w-5 h-5 text-primary" />
<span
data-testid={TESTIDS.asofBadge}
className="ml-1 inline-flex items-center rounded-md bg-mist-light/80 px-2 py-0.5
text-[11px] font-medium text-mist-deep border border-mist/20"
className="ml-1 inline-flex items-center rounded-full bg-bg-hover px-2 py-0.5 text-[11px] font-medium text-text-secondary border border-border"
>
2023-12
2023-12
</span>
</h1>
<p className="text-[12px] text-text-secondary"></p>
@@ -228,53 +238,35 @@ export function OverviewDashboard() {
/>
</div>
<section aria-label="关键指标">
<KpiRow kpi={kpi} />
</section>
{/* KPI Row */}
<KpiRow kpi={kpi} />
{/* 地图舞台 + 趋势:双栏构图 */}
<div className="grid grid-cols-1 xl:grid-cols-5 gap-5">
<section className="workbench-panel xl:col-span-3 overflow-hidden" aria-label="区县分布">
<div className="workbench-panel__head">
<div>
<h2 className="workbench-panel__title">
13 {METRIC_LABEL[metric]}
</h2>
<p className="workbench-panel__sub"> · </p>
</div>
</div>
<div className="px-2 pb-3 min-h-[340px]">
<DistrictChoropleth
metricLookup={metricLookup}
metricLabel={`${METRIC_LABEL[metric]}`}
/>
</div>
</section>
<div className="xl:col-span-2 flex flex-col gap-5 min-h-0">
<div className="workbench-panel flex-1">
<CaseAqiTrend data={mergedTrend} embed />
</div>
{/* Headline: Wuhan 13-district choropleth */}
<div className="card p-4">
<div className="text-[11px] font-medium text-text-secondary uppercase tracking-wide mb-3">
13{METRIC_LABEL[metric]}
</div>
<DistrictChoropleth
metricLookup={metricLookup}
metricLabel={`${METRIC_LABEL[metric]}`}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div className="workbench-panel">
<TopDistrictsBar
districts={joinedDistricts}
metric={metric}
metricLabel={METRIC_LABEL[metric]}
embed
/>
</div>
<div className="workbench-panel">
<TopDiagnosesBar diagnoses={topDiagnoses} embed />
</div>
{/* Case + AQI trend */}
<CaseAqiTrend data={mergedTrend} />
{/* Top districts (metric-driven) + Top diagnoses */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<TopDistrictsBar
districts={joinedDistricts}
metric={metric}
metricLabel={METRIC_LABEL[metric]}
/>
<TopDiagnosesBar diagnoses={topDiagnoses} />
</div>
<div className="workbench-panel max-w-xl">
<AlertSeverityDonut data={alertPie} embed />
</div>
{/* Alert severity donut */}
<AlertSeverityDonut data={alertPie} />
</div>
</div>
);

View File

@@ -1,6 +1,7 @@
import { lazy, Suspense, ComponentType } from 'react';
import { Navigate, RouteObject } from 'react-router-dom';
import { LoadingState } from '@/components/ui/LoadingState';
import { RoleRedirect } from '@/components/RoleRedirect';
import { TESTIDS } from '@/utils/testids';
// 按页面懒加载,路由层负责代码分割(原先位于 App.tsx
@@ -47,7 +48,7 @@ function lazyElement(Page: ComponentType): JSX.Element {
// AppShell 的子路由表(在 App.tsx 中作为 <Outlet/> 的内容渲染)。
export const appRoutes: RouteObject[] = [
{ index: true, element: <Navigate to="/monitoring" replace /> },
{ index: true, element: <RoleRedirect /> },
{ path: 'overview', element: lazyElement(OverviewDashboard) },
{ path: 'monitoring', element: lazyElement(MonitoringDashboard) },
{ path: 'alerts', element: lazyElement(AlertsDashboard) },

View File

@@ -178,13 +178,12 @@ export const riskApi = {
getStats: (): Promise<Stats> => cachedGet('/risk/stats'),
// Full-Wuhan 100m risk grid XYZ tiles. Absolute URL for GeoScene WebTileLayer.
// Full-Wuhan 100m risk grid served as XYZ raster tiles. Returns a Leaflet
// URL template (NOT an axios call) — the browser fetches PNGs directly.
tileUrlTemplate: (day: 1 | 3 | 7, date?: string): string => {
const apiBase = (import.meta.env.VITE_API_URL || '/api').replace(/\/$/, '');
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const prefix = apiBase.startsWith('http') ? apiBase : `${origin}${apiBase}`;
const base = import.meta.env.VITE_API_URL || '/api';
const dateParam = date ? `&date=${date}` : '';
return `${prefix}/risk/tiles/{z}/{x}/{y}.png?day=${day}${dateParam}`;
return `${base}/risk/tiles/{z}/{x}/{y}.png?day=${day}${dateParam}`;
},
getCell: (

View File

@@ -102,6 +102,9 @@ export const useRiskStore = create<RiskState>((set, get) => ({
export { useAnalysisStore } from './analysisStore';
export { useDrilldownStore } from './drilldownStore';
export { useDiseaseStore } from './diseaseStore';
export { useSessionStore } from './sessionStore';
interface TimelineState {
currentDate: string;
startDate: string;
@@ -174,7 +177,7 @@ interface MonitoringState {
clearError: () => void;
}
export const useMonitoringStore = create<MonitoringState>((set, get) => ({
export const useMonitoringStore = create<MonitoringState>((set) => ({
gridFeatures: [],
aggregatedData: [],
districtCases: [],
@@ -218,10 +221,7 @@ export const useMonitoringStore = create<MonitoringState>((set, get) => ({
},
fetchDistrictCases: async (diagnosis?: string, startDate?: string, endDate?: string) => {
// 播放时间轴会高频调用:不要拨全局 isLoading否则 Overview 会卸地图重建底图。
const firstLoad = get().districtCases.length === 0;
if (firstLoad) set({ isLoading: true, error: null });
else set({ error: null });
set({ isLoading: true, error: null });
try {
const params: Record<string, string> = {};
if (diagnosis) params.diagnosis = diagnosis;

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { getRoleSource, type Role } from './sessionStore';
const STORAGE_KEY = 'cbpoa_role';
const ALL_ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
describe('sessionStore', () => {
beforeEach(() => {
localStorage.clear();
vi.resetModules();
});
afterEach(() => {
localStorage.clear();
});
describe('getRoleSource', () => {
it("defaults to 'admin' when localStorage is empty", () => {
expect(getRoleSource()).toBe('admin');
});
it("defaults to 'admin' on an invalid stored value", () => {
localStorage.setItem(STORAGE_KEY, 'hacker');
expect(getRoleSource()).toBe('admin');
});
it('returns each valid stored role', () => {
for (const r of ALL_ROLES) {
localStorage.setItem(STORAGE_KEY, r);
expect(getRoleSource()).toBe(r);
}
});
});
describe('useSessionStore', () => {
// useSessionStore 在模块加载时从 getRoleSource() 初始化一次,故用
// resetModules + 动态 import 来获得一个「按当前 localStorage 初始化」的全新实例。
it('initializes role from localStorage', async () => {
localStorage.setItem(STORAGE_KEY, 'doctor');
vi.resetModules();
const { useSessionStore } = await import('./sessionStore');
expect(useSessionStore.getState().role).toBe('doctor');
});
it('setRole updates state and persists to localStorage', async () => {
vi.resetModules();
const { useSessionStore } = await import('./sessionStore');
useSessionStore.getState().setRole('community');
expect(useSessionStore.getState().role).toBe('community');
expect(localStorage.getItem(STORAGE_KEY)).toBe('community');
useSessionStore.getState().setRole('official');
expect(useSessionStore.getState().role).toBe('official');
expect(localStorage.getItem(STORAGE_KEY)).toBe('official');
});
});
});

View File

@@ -0,0 +1,59 @@
import { create } from 'zustand';
/**
* 视角/perspective 会话存储 —— 纯前端视图预设D2不做后端鉴权不加 JWT role claim
*
* 角色仅决定「默认落地页 + 粒度 + 过滤预设」,不是访问控制。切换器在 UI 上标注为
* 「视角」而非「权限」,因此 URL 可编辑不构成可信度陷阱。
*
* 角色来源被隔离在单一可替换的 getRoleSource() 接缝里:今天读 localStorage
* 将来若需真正 RBAC只改这一个函数改读 /api/auth/me其余代码不变。
*/
export type Role = 'official' | 'community' | 'doctor' | 'admin';
export const ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
// 标签与默认落地路径已迁出到纯模块 '@/utils/roleViews'ROLE_LABELS / roleDefaultPath
// 保持单一来源。本 store 只负责「当前视角是什么」+ 可替换的角色来源接缝。
const STORAGE_KEY = 'cbpoa_role';
const DEFAULT_ROLE: Role = 'admin';
function isRole(v: unknown): v is Role {
return typeof v === 'string' && (ROLES as string[]).includes(v);
}
/**
* 单一可替换接缝:角色来源。今天 = localStorage将来 = /api/auth/me。
* 改 RBAC 只动这一个函数。
*/
export function getRoleSource(): Role {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return isRole(raw) ? raw : DEFAULT_ROLE;
} catch {
return DEFAULT_ROLE;
}
}
function persistRole(role: Role): void {
try {
localStorage.setItem(STORAGE_KEY, role);
} catch {
/* localStorage 不可用时静默降级 */
}
}
interface SessionState {
role: Role;
setRole: (role: Role) => void;
}
export const useSessionStore = create<SessionState>((set) => ({
role: getRoleSource(),
setRole: (role) => {
persistRole(role);
set({ role });
},
}));

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { ROLE_LABELS, roleDefaultPath } from './roleViews';
import type { Role } from '@/stores/sessionStore';
const ALL_ROLES: Role[] = ['official', 'community', 'doctor', 'admin'];
describe('roleViews', () => {
describe('ROLE_LABELS', () => {
it('has a non-empty label for all 4 roles', () => {
for (const r of ALL_ROLES) {
expect(ROLE_LABELS[r]).toBeTruthy();
}
});
it('uses the expected short labels (no 视角 suffix)', () => {
expect(ROLE_LABELS).toEqual({
official: '厅领导',
community: '社区',
doctor: '医生',
admin: '管理员',
});
});
});
describe('roleDefaultPath', () => {
it('official → /overview with granularity=district', () => {
expect(roleDefaultPath('official')).toBe('/overview?granularity=district');
});
it('community → /monitoring with granularity=street', () => {
expect(roleDefaultPath('community')).toBe('/monitoring?granularity=street');
});
it('doctor → /alerts with view=cluster', () => {
expect(roleDefaultPath('doctor')).toBe('/alerts?view=cluster');
});
it('admin → /monitoring (legacy full view; keeps user-flows baseline green)', () => {
expect(roleDefaultPath('admin')).toBe('/monitoring');
});
});
});

View File

@@ -0,0 +1,42 @@
import type { Role } from '@/stores/sessionStore';
/**
* 视角/perspective 的纯展示元数据 —— 标签 + 默认落地路径。
*
* 纯模块(无副作用、无 React、无 store 依赖),便于单元测试。
* D2角色只是「前端视图预设」决定默认落地页 / 粒度 / 过滤预设,不是访问控制。
*
* wave-2 workers 的契约入口:
* import { roleDefaultPath, ROLE_LABELS } from '@/utils/roleViews'
*/
/** 视角中文短标签switcher 在前面拼接「视角:」前缀,故此处不带「视角」后缀)。 */
export const ROLE_LABELS: Record<Role, string> = {
official: '厅领导',
community: '社区',
doctor: '医生',
admin: '管理员',
};
/**
* 每个视角的默认落地 URL。query 参数由 wave-2 workers 消费:
* - granularity=district|street → 粒度控件初值(监测/概览)
* - view=cluster → 预警页医生聚类视图
*/
export function roleDefaultPath(role: Role): string {
switch (role) {
case 'official':
return '/overview?granularity=district';
case 'community':
return '/monitoring?granularity=street';
case 'doctor':
return '/alerts?view=cluster';
case 'admin':
default:
// admin = 旧「全量」视角,历史落地页即 /monitoring与改造前 sessionStore 的
// ROLE_DEFAULT_PATH 一致)。保持 /monitoring 以兼容既有 user-flows 基线测试
// (裸 '/' 无 cbpoa_role ⇒ 默认 admin ⇒ /monitoring。其余三个视角带查询参数
// 由 wave-2 workers 消费,不受此选择影响。
return '/monitoring';
}
}

View File

@@ -44,11 +44,14 @@ export const TESTIDS = {
asofBadge: 'asof-badge',
outinpatientToggle: 'outinpatient-toggle',
// 地图图层控件
// 视角/perspective + 粒度Phase 3
perspectiveSwitcher: 'perspective-switcher',
perspectiveOption: 'perspective-option', // 配合角色后缀,如 perspective-option-official
granularityControl: 'granularity-control',
districtRollup: 'district-rollup',
gridLayerWrapper: 'grid-layer-wrapper',
patientPoint: 'patient-point',
clusterView: 'cluster-view',
patientPoint: 'patient-point', // 个体病例点标记;医生视角下必须为 0
} as const;
export type TestId = (typeof TESTIDS)[keyof typeof TESTIDS];

View File

@@ -1,15 +1 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly VITE_TIANDITU_TK?: string;
readonly VITE_GEOSCENE_PORTAL_URL?: string;
readonly VITE_LAYER_DISTRICTS_URL?: string;
readonly VITE_LAYER_RISK_URL?: string;
readonly VITE_LAYER_CASES_URL?: string;
readonly VITE_GEOSCENE_WEBMAP_ID?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -8,73 +8,41 @@ export default {
extend: {
colors: {
primary: {
DEFAULT: '#0F766E',
light: '#14B8A6',
muted: '#CCFBF1',
deep: '#0D5C56',
},
mist: {
DEFAULT: '#5B8FA8',
light: '#E8F2F6',
deep: '#3D6B82',
DEFAULT: '#2563EB',
light: '#3B82F6',
muted: '#DBEAFE',
},
success: {
DEFAULT: '#0D9488',
light: '#CCFBF1',
DEFAULT: '#059669',
light: '#D1FAE5',
},
warning: {
DEFAULT: '#C27803',
DEFAULT: '#D97706',
light: '#FEF3C7',
},
danger: {
DEFAULT: '#C2410C',
light: '#FFEDD5',
DEFAULT: '#DC2626',
light: '#FEE2E2',
},
bg: {
page: '#F0F4F6',
page: '#F8FAFC',
card: '#FFFFFF',
hover: '#E8EEF1',
active: '#D8E4E9',
elevated: '#FAFCFD',
hover: '#F1F5F9',
active: '#E2E8F0',
},
text: {
primary: '#1A2B33',
secondary: '#5A6F7A',
muted: '#8A9BA5',
primary: '#1E293B',
secondary: '#64748B',
muted: '#94A3B8',
},
border: {
DEFAULT: '#D4DEE4',
light: '#E8EEF1',
DEFAULT: '#E2E8F0',
light: '#F1F5F9',
},
},
fontFamily: {
sans: ['"Noto Sans SC"', '"Outfit"', 'sans-serif'],
display: ['"Outfit"', '"Noto Sans SC"', 'sans-serif'],
mono: ['"IBM Plex Mono"', 'ui-monospace', 'monospace'],
},
boxShadow: {
soft: '0 1px 2px rgba(26, 43, 51, 0.04), 0 4px 16px rgba(26, 43, 51, 0.05)',
lift: '0 2px 8px rgba(15, 118, 110, 0.08), 0 8px 24px rgba(26, 43, 51, 0.06)',
brand: '0 8px 32px rgba(15, 118, 110, 0.18)',
},
keyframes: {
'fade-up': {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'breath': {
'0%, 100%': { opacity: '0.35' },
'50%': { opacity: '0.55' },
},
},
animation: {
'fade-up': 'fade-up 0.5s ease-out both',
'fade-in': 'fade-in 0.4s ease-out both',
breath: 'breath 8s ease-in-out infinite',
sans: ['Inter', 'Noto Sans SC', 'system-ui', 'sans-serif'],
display: ['Source Sans Pro', 'sans-serif'],
},
},
},

View File

@@ -9,9 +9,6 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
optimizeDeps: {
exclude: ['@geoscene/core'],
},
server: {
port: 3000,
allowedHosts: ['alpha.hyh.ink'],
@@ -20,17 +17,6 @@ export default defineConfig({
target: 'http://localhost:8000',
changeOrigin: true,
},
// Same-origin Gaode tiles — WebTileLayer needs CORS; direct autonavi URLs fail.
'/basemap-gaode': {
target: 'https://webrd01.is.autonavi.com',
changeOrigin: true,
rewrite: (p) => {
const m = p.match(/^\/basemap-gaode\/(\d+)\/(\d+)\/(\d+)/);
if (!m) return p;
const [, z, x, y] = m;
return `/appmaptile?lang=zh_cn&size=1&scale=1&style=8&z=${z}&x=${x}&y=${y}`;
},
},
},
},
preview: {

View File

@@ -1 +0,0 @@
{"20231125":0.4461,"20231126":0.4517,"20231127":0.4576,"20231128":0.4638,"20231129":0.4697,"20231130":0.4521,"20231201":0.4581}