style: refine acrylic workbench surfaces

This commit is contained in:
2026-07-15 17:07:32 +08:00
parent 8978f2d167
commit 16ecb69d00
41 changed files with 838 additions and 606 deletions
+30 -31
View File
@@ -1,7 +1,7 @@
"use client";
import type { Map as MapLibreMap } from "maplibre-gl";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps, type CSSProperties } from "react";
import { X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
@@ -42,6 +42,8 @@ import { useWorkbenchDrawing, type WorkbenchDrawMode } from "./hooks/use-workben
import { useWorkbenchMap } from "./hooks/use-workbench-map";
import { toMapFeatureReference } from "./hooks/use-map-interactions";
import { useWorkbenchMeasurement, type WorkbenchMeasureMode, type WorkbenchMeasureUnit } from "./hooks/use-workbench-measurement";
import { getWorkbenchBasemapTone, getWorkbenchLayoutCssVariables } from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
import {
BASE_LAYER_IDS,
@@ -58,6 +60,7 @@ import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-
const HEADER_DATA_TIME_STEP_MINUTES = 5;
const INITIAL_HEADER_DATA_TIME = "同步中";
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
function formatHeaderDataTime(date: Date) {
const snappedMinutes = Math.floor(date.getMinutes() / HEADER_DATA_TIME_STEP_MINUTES) * HEADER_DATA_TIME_STEP_MINUTES;
@@ -88,7 +91,7 @@ export function MapWorkbenchPage() {
const [impactVisible, setImpactVisible] = useState(false);
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [activeToolId, setActiveToolId] = useState<ToolbarToolId | null>(null);
const [conditionFeedVisible, setConditionFeedVisible] = useState(true);
const [conditionFeedVisible, setConditionFeedVisible] = useState(false);
const [conditionFeedMounted, setConditionFeedMounted] = useState(true);
const [conditionFeedExpanded, setConditionFeedExpanded] = useState(false);
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
@@ -98,6 +101,7 @@ export function MapWorkbenchPage() {
requestId: number;
} | null>(null);
const [activeBaseLayerId, setActiveBaseLayerId] = useState(hasMapboxToken ? "mapbox-light" : "none");
const basemapTone = getWorkbenchBasemapTone(activeBaseLayerId);
const [activeScenarioId, setActiveScenarioId] = useState(WORKBENCH_SCENARIOS[0]?.id ?? "");
const [activeMeasureModeId, setActiveMeasureModeId] = useState<WorkbenchMeasureMode>("distance");
const [activeMeasureUnitId, setActiveMeasureUnitId] = useState<WorkbenchMeasureUnit>("km");
@@ -125,6 +129,7 @@ export function MapWorkbenchPage() {
const handleChange = () => setIsLargeScreen(mediaQuery.matches);
handleChange();
setConditionFeedVisible(window.innerWidth >= 1280);
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
@@ -172,8 +177,12 @@ export function MapWorkbenchPage() {
return () => window.clearTimeout(timeoutId);
}, [shouldShowConditionFeed]);
const leftPanelOpen = isLargeScreen;
const rightPanelOpen = false;
const agent = useWorkbenchAgent({
onUiEnvelope: handleAgentUiEnvelope,
onFrontendAction: handleFrontendAction
});
const leftPanelOpen = isLargeScreen && agent.panelOpen;
const rightPanelOpen = isLargeScreen && shouldShowConditionFeed;
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
containerRef: mapContainerRef,
@@ -184,10 +193,6 @@ export function MapWorkbenchPage() {
selectedFeature: detailFeature
});
const agent = useWorkbenchAgent({
onUiEnvelope: handleAgentUiEnvelope,
onFrontendAction: handleFrontendAction
});
const activeAgentUiResults = useMemo(
() => agentUiResults.filter((result) => result.sessionId === agent.sessionId),
[agent.sessionId, agentUiResults]
@@ -221,19 +226,17 @@ export function MapWorkbenchPage() {
});
const prefersReducedMotion = useReducedMotion();
const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker = !agent.mobileOpen && taskTickerVisible && taskTickerAvailable;
const shouldShowTaskTicker = !agent.mobileOpen && !conditionFeedExpanded && taskTickerVisible && taskTickerAvailable;
const taskTickerEnterState = prefersReducedMotion
? {
opacity: 1,
scale: 1,
y: 0,
filter: "blur(0px)"
y: 0
}
: {
opacity: 1,
scale: 1,
y: 0,
filter: "blur(0px)",
transition: {
type: "spring" as const,
stiffness: 420,
@@ -246,14 +249,12 @@ export function MapWorkbenchPage() {
opacity: 0,
scale: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0 }
}
: {
opacity: 0,
scale: 0.96,
y: -8,
filter: "blur(4px)",
transition: {
duration: 0.18,
ease: [0.5, 0, 0.2, 1] as const
@@ -526,14 +527,14 @@ export function MapWorkbenchPage() {
const map = mapRef.current;
if (request.name === "zoom_to_map") {
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const x = Number(request.params.x); const y = Number(request.params.y);
const center: [number, number] = request.params.source_crs === "EPSG:4326" ? [x, y] : [x * 180 / 20037508.34, Math.atan(Math.exp(y * Math.PI / 20037508.34)) * 360 / Math.PI - 90]; const zoom = Number(request.params.zoom ?? 18); map.easeTo({ center, zoom, duration: Number(request.params.duration_ms ?? 500) }); return { center, zoom };
const center: [number, number] = request.params.source_crs === "EPSG:4326" ? [x, y] : [x * 180 / 20037508.34, Math.atan(Math.exp(y * Math.PI / 20037508.34)) * 360 / Math.PI - 90]; const zoom = Number(request.params.zoom ?? 18); map.easeTo({ center, zoom, padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), duration: Number(request.params.duration_ms ?? 500) }); return { center, zoom };
}
if (request.name === "locate_features") {
if (!map || !mapReady) throw new Error("MAP_NOT_READY"); const ids = request.params.ids as string[]; const featureType = String(request.params.feature_type);
const response = await fetch("/api/agent-locate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, featureType }), signal }); const geojson = await response.json(); if (!response.ok) throw new Error(geojson.code ?? "WFS_FAILED");
const features = geojson.features as Array<{ id?: string | number; properties?: Record<string, unknown>; geometry: { coordinates: unknown } }>; if (!features.length) throw new Error("FEATURE_NOT_FOUND");
if (map.getSource("agent-locate")) (map.getSource("agent-locate") as unknown as { setData: (data: unknown) => void }).setData(geojson); else { map.addSource("agent-locate", { type: "geojson", data: geojson }); map.addLayer({ id: "agent-locate-lines", type: "line", source: "agent-locate", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": "#f97316", "line-width": 6 } }); map.addLayer({ id: "agent-locate-points", type: "circle", source: "agent-locate", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": "#f97316", "circle-radius": 8, "circle-stroke-color": "#fff", "circle-stroke-width": 2 } }); }
const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: 80, maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds };
const coordinates: number[][] = []; const collect = (value: unknown) => { if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as number[]); else if (Array.isArray(value)) value.forEach(collect); }; features.forEach((feature) => collect(feature.geometry.coordinates)); const bounds: [number, number, number, number] = [Math.min(...coordinates.map((c) => c[0])), Math.min(...coordinates.map((c) => c[1])), Math.max(...coordinates.map((c) => c[0])), Math.max(...coordinates.map((c) => c[1]))]; map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen), maxZoom: 18 }); const locatedIds = features.map((feature) => String(feature.id ?? feature.properties?.id ?? "")).filter(Boolean); return { locatedIds, missingIds: ids.filter((id) => !locatedIds.includes(id)), featureType, bounds };
}
const history = request.name === "view_history"; const itemCount = history ? (request.params.feature_infos as unknown[]).length : Array.isArray(request.params.device_ids) ? request.params.device_ids.length : 1; setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope: { kind: "registered_component", schemaVersion: "agent-ui@1", component: history ? "HistoryPanel" : "ScadaPanel", surface: "side_panel", props: request.params } }]); return { component: history ? "HistoryPanel" : "ScadaPanel", rendered: true, itemCount };
}
@@ -559,6 +560,7 @@ export function MapWorkbenchPage() {
map.easeTo({
center: trustedAction.center,
zoom: trustedAction.zoom ?? Math.max(map.getZoom(), 14),
padding: getResponsiveWorkbenchPadding(map, leftPanelOpen, rightPanelOpen),
duration: 500
});
showMapNotice({
@@ -699,7 +701,11 @@ export function MapWorkbenchPage() {
};
return (
<main className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900">
<main
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
data-basemap-tone={basemapTone}
style={WORKBENCH_LAYOUT_CSS_VARIABLES as CSSProperties}
>
<div
ref={mapContainerRef}
className="map-grid"
@@ -707,8 +713,6 @@ export function MapWorkbenchPage() {
aria-label="排水管网地图"
/>
<div className="pointer-events-none absolute inset-0 z-10 bg-gradient-to-b from-white/25 via-white/5 to-slate-100/20" />
<WorkbenchTopBar
dataTime={headerDataTime}
modelName="DrainFlow 1.0.0"
@@ -736,7 +740,7 @@ export function MapWorkbenchPage() {
onExportConfig={handleExportConfig}
/>
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[460px] 2xl:w-[500px] lg:block">
<div className="pointer-events-none absolute bottom-4 left-3 top-24 z-20 hidden w-[var(--workbench-agent-width)] 2xl:w-[var(--workbench-agent-width-wide)] lg:block">
{agent.panelOpen ? (
<AgentCommandPanel
{...agentPanelProps}
@@ -757,17 +761,13 @@ export function MapWorkbenchPage() {
</div>
{conditionFeedMounted ? (
<div
className={cn(
"absolute right-[62px] top-24 z-20 hidden lg:block",
shouldShowConditionFeed ? "scheduled-feed-shell-enter" : "scheduled-feed-shell-exit"
)}
>
<div className="absolute right-16 top-24 z-20 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]">
<ScheduledConditionFeed
conditions={scheduledConditions}
expanded={conditionFeedExpanded}
focusRequest={conditionFocusRequest}
loading={scheduledConditionsLoading}
visible={shouldShowConditionFeed}
selectedConditionId={selectedConditionId}
onExpandedChange={setConditionFeedExpanded}
onFocusRequestHandled={handleConditionFocusRequestHandled}
@@ -779,7 +779,7 @@ export function MapWorkbenchPage() {
</div>
) : null}
<div className="absolute right-[62px] top-24 z-20 hidden lg:block">
<div className="absolute right-16 top-24 z-20 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} />
</div>
@@ -821,7 +821,7 @@ export function MapWorkbenchPage() {
{shouldShowTaskTicker ? (
<motion.div
key="agent-task-ticker-shell"
className="fixed left-3 right-3 top-24 z-20 w-auto [translate:0] md:absolute md:left-1/2 md:right-auto md:w-[460px] md:[translate:-50%_0]"
className="fixed left-1/2 right-auto top-24 z-20 w-[calc(100%-1.5rem)] [translate:-50%_0] md:absolute md:w-[var(--workbench-ticker-width)] 2xl:w-[var(--workbench-ticker-width-wide)]"
style={{ transformOrigin: "top center" }}
initial={
prefersReducedMotion
@@ -829,15 +829,14 @@ export function MapWorkbenchPage() {
: {
opacity: 0,
scale: 0.96,
y: -8,
filter: "blur(6px)"
y: -8
}
}
animate={taskTickerEnterState}
exit={taskTickerExitState}
>
<AgentTaskTicker
className="w-full"
className="w-full max-w-full"
conditions={runningTickerConditions}
/>
</motion.div>