Skip to content

Commit d07d997

Browse files
DoodleBUGS: Allow Nested Plates, add new layouts and fix lot of linting issues (#357)
Removed [`cytoscape-compound-drag-and-drop`](https://github.com/cytoscape/cytoscape.js-compound-drag-and-drop) because it only supports plates to depth 1 so I have written custom logic for plates which which supports plates in much better way! This PR adds these features for Plates: - Nested plates to any depth - Now supports empty plates --------- Co-authored-by: Xianda Sun <[email protected]>
1 parent 6136493 commit d07d997

26 files changed

+1429
-1043
lines changed

DoodleBUGS/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ yarn-error.log*
77
pnpm-debug.log*
88
lerna-debug.log*
99
digest.txt
10+
root.txt
11+
src.txt
12+
public.txt
1013

1114
node_modules
1215
.DS_Store

DoodleBUGS/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
A web-based graphical editor for creating Bayesian models, inspired by DoodleBUGS and designed to work with JuliaBUGS. This project aims to provide an visual interface for building, understanding, and sharing probabilistic models.
44

5+
Try DoodleBUGS at [`https://turinglang.org/JuliaBUGS.jl/DoodleBUGS/`](https://turinglang.org/JuliaBUGS.jl/DoodleBUGS/).
6+
57
# Project Status: Pre-Alpha
68

79
This project is currently in the pre-alpha phase of development as part of the Google Summer of Code 2025 program.

DoodleBUGS/package-lock.json

Lines changed: 701 additions & 581 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

DoodleBUGS/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
"@types/cytoscape": "^3.21.9",
1616
"codemirror": "^5.65.19",
1717
"cytoscape": "^3.32.0",
18-
"cytoscape-compound-drag-and-drop": "^1.1.0",
18+
"cytoscape-cola": "^2.5.1",
1919
"cytoscape-context-menus": "^4.2.1",
2020
"cytoscape-dagre": "^2.5.0",
2121
"cytoscape-fcose": "^2.2.0",
2222
"cytoscape-grid-guide": "^2.3.3",
23+
"cytoscape-klay": "^3.1.4",
2324
"cytoscape-no-overlap": "^1.0.1",
24-
"cytoscape-panzoom": "^2.5.3",
2525
"cytoscape-snap-to-grid": "^2.0.0",
2626
"cytoscape-svg": "^0.4.0",
2727
"pinia": "^3.0.2",

DoodleBUGS/src/components/canvas/GraphCanvas.vue

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ const { enableGridSnapping, disableGridSnapping, setGridSize } = useGridSnapping
2929
3030
const validNodeTypes: NodeType[] = ['stochastic', 'deterministic', 'constant', 'observed', 'plate'];
3131
32+
interface CompoundDropPayload {
33+
node: NodeSingular;
34+
newParent: NodeSingular | null;
35+
oldParent: NodeSingular | null;
36+
}
37+
3238
const formatElementsForCytoscape = (elements: GraphElement[], errors: Map<string, ValidationError[]>): ElementDefinition[] => {
3339
return elements.map(el => {
3440
if (el.type === 'node') {
@@ -56,7 +62,6 @@ const formatElementsForCytoscape = (elements: GraphElement[], errors: Map<string
5662
5763
/**
5864
* Synchronizes the Cytoscape instance with the current graph elements from props.
59-
* This function handles adding, updating, and removing nodes and edges.
6065
* @param elementsToSync The array of graph elements to display.
6166
* @param errorsToSync The map of validation errors.
6267
*/
@@ -68,14 +73,12 @@ const syncGraphWithProps = (elementsToSync: GraphElement[], errorsToSync: Map<st
6873
cy.batch(() => {
6974
const newElementIds = new Set(elementsToSync.map(el => el.id));
7075
71-
// Remove elements that are no longer in the props
7276
cy!.elements().forEach(cyEl => {
7377
if (!newElementIds.has(cyEl.id())) {
7478
cyEl.remove();
7579
}
7680
});
7781
78-
// Add or update elements
7982
formattedElements.forEach(formattedEl => {
8083
if (!formattedEl.data.id) return;
8184
@@ -108,7 +111,6 @@ onMounted(() => {
108111
if (cyContainer.value) {
109112
cy = initCytoscape(cyContainer.value, []);
110113
111-
// Perform the initial synchronization to draw the graph on load
112114
syncGraphWithProps(props.elements, props.validationErrors);
113115
114116
setGridSize(props.gridSize);
@@ -129,29 +131,15 @@ onMounted(() => {
129131
emit('canvas-tap', evt);
130132
});
131133
132-
cy.on('cdnddrop', 'node', (evt: EventObject, dropTarget: NodeSingular | undefined) => {
133-
const node = evt.target as NodeSingular;
134-
const newParentId = dropTarget ? dropTarget.id() : undefined;
135-
136-
const originalNode = props.elements.find(el => el.id === node.id() && el.type === 'node') as GraphNode | undefined;
137-
138-
if (originalNode && originalNode.parent && originalNode.parent !== newParentId) {
139-
const oldParentId = originalNode.parent;
140-
const oldParent = props.elements.find(el => el.id === oldParentId && el.type === 'node' && (el as GraphNode).nodeType === 'plate');
141-
142-
if (oldParent) {
143-
const siblings = props.elements.filter(el => el.type === 'node' && (el as GraphNode).parent === oldParentId);
144-
if (siblings.length === 1) {
145-
emit('plate-emptied', oldParentId);
146-
}
147-
}
148-
}
149-
150-
emit('node-moved', {
151-
nodeId: node.id(),
152-
position: node.position(),
153-
parentId: newParentId,
154-
});
134+
cy.on('compound-drop', (_evt: EventObject, data: CompoundDropPayload) => {
135+
const { node, newParent } = data;
136+
const newParentId = newParent ? newParent.id() : undefined;
137+
138+
emit('node-moved', {
139+
nodeId: node.id(),
140+
position: node.position(),
141+
parentId: newParentId
142+
});
155143
});
156144
157145
cy.on('tap', 'node, edge', (evt: EventObject) => {
@@ -182,7 +170,12 @@ onMounted(() => {
182170
const clientX = event.clientX;
183171
const clientY = event.clientY;
184172
const renderedPos = { x: clientX - bbox.left, y: clientY - bbox.top };
185-
const modelPos = cy.panzoom().renderedPositionToModelPosition(renderedPos);
173+
const pan = cy.pan();
174+
const zoom = cy.zoom();
175+
const modelPos = {
176+
x: (renderedPos.x - pan.x) / zoom,
177+
y: (renderedPos.y - pan.y) / zoom
178+
};
186179
emit('node-dropped', { nodeType: droppedItemType as NodeType, position: modelPos });
187180
}
188181
}
@@ -197,22 +190,21 @@ onUnmounted(() => {
197190
}
198191
});
199192
200-
watch(() => props.isGridEnabled, (newValue) => {
193+
watch(() => props.isGridEnabled, (newValue: boolean) => {
201194
if (newValue) {
202195
enableGridSnapping();
203196
} else {
204197
disableGridSnapping();
205198
}
206199
});
207200
208-
watch(() => props.gridSize, (newValue) => {
201+
watch(() => props.gridSize, (newValue: number) => {
209202
setGridSize(newValue);
210203
if (props.isGridEnabled) {
211204
enableGridSnapping();
212205
}
213206
});
214207
215-
// Watch for subsequent changes to props and re-sync the graph
216208
watch([() => props.elements, () => props.validationErrors], ([newElements, newErrors]) => {
217209
syncGraphWithProps(newElements, newErrors);
218210
}, { deep: true });
@@ -248,4 +240,22 @@ watch([() => props.elements, () => props.validationErrors], ([newElements, newEr
248240
.cytoscape-container.mode-add-edge {
249241
cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="%23333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-right"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>') 12 12, crosshair;
250242
}
243+
244+
/* Custom drag and drop styling */
245+
.cdnd-grabbed-node {
246+
background-color: #FFD700 !important;
247+
opacity: 0.7;
248+
border: 2px dashed #FFA500;
249+
}
250+
251+
.cdnd-drop-target {
252+
border: 3px solid #32CD32 !important;
253+
background-color: rgba(50, 205, 50, 0.1) !important;
254+
}
255+
256+
/* Visual indicator for nodes being dragged out of plates */
257+
.cdnd-drag-out {
258+
border: 2px dashed #FF0000 !important;
259+
background-color: rgba(255, 0, 0, 0.1) !important;
260+
}
251261
</style>

DoodleBUGS/src/components/canvas/GraphEditor.vue

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const emit = defineEmits<{
2323
(e: 'update:currentNodeType', type: NodeType): void;
2424
}>();
2525
26-
const { elements, addElement, updateElement, deleteElement } = useGraphElements();
26+
const { elements: graphElements, addElement, updateElement, deleteElement } = useGraphElements();
2727
const { getCyInstance } = useGraphInstance();
2828
2929
const sourceNode = ref<NodeSingular | null>(null);
@@ -35,13 +35,11 @@ const greekAlphabet = [
3535
'sigma', 'tau', 'upsilon', 'phi', 'chi', 'psi', 'omega'
3636
];
3737
38-
const MAX_NODE_NAME_ITERATIONS = 1000;
39-
4038
const getNextNodeName = (): string => {
4139
const existingNames = new Set(
42-
elements.value
43-
.filter(el => el.type === 'node')
44-
.map(el => (el as GraphNode).name)
40+
graphElements.value
41+
.filter((el: GraphElement) => el.type === 'node')
42+
.map((el: GraphElement) => (el as GraphNode).name)
4543
);
4644
4745
for (const letter of greekAlphabet) {
@@ -50,17 +48,13 @@ const getNextNodeName = (): string => {
5048
}
5149
}
5250
53-
// Fallback if all Greek letters are used
54-
let i = 1;
55-
while (i < MAX_NODE_NAME_ITERATIONS) { // Add a reasonable limit to prevent infinite loops
51+
for (let i = 1; i < 1000; i++) {
5652
const fallbackName = `var${i}`;
5753
if (!existingNames.has(fallbackName)) {
5854
return fallbackName;
5955
}
60-
i++;
6156
}
6257
63-
// Ultimate fallback for extreme cases
6458
return `node_${Date.now()}`;
6559
};
6660
@@ -97,7 +91,7 @@ const createNode = (nodeType: NodeType, position: { x: number; y: number }, pare
9791
const createPlateWithNode = (position: { x: number; y: number }, parentId?: string): GraphNode => {
9892
const newPlate = createNode('plate', position, parentId);
9993
const innerNode = createNode('stochastic', { x: position.x, y: position.y }, newPlate.id);
100-
elements.value = [...elements.value, newPlate, innerNode];
94+
graphElements.value = [...graphElements.value, newPlate, innerNode];
10195
return newPlate;
10296
}
10397
@@ -116,10 +110,6 @@ const handleCanvasTap = (event: EventObject) => {
116110
case 'add-node':
117111
if (isBackgroundClick || isPlateClick) {
118112
if (props.currentNodeType === 'plate') {
119-
if (isPlateClick) {
120-
alert("Nesting plates is not currently supported.");
121-
return;
122-
}
123113
const newPlate = createPlateWithNode(position, isPlateClick ? (target as NodeSingular).id() : undefined);
124114
emit('element-selected', newPlate);
125115
emit('update:currentMode', 'select');
@@ -174,7 +164,7 @@ const handleCanvasTap = (event: EventObject) => {
174164
};
175165
176166
const handleNodeMoved = (payload: { nodeId: string, position: { x: number; y: number }, parentId: string | undefined }) => {
177-
const elementToUpdate = elements.value.find(el => el.id === payload.nodeId) as GraphNode | undefined;
167+
const elementToUpdate = graphElements.value.find((el: GraphElement) => el.id === payload.nodeId) as GraphNode | undefined;
178168
179169
if (elementToUpdate) {
180170
const updatedNode: GraphNode = {
@@ -192,17 +182,18 @@ const handleNodeDropped = (payload: { nodeType: NodeType; position: { x: number;
192182
let parentPlateId: string | undefined = undefined;
193183
194184
if (nodeType === 'plate') {
185+
let parentPlateId: string | undefined = undefined;
195186
if (cy) {
196187
const plates = cy.nodes('[nodeType="plate"]');
197188
for (const plate of plates) {
198189
const bb = plate.boundingBox();
199190
if (position.x > bb.x1 && position.x < bb.x2 && position.y > bb.y1 && position.y < bb.y2) {
200-
alert("Nesting plates is not currently supported.");
201-
return;
191+
parentPlateId = plate.id();
192+
break;
202193
}
203194
}
204195
}
205-
const newPlate = createPlateWithNode(position);
196+
const newPlate = createPlateWithNode(position, parentPlateId);
206197
emit('element-selected', newPlate);
207198
emit('update:currentMode', 'select');
208199
return;
@@ -225,10 +216,6 @@ const handleNodeDropped = (payload: { nodeType: NodeType; position: { x: number;
225216
emit('update:currentMode', 'select');
226217
};
227218
228-
const handlePlateEmptied = (plateId: string) => {
229-
deleteElement(plateId);
230-
};
231-
232219
const handleDeleteElement = (elementId: string) => {
233220
deleteElement(elementId);
234221
};
@@ -250,7 +237,7 @@ watch(() => props.currentMode, (newMode) => {
250237
@update:current-mode="(mode: string) => emit('update:currentMode', mode)"
251238
@update:current-node-type="(type: NodeType) => emit('update:currentNodeType', type)"
252239
:is-connecting="isConnecting"
253-
:source-node-name="sourceNode?.data('name')"
240+
:source-node-name="sourceNode ? (sourceNode.data('name') as string) : undefined"
254241
/>
255242

256243
<GraphCanvas
@@ -262,7 +249,6 @@ watch(() => props.currentMode, (newMode) => {
262249
@canvas-tap="handleCanvasTap"
263250
@node-moved="handleNodeMoved"
264251
@node-dropped="handleNodeDropped"
265-
@plate-emptied="handlePlateEmptied"
266252
@element-remove="handleDeleteElement"
267253
/>
268254
</div>

DoodleBUGS/src/components/layouts/ExportModal.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ import BaseInput from '../ui/BaseInput.vue';
66
77
export type ExportType = 'png' | 'jpg' | 'svg';
88
9+
interface ExportOptions {
10+
bg: string;
11+
full: boolean;
12+
scale: number;
13+
quality?: number;
14+
maxWidth?: number;
15+
maxHeight?: number;
16+
}
17+
918
const props = defineProps<{
1019
isOpen: boolean;
1120
exportType: ExportType | null;
@@ -41,7 +50,7 @@ const title = computed(() => {
4150
});
4251
4352
const handleConfirm = () => {
44-
const exportOptions: any = {
53+
const exportOptions: ExportOptions = {
4554
bg: options.value.bg,
4655
full: options.value.full,
4756
scale: options.value.scale,

0 commit comments

Comments
 (0)