fix: align scale info with floating timeline

This commit is contained in:
2026-08-19 16:45:35 +08:00
parent 45f1ebee7e
commit 3db95c27b5
5 changed files with 65 additions and 15 deletions
+19 -7
View File
@@ -32,6 +32,7 @@ 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";
@@ -40,8 +41,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,
type OperationalEvent, type OperationalEvent,
type OperationalTimelineMode type OperationalTimelineMode
} from "./operational-timeline/operational-timeline-model"; } from "./operational-timeline/operational-timeline-model";
@@ -213,6 +216,11 @@ export function MapWorkbenchPage({
() => getOperationalEventSummary(operationalEvents), () => getOperationalEventSummary(operationalEvents),
[operationalEvents] [operationalEvents]
); );
const scaleInfoBesideTimeline = canDockMapScaleBesideTimeline(
businessWorkspaceWidth,
timelineMode,
timelineVisible
);
useEffect(() => { useEffect(() => {
setTimelineMode((current) => { setTimelineMode((current) => {
@@ -919,12 +927,8 @@ export function MapWorkbenchPage({
!timelineVisible !timelineVisible
? 0 ? 0
: !isLargeScreen : !isLargeScreen
? 48 ? OPERATIONAL_TIMELINE_LAYOUT.mobileSummaryHeight
: timelineMode === "expanded" : OPERATIONAL_TIMELINE_LAYOUT[timelineMode].height
? 240
: timelineMode === "summary"
? 52
: 72
}px` }px`
} as CSSProperties } as CSSProperties
} }
@@ -971,6 +975,7 @@ export function MapWorkbenchPage({
onCollapseArtifact={artifactWorkspace.collapseArtifact} onCollapseArtifact={artifactWorkspace.collapseArtifact}
onDestroyArtifact={artifactWorkspace.destroyArtifact} onDestroyArtifact={artifactWorkspace.destroyArtifact}
timelineMode={timelineMode} timelineMode={timelineMode}
reserveMapScaleSpace={scaleInfoBesideTimeline}
timelineContent={( timelineContent={(
timelineVisible ? <> timelineVisible ? <>
<div className="hidden lg:block"> <div className="hidden lg:block">
@@ -1016,7 +1021,14 @@ export function MapWorkbenchPage({
<div className="pointer-events-auto absolute right-16 top-3 hidden lg:block"> <div className="pointer-events-auto absolute right-16 top-3 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} /> <ToolbarPanel {...toolbarPanelProps} />
</div> </div>
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1rem)] right-0 flex flex-col items-end gap-2"> <div
className={cn(
"absolute right-0 flex flex-col items-end gap-2",
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>
@@ -1,6 +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 { clusterOperationalEvents, createOperationalEvents } from "./operational-timeline-model"; import {
canDockMapScaleBesideTimeline,
clusterOperationalEvents,
createOperationalEvents
} from "./operational-timeline-model";
const workOrder: ScheduledConditionItem = { const workOrder: ScheduledConditionItem = {
id: "work-1", kind: "work_order", code: "WO-1", scheduledAt: "2026-08-19T10:00:00.000Z", id: "work-1", kind: "work_order", code: "WO-1", scheduledAt: "2026-08-19T10:00:00.000Z",
@@ -39,4 +43,10 @@ describe("operational timeline model", () => {
expect(clusters).toHaveLength(2); expect(clusters).toHaveLength(2);
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", () => {
expect(canDockMapScaleBesideTimeline(2048, "expanded", true)).toBe(true);
expect(canDockMapScaleBesideTimeline(1380, "expanded", true)).toBe(false);
expect(canDockMapScaleBesideTimeline(1380, "expanded", false)).toBe(true);
});
}); });
@@ -30,6 +30,28 @@ export type OperationalEventCluster = {
events: OperationalEvent[]; events: OperationalEvent[];
}; };
export const OPERATIONAL_TIMELINE_LAYOUT = {
compact: { height: 72, maxWidth: 980 },
expanded: { height: 240, maxWidth: 1180 },
summary: { height: 52, maxWidth: 760 },
mobileSummaryHeight: 48,
mapScaleSafeWidth: 520,
floatingGap: 48
} as const;
export function canDockMapScaleBesideTimeline(
businessWorkspaceWidth: number,
mode: OperationalTimelineMode,
visible: boolean
) {
if (!visible) return true;
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
return businessWorkspaceWidth >=
layout.maxWidth +
OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth +
OPERATIONAL_TIMELINE_LAYOUT.floatingGap;
}
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] { export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
return items.flatMap((item) => return items.flatMap((item) =>
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item) item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
@@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shar
import { import {
clusterOperationalEvents, clusterOperationalEvents,
getOperationalEventSummary, getOperationalEventSummary,
OPERATIONAL_TIMELINE_LAYOUT,
type OperationalEvent, type OperationalEvent,
type OperationalEventCluster, type OperationalEventCluster,
type OperationalEventLane, type OperationalEventLane,
@@ -54,7 +55,7 @@ export function OperationalTimeline({
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%" : mode === "expanded" ? 240 : mode === "summary" ? 52 : 72; const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
if (mode === "summary" && !mobile) { if (mode === "summary" && !mobile) {
return ( return (
@@ -1,6 +1,7 @@
import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react"; import { ArrowLeft, FileChartColumnIncreasing } from "lucide-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 { AnalysisArtifactWorkspace } from "./analysis-document-view"; import { AnalysisArtifactWorkspace } from "./analysis-document-view";
import { ArtifactPeekBar } from "./artifact-peek-bar"; import { ArtifactPeekBar } from "./artifact-peek-bar";
import type { import type {
@@ -14,6 +15,7 @@ type WorkbenchMainFrameProps = {
mapOverlay?: ReactNode; mapOverlay?: ReactNode;
timelineContent: ReactNode; timelineContent: ReactNode;
timelineMode: "compact" | "expanded" | "summary"; timelineMode: "compact" | "expanded" | "summary";
reserveMapScaleSpace: boolean;
activeArtifact: AnalysisArtifact | null; activeArtifact: AnalysisArtifact | null;
pendingArtifact: AnalysisArtifact | null; pendingArtifact: AnalysisArtifact | null;
surfaceMode: WorkbenchSurfaceMode; surfaceMode: WorkbenchSurfaceMode;
@@ -30,6 +32,7 @@ export function WorkbenchMainFrame({
mapOverlay, mapOverlay,
timelineContent, timelineContent,
timelineMode, timelineMode,
reserveMapScaleSpace,
activeArtifact, activeArtifact,
pendingArtifact, pendingArtifact,
surfaceMode, surfaceMode,
@@ -111,12 +114,14 @@ export function WorkbenchMainFrame({
</div> </div>
<div <div
className={cn( className="pointer-events-auto absolute bottom-3 left-3 z-40 mx-auto min-h-0"
"pointer-events-auto absolute bottom-3 left-3 right-3 z-40 mx-auto min-h-0 w-[calc(100%-1.5rem)]", style={{
timelineMode === "expanded" && "max-w-[1180px]", maxWidth: OPERATIONAL_TIMELINE_LAYOUT[timelineMode].maxWidth,
timelineMode === "compact" && "max-w-[980px]", right: reserveMapScaleSpace
timelineMode === "summary" && "max-w-[760px]" ? OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth +
)} OPERATIONAL_TIMELINE_LAYOUT.floatingGap
: 12
}}
> >
{timelineContent} {timelineContent}
</div> </div>