Merge pull request #21932 from guerler/graph.000

This commit is contained in:
Marius van den Beek
2026-05-02 16:21:46 +02:00
committed by GitHub
49 changed files with 4317 additions and 268 deletions
+138
View File
@@ -2855,6 +2855,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/histories/{history_id}/graph": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Returns a history-scoped structural graph. */
get: operations["graph_api_histories__history_id__graph_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/histories/{history_id}/jobs_summary": {
parameters: {
query?: never;
@@ -13106,6 +13123,46 @@ export interface components {
*/
type: "genomebuild";
};
/** GraphEdge */
GraphEdge: {
/** Source */
source: string;
/** Target */
target: string;
/**
* Type
* @enum {string}
*/
type: "dataset_input" | "dataset_output" | "collection_input" | "collection_output";
};
/** GraphNode */
GraphNode: {
/** Collection Type */
collection_type?: string | null;
/** Deleted */
deleted?: boolean | null;
/** Extension */
extension?: string | null;
/** Hid */
hid?: number | null;
/** Id */
id: string;
/** Name */
name?: string | null;
/** State */
state?: string | null;
/** Tool Id */
tool_id?: string | null;
/** Tool Name */
tool_name?: string | null;
/**
* Type
* @enum {string}
*/
type: "dataset" | "collection" | "tool_request";
/** Visible */
visible?: boolean | null;
};
/**
* GroupCreatePayload
* @description Payload schema for creating a group.
@@ -15376,6 +15433,14 @@ export interface components {
*/
username_and_slug?: string | null;
};
/** HistoryGraphResponse */
HistoryGraphResponse: {
/** Edges */
edges: components["schemas"]["GraphEdge"][];
/** Nodes */
nodes: components["schemas"]["GraphNode"][];
truncated: components["schemas"]["TruncationInfo"];
};
/**
* HistorySummary
* @description History summary information.
@@ -23730,6 +23795,22 @@ export interface components {
*/
title?: string | null;
};
/** TruncationInfo */
TruncationInfo: {
/**
* Item Count Capped
* @default false
*/
item_count_capped: boolean;
/**
* Scope Type
* @default recent
* @enum {string}
*/
scope_type: "recent" | "seed_centered";
/** Seed In Scope */
seed_in_scope?: boolean | null;
};
/** UndeleteHistoriesPayload */
UndeleteHistoriesPayload: {
/**
@@ -39149,6 +39230,63 @@ export interface operations {
};
};
};
graph_api_histories__history_id__graph_get: {
parameters: {
query?: {
/** @description Maximum number of nodes. Applied at history scope. */
limit?: number;
/** @description Include deleted datasets and collections. */
include_deleted?: boolean;
/** @description Optional: focus on subgraph reachable from this node (e.g. d<encoded_id>). */
seed?: string | null;
/** @description Direction for seed-based subgraph extraction. */
direction?: "backward" | "forward" | "both";
/** @description Max depth for seed-based subgraph extraction. */
depth?: number;
/** @description Center the selection window on this item. Format: d{encoded_id} or c{encoded_id}. */
seed_scope?: string | null;
};
header?: {
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
"run-as"?: string | null;
};
path: {
/** @description The encoded database identifier of the History. */
history_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HistoryGraphResponse"];
};
};
/** @description Request Error */
"4XX": {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageExceptionModel"];
};
};
/** @description Server Error */
"5XX": {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MessageExceptionModel"];
};
};
};
};
index_jobs_summary_api_histories__history_id__jobs_summary_get: {
parameters: {
query?: {
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { curveBasisPath, orthogonalPath } from "@/utils/connectionPath";
import type { EdgeStyle, GraphEdge } from "./types";
interface Props {
edges: GraphEdge[];
selectedNodeId?: string | null;
width: number;
height: number;
edgeStyle?: EdgeStyle;
}
const props = withDefaults(defineProps<Props>(), {
selectedNodeId: null,
edgeStyle: "orthogonal",
});
/** Ribbon margin for collection edges — matches workflow editor's ribbonMargin */
const RIBBON_MARGIN = 4;
const RIBBON_OFFSETS = [-2 * RIBBON_MARGIN, -1 * RIBBON_MARGIN, 0, 1 * RIBBON_MARGIN, 2 * RIBBON_MARGIN];
function makePath(points: { x: number; y: number }[]): string {
if (props.edgeStyle === "curved") {
return curveBasisPath(points.map((p) => [p.x, p.y] as [number, number]));
}
return orthogonalPath(points);
}
/** For a single (non-collection) edge, return one path string */
function edgePaths(edge: GraphEdge): string[] {
if (!edge.isCollection || edge.points.length < 2) {
return [makePath(edge.points)];
}
// Collection ribbon: offset each path perpendicular to the edge direction.
// For orthogonal/curved layouts the edges run mostly left-to-right,
// so vertical offsets produce the ribbon effect.
return RIBBON_OFFSETS.map((offset) => {
const offsetPoints = edge.points.map((p) => ({ x: p.x, y: p.y + offset }));
return makePath(offsetPoints);
});
}
function edgeClass(edge: GraphEdge): Record<string, boolean> {
const isConnected =
!props.selectedNodeId || edge.source === props.selectedNodeId || edge.target === props.selectedNodeId;
return {
[edge.cssClass ?? "edge-default"]: true,
"edge-dimmed": !isConnected,
"edge-collection": !!edge.isCollection,
};
}
</script>
<template>
<svg class="graph-edges" :width="width" :height="height">
<template v-for="edge in edges">
<path
v-for="(path, idx) in edgePaths(edge)"
:key="`${edge.id}-${idx}`"
:d="path"
:class="edgeClass(edge)"
fill="none" />
</template>
</svg>
</template>
<style lang="scss" scoped>
@import "@/style/scss/theme/blue.scss";
.graph-edges {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
overflow: visible;
z-index: 0;
}
path {
stroke-width: 2;
stroke: $brand-primary;
transition: opacity 0.2s ease;
}
.edge-dimmed {
opacity: 0.3;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed } from "vue";
import type { GraphNode } from "./types";
interface Props {
node: GraphNode;
selected: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{ (e: "select", nodeId: string): void }>();
const nodeStyle = computed(() => ({
left: `${props.node.x}px`,
top: `${props.node.y}px`,
width: `${props.node.width}px`,
}));
const hasInputs = computed(() => props.node.inputs && props.node.inputs.length > 0);
const hasOutputs = computed(() => props.node.outputs && props.node.outputs.length > 0);
const hasPorts = computed(() => hasInputs.value || hasOutputs.value);
const showRule = computed(() => hasInputs.value && hasOutputs.value);
const showDataBody = computed(() => !hasPorts.value && (props.node.badge || props.node.data?.stateText));
const iconSpin = computed(() => Boolean(props.node.data?.stateSpin));
</script>
<template>
<div
class="graph-node card"
:class="[node.cssClass, { 'node-highlight': selected }]"
:style="nodeStyle"
@click.stop="emit('select', node.id)">
<div class="graph-node-header card-header unselectable py-1 px-2" :data-state="node.data?.state ?? undefined">
<FontAwesomeIcon :icon="node.icon" class="graph-node-icon mr-1" :spin="iconSpin" />
<span class="graph-node-label" :title="node.label">{{ node.label }}</span>
<span v-if="hasPorts && node.badge" class="badge badge-light ml-auto">{{ node.badge }}</span>
</div>
<div v-if="showDataBody" class="card-body p-0 mx-2 my-1">
<span v-if="node.badge" class="badge badge-secondary">{{ node.badge }}</span>
<div v-if="node.data?.stateText" class="node-state-text">{{ node.data.stateText }}</div>
</div>
<div v-if="hasPorts" class="node-body card-body p-0 mx-2">
<div v-for="input in node.inputs" :key="`in-${input.name}`" class="form-row dataRow input-data-row">
<span class="node-port-label">{{ input.label }}</span>
</div>
<div v-if="showRule" class="rule" />
<div v-for="output in node.outputs" :key="`out-${output.name}`" class="form-row dataRow output-data-row">
<span class="node-port-label">{{ output.label }}</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import "@/style/scss/theme/blue.scss";
.graph-node {
position: absolute;
cursor: pointer;
user-select: none;
border: solid $brand-primary 1px;
transition:
border-color 0.15s,
box-shadow 0.15s,
opacity 0.2s ease;
}
.node-highlight {
z-index: 1001;
border: solid $white 1px;
box-shadow: 0 0 0 3px $brand-primary;
}
.graph-node-header {
font-size: $font-size-base;
}
.graph-node-label {
font-weight: 500;
word-break: break-word;
}
.node-body {
font-size: $h6-font-size;
}
.node-state-text {
font-size: $h6-font-size;
color: $text-muted;
padding: 2px 0;
}
.form-row {
padding: 1px 0;
}
.output-data-row {
text-align: right;
}
.node-port-label {
color: $text-color;
padding: 0 2px;
}
.rule {
height: 0;
border: none;
border-bottom: dotted $brand-primary 1px;
margin: 0;
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { useElementBounding } from "@vueuse/core";
import { computed, nextTick, type Ref, ref, watch } from "vue";
import { useD3Zoom } from "@/composables/d3Zoom";
import { useViewportBoundingBox } from "@/composables/viewportBoundingBox";
import { maxZoom, minZoom } from "@/utils/zoomLevels";
import type { EdgeStyle, GraphLayout, GraphNode } from "./types";
import GraphEdges from "./GraphEdges.vue";
import GraphNodeComponent from "./GraphNode.vue";
import ZoomControl from "./ZoomControl.vue";
interface Props {
layout: GraphLayout | null;
focusNodeId?: string | null;
edgeStyle?: EdgeStyle;
showZoomControls?: boolean;
showMinimap?: boolean;
/** Minimap component — injected by consumer to avoid domain coupling */
minimapComponent?: object | null;
}
const props = withDefaults(defineProps<Props>(), {
focusNodeId: null,
edgeStyle: "orthogonal",
showZoomControls: true,
showMinimap: true,
minimapComponent: null,
});
const emit = defineEmits<{ (e: "nodeSelected", node: GraphNode | null): void }>();
const selectedNodeId = ref<string | null>(null);
// Separate ref for the zoom level displayed in ZoomControl.
// Set explicitly to snapped values — avoids floating point drift from d3.
const scale = ref(1);
// d3 zoom is attached to the inner canvas-container, NOT the outer wrapper.
// This keeps ZoomControl and minimap outside the d3 event target.
const canvasContainer: Ref<HTMLElement | null> = ref(null);
const { transform, setZoom, panBy, moveTo } = useD3Zoom(1, minZoom, maxZoom, canvasContainer, { x: 50, y: 50 });
// Element bounding for viewport computation (needed by minimap)
const elementBounding = useElementBounding(canvasContainer, { windowResize: false, windowScroll: false });
// Viewport bounding box in content coordinates
const { viewportBoundingBox } = useViewportBoundingBox(elementBounding, scale, transform);
// Sync scale ref from d3 zoom events (for scroll-wheel zoom)
watch(
() => transform.value.k,
(k) => {
scale.value = k;
},
);
function onZoom(zoomLevel: number) {
setZoom(zoomLevel);
}
const canvasStyle = computed(() => ({
transform: `translate(${transform.value.x}px, ${transform.value.y}px) scale(${transform.value.k})`,
}));
function onNodeSelect(nodeId: string) {
if (selectedNodeId.value === nodeId) {
selectedNodeId.value = null;
emit("nodeSelected", null);
} else {
selectedNodeId.value = nodeId;
const node = props.layout?.nodes.find((n) => n.id === nodeId) ?? null;
emit("nodeSelected", node);
}
}
// Canvas background click deselects
const mouseMovementThreshold = 9;
let pointerDownPos: { x: number; y: number } | null = null;
function onPointerDown(e: PointerEvent) {
pointerDownPos = { x: e.clientX, y: e.clientY };
}
function onPointerUp(e: PointerEvent) {
if (!pointerDownPos) {
return;
}
const dx = Math.abs(e.clientX - pointerDownPos.x);
const dy = Math.abs(e.clientY - pointerDownPos.y);
pointerDownPos = null;
const clickedOnNode = !!(e.target as HTMLElement).closest(".graph-node");
if (dx + dy <= mouseMovementThreshold && !clickedOnNode) {
selectedNodeId.value = null;
emit("nodeSelected", null);
}
}
// Center on focus node or bounding box after layout
watch(
() => props.layout,
async (newLayout) => {
if (!newLayout) {
return;
}
await nextTick();
if (props.focusNodeId) {
const focusNode = newLayout.nodes.find((n) => n.id === props.focusNodeId);
if (focusNode) {
moveTo({ x: focusNode.x + focusNode.width / 2, y: focusNode.y + focusNode.height / 2 });
return;
}
}
if (newLayout.nodes.length > 0) {
moveTo({ x: newLayout.width / 2, y: newLayout.height / 2 });
}
},
);
</script>
<template>
<div class="graph-canvas rounded">
<ZoomControl v-if="showZoomControls" :zoom-level="scale" @onZoom="onZoom" />
<div ref="canvasContainer" class="canvas-container" @pointerdown="onPointerDown" @pointerup="onPointerUp">
<div v-if="layout" class="graph-node-area" :style="canvasStyle">
<GraphEdges
:edges="layout.edges"
:selected-node-id="selectedNodeId"
:width="layout.width + 200"
:height="layout.height + 200"
:edge-style="edgeStyle" />
<GraphNodeComponent
v-for="node in layout.nodes"
:key="node.id"
:node="node"
:selected="node.id === selectedNodeId"
@select="onNodeSelect" />
</div>
</div>
<component
:is="minimapComponent"
v-if="showMinimap && minimapComponent && layout"
:layout="layout"
:viewport-bounding-box="viewportBoundingBox"
:selected-node-id="selectedNodeId"
:parent-right="elementBounding.right.value"
:parent-bottom="elementBounding.bottom.value"
@panBy="panBy"
@moveTo="moveTo" />
</div>
</template>
<style lang="scss" scoped>
@import "@/style/scss/theme/blue.scss";
.graph-canvas {
flex: 1;
width: 100%;
position: relative;
overflow: hidden;
}
.canvas-container {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
background: $white;
}
.graph-node-area {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
</style>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from "vue";
import { getZoomInLevel, getZoomOutLevel, isMaxZoom, isMinZoom } from "@/utils/zoomLevels";
const props = defineProps({
zoomLevel: { type: Number, default: 1 },
});
const emit = defineEmits<{
(e: "onZoom", zoom: number): void;
}>();
const zoomDefault = 1;
const zoomPercentage = computed(() => Math.round(props.zoomLevel * 100));
function onZoomIn() {
emit("onZoom", getZoomInLevel(props.zoomLevel));
}
function onZoomOut() {
emit("onZoom", getZoomOutLevel(props.zoomLevel));
}
function onZoomReset() {
emit("onZoom", zoomDefault);
}
</script>
<template>
<span class="zoom-control float-right btn-group-horizontal">
<b-button
:disabled="isMinZoom(props.zoomLevel)"
role="button"
class="fa fa-minus"
title="Zoom Out"
size="sm"
aria-label="Zoom Out"
@click="onZoomOut" />
<b-button
v-b-tooltip.hover
role="button"
class="zoom-reset"
variant="light"
title="Reset Zoom Level"
size="sm"
aria-label="Reset Zoom Level"
@click="onZoomReset">
{{ zoomPercentage }}%
</b-button>
<b-button
:disabled="isMaxZoom(props.zoomLevel)"
role="button"
class="fa fa-plus"
title="Zoom In"
size="sm"
aria-label="Zoom In"
@click="onZoomIn" />
</span>
</template>
<style scoped>
.zoom-reset {
width: 4rem;
}
.zoom-control {
position: absolute;
left: 1rem;
bottom: 1rem;
cursor: pointer;
z-index: 2000;
}
</style>
+48
View File
@@ -0,0 +1,48 @@
import type { IconDefinition } from "@fortawesome/free-solid-svg-icons";
/** A labeled port on a graph node (input or output connector) */
export interface GraphNodePort {
name: string;
label: string;
}
/** A positioned node after layout */
export interface GraphNode {
id: string;
x: number;
y: number;
width: number;
height: number;
label: string;
icon: IconDefinition;
badge?: string | null;
cssClass?: string;
/** Input ports displayed in the node body */
inputs?: GraphNodePort[];
/** Output ports displayed in the node body */
outputs?: GraphNodePort[];
/** Arbitrary domain data attached by the mapper */
data?: Record<string, unknown>;
}
/** A positioned edge after layout, with routed points */
export interface GraphEdge {
id: string;
source: string;
target: string;
cssClass?: string;
/** Render as a collection ribbon (multiple parallel lines) */
isCollection?: boolean;
points: { x: number; y: number }[];
}
/** Edge rendering style */
export type EdgeStyle = "orthogonal" | "curved";
/** Complete layout result ready for rendering */
export interface GraphLayout {
nodes: GraphNode[];
edges: GraphEdge[];
width: number;
height: number;
}
@@ -40,7 +40,7 @@ export type State =
| "new_populated_state"
| "inaccessible";
interface StateRepresentation {
export interface StateRepresentation {
status: "success" | "warning" | "info" | "danger" | "secondary";
text?: string;
displayName?: string;
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { BAlert } from "bootstrap-vue";
import { computed, ref, watch } from "vue";
import { GalaxyApi } from "@/api";
import type { GraphNode } from "@/components/Graph/types";
import Heading from "@/components/Common/Heading.vue";
import GenericHistoryItem from "@/components/History/Content/GenericItem.vue";
import JobInformation from "@/components/JobInformation/JobInformation.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
interface Props {
node: GraphNode;
}
const props = defineProps<Props>();
const nodeType = computed(() => (props.node?.data?.type as string) ?? null);
const encodedId = computed(() => (props.node?.data?.encodedId as string) ?? null);
/** Map graph node type to the item source GenericHistoryItem expects */
const itemSrc = computed(() => {
if (nodeType.value === "dataset") {
return "hda";
}
if (nodeType.value === "collection") {
return "hdca";
}
return null;
});
// Tool request -> job ID resolution
const jobId = ref<string | null>(null);
const jobLoading = ref(false);
const jobError = ref<string | null>(null);
watch(
() => [nodeType.value, encodedId.value],
async ([type, id]) => {
jobId.value = null;
jobError.value = null;
if (type !== "tool_request" || !id) {
return;
}
jobLoading.value = true;
try {
const { data, error } = await GalaxyApi().GET("/api/tool_requests/{id}", {
params: { path: { id: id as string } },
});
if (error) {
jobError.value = "Failed to load tool request details.";
} else if (data.jobs && data.jobs.length > 0) {
jobId.value = data.jobs[0]!.id;
} else {
jobError.value = "No job associated with this tool execution.";
}
} catch (e) {
jobError.value = "Failed to load tool request details.";
} finally {
jobLoading.value = false;
}
},
{ immediate: true },
);
</script>
<template>
<div class="history-graph-details border-left bg-white">
<div class="details-body">
<!-- Dataset or Collection -->
<div v-if="itemSrc && encodedId" :key="encodedId" class="p-2">
<Heading h1 separator inline size="md">
{{ nodeType === "dataset" ? "Dataset Information" : "Collection Information" }}
</Heading>
<GenericHistoryItem :item-id="encodedId" :item-src="itemSrc" />
</div>
<!-- Tool request / Job details -->
<div v-else-if="nodeType === 'tool_request'" class="p-2">
<LoadingSpan v-if="jobLoading" message="Loading job details" />
<BAlert v-else-if="jobError" variant="info" show class="mb-0">{{ jobError }}</BAlert>
<JobInformation v-else-if="jobId" :job-id="jobId" :include-times="true" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.history-graph-details {
flex-shrink: 0;
width: 320px;
height: 100%;
overflow-y: auto;
}
</style>
@@ -0,0 +1,233 @@
<script setup lang="ts">
import type { Ref } from "vue";
import { computed, onMounted, ref, toRef, watch } from "vue";
import type { GraphLayout } from "@/components/Graph/types";
import { useAnimationFrame } from "@/composables/sensors/animationFrame";
import { useMinimapInteraction } from "@/composables/useMinimapInteraction";
import { AxisAlignedBoundingBox } from "@/utils/geometry";
// Colors resolved once at mount from CSS custom properties on the canvas element.
// Uses brand-primary for nodes (set via SCSS in the scoped style block).
let nodeColor = "#000";
let viewColor = "rgba(0, 0, 0, 0.2)";
let viewOutlineColor = "#000";
const props = withDefaults(
defineProps<{
layout: GraphLayout | null;
viewportBoundingBox: AxisAlignedBoundingBox;
selectedNodeId?: string | null;
parentRight: number;
parentBottom: number;
}>(),
{
selectedNodeId: null,
},
);
const emit = defineEmits<{
(e: "panBy", offset: { x: number; y: number }): void;
(e: "moveTo", position: { x: number; y: number }): void;
}>();
const canvas: Ref<HTMLCanvasElement | null> = ref(null);
const minimap: Ref<HTMLElement | null> = ref(null);
let redraw = false;
const MINIMAP_SIZE = 150;
const MINIMAP_MIN = 50;
const MINIMAP_MAX = 300;
const MINIMAP_PADDING = 7;
const MINIMAP_BORDER = 1;
// ── Content bounds from layout ──
const contentBounds = computed(() => {
const aabb = new AxisAlignedBoundingBox();
if (props.layout) {
for (const node of props.layout.nodes) {
aabb.fitRectangle({ x: node.x, y: node.y, width: node.width, height: node.height });
}
aabb.squareCenter();
aabb.expand(60);
}
return aabb;
});
const viewportBoundsRef = toRef(props, "viewportBoundingBox");
const parentRightRef = toRef(props, "parentRight");
const parentBottomRef = toRef(props, "parentBottom");
// ── Shared interaction ──
const { getCanvasTransform, recomputeTransform, minimapSize } = useMinimapInteraction({
canvasRef: canvas,
containerRef: minimap,
parentRight: parentRightRef,
parentBottom: parentBottomRef,
contentBounds,
viewportBounds: viewportBoundsRef,
panBy: (delta) => {
if (props.layout && props.layout.nodes.length > 0) {
emit("panBy", delta);
}
},
moveTo: (pos) => {
if (props.layout && props.layout.nodes.length > 0) {
emit("moveTo", pos);
}
},
storageKey: "graph-overview-size",
minSize: MINIMAP_MIN,
maxSize: MINIMAP_MAX,
defaultSize: MINIMAP_SIZE,
});
onMounted(() => {
if (canvas.value) {
const style = getComputedStyle(canvas.value);
nodeColor = style.getPropertyValue("--node-color").trim() || nodeColor;
viewColor = style.getPropertyValue("--view-color").trim() || viewColor;
viewOutlineColor = style.getPropertyValue("--view-outline-color").trim() || viewOutlineColor;
}
recomputeTransform();
redraw = true;
});
// ── Redraw triggers ──
watch(
() => props.layout,
() => {
redraw = true;
},
{ deep: true },
);
watch(
() => props.viewportBoundingBox,
() => {
redraw = true;
},
{ deep: true },
);
watch(
() => props.selectedNodeId,
() => {
redraw = true;
},
);
watch(minimapSize, () => {
redraw = true;
});
watch(contentBounds, () => {
recomputeTransform();
redraw = true;
});
useAnimationFrame(() => {
if (redraw && canvas.value) {
renderMinimap();
redraw = false;
}
});
// ── Rendering ──
function renderMinimap() {
const canvasTransform = getCanvasTransform();
const ctx = canvas.value!.getContext("2d") as CanvasRenderingContext2D;
ctx.resetTransform();
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
if (!props.layout) {
return;
}
canvasTransform.applyToContext(ctx);
// Draw nodes
ctx.fillStyle = nodeColor;
ctx.beginPath();
for (const node of props.layout.nodes) {
ctx.rect(node.x, node.y, node.width, node.height);
}
ctx.fill();
// Draw selected node outline
if (props.selectedNodeId) {
const selected = props.layout.nodes.find((n) => n.id === props.selectedNodeId);
if (selected) {
const edge = 2 / canvasTransform.scaleX;
ctx.beginPath();
ctx.strokeStyle = nodeColor;
ctx.lineWidth = edge;
ctx.rect(selected.x - edge, selected.y - edge, selected.width + edge * 2, selected.height + edge * 2);
ctx.stroke();
}
}
// Draw viewport overlay
ctx.beginPath();
ctx.strokeStyle = viewOutlineColor;
ctx.fillStyle = viewColor;
ctx.lineWidth = 1 / canvasTransform.scaleX;
ctx.rect(
props.viewportBoundingBox.x,
props.viewportBoundingBox.y,
props.viewportBoundingBox.width,
props.viewportBoundingBox.height,
);
ctx.fill();
ctx.stroke();
}
</script>
<template>
<div
ref="minimap"
class="graph-overview"
:style="{ '--graph-overview-size': `${minimapSize + MINIMAP_PADDING + MINIMAP_BORDER}px` }">
<canvas ref="canvas" class="graph-overview-body" :width="MINIMAP_MAX" :height="MINIMAP_MAX" />
</div>
</template>
<style lang="scss" scoped>
@import "bootstrap/scss/_functions.scss";
@import "@/style/scss/theme/blue.scss";
.graph-overview {
--graph-overview-size: 150px;
border-top-left-radius: 0.3rem;
cursor: nwse-resize;
position: absolute;
width: var(--graph-overview-size);
height: var(--graph-overview-size);
right: 0px;
bottom: 0px;
border-top: solid $border-color 1px;
border-left: solid $border-color 1px;
background: $white no-repeat url("@/assets/images/resizable.png");
z-index: 2000;
overflow: hidden;
padding: 7px 0 0 7px;
max-width: calc(300px + 7px + 1px);
max-height: calc(300px + 7px + 1px);
min-width: calc(50px + 7px + 1px);
min-height: calc(50px + 7px + 1px);
.graph-overview-body {
cursor: pointer;
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
--node-color: #{$brand-primary};
--view-color: #{fade-out($brand-dark, 0.8)};
--view-outline-color: #{$brand-info};
}
}
</style>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { faBezierCurve, faProjectDiagram } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert } from "bootstrap-vue";
import { computed, ref, toRef } from "vue";
import type { EdgeStyle, GraphNode } from "@/components/Graph/types";
import { useHistoryStore } from "@/stores/historyStore";
import { useHistoryGraphData } from "./useHistoryGraphData";
import { useHistoryGraphLayout } from "./useHistoryGraphLayout";
import HistoryGraphDetails from "./HistoryGraphDetails.vue";
import HistoryGraphMinimap from "./HistoryGraphMinimap.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
import GButtonGroup from "@/components/BaseComponents/GButtonGroup.vue";
import Heading from "@/components/Common/Heading.vue";
import GraphView from "@/components/Graph/GraphView.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
interface Props {
historyId: string;
seedNodeId?: string;
}
const props = defineProps<Props>();
// History name
const historyStore = useHistoryStore();
const historyName = computed(() => historyStore.getHistoryNameById(props.historyId));
// Fetch params — product decisions owned here
const limit = ref(500);
const { graphData, loading, error } = useHistoryGraphData(toRef(props, "historyId"), limit, toRef(props, "seedNodeId"));
// Layout
const edgeStyle = ref<EdgeStyle>("orthogonal");
const { layout, layoutLoading } = useHistoryGraphLayout(graphData, edgeStyle);
// Selection
const selectedNode = ref<GraphNode | null>(null);
function onNodeSelected(node: GraphNode | null) {
selectedNode.value = node;
}
const isLoading = computed(() => loading.value || layoutLoading.value);
const isTruncated = computed(() => graphData.value?.truncated?.item_count_capped ?? false);
</script>
<template>
<div class="history-graph-view">
<BAlert v-if="error" variant="danger" show>{{ error }}</BAlert>
<LoadingSpan v-else-if="isLoading" message="Loading history graph" />
<template v-else>
<div class="ui-form-header-underlay sticky-top" />
<div class="tool-header sticky-top bg-secondary px-2 py-1 mb-2 rounded">
<div class="d-flex justify-content-between align-items-center">
<div class="py-1 d-flex flex-gapx-1 align-items-center">
<FontAwesomeIcon :icon="faBezierCurve" class="fa-fw" />
<Heading h1 inline bold size="text">{{ historyName }}</Heading>
</div>
<GButtonGroup>
<GButton
tooltip
:title="'Orthogonal edges'"
size="small"
color="blue"
:outline="edgeStyle !== 'orthogonal'"
:pressed="edgeStyle === 'orthogonal'"
@click="edgeStyle = 'orthogonal'">
<FontAwesomeIcon :icon="faProjectDiagram" fixed-width />
</GButton>
<GButton
tooltip
:title="'Curved edges'"
size="small"
color="blue"
:outline="edgeStyle !== 'curved'"
:pressed="edgeStyle === 'curved'"
@click="edgeStyle = 'curved'">
<FontAwesomeIcon :icon="faBezierCurve" fixed-width />
</GButton>
</GButtonGroup>
</div>
</div>
<div class="history-graph-content">
<GraphView
:layout="layout"
:focus-node-id="seedNodeId ?? null"
:edge-style="edgeStyle"
:minimap-component="HistoryGraphMinimap"
@nodeSelected="onNodeSelected" />
<HistoryGraphDetails v-if="selectedNode" :node="selectedNode" />
</div>
<div v-if="isTruncated" class="history-graph-truncation">
Showing a partial graph. Not all connections are visible.
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
@import "@/style/scss/theme/blue.scss";
.history-graph-view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 400px;
}
.history-graph-content {
display: flex;
flex-direction: row;
flex: 1;
min-height: 0;
}
.history-graph-truncation {
padding: 0.375rem 1rem;
background: $state-warning-bg;
color: $state-warning-text;
font-size: $h6-font-size;
text-align: center;
border-top: 1px solid $state-warning-border;
}
/* Tool request nodes use primary header (no dataset state) */
:deep(.node-tool-request) .graph-node-header {
background: $brand-primary;
color: $white;
}
/* Dataset/collection nodes use state-driven coloring via data-state attribute */
:deep(.node-dataset) .graph-node-header,
:deep(.node-collection) .graph-node-header {
color: $text-color;
}
</style>
@@ -0,0 +1,208 @@
import { faFile, faLayerGroup, faWrench } from "@fortawesome/free-solid-svg-icons";
import type { components } from "@/api/schema";
import type { GraphEdge, GraphNode, GraphNodePort } from "@/components/Graph/types";
import { type StateRepresentation, STATES } from "@/components/History/Content/model/states";
type ApiGraphNode = components["schemas"]["GraphNode"];
type ApiGraphEdge = components["schemas"]["GraphEdge"];
export type HistoryGraphResponse = components["schemas"]["HistoryGraphResponse"];
/** Node width — uniform across all types */
const NODE_WIDTH = 200;
/** Compute node height based on content: header + ports + padding */
const HEADER_LINE_HEIGHT = 20;
const HEADER_PADDING = 10;
const HEADER_ICON_CHARS = 3;
const CHARS_PER_LINE = 22;
const PORT_ROW_HEIGHT = 18;
const RULE_HEIGHT = 5;
const BODY_PADDING = 8;
const MIN_NODE_HEIGHT = 32;
function estimateHeaderLines(label: string): number {
const effectiveLength = label.length + HEADER_ICON_CHARS;
return Math.max(1, Math.ceil(effectiveLength / CHARS_PER_LINE));
}
function computeNodeHeight(
label: string,
inputCount: number,
outputCount: number,
hasBadgeBody: boolean = false,
): number {
const headerLines = estimateHeaderLines(label);
const headerHeight = headerLines * HEADER_LINE_HEIGHT + HEADER_PADDING;
if (inputCount === 0 && outputCount === 0 && !hasBadgeBody) {
return Math.max(MIN_NODE_HEIGHT, headerHeight);
}
const portRows = inputCount + outputCount;
const rule = inputCount > 0 && outputCount > 0 ? RULE_HEIGHT : 0;
const badgeRow = hasBadgeBody ? PORT_ROW_HEIGHT + BODY_PADDING : 0;
return headerHeight + portRows * PORT_ROW_HEIGHT + rule + BODY_PADDING + badgeRow;
}
/** User-facing type labels */
export const NODE_TYPE_LABELS: Record<string, string> = {
dataset: "Dataset",
collection: "Collection",
tool_request: "Tool Execution",
};
const NODE_ICONS: Record<string, typeof faFile> = {
dataset: faFile,
collection: faLayerGroup,
tool_request: faWrench,
};
const NODE_CSS_CLASS: Record<string, string> = {
dataset: "node-dataset",
collection: "node-collection",
tool_request: "node-tool-request",
};
// ── Label resolution ──
function resolveNodeLabel(node: ApiGraphNode): string {
const hid = node.hid ? `${node.hid}: ` : "";
switch (node.type) {
case "dataset":
return `${hid}${node.name ?? node.extension ?? "Dataset"}`;
case "collection":
return `${hid}${node.name ?? node.collection_type ?? "Collection"}`;
case "tool_request":
return node.tool_name ?? shortenToolId(node.tool_id);
}
}
function resolveNodeBadge(node: ApiGraphNode): string | null {
switch (node.type) {
case "dataset":
return node.extension ?? null;
case "collection":
return node.collection_type ?? null;
case "tool_request":
return null;
}
}
function shortenToolId(toolId: string | null | undefined): string {
if (!toolId) {
return "Tool";
}
// Strip toolshed prefix:
// "toolshed.g2.bx.psu.edu/repos/iuc/bwa_mem/bwa_mem/1.0" → "bwa_mem"
const parts = toolId.split("/");
if (parts.length >= 2) {
return parts[parts.length - 2] ?? toolId;
}
return toolId;
}
function isCollectionEdge(edge: ApiGraphEdge): boolean {
return edge.type === "collection_input" || edge.type === "collection_output";
}
// ── Public API ──
/**
* Map API graph nodes to generic GraphNode[] for the renderer.
* Returns nodes with dimensions, labels, icons, badges, and domain data.
*/
export function mapNodes(apiNodes: ApiGraphNode[], apiEdges: ApiGraphEdge[]): GraphNode[] {
// Build a lookup for node labels by ID
const nodeLabels = new Map<string, string>();
for (const node of apiNodes) {
nodeLabels.set(node.id, resolveNodeLabel(node));
}
// Build input/output port lists per node from edges
const inputPorts = new Map<string, GraphNodePort[]>();
const outputPorts = new Map<string, GraphNodePort[]>();
for (const edge of apiEdges) {
const sourceLabel = nodeLabels.get(edge.source) ?? edge.source;
const targetLabel = nodeLabels.get(edge.target) ?? edge.target;
// Target node gets an input port labeled by the source
if (!inputPorts.has(edge.target)) {
inputPorts.set(edge.target, []);
}
inputPorts.get(edge.target)!.push({ name: edge.source, label: sourceLabel });
// Source node gets an output port labeled by the target
if (!outputPorts.has(edge.source)) {
outputPorts.set(edge.source, []);
}
outputPorts.get(edge.source)!.push({ name: edge.target, label: targetLabel });
}
return apiNodes.map((node) => {
// Only tool_request nodes show input/output port lists.
// Dataset and collection nodes show state + badge in the body.
const isToolRequest = node.type === "tool_request";
const inputs = isToolRequest ? (inputPorts.get(node.id) ?? []) : [];
const outputs = isToolRequest ? (outputPorts.get(node.id) ?? []) : [];
const inputCount = inputPorts.get(node.id)?.length ?? 0;
const outputCount = outputPorts.get(node.id)?.length ?? 0;
const badge = resolveNodeBadge(node);
// For datasets/collections, use state to determine icon.
// State-based coloring is handled by data-state attribute on the node element,
// which triggers the global $galaxy-state-bg / $galaxy-state-border rules from base.scss.
// Collections return populated_state ("ok"/"new"/"failed") — map "failed" → "error"
// to align with the dataset state vocabulary used by the STATES map and CSS rules.
const rawState = node.state;
const displayState = rawState === "failed" ? "error" : rawState;
const stateKey = displayState as keyof typeof STATES | undefined;
const stateRep: StateRepresentation | null =
!isToolRequest && stateKey && stateKey in STATES ? STATES[stateKey] : null;
const icon = stateRep?.icon ?? NODE_ICONS[node.type] ?? faFile;
const cssClass = NODE_CSS_CLASS[node.type];
// Data nodes always have a body (badge + state text)
const hasBody = !isToolRequest;
const label = nodeLabels.get(node.id)!;
return {
id: node.id,
x: 0,
y: 0,
width: NODE_WIDTH,
height: computeNodeHeight(label, inputs.length, outputs.length, hasBody),
label,
icon,
badge,
cssClass,
inputs,
outputs,
data: {
type: node.type,
typeLabel: NODE_TYPE_LABELS[node.type] ?? node.type,
/** Encoded ID without the type prefix (d/c/r) */
encodedId: node.id.slice(1),
toolId: isToolRequest ? node.tool_id : null,
inputCount,
outputCount,
state: displayState,
stateText: stateRep?.text ?? null,
stateDisplayName: stateRep?.displayName ?? null,
stateSpin: stateRep?.spin ?? false,
},
};
});
}
/**
* Map API graph edges to generic GraphEdge[] for the renderer.
* Points are set to empty — the layout composable fills them in.
*/
export function mapEdges(apiEdges: ApiGraphEdge[]): GraphEdge[] {
return apiEdges.map((edge, idx) => ({
id: `e${idx}`,
source: edge.source,
target: edge.target,
cssClass: isCollectionEdge(edge) ? "edge-collection" : "edge-dataset",
isCollection: isCollectionEdge(edge),
points: [],
}));
}
@@ -0,0 +1,55 @@
import { type Ref, ref, watch } from "vue";
import { GalaxyApi } from "@/api";
import type { HistoryGraphResponse } from "./historyGraphMapper";
/**
* Fetch the history-scoped graph from the API.
*
* The default call returns the full history graph (within bounds).
* An optional seed parameter requests a focused subgraph.
*/
export function useHistoryGraphData(historyId: Ref<string>, limit: Ref<number>, seed?: Ref<string | undefined>) {
const graphData = ref<HistoryGraphResponse | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchGraph() {
loading.value = true;
error.value = null;
try {
const query: Record<string, unknown> = {
limit: limit.value,
};
if (seed?.value) {
query.seed = seed.value;
}
const { data, error: apiError } = await GalaxyApi().GET("/api/histories/{history_id}/graph", {
params: {
path: { history_id: historyId.value },
query: query as any,
},
});
if (apiError) {
error.value = apiError.err_msg || "Failed to load graph";
graphData.value = null;
} else {
graphData.value = data;
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load graph";
graphData.value = null;
} finally {
loading.value = false;
}
}
const watchSources = seed ? [historyId, limit, seed] : [historyId, limit];
watch(watchSources, () => fetchGraph(), { immediate: true });
return { graphData, loading, error };
}
@@ -0,0 +1,158 @@
import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled";
import { type Ref, ref, watch } from "vue";
import type { EdgeStyle, GraphEdge, GraphLayout, GraphNode } from "@/components/Graph/types";
import { computeControlPoints } from "@/utils/connectionPath";
import { type HistoryGraphResponse, mapEdges, mapNodes } from "./historyGraphMapper";
const elk = new ELK();
/**
* Composable that takes API graph data and produces a positioned layout using ELK.js.
*
* Uses the history graph mapper to convert API types to generic graph types,
* then runs ELK layered layout and extracts positioned nodes and edge paths.
*
* edgeStyle controls how edge points are computed:
* - "orthogonal": uses ELK's routed sections (straight segments with bends)
* - "curved": computes bezier control points between node ports (workflow editor style)
*/
export function useHistoryGraphLayout(
graphData: Ref<HistoryGraphResponse | null>,
edgeStyle: Ref<EdgeStyle> = ref("orthogonal") as Ref<EdgeStyle>,
) {
const layout = ref<GraphLayout | null>(null);
const layoutLoading = ref(false);
watch(
[graphData, edgeStyle],
async ([data, style]) => {
if (!data || data.nodes.length === 0) {
layout.value = null;
return;
}
layoutLoading.value = true;
try {
layout.value = await computeLayout(data, style);
} catch (e) {
console.error("History graph layout failed:", e);
layout.value = null;
} finally {
layoutLoading.value = false;
}
},
{ immediate: true },
);
return { layout, layoutLoading };
}
async function computeLayout(data: HistoryGraphResponse, style: EdgeStyle): Promise<GraphLayout> {
// Map API types to generic graph types via the history mapper
const graphNodes = mapNodes(data.nodes, data.edges);
const graphEdges = mapEdges(data.edges);
// Build ELK graph
const elkChildren: ElkNode[] = graphNodes.map((node) => ({
id: node.id,
width: node.width,
height: node.height,
}));
const elkEdges: ElkExtendedEdge[] = graphEdges.map((edge) => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
}));
const elkGraph: ElkNode = {
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "RIGHT",
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
"elk.layered.spacing.baseValue": "80",
"elk.spacing.nodeNode": "40",
"elk.layered.spacing.nodeNodeBetweenLayers": "80",
"elk.edgeRouting": "ORTHOGONAL",
},
children: elkChildren,
edges: elkEdges,
};
const result = await elk.layout(elkGraph);
// Apply ELK positions to graph nodes
const nodeById = new Map(graphNodes.map((n) => [n.id, n]));
const layoutNodes: GraphNode[] = (result.children ?? []).map((elkNode) => {
const gn = nodeById.get(elkNode.id)!;
return {
...gn,
x: elkNode.x ?? 0,
y: elkNode.y ?? 0,
width: elkNode.width ?? gn.width,
height: elkNode.height ?? gn.height,
};
});
// Build node position lookup for edge routing
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
for (const n of layoutNodes) {
nodePositions.set(n.id, { x: n.x, y: n.y, w: n.width, h: n.height });
}
let layoutEdges: GraphEdge[];
if (style === "curved") {
// Compute bezier control points between node ports (workflow editor style)
layoutEdges = graphEdges.map((ge) => {
const src = nodePositions.get(ge.source);
const tgt = nodePositions.get(ge.target);
let points: { x: number; y: number }[] = [];
if (src && tgt) {
const controlPoints = computeControlPoints(src.x + src.w, src.y + src.h / 2, tgt.x, tgt.y + tgt.h / 2);
points = controlPoints.map(([x, y]) => ({ x, y }));
}
return { ...ge, points };
});
} else {
// Extract edge paths from ELK's orthogonal routing sections
const edgeById = new Map(graphEdges.map((e) => [e.id, e]));
layoutEdges = (result.edges ?? []).map((elkEdge) => {
const ge = edgeById.get(elkEdge.id)!;
const points: { x: number; y: number }[] = [];
const sections = (elkEdge as ElkExtendedEdge).sections ?? [];
for (const section of sections) {
points.push({ x: section.startPoint.x, y: section.startPoint.y });
if (section.bendPoints) {
for (const bp of section.bendPoints) {
points.push({ x: bp.x, y: bp.y });
}
}
points.push({ x: section.endPoint.x, y: section.endPoint.y });
}
// Fallback: straight line between node edges
if (points.length === 0) {
const src = nodePositions.get(ge.source);
const tgt = nodePositions.get(ge.target);
if (src && tgt) {
points.push({ x: src.x + src.w, y: src.y + src.h / 2 });
points.push({ x: tgt.x, y: tgt.y + tgt.h / 2 });
}
}
return { ...ge, points };
});
}
return {
nodes: layoutNodes,
edges: layoutEdges,
width: result.width ?? 0,
height: result.height ?? 0,
};
}
@@ -25,11 +25,18 @@ const expectedOptions = [
"Archive History",
"Extract Workflow",
"Show Invocations",
"Show History Graph",
"Share & Manage Access",
];
// options enabled for logged-out users
const anonymousOptions = ["Resume Paused Jobs", "Delete History", "Export Tool References", "Export History to File"];
const anonymousOptions = [
"Resume Paused Jobs",
"Delete History",
"Export Tool References",
"Export History to File",
"Show History Graph",
];
// options disabled for logged-out users
const anonymousDisabledOptions = expectedOptions.filter((option) => !anonymousOptions.includes(option));
@@ -2,6 +2,7 @@
import {
faArchive,
faBars,
faBezierCurve,
faBurn,
faClone,
faColumns,
@@ -231,6 +232,11 @@ watch(
<span v-localize>Show Invocations</span>
</BDropdownItem>
<BDropdownItem :title="localize('View History Graph')" :to="`/histories/${history.id}/graph`">
<FontAwesomeIcon fixed-width :icon="faBezierCurve" />
<span v-localize>Show History Graph</span>
</BDropdownItem>
<BDropdownDivider />
<BDropdownItem
@@ -3,8 +3,7 @@ import type { UseElementBoundingReturn } from "@vueuse/core";
import { computed, onMounted, type Ref, ref, watch } from "vue";
import { useAnimationFrame } from "@/composables/sensors/animationFrame";
import { type AxisAlignedBoundingBox, Transform, type WorkflowTransform } from "./modules/geometry";
import { type AxisAlignedBoundingBox, Transform, type WorkflowTransform } from "@/utils/geometry";
const lineGap = 10;
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { Rectangle } from "@/components/Workflow/Editor/modules/geometry";
import type { Rectangle } from "@/utils/geometry";
import { wait } from "@/utils/utils";
const bounds = ref<Rectangle>({ x: 0, y: 0, width: 0, height: 0 });
@@ -7,10 +7,10 @@ import { BButton, BButtonGroup } from "bootstrap-vue";
import purify from "dompurify";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { AxisAlignedBoundingBox, type Rectangle } from "@/components/Workflow/Editor/modules/geometry";
import { useWorkflowStores } from "@/composables/workflowStores";
import type { FrameWorkflowComment, WorkflowComment, WorkflowCommentColor } from "@/stores/workflowEditorCommentStore";
import type { Step } from "@/stores/workflowStepStore";
import { AxisAlignedBoundingBox, type Rectangle } from "@/utils/geometry";
import { LazyMoveMultipleAction } from "../Actions/workflowActions";
import { brighterColors, darkenedColors } from "./colors";
@@ -4,8 +4,8 @@ import { computed } from "vue";
import { useWorkflowStores } from "@/composables/workflowStores";
import type { FreehandWorkflowComment } from "@/stores/workflowEditorCommentStore";
import { vecSubtract } from "@/utils/geometry";
import { vecSubtract } from "../modules/geometry";
import { colors } from "./colors";
const props = defineProps<{
@@ -2,8 +2,7 @@ import { useEventListener } from "@vueuse/core";
import { type Ref, watch } from "vue";
import { useWorkflowStores } from "@/composables/workflowStores";
import { vecSnap } from "../modules/geometry";
import { vecSnap } from "@/utils/geometry";
/**
* Common functionality required for handling a user resizable element.
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { curveBasis, line } from "d3";
import { computed, type PropType } from "vue";
import { useWorkflowStores } from "@/composables/workflowStores";
import { getConnectionId } from "@/stores/workflowConnectionStore";
import type { TerminalPosition } from "@/stores/workflowEditorStateStore";
import type { Connection } from "@/stores/workflowStoreTypes";
import { curveBasisPath } from "@/utils/connectionPath";
const props = defineProps({
id: String,
@@ -25,8 +25,6 @@ const props = defineProps({
const ribbonMargin = 4;
const curve = line().curve(curveBasis);
const { connectionStore, stateStore, stepStore } = useWorkflowStores();
const outputPos = computed(() => {
@@ -165,7 +163,7 @@ const paths = computed(() => {
}
});
return lines.map((l) => curve(l)!);
return lines.map((l) => curveBasisPath(l));
});
const lineWidth = computed(() => {
@@ -2,8 +2,8 @@
import { useEventListener } from "@vueuse/core";
import { computed, ref, watch } from "vue";
import { Transform, type WorkflowTransform } from "@/components/Workflow/Editor/modules/geometry";
import { useWorkflowStores } from "@/composables/workflowStores";
import { Transform, type WorkflowTransform } from "@/utils/geometry";
const props = defineProps<{
transform: WorkflowTransform;
@@ -4,10 +4,6 @@ import { ref, watch } from "vue";
import { useWorkflowStores } from "@/composables/workflowStores";
import type { BaseWorkflowComment } from "@/stores/workflowEditorCommentStore";
import { assertDefined } from "@/utils/assertions";
import { match } from "@/utils/utils";
import { AddCommentAction } from "../Actions/commentActions";
import { AddToSelectionAction, RemoveFromSelectionAction } from "../Actions/workflowActions";
import {
AxisAlignedBoundingBox,
vecMax,
@@ -16,7 +12,11 @@ import {
vecSnap,
vecSubtract,
type Vector,
} from "../modules/geometry";
} from "@/utils/geometry";
import { match } from "@/utils/utils";
import { AddCommentAction } from "../Actions/commentActions";
import { AddToSelectionAction, RemoveFromSelectionAction } from "../Actions/workflowActions";
export function useToolLogic() {
const comment = ref<BaseWorkflowComment | null>(null);
@@ -5,8 +5,8 @@ import { computed, type Ref } from "vue";
import { useWorkflowStores } from "@/composables/workflowStores";
import type { TerminalPosition } from "@/stores/workflowEditorStateStore";
import type { Connection, OutputTerminal } from "@/stores/workflowStoreTypes";
import type { WorkflowTransform } from "@/utils/geometry";
import type { WorkflowTransform } from "./modules/geometry";
import type { OutputTerminals } from "./modules/terminals";
import SVGConnection from "./SVGConnection.vue";
@@ -4,16 +4,16 @@ import { storeToRefs } from "pinia";
import { computed, type PropType, provide, reactive, type Ref, ref, watch, watchEffect } from "vue";
import { DatatypesMapperModel } from "@/components/Datatypes/model";
import { useD3Zoom } from "@/composables/d3Zoom";
import { useViewportBoundingBox } from "@/composables/viewportBoundingBox";
import { useWorkflowStores } from "@/composables/workflowStores";
import type { TerminalPosition, XYPosition } from "@/stores/workflowEditorStateStore";
import type { Step } from "@/stores/workflowStepStore";
import { assertDefined } from "@/utils/assertions";
import type { Vector } from "@/utils/geometry";
import { useD3Zoom } from "./composables/d3Zoom";
import { useFocusedNodes } from "./composables/useFocusedNodes";
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";
@@ -23,11 +23,11 @@ import BoxSelectPreview from "./Tools/BoxSelectPreview.vue";
import InputCatcher from "./Tools/InputCatcher.vue";
import ToolBar from "./Tools/ToolBar.vue";
import LoadingOverlay from "@/components/Common/LoadingOverlay.vue";
import ZoomControl from "@/components/Graph/ZoomControl.vue";
import AreaHighlight from "@/components/Workflow/Editor/AreaHighlight.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({
@@ -59,7 +59,7 @@ const {
setZoom,
moveTo,
setTransform: d3SetTransform,
} = useD3Zoom(scale.value, minZoom, maxZoom, canvas, scroll, props.initialPosition);
} = useD3Zoom(scale.value, minZoom, maxZoom, canvas, props.initialPosition);
watch(
() => transform.value,
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { useDraggable, type UseElementBoundingReturn } from "@vueuse/core";
import type { UseElementBoundingReturn } from "@vueuse/core";
import type { Ref } from "vue";
import { computed, nextTick, onMounted, ref, unref, watch } from "vue";
import { useAnimationFrame } from "@/composables/sensors/animationFrame";
import { useAnimationFrameThrottle } from "@/composables/throttle";
import { useMinimapInteraction } from "@/composables/useMinimapInteraction";
import { useWorkflowStores } from "@/composables/workflowStores";
import type {
FrameWorkflowComment,
@@ -14,6 +14,7 @@ import type {
WorkflowComment,
} from "@/stores/workflowEditorCommentStore";
import type { Step, Steps } from "@/stores/workflowStepStore";
import { AxisAlignedBoundingBox } from "@/utils/geometry";
import { useWorkflowBoundingBox } from "./composables/workflowBoundingBox";
import {
@@ -24,7 +25,6 @@ import {
getStepColor,
initStateColors,
} from "./modules/canvasDraw";
import { type AxisAlignedBoundingBox, Transform } from "./modules/geometry";
const props = defineProps<{
steps: Steps;
@@ -45,10 +45,6 @@ const { isJustCreated } = commentStore;
const canvas: Ref<HTMLCanvasElement | null> = ref(null);
let redraw = false;
// it is important these throttles are defined before useAnimationFrame,
// so that they are executed first in the frame loop
const { throttle: dragThrottle } = useAnimationFrameThrottle();
watch(
() => props.viewportBoundingBox,
() => (redraw = true),
@@ -59,20 +55,14 @@ const { getWorkflowBoundingBox } = useWorkflowBoundingBox();
let aabbChanged = false;
/** transform mapping workflow coordinates to minimap coordinates */
let canvasTransform = new Transform();
// Workflow-specific: compute padded, squared content bounds
const workflowContentBounds = ref(new AxisAlignedBoundingBox());
function recalculateAABB() {
const aabb = getWorkflowBoundingBox();
aabb.squareCenter();
aabb.expand(120);
// transform canvas to show entire workflow bounding box
if (canvas.value) {
const scale = canvas.value.width / aabb.width;
canvasTransform = new Transform().translate([-aabb.x * scale, -aabb.y * scale]).scale([scale, scale]);
}
workflowContentBounds.value = aabb;
}
// redraw if any steps or comments change
@@ -88,7 +78,6 @@ watch(
);
// these settings are controlled via css, so they can be defined in one common place
// this ensures future style changes wont break the minimap's behavior
const colors = {
node: "#000",
error: "#000",
@@ -129,10 +118,42 @@ onMounted(async () => {
redraw = true;
});
// ── Shared interaction ──
const minimap: Ref<HTMLElement | null> = ref(null);
const viewportBoundsRef = computed(() => props.viewportBoundingBox);
const parentRight = computed(() => unref(props.viewportBounds.right));
const parentBottom = computed(() => unref(props.viewportBounds.bottom));
const { getCanvasTransform, recomputeTransform, minimapSize } = useMinimapInteraction({
canvasRef: canvas,
containerRef: minimap,
parentRight,
parentBottom,
contentBounds: workflowContentBounds,
viewportBounds: viewportBoundsRef,
panBy: (delta) => {
if (Object.values(props.steps).length > 0) {
emit("panBy", delta);
}
},
moveTo: (pos) => {
if (Object.values(props.steps).length > 0) {
emit("moveTo", pos);
}
},
storageKey: "overview-size",
minSize: size.min,
maxSize: size.max,
defaultSize: size.default,
});
// for performance reasons, only draw and calculate on animation frames.
useAnimationFrame(() => {
if (aabbChanged) {
recalculateAABB();
recomputeTransform();
aabbChanged = false;
}
@@ -144,6 +165,7 @@ useAnimationFrame(() => {
/** Renders the entire minimap to the canvas */
function renderMinimap() {
const canvasTransform = getCanvasTransform();
const ctx = canvas.value!.getContext("2d") as CanvasRenderingContext2D;
ctx.resetTransform();
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
@@ -235,80 +257,6 @@ function renderMinimap() {
ctx.fill();
ctx.stroke();
}
// -- Resizing --
const minimap: Ref<HTMLCanvasElement | null> = ref(null);
const { position: dragHandlePosition, isDragging: isHandleDragging } = useDraggable(minimap, {
preventDefault: true,
exact: true,
});
const minimapSize = ref(parseInt(localStorage.getItem("overview-size") || size.default.toString()));
watch(dragHandlePosition, () => {
// resize
minimapSize.value = Math.max(
unref(props.viewportBounds.right) - dragHandlePosition.value.x,
unref(props.viewportBounds.bottom) - dragHandlePosition.value.y,
);
// clamp
minimapSize.value = Math.min(Math.max(minimapSize.value, size.min), size.max);
});
watch(isHandleDragging, () => {
if (!isHandleDragging.value) {
localStorage.setItem("overview-size", minimapSize.value.toString());
}
});
// -- Repositioning Viewport --
/** Scaling factor of the canvas element. Draw size in relation to actual size on screen */
const scaleFactor = computed(() => size.max / minimapSize.value);
let dragViewport = false;
useDraggable(canvas, {
onStart: (_position, event) => {
// minimap coordinates to global coordinates
const [x, y] = canvasTransform
.inverse()
.scale([scaleFactor.value, scaleFactor.value])
.apply([event.offsetX, event.offsetY]);
if (props.viewportBoundingBox.isPointInBounds({ x, y })) {
dragViewport = true;
}
},
onMove: (_position, event) => {
dragThrottle(() => {
if (!dragViewport || Object.values(props.steps).length === 0) {
return;
}
// minimap coordinates to global coordinates, without translation
const [x, y] = canvasTransform
.resetTranslation()
.inverse()
.scale([scaleFactor.value, scaleFactor.value])
.apply([-event.movementX, -event.movementY]);
emit("panBy", { x, y });
});
},
onEnd(_position, event) {
// minimap coordinates to global coordinates
const [x, y] = canvasTransform
.inverse()
.scale([scaleFactor.value, scaleFactor.value])
.apply([event.offsetX, event.offsetY]);
if (!dragViewport && Object.values(props.steps).length > 0) {
emit("moveTo", { x, y });
}
dragViewport = false;
},
});
</script>
<template>
@@ -1,76 +1,4 @@
<script setup lang="ts">
import BootstrapVue from "bootstrap-vue";
import Vue, { computed } from "vue";
import { getZoomInLevel, getZoomOutLevel, isMaxZoom, isMinZoom } from "./modules/zoomLevels";
Vue.use(BootstrapVue);
const props = defineProps({
zoomLevel: { type: Number, default: 1 },
});
const emit = defineEmits<{
(e: "onZoom", zoom: number): void;
}>();
const zoomDefault = 1;
const zoomPercentage = computed(() => Math.round(props.zoomLevel * 100));
function onZoomIn() {
emit("onZoom", getZoomInLevel(props.zoomLevel));
}
function onZoomOut() {
emit("onZoom", getZoomOutLevel(props.zoomLevel));
}
function onZoomReset() {
emit("onZoom", zoomDefault);
}
<script lang="ts">
// Re-export from shared location for backward compatibility
export { default } from "@/components/Graph/ZoomControl.vue";
</script>
<template>
<span class="zoom-control float-right btn-group-horizontal">
<b-button
:disabled="isMinZoom(props.zoomLevel)"
role="button"
class="fa fa-minus"
title="Zoom Out"
size="sm"
aria-label="Zoom Out"
@click="onZoomOut" />
<b-button
v-g-tooltip.hover
role="button"
class="zoom-reset"
variant="light"
title="Reset Zoom Level"
size="sm"
aria-label="Reset Zoom Level"
@click="onZoomReset">
{{ zoomPercentage }}%
</b-button>
<b-button
:disabled="isMaxZoom(props.zoomLevel)"
role="button"
class="fa fa-plus"
title="Zoom In"
size="sm"
aria-label="Zoom In"
@click="onZoomIn" />
</span>
</template>
<style scoped>
.zoom-reset {
width: 4rem;
}
.zoom-control {
position: absolute;
left: 1rem;
bottom: 1rem;
cursor: pointer;
z-index: 2000;
}
</style>
@@ -1,6 +1,5 @@
import { useWorkflowStores } from "@/composables/workflowStores";
import { AxisAlignedBoundingBox, type WorkflowTransform } from "../modules/geometry";
import { AxisAlignedBoundingBox, type WorkflowTransform } from "@/utils/geometry";
export interface BoundingBoxBounds {
minX: number;
@@ -10,10 +10,9 @@ import {
type WorkflowStepStore,
} from "@/stores/workflowStepStore";
import { assertDefined } from "@/utils/assertions";
import { AxisAlignedBoundingBox, type Rectangle, rectDistance } from "@/utils/geometry";
import { match } from "@/utils/utils";
import { AxisAlignedBoundingBox, type Rectangle, rectDistance } from "./geometry";
const elk = new ELK();
export const AUTO_LAYOUT_ORPHAN_EDGE_WARNING_PREFIX = "Auto Layout: skipping edge";
@@ -1,61 +1,12 @@
import type { Last } from "types/utilityTypes";
export const zoomLevels = [
0.1, 0.2, 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.33, 1.5, 2, 2.5, 3, 4, 5,
] as const;
export type ZoomLevel = (typeof zoomLevels)[number];
export const minZoom = zoomLevels[0];
export const maxZoom = zoomLevels[zoomLevels.length - 1] as Last<typeof zoomLevels>;
/**
* Finds the closest snapped zoom level
* @param zoom decimal number indicating current zoom multiplier
* @returns snapped zoom level
*/
export function getSnappedZoom(zoom: number): ZoomLevel {
return zoomLevels.reduce((a, b) => {
return Math.abs(b - zoom) < Math.abs(a - zoom) ? b : a;
});
}
/**
* Gets the next largest zoom level
* @param zoom decimal number indicating current zoom multiplier
* @returns snapped zoom level
*/
export function getZoomInLevel(zoom: number): ZoomLevel {
const snapped = getSnappedZoom(zoom);
const index = zoomLevels.indexOf(snapped);
if (index === zoomLevels.length - 1) {
return snapped;
} else {
return zoomLevels[index + 1] as ZoomLevel;
}
}
/**
* Get the next smaller zoom level
* @param zoom decimal number indicating current zoom multiplier
* @returns snapped zoom level
*/
export function getZoomOutLevel(zoom: number): ZoomLevel {
const snapped = getSnappedZoom(zoom);
const index = zoomLevels.indexOf(snapped);
if (index === 0) {
return snapped;
} else {
return zoomLevels[index - 1] as ZoomLevel;
}
}
export function isMinZoom(zoom: number): boolean {
return zoom === minZoom;
}
export function isMaxZoom(zoom: number): boolean {
return zoom === maxZoom;
}
// Re-export from shared location
export type { ZoomLevel } from "@/utils/zoomLevels";
export {
getSnappedZoom,
getZoomInLevel,
getZoomOutLevel,
isMaxZoom,
isMinZoom,
maxZoom,
minZoom,
zoomLevels,
} from "@/utils/zoomLevels";
@@ -1,10 +1,13 @@
import type { UseScrollReturn } from "@vueuse/core";
import { select } from "d3-selection";
import { type D3ZoomEvent, zoom, zoomIdentity } from "d3-zoom";
import { type Ref, ref, watch } from "vue";
import type { Vector, WorkflowTransform } from "@/components/Workflow/Editor/modules/geometry";
import type { XYPosition } from "@/stores/workflowEditorStateStore";
import type { Vector, WorkflowTransform } from "@/utils/geometry";
export interface XYPosition {
x: number;
y: number;
}
// if element is draggable it may implement its own drag handler,
// but d3zoom would call preventDefault
@@ -31,7 +34,6 @@ export function useD3Zoom(
minZoom: number,
maxZoom: number,
targetRef: Ref<HTMLElement | null>,
scroll: UseScrollReturn,
initialPan: XYPosition = { x: 0, y: 0 },
) {
const transform = ref<WorkflowTransform>({ x: initialPan.x, y: initialPan.y, k: k });
@@ -0,0 +1,144 @@
import { useDraggable } from "@vueuse/core";
import { computed, type Ref, ref, unref, watch } from "vue";
import { useAnimationFrameThrottle } from "@/composables/throttle";
import { type AxisAlignedBoundingBox, Transform } from "@/utils/geometry";
export interface MinimapInteractionOptions {
/** Canvas element for pointer interaction */
canvasRef: Ref<HTMLCanvasElement | null>;
/** Container element for resize dragging */
containerRef: Ref<HTMLElement | null>;
/** Reactive right/bottom edges of the parent element (for resize computation) */
parentRight: Ref<number>;
parentBottom: Ref<number>;
/** Final content bounding box (consumer owns padding/squaring policy) */
contentBounds: Ref<AxisAlignedBoundingBox>;
/** Viewport bounding box in content coordinates */
viewportBounds: Ref<AxisAlignedBoundingBox>;
/** Called with content-coordinate delta when user drags the viewport */
panBy: (delta: { x: number; y: number }) => void;
/** Called with content-coordinate position when user clicks outside viewport */
moveTo: (pos: { x: number; y: number }) => void;
/** localStorage key for resize persistence */
storageKey: string;
/** Minimum minimap display size in pixels */
minSize: number;
/** Maximum minimap display size in pixels (also used as canvas internal resolution) */
maxSize: number;
/** Default minimap display size in pixels */
defaultSize: number;
}
export function useMinimapInteraction(options: MinimapInteractionOptions) {
const {
canvasRef,
containerRef,
parentRight,
parentBottom,
contentBounds,
viewportBounds,
panBy,
moveTo,
storageKey,
minSize,
maxSize,
defaultSize,
} = options;
// ── Transform: content coordinates → minimap canvas pixels ──
let canvasTransform = new Transform();
function recomputeTransform() {
const canvas = unref(canvasRef);
const bounds = unref(contentBounds);
if (!canvas || bounds.width === 0 || bounds.height === 0) {
canvasTransform = new Transform();
return;
}
const scaleX = canvas.width / bounds.width;
const scaleY = canvas.height / bounds.height;
const s = Math.min(scaleX, scaleY);
canvasTransform = new Transform().translate([-bounds.x * s, -bounds.y * s]).scale([s, s]);
}
watch(contentBounds, recomputeTransform, { deep: true });
function getCanvasTransform(): Transform {
return canvasTransform;
}
// ── Resize ──
const minimapSize = ref(parseInt(localStorage.getItem(storageKey) || defaultSize.toString()));
const { position: dragHandlePosition, isDragging: isHandleDragging } = useDraggable(containerRef, {
preventDefault: true,
exact: true,
});
const scaleFactor = computed(() => maxSize / minimapSize.value);
watch(dragHandlePosition, () => {
const newSize = Math.max(
unref(parentRight) - dragHandlePosition.value.x,
unref(parentBottom) - dragHandlePosition.value.y,
);
minimapSize.value = Math.min(Math.max(newSize, minSize), maxSize);
});
watch(isHandleDragging, () => {
if (!isHandleDragging.value) {
localStorage.setItem(storageKey, minimapSize.value.toString());
}
});
// ── Pointer interaction: click-to-center / drag-to-pan ──
const { throttle: dragThrottle } = useAnimationFrameThrottle();
let dragViewport = false;
function minimapToContent(offsetX: number, offsetY: number): [number, number] {
return canvasTransform.inverse().scale([scaleFactor.value, scaleFactor.value]).apply([offsetX, offsetY]);
}
function minimapDeltaToContent(movementX: number, movementY: number): [number, number] {
return canvasTransform
.resetTranslation()
.inverse()
.scale([scaleFactor.value, scaleFactor.value])
.apply([-movementX, -movementY]);
}
useDraggable(canvasRef, {
onStart: (_position, event) => {
const [x, y] = minimapToContent(event.offsetX, event.offsetY);
if (unref(viewportBounds).isPointInBounds({ x, y })) {
dragViewport = true;
}
},
onMove: (_position, event) => {
dragThrottle(() => {
if (!dragViewport) {
return;
}
const [x, y] = minimapDeltaToContent(event.movementX, event.movementY);
panBy({ x, y });
});
},
onEnd: (_position, event) => {
if (!dragViewport) {
const [x, y] = minimapToContent(event.offsetX, event.offsetY);
moveTo({ x, y });
}
dragViewport = false;
},
});
return {
getCanvasTransform,
recomputeTransform,
minimapSize,
};
}
@@ -2,8 +2,7 @@ import type { UseElementBoundingReturn } from "@vueuse/core";
import { type Ref, ref, unref, watch } from "vue";
import { useAnimationFrameThrottle } from "@/composables/throttle";
import { AxisAlignedBoundingBox } from "../modules/geometry";
import { AxisAlignedBoundingBox } from "@/utils/geometry";
/**
* Constructs a bounding box following the editors pan and zoom in
+9
View File
@@ -43,6 +43,7 @@ import GridVisualization from "@/components/Grid/GridVisualization.vue";
import HelpTerm from "@/components/Help/HelpTerm.vue";
import HistoryArchiveWizard from "@/components/History/Archiving/HistoryArchiveWizard.vue";
import HistoryExportTasks from "@/components/History/Export/HistoryExport.vue";
import HistoryGraphView from "@/components/History/Graph/HistoryGraphView.vue";
import HistoryAccessibility from "@/components/History/HistoryAccessibility.vue";
import HistoryDatasetPermissions from "@/components/History/HistoryDatasetPermissions.vue";
import HistoryList from "@/components/History/HistoryList.vue";
@@ -426,6 +427,14 @@ export function getRouter(Galaxy) {
component: WorkflowExtractionForm,
props: true,
},
{
path: "histories/:historyId/graph",
component: HistoryGraphView,
props: (route) => ({
historyId: route.params.historyId,
seedNodeId: route.query.seed || undefined,
}),
},
{
path: "interactivetool_entry_points/list",
component: InteractiveTools,
@@ -1,6 +1,7 @@
import { computed, del, ref, set } from "vue";
import type { Color } from "@/components/Workflow/Editor/Comments/colors";
import { assertDefined } from "@/utils/assertions";
import {
AxisAlignedBoundingBox,
type Rectangle,
@@ -10,8 +11,7 @@ import {
vecReduceFigures,
vecSubtract,
type Vector,
} from "@/components/Workflow/Editor/modules/geometry";
import { assertDefined } from "@/utils/assertions";
} from "@/utils/geometry";
import { hasKeys, match } from "@/utils/utils";
import { defineScopedStore } from "./scopedStore";
@@ -1,9 +1,9 @@
import type { UseElementBoundingReturn } from "@vueuse/core";
import { computed, reactive, ref, set, type UnwrapRef } from "vue";
import type { Rectangle } from "@/components/Workflow/Editor/modules/geometry";
import type { OutputTerminals } from "@/components/Workflow/Editor/modules/terminals";
import reportDefault from "@/components/Workflow/Editor/reportDefault";
import type { Rectangle } from "@/utils/geometry";
import { defineScopedStore } from "./scopedStore";
@@ -1,8 +1,8 @@
import { computed, getCurrentScope, onScopeDispose, reactive, ref, watch } from "vue";
import type { Rectangle } from "@/components/Workflow/Editor/modules/geometry";
import { useMagicKeys } from "@/composables/useMagicKeys";
import { useUserLocalStorage } from "@/composables/userLocalStorage";
import type { Rectangle } from "@/utils/geometry";
import { defineScopedStore } from "./scopedStore";
import type { WorkflowCommentColor } from "./workflowEditorCommentStore";
+1 -1
View File
@@ -1,6 +1,5 @@
import { ref } from "vue";
import { type Rectangle, Transform } from "@/components/Workflow/Editor/modules/geometry";
import { defineScopedStore } from "@/stores/scopedStore";
import { useUndoRedoStore } from "@/stores/undoRedoStore";
import {
@@ -17,6 +16,7 @@ import {
useWorkflowStepStore,
} from "@/stores/workflowStepStore";
import { assertDefined } from "@/utils/assertions";
import { type Rectangle, Transform } from "@/utils/geometry";
export type SearchData =
| {
+127
View File
@@ -0,0 +1,127 @@
/**
* Shared SVG path generation utilities for graph edge rendering.
*
* Two rendering strategies:
* - curveBasisPath: smooth bezier curves via d3 curveBasis (used by workflow editor)
* - orthogonalPath: straight segments with arc-rounded bends (used for ELK-routed graphs)
*
* Both accept point arrays and return SVG path `d` attribute strings.
*/
import { curveBasis, line } from "d3";
/** Point with x/y coordinates */
export interface PathPoint {
x: number;
y: number;
}
// ── curveBasis rendering ──
const _curveBasisLine = line().curve(curveBasis);
/**
* Render points as a smooth bezier curve using d3's curveBasis interpolation.
* Used by the workflow editor for connections between nodes.
*/
export function curveBasisPath(points: [number, number][]): string {
return _curveBasisLine(points) ?? "";
}
// ── Orthogonal rendering with arc bends ──
const DEFAULT_RADIUS = 8;
/**
* Render orthogonal edge points as an SVG path with rounded corners.
* Uses arc segments at each bend point to produce smooth turns while
* faithfully following the layout engine's routing.
*
* Used for ELK-routed graph edges where points form orthogonal paths.
*/
export function orthogonalPath(points: PathPoint[], radius: number = DEFAULT_RADIUS): string {
if (points.length < 2) {
return "";
}
if (points.length === 2) {
return `M ${points[0]!.x} ${points[0]!.y} L ${points[1]!.x} ${points[1]!.y}`;
}
const parts: string[] = [`M ${points[0]!.x} ${points[0]!.y}`];
for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1]!;
const curr = points[i]!;
const next = points[i + 1]!;
const dx1 = prev.x - curr.x;
const dy1 = prev.y - curr.y;
const dx2 = next.x - curr.x;
const dy2 = next.y - curr.y;
const d1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
const d2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
const r = Math.min(radius, d1 / 2, d2 / 2);
const startX = curr.x + (dx1 / d1) * r;
const startY = curr.y + (dy1 / d1) * r;
const endX = curr.x + (dx2 / d2) * r;
const endY = curr.y + (dy2 / d2) * r;
const cross = dx1 * dy2 - dy1 * dx2;
const sweep = cross > 0 ? 0 : 1;
parts.push(`L ${startX} ${startY}`);
parts.push(`A ${r} ${r} 0 0 ${sweep} ${endX} ${endY}`);
}
const last = points[points.length - 1]!;
parts.push(`L ${last.x} ${last.y}`);
return parts.join(" ");
}
// ── Control point computation ──
const BASE_LINE_SHIFT = 15;
const LINE_SHIFT_GROW_X = 0.15;
const LINE_SHIFT_GROW_Y = 0.08;
/**
* Compute bezier control points for a connection between two endpoints.
* Uses the same algorithm as the Galaxy workflow editor (SVGConnection.vue):
* forward connections get 4 control points, backward connections get 6
* for smoother curves.
*/
export function computeControlPoints(startX: number, startY: number, endX: number, endY: number): [number, number][] {
const forward = endX >= startX;
const distX = Math.abs(endX - startX - BASE_LINE_SHIFT);
const distY = Math.abs(endY - startY);
let shiftX: number;
if (forward) {
shiftX = BASE_LINE_SHIFT + distX * LINE_SHIFT_GROW_X + distY * LINE_SHIFT_GROW_Y;
} else {
shiftX = BASE_LINE_SHIFT * 2 + distX * LINE_SHIFT_GROW_X * 0.5 + distY * LINE_SHIFT_GROW_Y * 0.5;
}
if (forward) {
return [
[startX, startY],
[startX + shiftX, startY],
[endX - shiftX, endY],
[endX, endY],
];
} else {
const shiftY = (endY - startY) / 2;
return [
[startX, startY],
[startX + shiftX, startY],
[startX + shiftX, startY + shiftY],
[endX - shiftX, endY - shiftY],
[endX - shiftX, endY],
[endX, endY],
];
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { Last } from "types/utilityTypes";
export const zoomLevels = [
0.1, 0.2, 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.33, 1.5, 2, 2.5, 3, 4, 5,
] as const;
export type ZoomLevel = (typeof zoomLevels)[number];
export const minZoom = zoomLevels[0];
export const maxZoom = zoomLevels[zoomLevels.length - 1] as Last<typeof zoomLevels>;
/**
* Finds the closest snapped zoom level
* @param zoom decimal number indicating current zoom multiplier
* @returns snapped zoom level
*/
export function getSnappedZoom(zoom: number): ZoomLevel {
return zoomLevels.reduce((a, b) => {
return Math.abs(b - zoom) < Math.abs(a - zoom) ? b : a;
});
}
/**
* Gets the next largest zoom level
* @param zoom decimal number indicating current zoom multiplier
* @returns snapped zoom level
*/
export function getZoomInLevel(zoom: number): ZoomLevel {
const snapped = getSnappedZoom(zoom);
const index = zoomLevels.indexOf(snapped);
if (index === zoomLevels.length - 1) {
return snapped;
} else {
return zoomLevels[index + 1] as ZoomLevel;
}
}
/**
* Get the next smaller zoom level
* @param zoom decimal number indicating current zoom multiplier
* @returns snapped zoom level
*/
export function getZoomOutLevel(zoom: number): ZoomLevel {
const snapped = getSnappedZoom(zoom);
const index = zoomLevels.indexOf(snapped);
if (index === 0) {
return snapped;
} else {
return zoomLevels[index - 1] as ZoomLevel;
}
}
export function isMinZoom(zoom: number): boolean {
return zoom === minZoom;
}
export function isMaxZoom(zoom: number): boolean {
return zoom === maxZoom;
}
+603
View File
@@ -0,0 +1,603 @@
"""Bounded user-action provenance graph for Galaxy histories.
For each selected top-level history item, resolves its producing
tool_request and that tool_request's declared inputs from the
submission payload, one hop out from the seed.
"""
import json
import logging
from typing import (
Literal,
Optional,
Union,
)
from boltons.iterutils import remap
from sqlalchemy import (
Select,
select,
union_all,
)
from sqlalchemy.orm import Session
from sqlalchemy.sql.expression import literal_column
from galaxy.exceptions import (
MessageException,
RequestParameterInvalidException,
)
from galaxy.model import (
Dataset,
DatasetCollection,
DatasetCollectionElement,
HistoryDatasetAssociation,
HistoryDatasetCollectionAssociation,
Job,
JobToOutputDatasetAssociation,
JobToOutputDatasetCollectionAssociation,
ToolRequest,
)
from galaxy.schema.history_graph import (
GraphEdge,
GraphNode,
HistoryGraphResponse,
TruncationInfo,
)
from galaxy.schema.schema import DataItemSourceType
from galaxy.security.idencoding import IdEncodingHelper
from galaxy.structured_app import MinimalManagerApp
from galaxy.tool_util.toolbox import AbstractToolBox
log = logging.getLogger(__name__)
TYPE_RANK = {"dataset": 0, "collection": 1, "tool_request": 2}
EDGE_TYPE_RANK = {"dataset_input": 0, "dataset_output": 1, "collection_input": 2, "collection_output": 3}
NODE_TYPE_PREFIX = {"dataset": "d", "collection": "c", "tool_request": "r"}
MAX_LIMIT = 1000
class HistoryGraphManager:
def __init__(self, app: MinimalManagerApp):
self.app = app
def build(
self,
sa_session: Session,
history_id: int,
limit: int = 500,
include_deleted: bool = False,
seed: Optional[str] = None,
direction: Literal["backward", "forward", "both"] = "both",
depth: int = 5,
seed_scope_hid: Optional[int] = None,
) -> HistoryGraphResponse:
return HistoryGraphBuilder(
sa_session=sa_session,
security=self.app.security,
toolbox=self.app.toolbox,
history_id=history_id,
limit=limit,
include_deleted=include_deleted,
seed=seed,
direction=direction,
depth=depth,
seed_scope_hid=seed_scope_hid,
).build()
class HistoryGraphBuilder:
"""Builds a provenance graph from selected history items.
Behaviors:
- HDA producer edges come from JobToOutputDatasetAssociation joined
to Job.
- HDCA producer edges come from JobToOutputDatasetCollectionAssociation
joined to Job.
- Input edges come from the tool_request.request submission payload.
- When an item resolves to more than one distinct producing
tool_request, the item is kept as a node and the producer edge is
skipped with a debug log.
- Malformed payloads are logged at debug level and the item becomes
a node with no input edges.
- Hidden HDAs that belong to a collection are filtered before
producer lookup and surfaced via their parent HDCA instead.
- ``limit`` is clamped to ``MAX_LIMIT`` to keep per-request work
bounded regardless of caller input.
"""
def __init__(
self,
sa_session: Session,
security: IdEncodingHelper,
history_id: int,
limit: int = 500,
toolbox: Optional[AbstractToolBox] = None,
include_deleted: bool = False,
seed: Optional[str] = None,
direction: Literal["backward", "forward", "both"] = "both",
depth: int = 5,
seed_scope_hid: Optional[int] = None,
):
self.sa_session = sa_session
self.security = security
self.history_id = history_id
self.limit = min(limit, MAX_LIMIT)
self.toolbox = toolbox
self.include_deleted = include_deleted
self.seed = seed
self.direction = direction
self.depth = depth
self.seed_scope_hid = seed_scope_hid
self._older_than_hid: Optional[int] = None
self._newer_than_hid: Optional[int] = None
self._sort_keys: dict[str, tuple[int, int]] = {}
def build(self) -> HistoryGraphResponse:
truncation = TruncationInfo()
if self.seed_scope_hid is not None:
truncation.scope_type = "seed_centered"
half = self.limit // 2
self._older_than_hid = self.seed_scope_hid + half + (self.limit % 2)
self._newer_than_hid = self.seed_scope_hid - half - 1
# 1. Select top-level items in scope.
dataset_ids, collection_ids, item_capped = self._select_items()
truncation.item_count_capped = item_capped
# 2. Remove hidden collection elements.
dataset_ids = self._remove_hidden_elements(dataset_ids)
# 3. Producer lookup + payload input resolution.
edges: list[GraphEdge] = []
tr_nodes: dict[int, Optional[str]] = {} # tr_id -> tool_id
closure_dataset_ids: set[int] = set()
closure_collection_ids: set[int] = set()
hda_producers = self._hda_producers(dataset_ids)
hdca_producers = self._hdca_producers(collection_ids)
all_producers = {**hda_producers, **hdca_producers}
# Emit output edges and collect tr_ids.
for item_key, (tr_id, tool_id) in all_producers.items():
item_type, item_id = item_key
tr_nodes[tr_id] = tool_id
src = self._encode("tool_request", tr_id)
tgt = self._encode(item_type, item_id)
etype = "dataset_output" if item_type == "dataset" else "collection_output"
edges.append(GraphEdge(source=src, target=tgt, type=etype))
# Batch-fetch all payloads, parse inputs, emit input edges.
payloads = self._fetch_payloads(set(tr_nodes.keys()))
for tr_id, payload in payloads.items():
input_refs = self._extract_inputs(payload)
for ref_type, ref_id in input_refs:
src = self._encode(ref_type, ref_id)
tgt = self._encode("tool_request", tr_id)
etype = "dataset_input" if ref_type == "dataset" else "collection_input"
edges.append(GraphEdge(source=src, target=tgt, type=etype))
if ref_type == "dataset" and ref_id not in dataset_ids:
closure_dataset_ids.add(ref_id)
elif ref_type == "collection" and ref_id not in collection_ids:
closure_collection_ids.add(ref_id)
# 4. Filter closure items by the same deleted policy as seed selection.
if not self.include_deleted and (closure_dataset_ids or closure_collection_ids):
closure_dataset_ids = self._filter_deleted_ids(HistoryDatasetAssociation, closure_dataset_ids)
closure_collection_ids = self._filter_deleted_ids(
HistoryDatasetCollectionAssociation, closure_collection_ids
)
# Build nodes.
all_dataset_ids = dataset_ids | closure_dataset_ids
all_collection_ids = collection_ids | closure_collection_ids
nodes: list[GraphNode] = []
nodes.extend(self._dataset_nodes(all_dataset_ids))
nodes.extend(self._collection_nodes(all_collection_ids))
nodes.extend(self._tr_nodes(tr_nodes))
# Invariant: every edge endpoint must be a node. Edges were emitted
# before closure deletion filtering, so drop any that now reference
# an item that did not survive.
node_id_set = {n.id for n in nodes}
edges = [e for e in edges if e.source in node_id_set and e.target in node_id_set]
# 5. Resolve tool names.
self._resolve_tool_names(nodes)
# 6. Optional seed subgraph filter (in-memory only).
if self.seed:
node_ids = {n.id for n in nodes}
truncation.seed_in_scope = self.seed in node_ids
nodes, edges = self._seed_filter(self.seed, nodes, edges)
# 7. Sort.
nodes, edges = self._sort(nodes, edges)
return HistoryGraphResponse(nodes=nodes, edges=edges, truncated=truncation)
# ── Item selection ──
def _select_items(self) -> tuple[set[int], set[int], bool]:
hda_q: Select = select(
HistoryDatasetAssociation.id,
HistoryDatasetAssociation.hid,
literal_column("'dataset'").label("item_type"),
).where(HistoryDatasetAssociation.history_id == self.history_id)
if not self.include_deleted:
hda_q = hda_q.where(HistoryDatasetAssociation.deleted == False) # noqa: E712
if self._older_than_hid is not None:
hda_q = hda_q.where(HistoryDatasetAssociation.hid < self._older_than_hid)
if self._newer_than_hid is not None:
hda_q = hda_q.where(HistoryDatasetAssociation.hid > self._newer_than_hid)
hdca_q: Select = select(
HistoryDatasetCollectionAssociation.id,
HistoryDatasetCollectionAssociation.hid,
literal_column("'collection'").label("item_type"),
).where(HistoryDatasetCollectionAssociation.history_id == self.history_id)
if not self.include_deleted:
hdca_q = hdca_q.where(HistoryDatasetCollectionAssociation.deleted == False) # noqa: E712
if self._older_than_hid is not None:
hdca_q = hdca_q.where(HistoryDatasetCollectionAssociation.hid < self._older_than_hid)
if self._newer_than_hid is not None:
hdca_q = hdca_q.where(HistoryDatasetCollectionAssociation.hid > self._newer_than_hid)
combined = union_all(hda_q, hdca_q).subquery()
# Deterministic order: hid desc, then id desc as a stable tiebreaker
# so duplicate hids (theoretically possible across imported data) do
# not affect internal selection stability.
stmt = (
select(combined.c.id, combined.c.hid, combined.c.item_type)
.order_by(combined.c.hid.desc(), combined.c.id.desc())
.limit(self.limit + 1)
)
dataset_ids: set[int] = set()
collection_ids: set[int] = set()
count = 0
for row in self.sa_session.execute(stmt):
count += 1
if count > self.limit:
break
if row.item_type == "dataset":
dataset_ids.add(row.id)
else:
collection_ids.add(row.id)
return dataset_ids, collection_ids, count > self.limit
def _remove_hidden_elements(self, dataset_ids: set[int]) -> set[int]:
if not dataset_ids:
return dataset_ids
stmt = (
select(HistoryDatasetAssociation.id)
.join(DatasetCollectionElement, DatasetCollectionElement.hda_id == HistoryDatasetAssociation.id)
.where(
HistoryDatasetAssociation.id.in_(dataset_ids),
HistoryDatasetAssociation.visible == False, # noqa: E712
)
.distinct()
)
to_remove = {row.id for row in self.sa_session.execute(stmt)}
return dataset_ids - to_remove
def _filter_deleted_ids(
self,
model_cls: Union[type[HistoryDatasetAssociation], type[HistoryDatasetCollectionAssociation]],
ids: set[int],
) -> set[int]:
"""Return the subset of ``ids`` whose rows are not marked deleted.
Used to apply the seed-side deleted policy to closure items
pulled in via payload refs."""
if not ids:
return ids
stmt = select(model_cls.id).where(
model_cls.id.in_(ids),
model_cls.deleted == False, # noqa: E712
)
return {row.id for row in self.sa_session.execute(stmt)}
# ── Producer lookup ──
def _hda_producers(self, dataset_ids: set[int]) -> dict[tuple[str, int], tuple[int, str]]:
"""Resolve each HDA's producing (tool_request_id, tool_id) by
joining JobToOutputDatasetAssociation to Job. Only HDAs with
exactly one distinct producing tool_request are returned;
ambiguous ones are logged at debug level."""
if not dataset_ids:
return {}
stmt = (
select(
JobToOutputDatasetAssociation.dataset_id.label("hda_id"),
Job.tool_request_id,
Job.tool_id,
)
.join(Job, Job.id == JobToOutputDatasetAssociation.job_id)
.where(
JobToOutputDatasetAssociation.dataset_id.in_(dataset_ids),
Job.tool_request_id.isnot(None),
Job.tool_id.isnot(None),
Job.tool_id != "__DATA_FETCH__",
)
)
candidates: dict[int, dict[int, str]] = {} # hda_id -> {tr_id: tool_id}
for row in self.sa_session.execute(stmt):
candidates.setdefault(row.hda_id, {})[row.tool_request_id] = row.tool_id
result: dict[tuple[str, int], tuple[int, str]] = {}
for hda_id, tr_map in candidates.items():
if len(tr_map) == 1:
tr_id, tool_id = next(iter(tr_map.items()))
result[("dataset", hda_id)] = (tr_id, tool_id)
else:
log.debug("history_graph: skipping HDA %d — ambiguous producer (%s)", hda_id, set(tr_map.keys()))
return result
def _hdca_producers(self, collection_ids: set[int]) -> dict[tuple[str, int], tuple[int, str]]:
"""Resolve each HDCA's producing (tool_request_id, tool_id) by
joining JobToOutputDatasetCollectionAssociation to Job. Only
HDCAs with exactly one distinct producing tool_request are
returned; ambiguous ones are logged at debug level."""
if not collection_ids:
return {}
stmt = (
select(
JobToOutputDatasetCollectionAssociation.dataset_collection_id.label("hdca_id"),
Job.tool_request_id,
Job.tool_id,
)
.join(Job, Job.id == JobToOutputDatasetCollectionAssociation.job_id)
.where(
JobToOutputDatasetCollectionAssociation.dataset_collection_id.in_(collection_ids),
Job.tool_request_id.isnot(None),
Job.tool_id.isnot(None),
Job.tool_id != "__DATA_FETCH__",
)
)
candidates: dict[int, dict[int, str]] = {}
for row in self.sa_session.execute(stmt):
candidates.setdefault(row.hdca_id, {})[row.tool_request_id] = row.tool_id
result: dict[tuple[str, int], tuple[int, str]] = {}
for hdca_id, tr_map in candidates.items():
if len(tr_map) == 1:
tr_id, tool_id = next(iter(tr_map.items()))
result[("collection", hdca_id)] = (tr_id, tool_id)
else:
log.debug("history_graph: skipping HDCA %d — ambiguous producer (%s)", hdca_id, set(tr_map.keys()))
return result
# ── Payload input resolution ──
def _fetch_payloads(self, tr_ids: set[int]) -> dict[int, dict]:
"""Return raw ``tool_request.request`` payloads keyed by tool
request id. Rows whose ``request`` field is not a dict (after
decoding a JSON string form) are silently dropped with a debug
log, on the assumption that a malformed payload contributes no
usable input refs."""
if not tr_ids:
return {}
stmt = select(ToolRequest.id, ToolRequest.request).where(ToolRequest.id.in_(tr_ids))
result: dict[int, dict] = {}
for row in self.sa_session.execute(stmt):
try:
payload = json.loads(row.request) if isinstance(row.request, str) else row.request
if isinstance(payload, dict):
result[row.id] = payload
except (json.JSONDecodeError, TypeError):
log.debug("history_graph: malformed payload for tool_request %d", row.id)
return result
def _extract_inputs(self, payload: dict) -> set[tuple[str, int]]:
"""Sole payload entry point inside the builder. Walks a single
``tool_request.request`` payload and returns the deduplicated
set of input refs the builder will emit edges for.
Contract, in order:
1. Extract declared ``{src, id}`` refs from the payload, using
the same ``boltons.iterutils.remap`` traversal idiom as
``jobs.py::populate_input_data_input_id`` — when we hit a
leaf keyed ``id``, we peek at its sibling ``src`` on the
parent container to decide whether it is an HDA/HDCA ref.
2. Normalize refs so that each one points at a top-level
history item (see ``_normalize_refs``).
3. Return as a set — the caller does not need to dedupe.
Payload shape is trusted to be Pydantic-validated upstream when
the tool_request was accepted; no explicit depth or ref caps are
applied here by design."""
refs: set[tuple[str, int]] = set()
def visit(path, key, value):
if key == "id" and isinstance(value, int) and not isinstance(value, bool):
parent = payload
for step in path:
parent = parent[step]
if isinstance(parent, dict):
src = parent.get("src")
if src == DataItemSourceType.hda:
refs.add(("dataset", value))
elif src == DataItemSourceType.hdca:
refs.add(("collection", value))
return key, value
remap(payload, visit=visit)
return self._normalize_refs(refs)
def _normalize_refs(self, refs: set[tuple[str, int]]) -> set[tuple[str, int]]:
"""Apply the single normalization rule: a hidden-element HDA
ref is replaced with its parent HDCA. Single-hop only — the
replacement is not re-normalized. No other rules apply here
by design; adding more cases should be weighed against the
invariant that every emitted ref points at a top-level item."""
hda_ids = [item_id for t, item_id in refs if t == "dataset"]
hdca_parents: dict[int, int] = {}
if hda_ids:
stmt = (
select(
HistoryDatasetAssociation.id.label("hda_id"),
HistoryDatasetCollectionAssociation.id.label("hdca_id"),
)
.join(DatasetCollectionElement, DatasetCollectionElement.hda_id == HistoryDatasetAssociation.id)
.join(
HistoryDatasetCollectionAssociation,
HistoryDatasetCollectionAssociation.collection_id == DatasetCollectionElement.dataset_collection_id,
)
.where(
HistoryDatasetAssociation.id.in_(hda_ids),
HistoryDatasetAssociation.visible == False, # noqa: E712
)
)
for row in self.sa_session.execute(stmt):
hdca_parents[row.hda_id] = row.hdca_id
result: set[tuple[str, int]] = set()
for ref_type, ref_id in refs:
if ref_type == "dataset" and ref_id in hdca_parents:
result.add(("collection", hdca_parents[ref_id]))
else:
result.add((ref_type, ref_id))
return result
# ── Node construction ──
def _dataset_nodes(self, db_ids: set[int]) -> list[GraphNode]:
if not db_ids:
return []
stmt = (
select(
HistoryDatasetAssociation.id,
HistoryDatasetAssociation.name,
HistoryDatasetAssociation.hid,
HistoryDatasetAssociation._state,
Dataset.state.label("dataset_state"),
HistoryDatasetAssociation.extension,
HistoryDatasetAssociation.deleted,
HistoryDatasetAssociation.visible,
)
.join(Dataset, HistoryDatasetAssociation.dataset_id == Dataset.id)
.where(HistoryDatasetAssociation.id.in_(db_ids))
)
return [
GraphNode(
id=self._encode("dataset", row.id),
type="dataset",
name=row.name,
hid=row.hid,
state=row._state if row._state else row.dataset_state,
extension=row.extension,
deleted=row.deleted,
visible=row.visible,
)
for row in self.sa_session.execute(stmt)
]
def _collection_nodes(self, db_ids: set[int]) -> list[GraphNode]:
if not db_ids:
return []
stmt = (
select(
HistoryDatasetCollectionAssociation.id,
HistoryDatasetCollectionAssociation.name,
HistoryDatasetCollectionAssociation.hid,
HistoryDatasetCollectionAssociation.deleted,
HistoryDatasetCollectionAssociation.visible,
DatasetCollection.collection_type,
DatasetCollection.populated_state,
)
.join(DatasetCollection, HistoryDatasetCollectionAssociation.collection_id == DatasetCollection.id)
.where(HistoryDatasetCollectionAssociation.id.in_(db_ids))
)
return [
GraphNode(
id=self._encode("collection", row.id),
type="collection",
name=row.name,
hid=row.hid,
state=row.populated_state,
collection_type=row.collection_type,
deleted=row.deleted,
visible=row.visible,
)
for row in self.sa_session.execute(stmt)
]
def _tr_nodes(self, tr_map: dict[int, Optional[str]]) -> list[GraphNode]:
return [
GraphNode(
id=self._encode("tool_request", tr_id),
type="tool_request",
tool_id=tool_id,
)
for tr_id, tool_id in tr_map.items()
]
def _resolve_tool_names(self, nodes: list[GraphNode]) -> None:
toolbox = self.toolbox
if toolbox is None:
return
tool_ids = {n.tool_id for n in nodes if n.type == "tool_request" and n.tool_id}
name_map: dict[str, str] = {}
for tool_id in tool_ids:
try:
tool = toolbox.get_tool(tool_id)
if tool and tool.name:
name_map[tool_id] = tool.name
except MessageException:
pass
for node in nodes:
if node.type == "tool_request" and node.tool_id and node.tool_id in name_map:
node.tool_name = name_map[node.tool_id]
# ── Seed subgraph filter (in-memory only) ──
def _seed_filter(
self, seed: str, nodes: list[GraphNode], edges: list[GraphEdge]
) -> tuple[list[GraphNode], list[GraphEdge]]:
node_ids = {n.id for n in nodes}
if seed not in node_ids:
raise RequestParameterInvalidException(f"Seed {seed} not found in graph.")
out_map: dict[str, set[str]] = {}
in_map: dict[str, set[str]] = {}
for e in edges:
out_map.setdefault(e.source, set()).add(e.target)
in_map.setdefault(e.target, set()).add(e.source)
reachable: set[str] = {seed}
frontier = {seed}
for _ in range(self.depth):
nxt: set[str] = set()
for nid in frontier:
if self.direction in ("forward", "both"):
nxt |= out_map.get(nid, set()) - reachable
if self.direction in ("backward", "both"):
nxt |= in_map.get(nid, set()) - reachable
if not nxt:
break
reachable |= nxt
frontier = nxt
return (
[n for n in nodes if n.id in reachable],
[e for e in edges if e.source in reachable and e.target in reachable],
)
# ── Sort ──
def _sort(self, nodes: list[GraphNode], edges: list[GraphEdge]) -> tuple[list[GraphNode], list[GraphEdge]]:
fallback = (99, 0)
nodes.sort(key=lambda n: self._sort_keys.get(n.id, fallback))
edges.sort(
key=lambda e: (
self._sort_keys.get(e.source, fallback),
self._sort_keys.get(e.target, fallback),
EDGE_TYPE_RANK.get(e.type, 99),
)
)
return nodes, edges
# ── Utilities ──
def _encode(self, node_type: str, db_id: int) -> str:
encoded = f"{NODE_TYPE_PREFIX[node_type]}{self.security.encode_id(db_id)}"
self._sort_keys[encoded] = (TYPE_RANK[node_type], db_id)
return encoded
+38
View File
@@ -0,0 +1,38 @@
from typing import (
Literal,
Optional,
)
from pydantic import BaseModel
class GraphNode(BaseModel):
id: str
type: Literal["dataset", "collection", "tool_request"]
name: Optional[str] = None
hid: Optional[int] = None
state: Optional[str] = None
extension: Optional[str] = None
collection_type: Optional[str] = None
deleted: Optional[bool] = None
visible: Optional[bool] = None
tool_id: Optional[str] = None
tool_name: Optional[str] = None
class GraphEdge(BaseModel):
source: str
target: str
type: Literal["dataset_input", "dataset_output", "collection_input", "collection_output"]
class TruncationInfo(BaseModel):
item_count_capped: bool = False
scope_type: Literal["recent", "seed_centered"] = "recent"
seed_in_scope: Optional[bool] = None
class HistoryGraphResponse(BaseModel):
nodes: list[GraphNode]
edges: list[GraphEdge]
truncated: TruncationInfo
@@ -48,6 +48,7 @@ from galaxy.schema.history import (
HistoryIndexQueryPayload,
HistorySortByEnum,
)
from galaxy.schema.history_graph import HistoryGraphResponse
from galaxy.schema.schema import (
AnyArchivedHistoryView,
AnyHistoryView,
@@ -357,6 +358,56 @@ class FastAPIHistories:
) -> AnyHistoryView:
return self.service.show(trans, serialization_params, history_id)
@router.get(
"/api/histories/{history_id}/graph",
summary="Returns a history-scoped structural graph.",
)
def graph(
self,
history_id: HistoryIDPathParam,
limit: int = Query(
default=500,
description="Maximum number of nodes. Applied at history scope.",
ge=1,
le=2000,
),
include_deleted: bool = Query(
default=False,
description="Include deleted datasets and collections.",
),
seed: Optional[str] = Query(
default=None,
description="Optional: focus on subgraph reachable from this node (e.g. d<encoded_id>).",
pattern=r"^[dcr].+$",
),
direction: Literal["backward", "forward", "both"] = Query(
default="both",
description="Direction for seed-based subgraph extraction.",
),
depth: int = Query(
default=20,
description="Max depth for seed-based subgraph extraction.",
ge=1,
le=20,
),
seed_scope: Optional[str] = Query(
default=None,
description="Center the selection window on this item. Format: d{encoded_id} or c{encoded_id}.",
pattern=r"^[dc].+$",
),
trans: ProvidesHistoryContext = DependsOnTrans,
) -> HistoryGraphResponse:
return self.service.graph(
trans,
history_id,
limit=limit,
include_deleted=include_deleted,
seed=seed,
direction=direction,
depth=depth,
seed_scope=seed_scope,
)
@router.post(
"/api/histories/{history_id}/prepare_store_download",
summary="Return a short term storage token to monitor download of the history.",
+1
View File
@@ -276,6 +276,7 @@ def app_pair(global_conf, load_app_kwds=None, wsgi_preflight=True, **kwargs):
webapp.add_client_route("/histories/{history_id}/extract_workflow")
webapp.add_client_route("/histories/{history_id}/archive")
webapp.add_client_route("/histories/{history_id}/invocations")
webapp.add_client_route("/histories/{history_id}/graph")
webapp.add_client_route("/histories/archived")
webapp.add_client_route("/histories/list_published")
webapp.add_client_route("/histories/list_shared")
@@ -40,8 +40,12 @@ from galaxy.managers.histories import (
HistoryManager,
HistorySerializer,
)
from galaxy.managers.history_graph import HistoryGraphManager
from galaxy.managers.users import UserManager
from galaxy.model import HistoryDatasetAssociation
from galaxy.model import (
HistoryDatasetAssociation,
HistoryDatasetCollectionAssociation,
)
from galaxy.model.scoped_session import galaxy_scoped_session
from galaxy.model.store import payload_to_source_uri
from galaxy.schema import (
@@ -50,6 +54,7 @@ from galaxy.schema import (
)
from galaxy.schema.fields import DecodedDatabaseIdField
from galaxy.schema.history import HistoryIndexQueryPayload
from galaxy.schema.history_graph import HistoryGraphResponse
from galaxy.schema.schema import (
AnyArchivedHistoryView,
AnyHistoryView,
@@ -126,6 +131,7 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
deserializer: HistoryDeserializer,
citations_manager: CitationsManager,
history_export_manager: HistoryExportManager,
history_graph_manager: HistoryGraphManager,
filters: HistoryFilters,
short_term_storage_allocator: ShortTermStorageAllocator,
notification_service: NotificationService,
@@ -137,6 +143,7 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
self.deserializer = deserializer
self.citations_manager = citations_manager
self.history_export_manager = history_export_manager
self.history_graph_manager = history_graph_manager
self.filters = filters
self.shareable_service = ShareableHistoryService(self.manager, self.serializer, notification_service)
self.short_term_storage_allocator = short_term_storage_allocator
@@ -379,6 +386,40 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
history = self.manager.get_accessible(history_id, trans.user, current_history=trans.history)
return self._serialize_history(trans, history, serialization_params)
def graph(
self,
trans: ProvidesHistoryContext,
history_id: DecodedDatabaseIdField,
limit: int = 500,
include_deleted: bool = False,
seed: Optional[str] = None,
direction: Literal["backward", "forward", "both"] = "both",
depth: int = 20,
seed_scope: Optional[str] = None,
) -> HistoryGraphResponse:
history = self.manager.get_accessible(history_id, trans.user, current_history=trans.history)
seed_scope_hid = self._resolve_seed_scope_hid(trans, history.id, seed_scope) if seed_scope else None
return self.history_graph_manager.build(
sa_session=trans.sa_session,
history_id=history.id,
limit=limit,
include_deleted=include_deleted,
seed=seed,
direction=direction,
depth=depth,
seed_scope_hid=seed_scope_hid,
)
def _resolve_seed_scope_hid(self, trans: ProvidesHistoryContext, history_id: int, seed_scope: str) -> int:
db_id = self.security.decode_id(seed_scope[1:])
model_class = HistoryDatasetAssociation if seed_scope[0] == "d" else HistoryDatasetCollectionAssociation
row = trans.sa_session.execute(
select(model_class.hid).where(model_class.id == db_id, model_class.history_id == history_id)
).first()
if row is None or row.hid is None:
raise glx_exceptions.ObjectNotFound(f"seed_scope {seed_scope} not found in history.")
return row.hid
def prepare_download(
self, trans: ProvidesHistoryContext, history_id: DecodedDatabaseIdField, payload: StoreExportPayload
) -> AsyncFile:
+88
View File
@@ -3,6 +3,7 @@ from typing import ClassVar
from unittest import SkipTest
from uuid import uuid4
import pytest
from requests import put
from galaxy.model.unittest_utils.store_fixtures import (
@@ -1247,3 +1248,90 @@ class TestArchivingHistoriesWithoutExportRecord(ApiTestCase, BaseHistories):
histories = self.dataset_populator.get_histories()
for history in histories:
assert history["id"] != history_id
class TestHistoryGraphApi(ApiTestCase, BaseHistories):
"""API-level tests for ``GET /api/histories/{id}/graph``.
These cover the surface the endpoint owns: status codes, query
parameter validation, auth boundaries, and response shape. Builder
logic is exercised separately in ``test_HistoryGraphBuilder``.
"""
def setUp(self):
super().setUp()
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
# ── response shape ──
def test_empty_history_returns_empty_graph(self):
history_id = self.dataset_populator.new_history()
body = self.dataset_populator.get_history_graph(history_id)
self._assert_has_keys(body, "nodes", "edges", "truncated")
assert body["nodes"] == []
assert body["edges"] == []
assert body["truncated"]["item_count_capped"] is False
assert body["truncated"]["scope_type"] == "recent"
def test_standalone_datasets_appear_as_dataset_nodes(self):
history_id = self.dataset_populator.new_history()
self.dataset_populator.new_dataset(history_id, content="a", wait=True)
self.dataset_populator.new_dataset(history_id, content="b", wait=True)
body = self.dataset_populator.get_history_graph(history_id)
assert len(body["nodes"]) == 2
assert body["edges"] == []
assert all(n["type"] == "dataset" for n in body["nodes"])
def test_limit_caps_items_and_sets_truncation_flag(self):
history_id = self.dataset_populator.new_history()
for i in range(5):
self.dataset_populator.new_dataset(history_id, content=f"row {i}", wait=True)
body = self.dataset_populator.get_history_graph(history_id, limit=3)
assert len(body["nodes"]) == 3
assert body["truncated"]["item_count_capped"] is True
def test_seed_scope_returns_seed_centered_window(self):
history_id = self.dataset_populator.new_history()
dataset = self.dataset_populator.new_dataset(history_id, content="seed", wait=True)
seed_scope = f"d{dataset['id']}"
body = self.dataset_populator.get_history_graph(history_id, seed_scope=seed_scope, limit=5)
assert body["truncated"]["scope_type"] == "seed_centered"
assert seed_scope in {n["id"] for n in body["nodes"]}
# ── query-parameter validation (API-layer regex and bounds) ──
@pytest.mark.parametrize(
"param,value",
[
("seed_scope", "xabc"), # wrong prefix
("seed_scope", "d"), # prefix but empty body
("seed", "z"), # wrong prefix
("limit", 5000), # above max
("depth", 21), # above max
],
)
def test_invalid_query_params_return_400(self, param, value):
history_id = self.dataset_populator.new_history()
response = self.dataset_populator.get_history_graph_raw(history_id, **{param: value})
self._assert_status_code_is(response, 400)
# ── manager-level validation (after API regex passes) ──
def test_seed_scope_not_in_target_history_is_rejected(self):
source_history = self.dataset_populator.new_history()
dataset = self.dataset_populator.new_dataset(source_history, content="a", wait=True)
target_history = self.dataset_populator.new_history()
response = self.dataset_populator.get_history_graph_raw(target_history, seed_scope=f"d{dataset['id']}")
self._assert_status_code_is(response, 404)
# ── auth ──
def test_other_users_history_is_forbidden(self):
with self._different_user():
other_history_id = self.dataset_populator.new_history()
response = self.dataset_populator.get_history_graph_raw(other_history_id)
self._assert_status_code_is(response, 403)
def test_nonexistent_history_is_rejected(self):
response = self.dataset_populator.get_history_graph_raw("0000000000000000")
self._assert_status_code_is(response, 400)
+8
View File
@@ -1842,6 +1842,14 @@ class BaseDatasetPopulator(BasePopulator):
api_asserts.assert_status_code_is_ok(response)
return response.json()
def get_history_graph(self, history_id: str, **params: Any) -> dict[str, Any]:
response = self.get_history_graph_raw(history_id, **params)
api_asserts.assert_status_code_is_ok(response)
return response.json()
def get_history_graph_raw(self, history_id: str, **params: Any) -> Response:
return self._get(f"histories/{history_id}/graph", data=params or None)
def wait_on_task(self, async_task_response: Response):
response_json = async_task_response.json()
return self.wait_on_task_object(response_json)
File diff suppressed because it is too large Load Diff