mirror of
https://github.com/SecurityBrewery/catalyst.git
synced 2025-12-14 11:12:47 +01:00
Add playbook editor (#702)
This commit is contained in:
273
ui/src/components/playbookeditor/EditTask.vue
Normal file
273
ui/src/components/playbookeditor/EditTask.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<v-card data-app outlined>
|
||||
<v-card-title class="d-flex">
|
||||
<span>Edit Task</span>
|
||||
<v-spacer/>
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<template #activator="{ on }">
|
||||
<v-btn outlined v-on="on" class="mr-2" small>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-title class="headline">Delete Task</v-card-title>
|
||||
<v-card-text>Are you sure you want to delete this task?</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-btn color="blue darken-1" text @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="blue darken-1" text @click="deleteTask">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<v-btn @click="close" outlined small>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="task.name"
|
||||
label="Name"
|
||||
variant="underlined"/>
|
||||
<v-textarea
|
||||
v-model="task.description"
|
||||
label="Description"
|
||||
auto-grow
|
||||
rows="1"
|
||||
variant="underlined"/>
|
||||
<v-select
|
||||
v-model="task.type"
|
||||
:items="['input','automation','task']"
|
||||
label="Type"
|
||||
variant="underlined"/>
|
||||
|
||||
<AdvancedJSONSchemaEditor
|
||||
v-if="task.type === 'input'"
|
||||
:schema="task.schema"
|
||||
@save="task.schema = JSON.parse($event)" />
|
||||
|
||||
<v-select
|
||||
v-if="task.type === 'automation'"
|
||||
v-model="task.automation"
|
||||
:items="automations"
|
||||
item-text="id"
|
||||
item-value="id"
|
||||
label="Automation"
|
||||
variant="underlined"/>
|
||||
|
||||
<v-list v-if="task.type === 'automation'">
|
||||
<v-subheader class="pa-0" style="padding-inline-start: 0 !important;">Payload Mapping</v-subheader>
|
||||
<v-toolbar v-for="(expr, key) in task.payload" :key="key" class="next-row" flat dense>
|
||||
{{ key }}:
|
||||
<v-text-field
|
||||
v-model="task.payload[key]"
|
||||
label="Expression"
|
||||
variant="solo"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
<v-btn @click="deletePayloadMapping(key)" color="error" class="pa-0 ma-0" icon>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-toolbar class="next-row" flat dense>
|
||||
<v-text-field
|
||||
v-model="newPayloadMapping"
|
||||
label="Payload Field"
|
||||
variant="solo"
|
||||
bg-color="surface"
|
||||
hide-details
|
||||
density="compact"
|
||||
/>:
|
||||
<v-text-field
|
||||
v-model="newExpression"
|
||||
label="CAQL Expression"
|
||||
variant="solo"
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
<v-btn
|
||||
@click="addPayloadMapping"
|
||||
:disabled="!newPayloadMapping || !newExpression"
|
||||
class="pa-0 ma-0"
|
||||
icon>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
</v-list>
|
||||
|
||||
<v-list v-if="task.next || possibleNexts.length > 1">
|
||||
<v-subheader class="pa-0" style="padding-inline-start: 0 !important;">Next Task(s)</v-subheader>
|
||||
<v-toolbar v-for="(expr, key) in task.next" :key="key" class="next-row" flat dense>
|
||||
If
|
||||
<v-text-field
|
||||
v-model="task.next[key]"
|
||||
label="Condition (leave empty to always run)"
|
||||
variant="solo"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
run
|
||||
<span class="font-weight-black">
|
||||
{{ playbook.tasks[key].name }}
|
||||
</span>
|
||||
<v-btn @click="deleteNext(key)" color="error" class="pa-0 ma-0" icon>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-toolbar v-if="possibleNexts.length > 0" class="next-row" flat dense>
|
||||
If
|
||||
<v-text-field
|
||||
v-model="newCondition"
|
||||
label="Condition (leave empty to always run)"
|
||||
variant="solo"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
run
|
||||
<v-select
|
||||
v-model="newNext"
|
||||
item-text="name"
|
||||
item-value="key"
|
||||
:items="possibleNexts"
|
||||
variant="solo"
|
||||
bg-color="surface"
|
||||
hide-details
|
||||
density="compact"
|
||||
/>
|
||||
<v-btn
|
||||
@click="addNext"
|
||||
:disabled="!newNext"
|
||||
class="pa-0 ma-0"
|
||||
icon>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
</v-list>
|
||||
<v-switch
|
||||
v-if="parents.length > 1"
|
||||
label="Join (Require all previous tasks to be completed)"
|
||||
v-model="task.join"
|
||||
color="primary"/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {defineProps, ref, watch, defineEmits, del, set, onMounted, computed} from "vue";
|
||||
import AdvancedJSONSchemaEditor from "@/components/AdvancedJSONSchemaEditor.vue";
|
||||
import {API} from "@/services/api";
|
||||
import {AutomationResponse} from "@/client";
|
||||
|
||||
interface Task {
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
next: Record<string, string>;
|
||||
payload: Record<string, string>;
|
||||
join: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value: Task;
|
||||
possibleNexts: Array<Record<string, string>>;
|
||||
parents: Array<string>;
|
||||
playbook: object;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["input", "delete", "close"]);
|
||||
|
||||
const deleteDialog = ref(false);
|
||||
const deleteTask = () => emit("delete");
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const task = ref(props.value);
|
||||
watch(() => props.value, (value) => {
|
||||
task.value = value;
|
||||
});
|
||||
watch(task, (value) => {
|
||||
emit("input", value);
|
||||
});
|
||||
|
||||
// const task = computed({
|
||||
// get: () => {
|
||||
// console.log("get", props.value);
|
||||
// return props.value;
|
||||
// },
|
||||
// set: (value) => {
|
||||
// console.log("set", value);
|
||||
// emit("input", value);
|
||||
// }
|
||||
// });
|
||||
|
||||
const deleteNext = (key: string) => {
|
||||
del(task.value.next, key);
|
||||
};
|
||||
|
||||
const deletePayloadMapping = (key: string) => {
|
||||
del(task.value.payload, key);
|
||||
};
|
||||
|
||||
const newNext = ref('');
|
||||
const newCondition = ref('');
|
||||
|
||||
const newPayloadMapping = ref('');
|
||||
const newExpression = ref('');
|
||||
|
||||
watch(() => props.possibleNexts, () => {
|
||||
if (props.possibleNexts.length > 0) {
|
||||
newNext.value = props.possibleNexts[0].key;
|
||||
}
|
||||
}, {deep: true, immediate: true});
|
||||
|
||||
|
||||
const addNext = () => {
|
||||
if (task.value.next === undefined) {
|
||||
// task.value.next = {};
|
||||
set(task.value, 'next', {});
|
||||
}
|
||||
// task.value.next[newNext.value] = newCondition.value;
|
||||
set(task.value.next, newNext.value, newCondition.value);
|
||||
newNext.value = "";
|
||||
newCondition.value = "";
|
||||
};
|
||||
|
||||
const addPayloadMapping = () => {
|
||||
if (task.value.payload === undefined) {
|
||||
// task.value.payload = {};
|
||||
set(task.value, 'payload', {});
|
||||
}
|
||||
// task.value.payload[newPayloadMapping.value] = newExpression.value;
|
||||
set(task.value.payload, newPayloadMapping.value, newExpression.value);
|
||||
newPayloadMapping.value = "";
|
||||
newExpression.value = "";
|
||||
};
|
||||
|
||||
const automations = ref<Array<AutomationResponse>>([]);
|
||||
onMounted(() => {
|
||||
API.listAutomations().then((response) => {
|
||||
automations.value = response.data;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.next-row {
|
||||
padding-top: 10px;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.next-row .v-toolbar__content {
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
80
ui/src/components/playbookeditor/NewTask.vue
Normal file
80
ui/src/components/playbookeditor/NewTask.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<v-card outlined>
|
||||
<v-card-title>
|
||||
Create a new step
|
||||
<v-spacer />
|
||||
<v-btn @click="close" outlined small>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form v-model="valid">
|
||||
<v-text-field
|
||||
v-model="newTask.name"
|
||||
label="Name"
|
||||
:rules="[val => (val || '').length > 0 || 'This field is required']"
|
||||
variant="underlined"/>
|
||||
<v-textarea
|
||||
v-model="newTask.description"
|
||||
label="Description"
|
||||
auto-grow
|
||||
rows="1"
|
||||
variant="underlined"/>
|
||||
<v-text-field
|
||||
v-model="newTask.key"
|
||||
label="Key (generated automatically)"
|
||||
readonly
|
||||
disabled
|
||||
:rules="[val => (val || '').length > 0 || 'This field is required']"
|
||||
variant="underlined"/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-btn color="primary" @click="createTask" :disabled="!valid">Create</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {defineProps, ref, watch, defineEmits} from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
playbook: any;
|
||||
}>();
|
||||
|
||||
const valid = ref(false);
|
||||
const newTask = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
key: '',
|
||||
next: {},
|
||||
});
|
||||
|
||||
watch(newTask, (val) => {
|
||||
if (val.name) {
|
||||
const newKeyBase = val.name.toLowerCase().replace(/ /g, '_');
|
||||
let newKey = newKeyBase;
|
||||
if (!(newKey in props.playbook.tasks)) {
|
||||
newTask.value.key = newKey;
|
||||
} else {
|
||||
let i = 1;
|
||||
while (newKey in props.playbook.tasks) {
|
||||
newKey = newKeyBase + '_' + i;
|
||||
i++;
|
||||
}
|
||||
newTask.value.key = newKey;
|
||||
}
|
||||
}
|
||||
}, {deep: true});
|
||||
|
||||
const emit = defineEmits(["createTask", "close"]);
|
||||
|
||||
const createTask = () => {
|
||||
emit('createTask', newTask.value);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
};
|
||||
</script>
|
||||
164
ui/src/components/playbookeditor/PanZoom.vue
Normal file
164
ui/src/components/playbookeditor/PanZoom.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div id="graphwrapper">
|
||||
<div id="gab">
|
||||
<slot name="actionbar"/>
|
||||
</div>
|
||||
<v-toolbar
|
||||
id="gtb"
|
||||
class="ma-2"
|
||||
floating
|
||||
dense
|
||||
>
|
||||
<v-btn @click.prevent.stop="reset" title="Reset" icon>
|
||||
<v-icon color="#000">mdi-image-filter-center-focus</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click.prevent.stop="zoomIn" title="Zoom in" :disabled="isMaxZoom" icon>
|
||||
<v-icon color="#000">mdi-magnify-plus-outline</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click.prevent.stop="zoomOut" title="Zoom out" :disabled="isMinZoom" icon>
|
||||
<v-icon color="#000">mdi-magnify-minus-outline</v-icon>
|
||||
</v-btn>
|
||||
<slot name="toolbar"/>
|
||||
</v-toolbar>
|
||||
<div id="panzoom">
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref, defineProps, defineExpose} from "vue";
|
||||
import createPanZoom from "panzoom";
|
||||
import * as panzoom from "panzoom";
|
||||
|
||||
const props = defineProps<{
|
||||
config: panzoom.PanZoomOptions
|
||||
}>();
|
||||
|
||||
const panZoom = ref<panzoom.PanZoom | null>(null);
|
||||
|
||||
const zoomLevel = ref<number>(1);
|
||||
|
||||
const minZoom = ref<number>(0.5);
|
||||
const maxZoom = ref<number>(1.5);
|
||||
|
||||
const isMaxZoom = computed(() => {
|
||||
if (zoomLevel.value) {
|
||||
return zoomLevel.value >= maxZoom.value;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isMinZoom = computed(() => {
|
||||
if (zoomLevel.value) {
|
||||
return zoomLevel.value <= minZoom.value;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const initialZoom = ref<number>(1);
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = document.getElementById("panzoom")
|
||||
if (!canvas) {
|
||||
throw new Error("No element with id panzoom")
|
||||
}
|
||||
|
||||
const firstChild = canvas.firstElementChild
|
||||
if (!firstChild) {
|
||||
throw new Error("No child element")
|
||||
}
|
||||
|
||||
const startX = canvas.getBoundingClientRect().width / 2 - firstChild.getBoundingClientRect().width / 2
|
||||
const startY = canvas.getBoundingClientRect().height / 2 - firstChild.getBoundingClientRect().height / 2
|
||||
|
||||
initialZoom.value = props.config.initialZoom ? props.config.initialZoom : 1
|
||||
minZoom.value = props.config.initialZoom / 2
|
||||
maxZoom.value = props.config.initialZoom * 2
|
||||
|
||||
panZoom.value = createPanZoom(canvas, {
|
||||
...props.config,
|
||||
zoomDoubleClickSpeed: 1, // disable double click zoom
|
||||
autocenter: true,
|
||||
initialX: startX,
|
||||
initialY: startY,
|
||||
minZoom: minZoom.value,
|
||||
maxZoom: maxZoom.value,
|
||||
});
|
||||
|
||||
panZoom.value.on("zoom", (e: panzoom.PanZoom) => {
|
||||
zoomLevel.value = e.getTransform().scale;
|
||||
});
|
||||
|
||||
reset(false);
|
||||
});
|
||||
|
||||
const ZOOM_FACTOR = 0.255
|
||||
|
||||
const zoomIn = () => {
|
||||
if (!panZoom.value) return
|
||||
const currentZoom = panZoom.value.getTransform().scale
|
||||
panZoom.value.smoothZoomAbs(0, 0, currentZoom + ZOOM_FACTOR)
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
if (!panZoom.value) return
|
||||
const currentZoom = panZoom.value.getTransform().scale
|
||||
panZoom.value.smoothZoomAbs(0, 0, currentZoom - ZOOM_FACTOR)
|
||||
}
|
||||
|
||||
const reset = (smooth = true) => {
|
||||
if (!panZoom.value) return
|
||||
|
||||
const canvas = document.getElementById("panzoom")
|
||||
if (!canvas) {
|
||||
throw new Error("No element with id panzoom")
|
||||
}
|
||||
|
||||
const firstChild = canvas.firstElementChild
|
||||
if (!firstChild) {
|
||||
throw new Error("No child element")
|
||||
}
|
||||
|
||||
const startX = canvas.getBoundingClientRect().width / 2 - firstChild.getBoundingClientRect().width / 2
|
||||
const startY = canvas.getBoundingClientRect().height / 2 - firstChild.getBoundingClientRect().height / 2
|
||||
|
||||
panZoom.value.pause()
|
||||
if (smooth) {
|
||||
panZoom.value.smoothZoomAbs(0, 0, initialZoom.value)
|
||||
panZoom.value.smoothMoveTo(startX * 0.5, startY)
|
||||
} else {
|
||||
panZoom.value.zoomTo(0, 0, initialZoom.value)
|
||||
panZoom.value.moveTo(startX * 0.5, startY)
|
||||
}
|
||||
panZoom.value.resume()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reset,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomLevel,
|
||||
isMaxZoom,
|
||||
isMinZoom,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#graphwrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#gtb .v-toolbar__content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#panzoom {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
cursor: move;
|
||||
}
|
||||
</style>
|
||||
235
ui/src/components/playbookeditor/PlaybookEditor.vue
Normal file
235
ui/src/components/playbookeditor/PlaybookEditor.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<v-row style="position: relative; min-height: 600px">
|
||||
<v-col :cols="(selectedStep && selectedStep in playbook.tasks) || showNewDialog ? 8 :12">
|
||||
<v-card style="overflow: hidden" outlined>
|
||||
<v-card-text>
|
||||
<PanZoom ref="panZoomPanel" :config="panZoomConfig">
|
||||
<template #default>
|
||||
<PlaybookGraph
|
||||
v-if="playbook"
|
||||
:playbook="playbook"
|
||||
:horizontal="horizontal"
|
||||
:selected="selectedStep"
|
||||
@update:selected="showNewDialog = false; selectedStep = $event"
|
||||
/>
|
||||
</template>
|
||||
<template #actionbar>
|
||||
<v-btn @click="showNewDialog = true; selectedStep = ''" large rounded>
|
||||
<v-icon color="#000">mdi-plus</v-icon>
|
||||
New Step
|
||||
</v-btn>
|
||||
</template>
|
||||
<template #toolbar>
|
||||
<v-btn @click="toggleOrientation" label="Toggle Orientation" icon>
|
||||
<v-icon color="#000">mdi-format-rotate-90</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</PanZoom>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="(selectedStep && selectedStep in playbook.tasks) || showNewDialog"
|
||||
cols="4"
|
||||
>
|
||||
<EditTask
|
||||
v-if="selectedStep && selectedStep in playbook.tasks"
|
||||
:value="playbook.tasks[selectedStep]"
|
||||
@input="updateTask"
|
||||
:possibleNexts="possibleNexts"
|
||||
:parents="parents"
|
||||
:playbook="playbook"
|
||||
@delete="deleteTask"
|
||||
@close="unselectStep"/>
|
||||
<NewTask
|
||||
v-else-if="showNewDialog"
|
||||
:playbook="playbook"
|
||||
@createTask="createTask"
|
||||
@close="showNewDialog = false"/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, nextTick, ref, defineProps, set, defineEmits, del} from "vue";
|
||||
import PlaybookGraph from "@/components/playbookeditor/PlaybookGraph.vue";
|
||||
import PanZoom from "@/components/playbookeditor/PanZoom.vue";
|
||||
import EditTask from "@/components/playbookeditor/EditTask.vue";
|
||||
import NewTask from "@/components/playbookeditor/NewTask.vue";
|
||||
|
||||
const props = defineProps({
|
||||
'value': {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['input']);
|
||||
|
||||
const playbook = computed({
|
||||
get: () => props.value,
|
||||
set: (value) => {
|
||||
emit('input', value);
|
||||
}
|
||||
});
|
||||
|
||||
// selected step
|
||||
const selectedStep = ref("")
|
||||
const unselectStep = () => {
|
||||
selectedStep.value = "";
|
||||
}
|
||||
|
||||
const updateTask = (task: any) => {
|
||||
set(playbook.value.tasks, selectedStep.value, task);
|
||||
emit('input', playbook.value);
|
||||
}
|
||||
|
||||
const deleteTask = () => {
|
||||
const parents = Array<any>();
|
||||
for (const task in playbook.value.tasks) {
|
||||
if (playbook.value.tasks[task].next && playbook.value.tasks[task].next[selectedStep.value]) {
|
||||
parents.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
const children = Array<any>();
|
||||
if (playbook.value.tasks[selectedStep.value].next) {
|
||||
for (const next in playbook.value.tasks[selectedStep.value].next) {
|
||||
children.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parent of parents) {
|
||||
del(playbook.value.tasks[parent].next, selectedStep.value);
|
||||
for (const child of children) {
|
||||
set(playbook.value.tasks[parent].next, child, playbook.value.tasks[selectedStep.value].next[child]);
|
||||
}
|
||||
}
|
||||
|
||||
del(playbook.value.tasks, selectedStep.value);
|
||||
|
||||
// for (const task in playbook.value.tasks) {
|
||||
// if (playbook.value.tasks[task].next && playbook.value.tasks[task].next[selectedStep.value]) {
|
||||
// del(playbook.value.tasks[task].next, selectedStep.value);
|
||||
// }
|
||||
// }
|
||||
// del(playbook.value.tasks, selectedStep.value);
|
||||
emit('input', playbook.value);
|
||||
}
|
||||
|
||||
const panZoomConfig = ref({})
|
||||
|
||||
const panZoomPanel = ref(null);
|
||||
|
||||
const horizontal = ref(false);
|
||||
const toggleOrientation = () => {
|
||||
horizontal.value = !horizontal.value;
|
||||
|
||||
nextTick(() => {
|
||||
panZoomPanel.value?.reset(false);
|
||||
});
|
||||
}
|
||||
|
||||
const showNewDialog = ref(false);
|
||||
const createTask = (task: any) => {
|
||||
const t = {
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
type: 'task',
|
||||
next: {}
|
||||
};
|
||||
set(playbook.value.tasks, task.key, t);
|
||||
selectedStep.value = task.key;
|
||||
};
|
||||
|
||||
// edit task
|
||||
const possibleNexts = computed(() => {
|
||||
if (!selectedStep.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let nexts = Object.keys(playbook.value.tasks);
|
||||
nexts = nexts.filter((n) => n !== selectedStep.value);
|
||||
|
||||
// remove any nexts that are already in the list
|
||||
if (playbook.value.tasks[selectedStep.value] && 'next' in playbook.value.tasks[selectedStep.value]) {
|
||||
for (const next in playbook.value.tasks[selectedStep.value].next) {
|
||||
nexts = nexts.filter((n) => n !== next);
|
||||
}
|
||||
}
|
||||
|
||||
// remove parents recursively
|
||||
const parents = findAncestor(selectedStep.value);
|
||||
for (const parent of parents) {
|
||||
nexts = nexts.filter((n) => n !== parent);
|
||||
}
|
||||
|
||||
const result: Array<Record<string, string>> = [];
|
||||
for (const next of nexts) {
|
||||
if (next && playbook.value.tasks[next].name) {
|
||||
result.push({"key": next, "name": playbook.value.tasks[next].name});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const findAncestor = (step: string): Array<string> => {
|
||||
const parents: Array<string> = [];
|
||||
for (const task in playbook.value.tasks) {
|
||||
for (const next in playbook.value.tasks[task].next) {
|
||||
if (next === step) {
|
||||
if (!parents.includes(task)) {
|
||||
parents.push(task);
|
||||
parents.push(...findAncestor(task));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
};
|
||||
|
||||
const parents = computed(() => {
|
||||
const parents: Array<string> = [];
|
||||
for (const task in playbook.value.tasks) {
|
||||
for (const next in playbook.value.tasks[task].next) {
|
||||
if (next === selectedStep.value) {
|
||||
if (!parents.includes(task)) {
|
||||
parents.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#graphwrapper {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#gab {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 20px;
|
||||
border-radius: 30px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
#gtb {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 20px;
|
||||
border-radius: 30px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
#gtb .v-toolbar__content > .v-btn:first-child {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
#gtb .v-toolbar__content > .v-btn:last-child {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
</style>
|
||||
511
ui/src/components/playbookeditor/PlaybookGraph.vue
Normal file
511
ui/src/components/playbookeditor/PlaybookGraph.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<svg
|
||||
class="pe-graph"
|
||||
:width="width * scale"
|
||||
:height="height * scale"
|
||||
:viewBox="`${minX - config.graphPadding} ${minY - config.graphPadding} ${maxX - minX + config.boxWidth + config.graphPadding * 2} ${maxY - minY + config.boxHeight + config.graphPadding * 2}`"
|
||||
>
|
||||
<defs>
|
||||
<path
|
||||
id="cog"
|
||||
d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
|
||||
<path
|
||||
id="clipboard-outline"
|
||||
d="M19,3H14.82C14.4,1.84 13.3,1 12,1C10.7,1 9.6,1.84 9.18,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M12,3A1,1 0 0,1 13,4A1,1 0 0,1 12,5A1,1 0 0,1 11,4A1,1 0 0,1 12,3M7,7H17V5H19V19H5V5H7V7Z"/>
|
||||
<path
|
||||
id="keyboard"
|
||||
d="M19,10H17V8H19M19,13H17V11H19M16,10H14V8H16M16,13H14V11H16M16,17H8V15H16M7,10H5V8H7M7,13H5V11H7M8,11H10V13H8M8,8H10V10H8M11,11H13V13H11M11,8H13V10H11M20,5H4C2.89,5 2,5.89 2,7V17A2,2 0 0,0 4,19H20A2,2 0 0,0 22,17V7C22,5.89 21.1,5 20,5Z"/>
|
||||
<path
|
||||
id="star"
|
||||
d="M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z"/>
|
||||
</defs>
|
||||
<g class="pe-links">
|
||||
<path v-for="(link, index) in links" :key="index" :d="link.path"/>
|
||||
</g>
|
||||
<g class="pe-activelinks">
|
||||
<path
|
||||
v-for="(link, index) in links"
|
||||
:key="index"
|
||||
:d="link.path"
|
||||
:class="{
|
||||
'hovered': !selectedNode && (link.source === hoverNode || link.target === hoverNode),
|
||||
'selected': link.source === selectedNode || link.target === selectedNode
|
||||
}"
|
||||
/>
|
||||
</g>
|
||||
<g class="pe-nodes">
|
||||
<g
|
||||
v-for="(node, index) in positionedNodes"
|
||||
:key="index">
|
||||
<g
|
||||
v-if="node.type === 'start'"
|
||||
:transform="`translate(${props.horizontal ? node.x + config.boxWidth : node.x + (config.boxWidth / 2)}, ${node.y})`"
|
||||
class="start"
|
||||
>
|
||||
<circle
|
||||
:transform="`translate(0, ${config.boxHeight / 2})`"
|
||||
r="20"
|
||||
/>
|
||||
<g
|
||||
class="icon"
|
||||
:transform="`translate(${0 - 12}, ${(config.boxHeight - 24) / 2})`"
|
||||
>
|
||||
<use href="#star" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
v-else
|
||||
:transform="`translate(${node.x}, ${node.y})`"
|
||||
:class="{
|
||||
'pe-node': true,
|
||||
'start': node.type === 'start',
|
||||
'hovered': node.id === hoverNode,
|
||||
'selected': node.id === selectedNode,
|
||||
'unhovered': hoverNode && !selectedNode && node.id !== hoverNode,
|
||||
'unselected': selectedNode && node.id !== selectedNode,
|
||||
}"
|
||||
@mouseover="hoverNode = node.id"
|
||||
@mouseout="hoverNode = null"
|
||||
@click="selectedNode === node.id ? selectedNode = null : selectedNode = node.id"
|
||||
>
|
||||
<rect
|
||||
class="pe-box"
|
||||
:width="config.boxWidth"
|
||||
:height="config.boxHeight"
|
||||
:rx="config.boxRadius"
|
||||
:ry="config.boxRadius"
|
||||
/>
|
||||
<g
|
||||
class="pe-icon"
|
||||
:transform="`translate(10, ${(config.boxHeight - 24) / 2})`"
|
||||
>
|
||||
<use v-if="node.type === 'automation'" href="#cog"/>
|
||||
<use v-else-if="node.type === 'start'" href="#star"/>
|
||||
<use v-else-if="node.type === 'input'" href="#keyboard"/>
|
||||
<use v-else href="#clipboard-outline"/>
|
||||
</g>
|
||||
<text
|
||||
class="pe-text"
|
||||
:x="config.boxWidth / 2"
|
||||
:y="config.boxHeight / 2"
|
||||
>
|
||||
{{ node.label ? node.label : node.id }}
|
||||
</text>
|
||||
<g
|
||||
class="add"
|
||||
:transform="`translate(${config.boxWidth - 34}, ${(config.boxHeight - 24) / 2})`"
|
||||
>
|
||||
<use href="#star" fill="none"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g class="pe-connectors">
|
||||
<g
|
||||
v-for="(link, index) in links"
|
||||
:key="index"
|
||||
:class="{
|
||||
'pe-connector': true,
|
||||
'hovered': !selectedNode && (link.source === hoverNode || link.target === hoverNode),
|
||||
'selected': link.source === selectedNode || link.target === selectedNode
|
||||
}"
|
||||
>
|
||||
<circle
|
||||
v-if="link.source !== 'start'"
|
||||
:cx="link.start.x"
|
||||
:cy="link.start.y"
|
||||
:r="4"/>
|
||||
<circle
|
||||
:cx="link.end.x"
|
||||
:cy="link.end.y"
|
||||
:r="7"/>
|
||||
<circle
|
||||
:cx="link.end.x"
|
||||
:cy="link.end.y"
|
||||
:r="5"
|
||||
fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as d3 from "d3";
|
||||
import {digl} from "@crinkles/digl";
|
||||
import {Edge as DiglEdge, Node as DiglNode, Position, Rank} from "@crinkles/digl/dist/types";
|
||||
import {computed, defineEmits, ref, Ref, defineProps, ComputedRef} from "vue";
|
||||
|
||||
interface Config {
|
||||
graphPadding: number;
|
||||
boxWidth: number;
|
||||
boxHeight: number;
|
||||
boxMarginX: number;
|
||||
boxMarginY: number;
|
||||
boxRadius: number;
|
||||
lineDistance: number;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
playbook: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
selected: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
horizontal: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 1
|
||||
}
|
||||
})
|
||||
|
||||
const config = ref({
|
||||
graphPadding: 10,
|
||||
boxWidth: 220,
|
||||
boxHeight: 40,
|
||||
boxMarginX: 60,
|
||||
boxMarginY: 70,
|
||||
boxRadius: 20,
|
||||
lineDistance: 0,
|
||||
});
|
||||
|
||||
interface Edge extends DiglEdge {
|
||||
label?: { text: string, x: number, y: number };
|
||||
}
|
||||
|
||||
interface Node extends DiglNode {
|
||||
type: 'automation' | 'input' | 'task' | 'start';
|
||||
}
|
||||
|
||||
const edges = computed(() => {
|
||||
const edges: Array<Edge> = [];
|
||||
|
||||
for (const key in props.playbook.tasks) {
|
||||
for (const next in props.playbook.tasks[key].next) {
|
||||
edges.push({
|
||||
source: key,
|
||||
target: next,
|
||||
label: props.playbook.tasks[key].next[next]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rootNodes = nodes.value.filter(node => edges.every(edge => edge.target !== node.id));
|
||||
for (const node of rootNodes) {
|
||||
if (node.id !== 'start') {
|
||||
edges.push({
|
||||
source: 'start',
|
||||
target: node.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
});
|
||||
|
||||
const nodes: ComputedRef<Array<Node>> = computed(() => {
|
||||
const nodes = [{
|
||||
id: "start",
|
||||
label: "Start",
|
||||
type: "start" as 'automation' | 'input' | 'task' | 'start',
|
||||
}];
|
||||
for (const key in props.playbook.tasks) {
|
||||
nodes.push({
|
||||
id: key,
|
||||
label: props.playbook.tasks[key].name,
|
||||
type: props.playbook.tasks[key].type
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
|
||||
|
||||
const hoverNode: Ref<string | null> = ref(null);
|
||||
|
||||
const emits = defineEmits(['update:selected']);
|
||||
const selectedNode = computed({
|
||||
get: () => props.selected,
|
||||
set: (newVal) => {
|
||||
emits('update:selected', newVal);
|
||||
}
|
||||
});
|
||||
|
||||
const rankses: Ref<Array<Array<Rank>>> = computed(() => {
|
||||
return digl(edges.value);
|
||||
});
|
||||
|
||||
const ranks = computed(() => {
|
||||
return rankses.value.length > 0 ? rankses.value[0] : [];
|
||||
});
|
||||
|
||||
const positionedNodes = computed(() => {
|
||||
return positioning(config.value, props.horizontal, nodes.value, ranks.value);
|
||||
});
|
||||
|
||||
const width = computed(() => {
|
||||
return maxX.value - minX.value + config.value.graphPadding * 2 + config.value.boxWidth;
|
||||
});
|
||||
|
||||
const height = computed(() => {
|
||||
return maxY.value - minY.value + config.value.graphPadding * 2 + config.value.boxHeight;
|
||||
});
|
||||
|
||||
const minX = computed(() => {
|
||||
if (props.horizontal) {
|
||||
return Math.min(...positionedNodes.value.map(node => node.x)) + config.value.boxWidth - 22;
|
||||
}
|
||||
return Math.min(...positionedNodes.value.map(node => node.x));
|
||||
});
|
||||
|
||||
const minY = computed(() => {
|
||||
if (!props.horizontal) {
|
||||
return Math.min(...positionedNodes.value.map(node => node.y)) + config.value.boxHeight - 30;
|
||||
}
|
||||
return Math.min(...positionedNodes.value.map(node => node.y));
|
||||
});
|
||||
|
||||
const maxX = computed(() => {
|
||||
return Math.max(...positionedNodes.value.map(node => node.x));
|
||||
});
|
||||
|
||||
const maxY = computed(() => {
|
||||
return Math.max(...positionedNodes.value.map(node => node.y)) + 50;
|
||||
});
|
||||
|
||||
const links = computed(() => {
|
||||
return edges.value.map(edge => {
|
||||
const source = positionedNodes.value.find(node => node.id === edge.source);
|
||||
const target = positionedNodes.value.find(node => node.id === edge.target);
|
||||
|
||||
if (!source || !target) return;
|
||||
|
||||
// index within rank
|
||||
const sourceIndex = ranks.value.find(rank => rank.includes(edge.source))?.indexOf(edge.source);
|
||||
if (sourceIndex === undefined) {
|
||||
throw new Error(`sourceIndex is undefined for ${edge.source}`);
|
||||
}
|
||||
|
||||
const path = props.horizontal ?
|
||||
horizontalConnectionLine(source, target, sourceIndex, config.value) :
|
||||
verticalConnectionLine(source, target, sourceIndex, config.value);
|
||||
|
||||
const start = props.horizontal ?
|
||||
{x: source.x + config.value.boxWidth, y: source.y + config.value.boxHeight / 2} :
|
||||
{x: source.x + config.value.boxWidth / 2, y: source.y + config.value.boxHeight};
|
||||
|
||||
const end = props.horizontal ?
|
||||
{x: target.x, y: target.y + config.value.boxHeight / 2} :
|
||||
{x: target.x + config.value.boxWidth / 2, y: target.y};
|
||||
|
||||
return {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
path: path.toString(),
|
||||
start: start,
|
||||
end: end,
|
||||
};
|
||||
}).filter(link => link);
|
||||
});
|
||||
|
||||
interface PositionedNode extends Node, Position {
|
||||
}
|
||||
|
||||
function positioning(
|
||||
config: Config,
|
||||
horizontal: boolean,
|
||||
nodes: Node[],
|
||||
ranks: Rank[]
|
||||
): PositionedNode[] {
|
||||
const _nodes: PositionedNode[] = [];
|
||||
const _h = horizontal;
|
||||
|
||||
ranks.forEach((rank, i) => {
|
||||
const xStart = _h
|
||||
? (config.boxWidth + config.boxMarginX) * i
|
||||
: -0.5 * (rank.length - 1) * (config.boxWidth + config.boxMarginX);
|
||||
const yStart = _h
|
||||
? -0.5 * (rank.length - 1) * (config.boxHeight + config.boxMarginY)
|
||||
: (config.boxHeight + config.boxMarginY) * i;
|
||||
|
||||
rank.forEach((nodeId, nIndex) => {
|
||||
const _node: Node = nodes.find((n) => n.id == nodeId) as Node;
|
||||
if (!_node) return;
|
||||
const x = _h ? xStart : xStart + (config.boxWidth + config.boxMarginX) * nIndex;
|
||||
const y = _h ? yStart + (config.boxHeight + config.boxMarginY) * nIndex : yStart;
|
||||
_nodes.push({..._node, x, y});
|
||||
});
|
||||
});
|
||||
|
||||
return _nodes;
|
||||
}
|
||||
|
||||
function verticalConnectionLine(source: PositionedNode, target: PositionedNode, sourceIndex: number, config: Config) {
|
||||
const sourceBottomCenter = {
|
||||
x: source.x + config.boxWidth / 2,
|
||||
y: source.y + config.boxHeight,
|
||||
};
|
||||
|
||||
const targetTopCenter = {
|
||||
x: target.x + config.boxWidth / 2,
|
||||
y: target.y,
|
||||
};
|
||||
|
||||
const path = d3.path();
|
||||
path.moveTo(sourceBottomCenter.x, sourceBottomCenter.y);
|
||||
|
||||
const lineCurve = config.boxMarginY / 2;
|
||||
|
||||
if (sourceBottomCenter.x == targetTopCenter.x) {
|
||||
path.lineTo(targetTopCenter.x, targetTopCenter.y);
|
||||
} else if (sourceBottomCenter.x < targetTopCenter.x) {
|
||||
if (target.y !== source.y + config.boxHeight + config.boxMarginY) {
|
||||
path.lineTo(sourceBottomCenter.x, target.y - config.boxMarginY);
|
||||
}
|
||||
sourceBottomCenter.y = target.y - config.boxMarginY;
|
||||
path.quadraticCurveTo(
|
||||
sourceBottomCenter.x, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
sourceBottomCenter.x + lineCurve, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
);
|
||||
path.lineTo(targetTopCenter.x - lineCurve, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance);
|
||||
path.quadraticCurveTo(
|
||||
targetTopCenter.x, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance,
|
||||
targetTopCenter.x, targetTopCenter.y,
|
||||
);
|
||||
} else {
|
||||
if (target.y !== source.y + config.boxHeight + config.boxMarginY) {
|
||||
path.lineTo(sourceBottomCenter.x, target.y - config.boxMarginY);
|
||||
}
|
||||
sourceBottomCenter.y = target.y - config.boxMarginY;
|
||||
path.quadraticCurveTo(
|
||||
sourceBottomCenter.x, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
sourceBottomCenter.x - lineCurve, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
);
|
||||
path.lineTo(targetTopCenter.x + lineCurve, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance);
|
||||
path.quadraticCurveTo(
|
||||
targetTopCenter.x, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance,
|
||||
targetTopCenter.x, targetTopCenter.y,
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function horizontalConnectionLine(source: PositionedNode, target: PositionedNode, sourceIndex: number, config: Config) {
|
||||
const sourceRightCenter = {
|
||||
x: source.x + config.boxWidth,
|
||||
y: source.y + config.boxHeight / 2,
|
||||
};
|
||||
|
||||
const targetLeftCenter = {
|
||||
x: target.x,
|
||||
y: target.y + config.boxHeight / 2,
|
||||
};
|
||||
|
||||
const path = d3.path();
|
||||
path.moveTo(sourceRightCenter.x, sourceRightCenter.y);
|
||||
|
||||
const lineCurve = config.boxMarginX / 2;
|
||||
|
||||
if (sourceRightCenter.y == targetLeftCenter.y) {
|
||||
path.lineTo(targetLeftCenter.x, targetLeftCenter.y);
|
||||
} else if (sourceRightCenter.y < targetLeftCenter.y) {
|
||||
if (target.x !== source.x + config.boxWidth + config.boxMarginX) {
|
||||
path.lineTo(target.x - config.boxMarginX, sourceRightCenter.y);
|
||||
}
|
||||
sourceRightCenter.x = target.x - config.boxMarginX;
|
||||
path.quadraticCurveTo(
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y,
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y + lineCurve,
|
||||
);
|
||||
path.lineTo(targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y - lineCurve);
|
||||
path.quadraticCurveTo(
|
||||
targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y,
|
||||
targetLeftCenter.x, targetLeftCenter.y,
|
||||
);
|
||||
} else {
|
||||
if (target.x !== source.x + config.boxWidth + config.boxMarginX) {
|
||||
path.lineTo(target.x - config.boxMarginX, sourceRightCenter.y);
|
||||
}
|
||||
sourceRightCenter.x = target.x - config.boxMarginX;
|
||||
path.quadraticCurveTo(
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y,
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y - lineCurve,
|
||||
);
|
||||
path.lineTo(targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y + lineCurve);
|
||||
path.quadraticCurveTo(
|
||||
targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y,
|
||||
targetLeftCenter.x, targetLeftCenter.y,
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
svg .pe-node.unhovered {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
svg .pe-node .pe-box {
|
||||
fill: white;
|
||||
stroke: #4C566A;
|
||||
stroke-width: 1px;
|
||||
filter: drop-shadow(0 1.5px 1.5px rgba(0, 0, 0, .15));
|
||||
}
|
||||
|
||||
svg .pe-node.selected .pe-box {
|
||||
stroke: #88C0D0;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
svg .pe-node text {
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
svg .pe-node:hover .pe-box {
|
||||
filter: drop-shadow(0 0 0.1rem #333);
|
||||
}
|
||||
|
||||
svg .pe-node:hover .pe-box,
|
||||
svg .pe-node:hover text {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg .pe-node:hover .add use {
|
||||
/* fill: red !important; */
|
||||
}
|
||||
|
||||
svg .pe-links path {
|
||||
stroke: black;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .15));
|
||||
}
|
||||
|
||||
svg .pe-activelinks path {
|
||||
stroke: none;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
svg .pe-activelinks path.selected,
|
||||
svg .pe-activelinks path.hovered {
|
||||
stroke-width: 3;
|
||||
stroke-linejoin: round;
|
||||
stroke: #88C0D0;
|
||||
filter: drop-shadow(0 0 0.1rem #88C0D0);
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user