fix: refine floating workspace controls
Keep ScaleInfo pinned to the map corner and move the centered timeline above collisions, preventing the previous conditional offset from lifting the scale. Remove collapsed Agent rail width reservation and add timeline state transitions.
This commit is contained in:
@@ -36,7 +36,7 @@ export function WorkbenchAgentPanels({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
|
className="pointer-events-none absolute bottom-0 left-0 top-14 z-50 hidden lg:block"
|
||||||
style={{ width: panelOpen ? committedWidth : 72 }}
|
style={{ width: panelOpen ? committedWidth : 0 }}
|
||||||
>
|
>
|
||||||
{panelOpen ? (
|
{panelOpen ? (
|
||||||
<AgentCommandPanel
|
<AgentCommandPanel
|
||||||
@@ -46,7 +46,7 @@ export function WorkbenchAgentPanels({
|
|||||||
onCollapse={onCollapsePanel}
|
onCollapse={onCollapsePanel}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full bg-[#e8eef4] px-1.5 pt-5 shadow-[1px_0_0_rgba(15,23,42,0.08)]">
|
<div className="absolute left-3 top-5">
|
||||||
<AgentCollapsedRail
|
<AgentCollapsedRail
|
||||||
personaState={personaState}
|
personaState={personaState}
|
||||||
statusLabel={statusLabel}
|
statusLabel={statusLabel}
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ describe("workbench floating panels", () => {
|
|||||||
).toEqual({ top: 72, right: 952, bottom: 32, left: 476 });
|
).toEqual({ top: 72, right: 952, bottom: 32, left: 476 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not reserve map width for the collapsed Agent launcher", () => {
|
||||||
|
expect(
|
||||||
|
getWorkbenchCameraPadding(1440, {
|
||||||
|
agentOpen: false,
|
||||||
|
conditionOpen: false
|
||||||
|
}).left
|
||||||
|
).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the committed Agent width without exceeding the hard limit", () => {
|
it("uses the committed Agent width without exceeding the hard limit", () => {
|
||||||
expect(
|
expect(
|
||||||
getWorkbenchCameraPadding(1920, {
|
getWorkbenchCameraPadding(1920, {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const WORKBENCH_LAYOUT = {
|
|||||||
desktopMinWidth: 1024,
|
desktopMinWidth: 1024,
|
||||||
persistentConditionMinWidth: 1280,
|
persistentConditionMinWidth: 1280,
|
||||||
wideMinWidth: 1536,
|
wideMinWidth: 1536,
|
||||||
collapsedAgentWidth: 72,
|
collapsedAgentWidth: 0,
|
||||||
maxAgentWidth: 720,
|
maxAgentWidth: 720,
|
||||||
agentPanelLeftInset: 12,
|
agentPanelLeftInset: 12,
|
||||||
conditionPanelRightInset: 64,
|
conditionPanelRightInset: 64,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
} from "@/features/map/core";
|
} from "@/features/map/core";
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
import { cn } from "@/shared/ui/cn";
|
|
||||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||||
import { MapDevPanel } from "./components/map-dev-panel";
|
import { MapDevPanel } from "./components/map-dev-panel";
|
||||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||||
@@ -41,10 +40,10 @@ import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
|||||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||||
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
|
import { OperationalTimeline } from "./operational-timeline/operational-timeline";
|
||||||
import {
|
import {
|
||||||
canDockMapScaleBesideTimeline,
|
|
||||||
createOperationalEvents,
|
createOperationalEvents,
|
||||||
getOperationalEventSummary,
|
getOperationalEventSummary,
|
||||||
OPERATIONAL_TIMELINE_LAYOUT,
|
OPERATIONAL_TIMELINE_LAYOUT,
|
||||||
|
resolveTimelineScaleLayout,
|
||||||
type OperationalEvent,
|
type OperationalEvent,
|
||||||
type OperationalTimelineMode
|
type OperationalTimelineMode
|
||||||
} from "./operational-timeline/operational-timeline-model";
|
} from "./operational-timeline/operational-timeline-model";
|
||||||
@@ -216,11 +215,14 @@ export function MapWorkbenchPage({
|
|||||||
() => getOperationalEventSummary(operationalEvents),
|
() => getOperationalEventSummary(operationalEvents),
|
||||||
[operationalEvents]
|
[operationalEvents]
|
||||||
);
|
);
|
||||||
const scaleInfoBesideTimeline = canDockMapScaleBesideTimeline(
|
const timelineScaleLayout = resolveTimelineScaleLayout(
|
||||||
businessWorkspaceWidth,
|
businessWorkspaceWidth,
|
||||||
timelineMode,
|
timelineMode,
|
||||||
timelineVisible
|
timelineVisible
|
||||||
);
|
);
|
||||||
|
const timelineBottom = isLargeScreen
|
||||||
|
? timelineScaleLayout.timelineBottom
|
||||||
|
: 12;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimelineMode((current) => {
|
setTimelineMode((current) => {
|
||||||
@@ -927,8 +929,8 @@ export function MapWorkbenchPage({
|
|||||||
!timelineVisible
|
!timelineVisible
|
||||||
? 0
|
? 0
|
||||||
: !isLargeScreen
|
: !isLargeScreen
|
||||||
? OPERATIONAL_TIMELINE_LAYOUT.mobileSummaryHeight
|
? OPERATIONAL_TIMELINE_LAYOUT.mobileSummaryHeight + timelineBottom
|
||||||
: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].height
|
: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].height + timelineBottom
|
||||||
}px`
|
}px`
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
@@ -975,7 +977,7 @@ export function MapWorkbenchPage({
|
|||||||
onCollapseArtifact={artifactWorkspace.collapseArtifact}
|
onCollapseArtifact={artifactWorkspace.collapseArtifact}
|
||||||
onDestroyArtifact={artifactWorkspace.destroyArtifact}
|
onDestroyArtifact={artifactWorkspace.destroyArtifact}
|
||||||
timelineMode={timelineMode}
|
timelineMode={timelineMode}
|
||||||
reserveMapScaleSpace={scaleInfoBesideTimeline}
|
timelineBottom={timelineBottom}
|
||||||
timelineContent={(
|
timelineContent={(
|
||||||
timelineVisible ? <>
|
timelineVisible ? <>
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
@@ -1022,17 +1024,17 @@ export function MapWorkbenchPage({
|
|||||||
<ToolbarPanel {...toolbarPanelProps} />
|
<ToolbarPanel {...toolbarPanelProps} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className="absolute bottom-0 right-0 flex flex-col items-end gap-2"
|
||||||
"absolute right-0 flex flex-col items-end gap-2",
|
style={{ bottom: timelineScaleLayout.scaleBottom }}
|
||||||
scaleInfoBesideTimeline
|
|
||||||
? "bottom-3"
|
|
||||||
: "bottom-[calc(var(--workbench-timeline-height)+1rem)]"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-end gap-2 pr-2">
|
<div className="flex items-end gap-2 pr-2">
|
||||||
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
|
||||||
</div>
|
</div>
|
||||||
<MapScaleLine mapRef={mapRef} mapReady={mapReady} />
|
<MapScaleLine
|
||||||
|
className="hidden lg:inline-flex"
|
||||||
|
mapRef={mapRef}
|
||||||
|
mapReady={mapReady}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ScheduledConditionItem } from "../types";
|
import type { ScheduledConditionItem } from "../types";
|
||||||
import {
|
import {
|
||||||
canDockMapScaleBesideTimeline,
|
canCenterTimelineBesideMapScale,
|
||||||
clusterOperationalEvents,
|
clusterOperationalEvents,
|
||||||
createOperationalEvents
|
createOperationalEvents,
|
||||||
|
resolveTimelineScaleLayout
|
||||||
} from "./operational-timeline-model";
|
} from "./operational-timeline-model";
|
||||||
|
|
||||||
const workOrder: ScheduledConditionItem = {
|
const workOrder: ScheduledConditionItem = {
|
||||||
@@ -44,9 +45,20 @@ describe("operational timeline model", () => {
|
|||||||
expect(clusters.find((cluster) => cluster.lane === "plan")?.events).toHaveLength(2);
|
expect(clusters.find((cluster) => cluster.lane === "plan")?.events).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("docks scale info beside a floating timeline only when the business area is wide enough", () => {
|
it("keeps a centered timeline beside scale info only when both side margins are wide enough", () => {
|
||||||
expect(canDockMapScaleBesideTimeline(2048, "expanded", true)).toBe(true);
|
expect(canCenterTimelineBesideMapScale(2560, "expanded", true)).toBe(true);
|
||||||
expect(canDockMapScaleBesideTimeline(1380, "expanded", true)).toBe(false);
|
expect(canCenterTimelineBesideMapScale(2048, "expanded", true)).toBe(false);
|
||||||
expect(canDockMapScaleBesideTimeline(1380, "expanded", false)).toBe(true);
|
expect(canCenterTimelineBesideMapScale(1380, "expanded", false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps scale info in the map corner and moves the timeline when horizontal space is tight", () => {
|
||||||
|
expect(resolveTimelineScaleLayout(2560, "expanded", true)).toEqual({
|
||||||
|
scaleBottom: 0,
|
||||||
|
timelineBottom: 24
|
||||||
|
});
|
||||||
|
expect(resolveTimelineScaleLayout(2048, "expanded", true)).toEqual({
|
||||||
|
scaleBottom: 0,
|
||||||
|
timelineBottom: 56
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,15 +31,22 @@ export type OperationalEventCluster = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const OPERATIONAL_TIMELINE_LAYOUT = {
|
export const OPERATIONAL_TIMELINE_LAYOUT = {
|
||||||
compact: { height: 72, maxWidth: 980 },
|
compact: { height: 88, maxWidth: 980 },
|
||||||
expanded: { height: 240, maxWidth: 1180 },
|
expanded: { height: 240, maxWidth: 1180 },
|
||||||
summary: { height: 52, maxWidth: 760 },
|
summary: { height: 52, maxWidth: 760 },
|
||||||
mobileSummaryHeight: 48,
|
mobileSummaryHeight: 48,
|
||||||
mapScaleSafeWidth: 520,
|
mapScaleSafeWidth: 520,
|
||||||
floatingGap: 48
|
floatingGap: 48,
|
||||||
|
edgeInset: 24,
|
||||||
|
scaleCollisionOffset: 56
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function canDockMapScaleBesideTimeline(
|
export type TimelineScaleLayout = {
|
||||||
|
scaleBottom: number;
|
||||||
|
timelineBottom: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function canCenterTimelineBesideMapScale(
|
||||||
businessWorkspaceWidth: number,
|
businessWorkspaceWidth: number,
|
||||||
mode: OperationalTimelineMode,
|
mode: OperationalTimelineMode,
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -48,8 +55,30 @@ export function canDockMapScaleBesideTimeline(
|
|||||||
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
|
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
|
||||||
return businessWorkspaceWidth >=
|
return businessWorkspaceWidth >=
|
||||||
layout.maxWidth +
|
layout.maxWidth +
|
||||||
OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth +
|
2 * (
|
||||||
OPERATIONAL_TIMELINE_LAYOUT.floatingGap;
|
OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth +
|
||||||
|
OPERATIONAL_TIMELINE_LAYOUT.floatingGap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimelineScaleLayout(
|
||||||
|
businessWorkspaceWidth: number,
|
||||||
|
mode: OperationalTimelineMode,
|
||||||
|
visible: boolean
|
||||||
|
): TimelineScaleLayout {
|
||||||
|
const scaleClearsCenteredTimeline = canCenterTimelineBesideMapScale(
|
||||||
|
businessWorkspaceWidth,
|
||||||
|
mode,
|
||||||
|
visible
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scaleBottom: 0,
|
||||||
|
timelineBottom:
|
||||||
|
visible && !scaleClearsCenteredTimeline
|
||||||
|
? OPERATIONAL_TIMELINE_LAYOUT.scaleCollisionOffset
|
||||||
|
: OPERATIONAL_TIMELINE_LAYOUT.edgeInset
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
|
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Clock3,
|
Clock3,
|
||||||
OctagonAlert,
|
OctagonAlert,
|
||||||
|
PanelBottomClose,
|
||||||
|
PanelBottomOpen,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
TriangleAlert
|
TriangleAlert
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import { cn } from "@/shared/ui/cn";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
@@ -51,18 +52,24 @@ export function OperationalTimeline({
|
|||||||
onClearSelection,
|
onClearSelection,
|
||||||
onReturnNow
|
onReturnNow
|
||||||
}: OperationalTimelineProps) {
|
}: OperationalTimelineProps) {
|
||||||
|
const prefersReducedMotion = useReducedMotion();
|
||||||
const summary = getOperationalEventSummary(events);
|
const summary = getOperationalEventSummary(events);
|
||||||
const clusters = clusterOperationalEvents(events, mobile ? 60 : 10);
|
const clusters = clusterOperationalEvents(events, mobile ? 60 : 10);
|
||||||
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
|
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
|
||||||
const cursorPosition = getDayPosition(cursorTime);
|
const cursorPosition = getDayPosition(cursorTime);
|
||||||
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
|
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
|
||||||
|
const sizeTransition = prefersReducedMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: { duration: 0.3, ease: [0.22, 1, 0.36, 1] as const };
|
||||||
|
|
||||||
if (mode === "summary" && !mobile) {
|
if (mode === "summary" && !mobile) {
|
||||||
return (
|
return (
|
||||||
<section
|
<motion.section
|
||||||
aria-label="运行时间轴摘要"
|
aria-label="运行时间轴摘要"
|
||||||
className="acrylic-panel flex h-full items-center gap-3 rounded-2xl border px-3 text-slate-900"
|
className="acrylic-panel flex h-full items-center gap-3 rounded-2xl border px-3 text-slate-900"
|
||||||
style={{ height }}
|
initial={false}
|
||||||
|
animate={{ height }}
|
||||||
|
transition={sizeTransition}
|
||||||
>
|
>
|
||||||
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
|
<Clock3 size={15} className="text-blue-600" aria-hidden="true" />
|
||||||
<span className="text-xs font-semibold">{formatDate(date)}</span>
|
<span className="text-xs font-semibold">{formatDate(date)}</span>
|
||||||
@@ -80,13 +87,13 @@ export function OperationalTimeline({
|
|||||||
<span className="min-w-0 truncate text-xs text-slate-500">分析视图保持独立时间范围</span>
|
<span className="min-w-0 truncate text-xs text-slate-500">分析视图保持独立时间范围</span>
|
||||||
)}
|
)}
|
||||||
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||||
</section>
|
</motion.section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={180}>
|
<TooltipProvider delayDuration={180}>
|
||||||
<section
|
<motion.section
|
||||||
aria-label="运行时间轴"
|
aria-label="运行时间轴"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
"flex min-h-0 flex-col overflow-hidden text-slate-900",
|
||||||
@@ -94,12 +101,14 @@ export function OperationalTimeline({
|
|||||||
? "surface-reading h-full rounded-t-xl border-t"
|
? "surface-reading h-full rounded-t-xl border-t"
|
||||||
: "acrylic-panel rounded-2xl border"
|
: "acrylic-panel rounded-2xl border"
|
||||||
)}
|
)}
|
||||||
style={{ height }}
|
initial={false}
|
||||||
|
animate={{ height }}
|
||||||
|
transition={sizeTransition}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
|
"flex shrink-0 items-center gap-3 border-b border-slate-300/70 px-3",
|
||||||
mode === "compact" && !mobile ? "h-8" : "h-12"
|
mode === "compact" && !mobile ? "h-10" : "h-12"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
@@ -124,8 +133,16 @@ export function OperationalTimeline({
|
|||||||
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === "expanded" || mobile ? (
|
<AnimatePresence initial={false} mode="wait">
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-[52px_minmax(0,1fr)] px-3 pb-2 pt-1.5 sm:grid-cols-[76px_minmax(0,1fr)]">
|
{mode === "expanded" || mobile ? (
|
||||||
|
<motion.div
|
||||||
|
key="expanded-timeline"
|
||||||
|
className="grid min-h-0 flex-1 grid-cols-[52px_minmax(0,1fr)] px-3 pb-2 pt-1.5 sm:grid-cols-[76px_minmax(0,1fr)]"
|
||||||
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
|
||||||
|
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
||||||
|
>
|
||||||
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500">
|
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500">
|
||||||
<span />
|
<span />
|
||||||
{LANES.map((lane) => (
|
{LANES.map((lane) => (
|
||||||
@@ -150,11 +167,18 @@ export function OperationalTimeline({
|
|||||||
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
|
<span className="absolute -left-1 -top-1 h-2 w-2 rotate-45 bg-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative min-h-0 flex-1 px-3">
|
<motion.div
|
||||||
|
key="compact-timeline"
|
||||||
|
className="relative min-h-0 flex-1 px-3"
|
||||||
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
|
||||||
|
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
|
||||||
|
>
|
||||||
<CompactTimeTicks />
|
<CompactTimeTicks />
|
||||||
<div className="absolute bottom-1 left-3 right-3 top-3.5">
|
<div className="absolute bottom-2 left-3 right-3 top-4">
|
||||||
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
|
<div className="absolute inset-x-0 top-1/2 h-px bg-slate-300" />
|
||||||
{clusters.map((cluster, index) => (
|
{clusters.map((cluster, index) => (
|
||||||
<TimelineMarker
|
<TimelineMarker
|
||||||
@@ -171,9 +195,10 @@ export function OperationalTimeline({
|
|||||||
style={{ left: `${cursorPosition}%` }}
|
style={{ left: `${cursorPosition}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</AnimatePresence>
|
||||||
|
</motion.section>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -316,26 +341,35 @@ function TimelineActions({
|
|||||||
onReturnNow: () => void;
|
onReturnNow: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
<div className="surface-control ml-auto flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="返回现在"
|
title="返回现在"
|
||||||
aria-label="返回现在"
|
aria-label="返回现在"
|
||||||
onClick={onReturnNow}
|
onClick={onReturnNow}
|
||||||
className="surface-control grid h-8 w-8 place-items-center rounded-lg border text-slate-500 hover:text-blue-700"
|
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={13} aria-hidden="true" />
|
||||||
|
<span className="hidden sm:inline">现在</span>
|
||||||
</button>
|
</button>
|
||||||
{onModeChange ? (
|
{onModeChange ? (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<span className="mx-0.5 h-4 w-px bg-slate-300/80" aria-hidden="true" />
|
||||||
title={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
<button
|
||||||
aria-label={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
type="button"
|
||||||
onClick={() => onModeChange(mode === "expanded" ? "compact" : "expanded")}
|
title={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
||||||
className="surface-control grid h-8 w-8 place-items-center rounded-lg border text-slate-500 hover:text-blue-700"
|
aria-label={mode === "expanded" ? "收起时间轴" : "展开时间轴"}
|
||||||
>
|
onClick={() => onModeChange(mode === "expanded" ? "compact" : "expanded")}
|
||||||
{mode === "expanded" ? <ChevronDown size={15} /> : <ChevronUp size={15} />}
|
className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700"
|
||||||
</button>
|
>
|
||||||
|
{mode === "expanded" ? (
|
||||||
|
<PanelBottomClose size={14} aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<PanelBottomOpen size={14} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span>{mode === "expanded" ? "收起" : "展开"}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react";
|
import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { cn } from "@/shared/ui/cn";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
|
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
|
||||||
@@ -15,7 +16,7 @@ type WorkbenchMainFrameProps = {
|
|||||||
mapOverlay?: ReactNode;
|
mapOverlay?: ReactNode;
|
||||||
timelineContent: ReactNode;
|
timelineContent: ReactNode;
|
||||||
timelineMode: "compact" | "expanded" | "summary";
|
timelineMode: "compact" | "expanded" | "summary";
|
||||||
reserveMapScaleSpace: boolean;
|
timelineBottom: number;
|
||||||
activeArtifact: AnalysisArtifact | null;
|
activeArtifact: AnalysisArtifact | null;
|
||||||
pendingArtifact: AnalysisArtifact | null;
|
pendingArtifact: AnalysisArtifact | null;
|
||||||
surfaceMode: WorkbenchSurfaceMode;
|
surfaceMode: WorkbenchSurfaceMode;
|
||||||
@@ -32,7 +33,7 @@ export function WorkbenchMainFrame({
|
|||||||
mapOverlay,
|
mapOverlay,
|
||||||
timelineContent,
|
timelineContent,
|
||||||
timelineMode,
|
timelineMode,
|
||||||
reserveMapScaleSpace,
|
timelineBottom,
|
||||||
activeArtifact,
|
activeArtifact,
|
||||||
pendingArtifact,
|
pendingArtifact,
|
||||||
surfaceMode,
|
surfaceMode,
|
||||||
@@ -43,8 +44,12 @@ export function WorkbenchMainFrame({
|
|||||||
onCollapseArtifact,
|
onCollapseArtifact,
|
||||||
onDestroyArtifact
|
onDestroyArtifact
|
||||||
}: WorkbenchMainFrameProps) {
|
}: WorkbenchMainFrameProps) {
|
||||||
|
const prefersReducedMotion = useReducedMotion();
|
||||||
const showMap = surfaceMode !== "focus";
|
const showMap = surfaceMode !== "focus";
|
||||||
const showArtifact = activeArtifact && surfaceMode !== "map_only";
|
const showArtifact = activeArtifact && surfaceMode !== "map_only";
|
||||||
|
const timelineTransition = prefersReducedMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -113,18 +118,30 @@ export function WorkbenchMainFrame({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<motion.div
|
||||||
className="pointer-events-auto absolute bottom-3 left-3 z-40 mx-auto min-h-0"
|
className="pointer-events-auto absolute left-3 z-40 mx-auto min-h-0"
|
||||||
style={{
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
bottom: timelineBottom,
|
||||||
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
|
maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
|
||||||
right: reserveMapScaleSpace
|
right: 12
|
||||||
? OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth +
|
|
||||||
OPERATIONAL_TIMELINE_LAYOUT.floatingGap
|
|
||||||
: 12
|
|
||||||
}}
|
}}
|
||||||
|
transition={timelineTransition}
|
||||||
>
|
>
|
||||||
{timelineContent}
|
<AnimatePresence initial={false}>
|
||||||
</div>
|
{timelineContent ? (
|
||||||
|
<motion.div
|
||||||
|
key="operational-timeline"
|
||||||
|
initial={prefersReducedMotion ? false : { opacity: 0, y: 14, scale: 0.985 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.985 }}
|
||||||
|
transition={timelineTransition}
|
||||||
|
>
|
||||||
|
{timelineContent}
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user