implement fit workflow function

This commit is contained in:
Laila Los
2025-01-23 14:51:14 +01:00
parent 4a2bdac98b
commit 501e312ac2
8 changed files with 305 additions and 186 deletions
@@ -158,6 +158,7 @@
</div>
<WorkflowGraph
v-if="!datatypesMapperLoading"
ref="workflowGraph"
:steps="steps"
:datatypes-mapper="datatypesMapper"
:highlight-id="highlightId"
@@ -193,7 +194,7 @@
import { library } from "@fortawesome/fontawesome-svg-core";
import { faArrowLeft, faArrowRight, faCog, faHistory, faSave, faTimes } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { whenever } from "@vueuse/core";
import { until, whenever } from "@vueuse/core";
import { logicAnd, logicNot, logicOr } from "@vueuse/math";
import { Toast } from "composables/toast";
import { storeToRefs } from "pinia";
@@ -297,6 +298,7 @@ export default {
whenever(redoKeys, redo);
const activityBar = ref(null);
const workflowGraph = ref(null);
const reportActive = computed(() => activityBar.value?.isActiveSideBar("workflow-editor-report"));
const parameters = ref(null);
@@ -483,6 +485,7 @@ export default {
id,
name,
parameters,
workflowGraph,
ensureParametersSet,
showAttributes,
setName,
@@ -996,6 +999,8 @@ export default {
await this.resetStores();
this.onWorkflowMessage("Loading workflow...", "progress");
console.log("loading");
try {
const data = await this.lastQueue.enqueue(loadWorkflow, { id, version });
await fromSimple(id, data);
@@ -1003,6 +1008,11 @@ export default {
} catch (e) {
this.onWorkflowError("Loading workflow failed...", e);
}
await until(() => this.datatypesMapperLoading).toBe(false);
await nextTick();
this.workflowGraph.fitWorkflow();
}
},
onLicense(license) {
@@ -1,10 +1,168 @@
<script setup lang="ts">
import { useElementBounding, useScroll } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { computed, type PropType, provide, reactive, type Ref, ref, watch, watchEffect } from "vue";
import { DatatypesMapperModel } from "@/components/Datatypes/model";
import { useWorkflowStores } from "@/composables/workflowStores";
import type { TerminalPosition, XYPosition } from "@/stores/workflowEditorStateStore";
import type { Step } from "@/stores/workflowStepStore";
import { assertDefined } from "@/utils/assertions";
import { useD3Zoom } from "./composables/d3Zoom";
import { useViewportBoundingBox } from "./composables/viewportBoundingBox";
import { useWorkflowBoundingBox } from "./composables/workflowBoundingBox";
import type { Vector } from "./modules/geometry";
import type { OutputTerminals } from "./modules/terminals";
import { maxZoom, minZoom } from "./modules/zoomLevels";
import AdaptiveGrid from "./AdaptiveGrid.vue";
import WorkflowComment from "./Comments/WorkflowComment.vue";
import BoxSelectPreview from "./Tools/BoxSelectPreview.vue";
import InputCatcher from "./Tools/InputCatcher.vue";
import ToolBar from "./Tools/ToolBar.vue";
import WorkflowNode from "@/components/Workflow/Editor/Node.vue";
import WorkflowEdges from "@/components/Workflow/Editor/WorkflowEdges.vue";
import WorkflowMinimap from "@/components/Workflow/Editor/WorkflowMinimap.vue";
import ZoomControl from "@/components/Workflow/Editor/ZoomControl.vue";
const emit = defineEmits(["transform", "graph-offset", "onRemove", "scrollTo", "stepClicked"]);
const props = defineProps({
steps: { type: Object as PropType<{ [index: string]: Step }>, required: true },
datatypesMapper: { type: DatatypesMapperModel, required: true },
highlightId: { type: Number as PropType<number | null>, default: null },
scrollToId: { type: Number as PropType<number | null>, default: null },
readonly: { type: Boolean, default: false },
initialPosition: { type: Object as PropType<{ x: number; y: number }>, default: () => ({ x: 50, y: 20 }) },
isInvocation: { type: Boolean, default: false },
showMinimap: { type: Boolean, default: true },
showZoomControls: { type: Boolean, default: true },
});
const { stateStore, stepStore } = useWorkflowStores();
const { scale, activeNodeId, draggingPosition, draggingTerminal } = storeToRefs(stateStore);
const canvas: Ref<HTMLElement | null> = ref(null);
const elementBounding = useElementBounding(canvas, { windowResize: false, windowScroll: false });
const scroll = useScroll(canvas);
const { transform, panBy, setZoom, moveTo } = useD3Zoom(
scale.value,
minZoom,
maxZoom,
canvas,
scroll,
props.initialPosition
);
const { viewportBoundingBox, updateViewportBaseBoundingBox } = useViewportBoundingBox(
elementBounding,
scale,
transform
);
const { getWorkflowBoundingBox } = useWorkflowBoundingBox();
function fitWorkflow(minimumFitZoom = 0.5, maximumFitZoom = 1.0, padding = 50.0) {
const aabb = getWorkflowBoundingBox();
aabb.expand(padding);
const transform = updateViewportBaseBoundingBox().transformTo(aabb);
const scale = Math.min(transform.scaleX, transform.scaleY);
const clampedScale = Math.max(minimumFitZoom, Math.min(maximumFitZoom, scale));
zoomTo(clampedScale);
moveTo({ x: aabb.x, y: aabb.y }, [0, 0]);
}
const isDragging = ref(false);
provide("isDragging", isDragging);
provide("transform", transform);
watch(
() => props.scrollToId,
() => {
if (props.scrollToId !== null) {
const scrollToPosition = stateStore.stepPosition[props.scrollToId];
const step = stepStore.getStep(props.scrollToId);
assertDefined(scrollToPosition);
assertDefined(step);
const { width: stepWidth, height: stepHeight } = scrollToPosition;
const { position: stepPosition } = step;
if (stepPosition) {
const { width, height } = reactive(elementBounding);
const centerScreenX = width / 2;
const centerScreenY = height / 2;
const offsetX = centerScreenX - (stepPosition.left + stepWidth / 2);
const offsetY = centerScreenY - (stepPosition.top + stepHeight / 2);
zoomTo(1, { x: offsetX, y: offsetY });
} else {
console.log("Step has no position");
}
emit("scrollTo");
}
}
);
function zoomTo(zoomLevel: number, panTo: XYPosition | null = null, origin?: Vector) {
setZoom(zoomLevel, origin);
if (panTo) {
panBy({ x: panTo.x - transform.value.x, y: panTo.y - transform.value.y });
}
stateStore.scale = zoomLevel;
}
function onStopDragging() {
stateStore.draggingPosition = null;
stateStore.draggingTerminal = null;
isDragging.value = false;
}
function onDragConnector(position: TerminalPosition, draggingTerminal: OutputTerminals) {
stateStore.draggingPosition = position;
stateStore.draggingTerminal = draggingTerminal;
isDragging.value = true;
}
function onActivate(nodeId: number | null) {
emit("stepClicked", nodeId);
if (activeNodeId.value !== nodeId) {
stateStore.activeNodeId = nodeId;
}
}
function onDeactivate() {
stateStore.activeNodeId = null;
}
watch(
() => transform.value.k,
() => (stateStore.scale = transform.value.k)
);
watch(transform, () => emit("transform", transform.value));
watchEffect(() => {
emit("graph-offset", reactive(elementBounding));
});
const canvasStyle = computed(() => {
return { transform: `translate(${transform.value.x}px, ${transform.value.y}px) scale(${transform.value.k})` };
});
const { commentStore } = useWorkflowStores();
const { comments } = storeToRefs(commentStore);
defineExpose({
fitWorkflow,
setZoom,
moveTo,
});
</script>
<template>
<div id="workflow-canvas" class="unified-panel-body workflow-canvas">
<ZoomControl
v-if="props.showZoomControls"
:zoom-level="scale"
:pan="transform"
@onZoom="onZoom"
@onZoom="zoomTo"
@update:pan="panBy" />
<ToolBar v-if="!readonly" />
<div
@@ -67,142 +225,6 @@
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { useElementBounding, useScroll } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { computed, type PropType, provide, reactive, type Ref, ref, watch, watchEffect } from "vue";
import { DatatypesMapperModel } from "@/components/Datatypes/model";
import { useWorkflowStores } from "@/composables/workflowStores";
import type { TerminalPosition, XYPosition } from "@/stores/workflowEditorStateStore";
import type { Step } from "@/stores/workflowStepStore";
import { assertDefined } from "@/utils/assertions";
import { useD3Zoom } from "./composables/d3Zoom";
import { useViewportBoundingBox } from "./composables/viewportBoundingBox";
import type { OutputTerminals } from "./modules/terminals";
import { maxZoom, minZoom } from "./modules/zoomLevels";
import AdaptiveGrid from "./AdaptiveGrid.vue";
import WorkflowComment from "./Comments/WorkflowComment.vue";
import BoxSelectPreview from "./Tools/BoxSelectPreview.vue";
import InputCatcher from "./Tools/InputCatcher.vue";
import ToolBar from "./Tools/ToolBar.vue";
import WorkflowNode from "@/components/Workflow/Editor/Node.vue";
import WorkflowEdges from "@/components/Workflow/Editor/WorkflowEdges.vue";
import WorkflowMinimap from "@/components/Workflow/Editor/WorkflowMinimap.vue";
import ZoomControl from "@/components/Workflow/Editor/ZoomControl.vue";
const emit = defineEmits(["transform", "graph-offset", "onRemove", "scrollTo", "stepClicked"]);
const props = defineProps({
steps: { type: Object as PropType<{ [index: string]: Step }>, required: true },
datatypesMapper: { type: DatatypesMapperModel, required: true },
highlightId: { type: Number as PropType<number | null>, default: null },
scrollToId: { type: Number as PropType<number | null>, default: null },
readonly: { type: Boolean, default: false },
initialPosition: { type: Object as PropType<{ x: number; y: number }>, default: () => ({ x: 50, y: 20 }) },
isInvocation: { type: Boolean, default: false },
showMinimap: { type: Boolean, default: true },
showZoomControls: { type: Boolean, default: true },
});
const { stateStore, stepStore } = useWorkflowStores();
const { scale, activeNodeId, draggingPosition, draggingTerminal } = storeToRefs(stateStore);
const canvas: Ref<HTMLElement | null> = ref(null);
const elementBounding = useElementBounding(canvas, { windowResize: false, windowScroll: false });
const scroll = useScroll(canvas);
const { transform, panBy, setZoom, moveTo } = useD3Zoom(
scale.value,
minZoom,
maxZoom,
canvas,
scroll,
props.initialPosition
);
defineExpose({
setZoom,
moveTo,
});
const { viewportBoundingBox } = useViewportBoundingBox(elementBounding, scale, transform);
const isDragging = ref(false);
provide("isDragging", isDragging);
provide("transform", transform);
watch(
() => props.scrollToId,
() => {
if (props.scrollToId !== null) {
const scrollToPosition = stateStore.stepPosition[props.scrollToId];
const step = stepStore.getStep(props.scrollToId);
assertDefined(scrollToPosition);
assertDefined(step);
const { width: stepWidth, height: stepHeight } = scrollToPosition;
const { position: stepPosition } = step;
if (stepPosition) {
const { width, height } = reactive(elementBounding);
const centerScreenX = width / 2;
const centerScreenY = height / 2;
const offsetX = centerScreenX - (stepPosition.left + stepWidth / 2);
const offsetY = centerScreenY - (stepPosition.top + stepHeight / 2);
onZoom(1, { x: offsetX, y: offsetY });
} else {
console.log("Step has no position");
}
emit("scrollTo");
}
}
);
function onZoom(zoomLevel: number, panTo: XYPosition | null = null) {
setZoom(zoomLevel);
if (panTo) {
panBy({ x: panTo.x - transform.value.x, y: panTo.y - transform.value.y });
}
stateStore.scale = zoomLevel;
}
function onStopDragging() {
stateStore.draggingPosition = null;
stateStore.draggingTerminal = null;
isDragging.value = false;
}
function onDragConnector(position: TerminalPosition, draggingTerminal: OutputTerminals) {
stateStore.draggingPosition = position;
stateStore.draggingTerminal = draggingTerminal;
isDragging.value = true;
}
function onActivate(nodeId: number | null) {
emit("stepClicked", nodeId);
if (activeNodeId.value !== nodeId) {
stateStore.activeNodeId = nodeId;
}
}
function onDeactivate() {
stateStore.activeNodeId = null;
}
watch(
() => transform.value.k,
() => (stateStore.scale = transform.value.k)
);
watch(transform, () => emit("transform", transform.value));
watchEffect(() => {
emit("graph-offset", reactive(elementBounding));
});
const canvasStyle = computed(() => {
return { transform: `translate(${transform.value.x}px, ${transform.value.y}px) scale(${transform.value.k})` };
});
const { commentStore } = useWorkflowStores();
const { comments } = storeToRefs(commentStore);
</script>
<style scoped land="scss">
.workflow-canvas {
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useDraggable, type UseElementBoundingReturn } from "@vueuse/core";
import type { Ref } from "vue";
import { computed, onMounted, ref, unref, watch } from "vue";
import { computed, nextTick, onMounted, ref, unref, watch } from "vue";
import { useAnimationFrame } from "@/composables/sensors/animationFrame";
import { useAnimationFrameThrottle } from "@/composables/throttle";
@@ -15,8 +15,9 @@ import type {
} from "@/stores/workflowEditorCommentStore";
import type { Step, Steps } from "@/stores/workflowStepStore";
import { useWorkflowBoundingBox } from "./composables/workflowBoundingBox";
import { drawBoxComments, drawFreehandComments, drawSteps } from "./modules/canvasDraw";
import { AxisAlignedBoundingBox, Transform } from "./modules/geometry";
import { type AxisAlignedBoundingBox, Transform } from "./modules/geometry";
const props = defineProps<{
steps: Steps;
@@ -47,37 +48,15 @@ watch(
{ deep: true }
);
/** bounding box encompassing all nodes in the workflow */
const aabb = new AxisAlignedBoundingBox();
const { getWorkflowBoundingBox } = useWorkflowBoundingBox();
let aabbChanged = false;
/** transform mapping workflow coordinates to minimap coordinates */
let canvasTransform = new Transform();
function recalculateAABB() {
aabb.reset();
Object.values(props.steps).forEach((step) => {
const rect = stateStore.stepPosition[step.id];
if (rect) {
aabb.fitRectangle({
x: step.position!.left,
y: step.position!.top,
width: rect.width,
height: rect.height,
});
}
});
props.comments.forEach((comment) => {
aabb.fitRectangle({
x: comment.position[0],
y: comment.position[1],
width: comment.size[0],
height: comment.size[1],
});
});
const aabb = getWorkflowBoundingBox();
aabb.squareCenter();
aabb.expand(120);
@@ -119,7 +98,7 @@ const size = {
border: 0,
};
onMounted(() => {
onMounted(async () => {
const element = canvas.value!;
const style = getComputedStyle(element);
@@ -135,6 +114,8 @@ onMounted(() => {
size.padding = parseInt(style.getPropertyValue("--workflow-overview-padding"));
size.border = parseInt(style.getPropertyValue("--workflow-overview-border"));
await nextTick();
recalculateAABB();
redraw = true;
});
@@ -278,7 +259,7 @@ const scaleFactor = computed(() => size.max / minimapSize.value);
let dragViewport = false;
useDraggable(canvas, {
onStart: (position, event) => {
onStart: (_position, event) => {
// minimap coordinates to global coordinates
const [x, y] = canvasTransform
.inverse()
@@ -289,7 +270,7 @@ useDraggable(canvas, {
dragViewport = true;
}
},
onMove: (position, event) => {
onMove: (_position, event) => {
dragThrottle(() => {
if (!dragViewport || Object.values(props.steps).length === 0) {
return;
@@ -305,7 +286,7 @@ useDraggable(canvas, {
emit("panBy", { x, y });
});
},
onEnd(position, event) {
onEnd(_position, event) {
// minimap coordinates to global coordinates
const [x, y] = canvasTransform
.inverse()
@@ -3,6 +3,7 @@ import { select } from "d3-selection";
import { type D3ZoomEvent, zoom, zoomIdentity } from "d3-zoom";
import { type Ref, ref, watch } from "vue";
import type { Vector } from "@/components/Workflow/Editor/modules/geometry";
import { type XYPosition } from "@/stores/workflowEditorStateStore";
// if element is draggable it may implement its own drag handler,
@@ -58,10 +59,10 @@ export function useD3Zoom(
});
});
function setZoom(k: number) {
function setZoom(k: number, p?: Vector) {
if (targetRef.value) {
const d3Selection = select(targetRef.value).call(d3Zoom);
d3Zoom.scaleTo(d3Selection, k);
d3Zoom.scaleTo(d3Selection, k, p);
}
}
@@ -72,10 +73,10 @@ export function useD3Zoom(
}
}
function moveTo(coordinate: XYPosition) {
function moveTo(coordinate: XYPosition, p?: Vector) {
if (targetRef.value) {
const d3Selection = select(targetRef.value).call(d3Zoom);
d3Zoom.translateTo(d3Selection, coordinate.x, coordinate.y);
d3Zoom.translateTo(d3Selection, coordinate.x, coordinate.y, p);
}
}
@@ -10,39 +10,61 @@ import { AxisAlignedBoundingBox } from "../modules/geometry";
* animation frame intervals.
*
* @param bounds vueuse bounding box
* @param scale scale to apply to bounding box
* @param zoom zoom to apply to bounding box
* @param pan pan to apply to bounding box
* @returns Axis Aligned Bounding Box following the viewport
*/
export function useViewportBoundingBox(
bounds: UseElementBoundingReturn,
scale: Ref<number>,
zoom: Ref<number>,
pan: Ref<{ x: number; y: number }>
) {
const { throttle } = useAnimationFrameThrottle(100);
const viewportBaseBoundingBox = ref(new AxisAlignedBoundingBox());
const viewportBoundingBox = ref(new AxisAlignedBoundingBox());
const updateViewportBaseBoundingBox = () => {
const width = unref(bounds.width);
const height = unref(bounds.height);
viewportBaseBoundingBox.value.x = 0;
viewportBaseBoundingBox.value.y = 0;
viewportBaseBoundingBox.value.width = width;
viewportBaseBoundingBox.value.height = height;
return viewportBaseBoundingBox.value;
};
const updateViewportBoundingBox = () => {
const { width, height } = updateViewportBaseBoundingBox();
const x = unref(pan).x;
const y = unref(pan).y;
const scaleValue = 1.0 / unref(zoom);
viewportBoundingBox.value.move([-x * scaleValue, -y * scaleValue]);
viewportBoundingBox.value.width = width * scaleValue;
viewportBoundingBox.value.height = height * scaleValue;
return viewportBoundingBox.value;
};
watch(
() => ({
x: unref(pan).x,
y: unref(pan).y,
scale: unref(scale),
scale: unref(zoom),
width: unref(bounds.width),
height: unref(bounds.height),
}),
({ x, y, scale, width, height }) => {
() => {
throttle(() => {
const bounds = viewportBoundingBox.value;
bounds.x = -x / scale;
bounds.y = -y / scale;
bounds.width = width / scale;
bounds.height = height / scale;
viewportBoundingBox.value = bounds;
updateViewportBoundingBox();
});
}
);
return { viewportBoundingBox };
return { viewportBoundingBox, updateViewportBoundingBox, viewportBaseBoundingBox, updateViewportBaseBoundingBox };
}
@@ -0,0 +1,39 @@
import { useWorkflowStores } from "@/composables/workflowStores";
import { AxisAlignedBoundingBox } from "../modules/geometry";
export function useWorkflowBoundingBox(workflowId?: string) {
const { stepStore, stateStore, commentStore } = useWorkflowStores(workflowId);
const getWorkflowBoundingBox = () => {
const aabb = new AxisAlignedBoundingBox();
Object.values(stepStore.steps).forEach((step) => {
const rect = stateStore.stepPosition[step.id];
if (rect) {
aabb.fitRectangle({
x: step.position!.left,
y: step.position!.top,
width: rect.width,
height: rect.height,
});
}
});
commentStore.comments.forEach((comment) => {
aabb.fitRectangle({
x: comment.position[0],
y: comment.position[1],
width: comment.size[0],
height: comment.size[1],
});
});
return aabb;
};
return {
getWorkflowBoundingBox,
};
}
@@ -117,6 +117,31 @@ export class AxisAlignedBoundingBox implements Rectangle {
this.endY > other.y
);
}
move(position: Vector) {
const storedWidth = this.width;
const storedHeight = this.height;
this.x = position[0];
this.y = position[1];
this.width = storedWidth;
this.height = storedHeight;
}
scale(factor: number) {
this.width *= factor;
this.height *= factor;
}
transformTo(other: Rectangle): Transform {
const scale: Vector = [this.width / other.width, this.height / other.height];
const offset: Vector = [other.x - this.x, other.y - this.y];
const transform = new Transform().scale(scale).translate(offset);
return transform;
}
}
/* Format
@@ -218,6 +243,14 @@ export class Transform {
get scaleY() {
return this.matrix[3];
}
get offsetX() {
return this.matrix[4];
}
get offsetY() {
return this.matrix[5];
}
}
/** returns a vector constructed of both vectors smaller coordinates */
@@ -318,6 +318,16 @@ export const useWorkflowCommentStore = defineScopedStore("workflowCommentStore",
});
}
function allCommentBounds() {
const bounds = new AxisAlignedBoundingBox();
comments.value.forEach((frame) => {
bounds.fitRectangle(commentToRectangle(frame));
});
return bounds;
}
return {
commentsRecord,
comments,
@@ -342,5 +352,6 @@ export const useWorkflowCommentStore = defineScopedStore("workflowCommentStore",
resolveCommentsInFrames,
resolveStepsInFrames,
$reset,
allCommentBounds,
};
});