Skip to content

Commit 1d9ffdd

Browse files
das7padtimja
andauthored
Center graph and scale to fit width (#1288)
Co-authored-by: Tim Jacomb <timjacomb1+github@gmail.com>
1 parent 2972283 commit 1d9ffdd

6 files changed

Lines changed: 1536 additions & 1485 deletions

File tree

src/main/frontend/pipeline-console-view/pipeline-console/main/components/stages.scss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,12 @@
9393
border: var(--jenkins-border-width) solid transparent;
9494
background-clip: padding-box;
9595
}
96+
97+
// Space for "Stages" overlay
98+
.pvg-stages-graph--spacing-top {
99+
padding-top: 26px;
100+
}
101+
// Spacing for "Expand" button
102+
.pvg-stages-graph--spacing-right {
103+
padding-right: 26px;
104+
}

src/main/frontend/pipeline-console-view/pipeline-console/main/components/stages.tsx

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import "./stages.scss";
22

33
import { useCallback, useState } from "react";
44
import {
5+
ReactZoomPanPinchContextState,
56
TransformComponent,
67
TransformWrapper,
78
useControls,
@@ -14,6 +15,8 @@ import { PipelineGraph } from "../../../../pipeline-graph-view/pipeline-graph/ma
1415
import { StageInfo } from "../../../../pipeline-graph-view/pipeline-graph/main/PipelineGraphModel.tsx";
1516
import { StageViewPosition } from "../providers/user-preference-provider.tsx";
1617

18+
const MAX_SCALE = 3;
19+
1720
export default function Stages({
1821
stages,
1922
selectedStage,
@@ -31,18 +34,18 @@ export default function Stages({
3134
[onStageSelect],
3235
);
3336

37+
const [initialScale, setInitialScale] = useState(1);
38+
const [minScale, setMinScale] = useState(0.75);
39+
3440
return (
3541
<div
3642
className={classNames("pgv-stages-graph", {
3743
"pgv-stages-graph--left": stageViewPosition === StageViewPosition.LEFT,
3844
"pgv-stages-graph--dialog": isExpanded,
45+
"pvg-stages-graph--spacing-top": onRunPage,
46+
"pvg-stages-graph--spacing-right": !onRunPage && !isExpanded,
3947
})}
4048
>
41-
{!onRunPage && (
42-
<div className={"pgv-stages-graph__controls pgv-stages-graph__heading"}>
43-
Graph
44-
</div>
45-
)}
4649
{onRunPage && (
4750
<a
4851
className={"pgv-stages-graph__controls pgv-stages-graph__heading"}
@@ -99,16 +102,19 @@ export default function Stages({
99102
</Tooltip>
100103
</div>
101104
<TransformWrapper
102-
minScale={0.75}
103-
maxScale={3}
105+
initialScale={initialScale}
106+
minScale={minScale}
107+
maxScale={MAX_SCALE}
104108
wheel={{ activationKeys: isExpanded ? [] : ["Control"] }}
105109
>
106-
<ZoomControls />
110+
<ZoomControls initialScale={initialScale} minScale={minScale} />
107111

108112
<TransformComponent wrapperStyle={{ width: "100%", height: "100%" }}>
109113
<PipelineGraph
110114
stages={stages}
111115
selectedStage={selectedStage}
116+
setInitialScale={setInitialScale}
117+
setMinScale={setMinScale}
112118
{...(onStageSelect && { onStageSelect: handleStageSelect })}
113119
/>
114120
</TransformComponent>
@@ -125,33 +131,27 @@ interface StagesProps {
125131
onRunPage?: boolean;
126132
}
127133

128-
function ZoomControls() {
129-
const { zoomIn, zoomOut, resetTransform } = useControls();
130-
const [buttonState, setButtonState] = useState({
131-
zoomIn: false,
132-
zoomOut: false,
133-
reset: true,
134-
});
135-
136-
useTransformEffect(({ state, instance }) => {
137-
const cantZoomIn = state.scale >= instance.props.maxScale!;
138-
const cantZoomOut = state.scale <= instance.props.minScale!;
139-
const cantReset = state.scale === 1;
134+
interface ZoomControlsProps {
135+
initialScale: number;
136+
minScale: number;
137+
}
140138

141-
setButtonState({
142-
zoomIn: cantZoomIn,
143-
zoomOut: cantZoomOut,
144-
reset: cantReset,
145-
});
146-
});
139+
function ZoomControls({ initialScale, minScale }: ZoomControlsProps) {
140+
const { zoomIn, zoomOut, centerView } = useControls();
141+
const [scale, setScale] = useState(initialScale);
142+
const handleTransformEffect = useCallback(
143+
(ref: ReactZoomPanPinchContextState) => setScale(ref.state.scale),
144+
[],
145+
);
146+
useTransformEffect(handleTransformEffect);
147147

148148
return (
149149
<div className="pgv-stages-graph__controls pgw-zoom-controls">
150150
<Tooltip content={"Zoom in"}>
151151
<button
152152
className={"jenkins-button jenkins-button--tertiary"}
153153
onClick={() => zoomIn()}
154-
disabled={buttonState.zoomIn}
154+
disabled={scale >= MAX_SCALE}
155155
>
156156
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
157157
<path
@@ -169,7 +169,7 @@ function ZoomControls() {
169169
<button
170170
className={"jenkins-button jenkins-button--tertiary"}
171171
onClick={() => zoomOut()}
172-
disabled={buttonState.zoomOut}
172+
disabled={scale <= minScale}
173173
>
174174
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
175175
<path
@@ -186,8 +186,8 @@ function ZoomControls() {
186186
<Tooltip content={"Reset"}>
187187
<button
188188
className={"jenkins-button jenkins-button--tertiary"}
189-
onClick={() => resetTransform()}
190-
disabled={buttonState.reset}
189+
onClick={() => centerView(initialScale)}
190+
disabled={scale === initialScale}
191191
>
192192
<svg className="ionicon" viewBox="0 0 512 512">
193193
<path

src/main/frontend/pipeline-graph-view/pipeline-graph/main/NestedPipelineGraphLayout.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function nestedGraphLayout(
2222
showDurations: boolean,
2323
maxColumnsWhenCollapsed: number = DEFAULT_MAX_COLUMNS_WHEN_COLLAPSED,
2424
): PositionedGraph {
25-
const graphSpacingX = layout.nodeSpacingH / 2;
25+
const graphSpacingX = layout.nodeSpacingH / 4;
2626
const startEndReducedSpacing = Math.floor(layout.nodeSpacingH * 0.3);
2727
const root: GraphNode = {
2828
...baseGraphNode(layout),
@@ -59,10 +59,8 @@ export function nestedGraphLayout(
5959
buildGraphNested(root, stages, layout, false);
6060
}
6161

62-
root.y = Math.max(
63-
layout.ypStart,
64-
root.shiftY + (showNames ? layout.nodeRadius + layout.labelOffsetV : 0),
65-
);
62+
root.y =
63+
root.shiftY + (showNames ? layout.nodeRadius + layout.labelOffsetV : 0);
6664
root.children.push({
6765
...baseGraphNode(layout, showNames),
6866
width: graphSpacingX,
@@ -115,6 +113,7 @@ function roundToMultipleOf(n: number, multiple: number): number {
115113
}
116114

117115
function centerOfNode(node: GraphNode, layout: LayoutInfo) {
116+
if (node.isPlaceholder) return node.x;
118117
return (
119118
node.x +
120119
roundToMultipleOf(

src/main/frontend/pipeline-graph-view/pipeline-graph/main/PipelineGraph.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export function PipelineGraph({
5151
selectedStage,
5252
collapsed,
5353
onStageSelect,
54+
setMinScale,
55+
setInitialScale,
5456
}: Props) {
5557
const fullLayout = useMemo(() => {
5658
return {
@@ -144,10 +146,50 @@ export function PipelineGraph({
144146
[selectedStage],
145147
);
146148

149+
const transform = useContext(TransformContext);
150+
const [transformWidth, setTransformWidth] = useState<number>(0);
151+
useEffect(() => {
152+
if (!transform?.wrapperComponent) return;
153+
const observer = new ResizeObserver((entries) => {
154+
for (const entry of entries) {
155+
setTransformWidth(entry.contentRect.width);
156+
}
157+
});
158+
observer.observe(transform.wrapperComponent);
159+
return () => observer.disconnect();
160+
}, [transform?.wrapperComponent]);
161+
162+
const [fitToWidth, setFitToWidth] = useState(true);
163+
useEffect(() => {
164+
if (!setMinScale || !setInitialScale || !transform) return;
165+
const initialScale = Math.min(1, transformWidth / measuredWidth);
166+
const minScale = initialScale * 0.75;
167+
setMinScale(minScale);
168+
setInitialScale(initialScale);
169+
if (fitToWidth) {
170+
// Don't scale too small by default.
171+
const autoScale = Math.max(initialScale, 0.5);
172+
const centerOffset = Math.max(0, (transformWidth - measuredWidth) / 2);
173+
if (
174+
transform.state.scale !== autoScale ||
175+
transform.state.positionX !== centerOffset
176+
) {
177+
transform.setState(autoScale, centerOffset, 0);
178+
}
179+
return transform.onChange(() => setFitToWidth(false));
180+
}
181+
}, [
182+
transform,
183+
transformWidth,
184+
fitToWidth,
185+
measuredWidth,
186+
setMinScale,
187+
setInitialScale,
188+
]);
189+
147190
// When inside a TransformWrapper, only mount the nodes/labels intersecting
148191
// the visible region. Mounting thousands of absolute-positioned divs forces
149192
// a synchronous layout flush that blocks the main thread for seconds.
150-
const transform = useContext(TransformContext);
151193
const virtualize = transform != null;
152194
const [viewport, setViewport] = useState<Viewport | null>(null);
153195
const cachedViewport = useRef<Viewport | null>(null);
@@ -331,4 +373,6 @@ interface Props {
331373
selectedStage?: StageInfo;
332374
collapsed?: boolean;
333375
onStageSelect?: (nodeId: string) => void;
376+
setMinScale?: (value: number) => void;
377+
setInitialScale?: (value: number) => void;
334378
}

0 commit comments

Comments
 (0)