Skip to content

Commit 20f2d58

Browse files
store manual layout and fix linting and typescript errors (#393)
Closes #361
1 parent 5051738 commit 20f2d58

File tree

4 files changed

+87
-73
lines changed

4 files changed

+87
-73
lines changed

DoodleBUGS/src/components/canvas/GraphCanvas.vue

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,6 @@ 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-
3832
const formatElementsForCytoscape = (elements: GraphElement[], errors: Map<string, ValidationError[]>): ElementDefinition[] => {
3933
return elements.map(el => {
4034
if (el.type === 'node') {
@@ -131,15 +125,18 @@ onMounted(() => {
131125
emit('canvas-tap', evt);
132126
});
133127
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-
});
128+
// Capture the final position of a node after any drag operation (including grid snapping).
129+
// This is the definitive event for updating node positions and saving the 'preset' layout.
130+
cy.on('free', 'node', (evt: EventObject) => {
131+
const node = evt.target as NodeSingular;
132+
const parentCollection = node.parent();
133+
const parentId = parentCollection.length > 0 ? parentCollection.first().id() : undefined;
134+
135+
emit('node-moved', {
136+
nodeId: node.id(),
137+
position: node.position(),
138+
parentId: parentId,
139+
});
143140
});
144141
145142
cy.on('tap', 'node, edge', (evt: EventObject) => {
@@ -238,7 +235,7 @@ watch([() => props.elements, () => props.validationErrors], ([newElements, newEr
238235
}
239236
240237
.cytoscape-container.mode-add-edge {
241-
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;
238+
cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24" fill="none" stroke="%23333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-right"><line x1="5" y1="2" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>') 12 12, crosshair;
242239
}
243240
244241
/* Custom drag and drop styling */
@@ -259,3 +256,4 @@ watch([() => props.elements, () => props.validationErrors], ([newElements, newEr
259256
background-color: rgba(255, 0, 0, 0.1) !important;
260257
}
261258
</style>
259+

DoodleBUGS/src/components/canvas/GraphEditor.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const emit = defineEmits<{
2121
(e: 'element-selected', element: GraphElement | null): void;
2222
(e: 'update:currentMode', mode: string): void;
2323
(e: 'update:currentNodeType', type: NodeType): void;
24+
(e: 'layout-updated', layoutName: string): void;
2425
}>();
2526
2627
const { elements: graphElements, addElement, updateElement, deleteElement } = useGraphElements();
@@ -173,6 +174,7 @@ const handleNodeMoved = (payload: { nodeId: string, position: { x: number; y: nu
173174
parent: payload.parentId
174175
};
175176
updateElement(updatedNode);
177+
emit('layout-updated', 'preset');
176178
}
177179
};
178180

DoodleBUGS/src/components/layouts/MainLayout.vue

Lines changed: 58 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
<!-- src/components/layouts/MainLayout.vue -->
21
<script setup lang="ts">
32
import { ref, computed, onUnmounted, watch, onMounted, nextTick } from 'vue';
43
import type { StyleValue } from 'vue';
@@ -123,11 +122,13 @@ onMounted(async () => {
123122
124123
watch(
125124
() => graphStore.currentGraphId,
126-
(newId, oldId) => {
127-
if (newId && newId !== oldId) {
125+
(newId) => {
126+
if (newId) {
128127
nextTick(() => {
129128
setTimeout(() => {
130-
handleGraphLayout('dagre');
129+
const graphContent = graphStore.graphContents.get(newId);
130+
const layoutToApply = graphContent?.lastLayout || 'dagre';
131+
handleGraphLayout(layoutToApply);
131132
}, 100);
132133
});
133134
}
@@ -242,20 +243,50 @@ const handleDeleteElement = (elementId: string) => {
242243
deleteElement(elementId);
243244
};
244245
246+
const handleLayoutUpdated = (layoutName: string) => {
247+
if (graphStore.currentGraphId) {
248+
graphStore.updateGraphLayout(graphStore.currentGraphId, layoutName);
249+
}
250+
};
251+
245252
const handleGraphLayout = (layoutName: string) => {
246-
const cy = getCyInstance();
247-
if (!cy) return;
248-
/* eslint-disable @typescript-eslint/no-explicit-any */
249-
const layoutOptionsMap: Record<string, LayoutOptions> = {
250-
dagre: { name: 'dagre', animate: true, animationDuration: 500, fit: true, padding: 30 } as any,
251-
fcose: { name: 'fcose', animate: true, animationDuration: 500, fit: true, padding: 30, randomize: false, quality: 'proof' } as any,
252-
cola: { name: 'cola', animate: true, fit: true, padding: 30, refresh: 1, avoidOverlap: true, infinite: false, centerGraph: true, flow: { axis: 'y', minSeparation: 30 }, handleDisconnected: false, randomize: false } as any,
253-
klay: { name: 'klay', animate: true, animationDuration: 500, fit: true, padding: 30, klay: { direction: 'RIGHT', edgeRouting: 'SPLINES', nodePlacement: 'LINEAR_SEGMENTS' } } as any,
254-
preset: { name: 'preset' }
255-
};
256-
/* eslint-enable @typescript-eslint/no-explicit-any */
257-
const options = layoutOptionsMap[layoutName] || layoutOptionsMap.preset;
258-
cy.layout(options).run();
253+
const cy = getCyInstance();
254+
if (!cy) return;
255+
256+
const shouldUpdatePositions = layoutName !== 'preset';
257+
258+
/* eslint-disable @typescript-eslint/no-explicit-any */
259+
const layoutOptionsMap: Record<string, LayoutOptions> = {
260+
dagre: { name: 'dagre', animate: true, animationDuration: 500, fit: true, padding: 30 } as any,
261+
fcose: { name: 'fcose', animate: true, animationDuration: 500, fit: true, padding: 30, randomize: false, quality: 'proof' } as any,
262+
cola: { name: 'cola', animate: true, fit: true, padding: 30, refresh: 1, avoidOverlap: true, infinite: false, centerGraph: true, flow: { axis: 'y', minSeparation: 30 }, handleDisconnected: false, randomize: false } as any,
263+
klay: { name: 'klay', animate: true, animationDuration: 500, fit: true, padding: 30, klay: { direction: 'RIGHT', edgeRouting: 'SPLINES', nodePlacement: 'LINEAR_SEGMENTS' } } as any,
264+
preset: { name: 'preset' }
265+
};
266+
/* eslint-enable @typescript-eslint/no-explicit-any */
267+
const options = layoutOptionsMap[layoutName] || layoutOptionsMap.preset;
268+
const layout = cy.layout(options);
269+
270+
if (shouldUpdatePositions) {
271+
layout.one('layoutstop', () => {
272+
const updatedElements = graphStore.currentGraphElements.map(el => {
273+
if (el.type === 'node') {
274+
const cyNode = cy.getElementById(el.id);
275+
if (cyNode.length > 0) {
276+
const newPos = cyNode.position();
277+
return { ...el, position: { x: newPos.x, y: newPos.y } };
278+
}
279+
}
280+
return el;
281+
});
282+
if (graphStore.currentGraphId) {
283+
graphStore.updateGraphElements(graphStore.currentGraphId, updatedElements);
284+
}
285+
});
286+
}
287+
288+
layout.run();
289+
handleLayoutUpdated(layoutName);
259290
};
260291
261292
const handlePaletteSelection = (itemType: PaletteItemType) => {
@@ -524,14 +555,15 @@ const runModel = async () => {
524555
}
525556
});
526557
const frontendStandaloneFile: GeneratedFile = { name: 'standalone.jl', content: frontendStandaloneScript };
527-
const backendFiles = (result.files ?? [])
528-
.filter(file => file.name !== 'standalone.jl')
529-
.map(file => ({
530-
name: file.name,
531-
content: typeof (file as any).content === 'string'
532-
? (file as any).content
533-
: ((file as any).content == null ? '' : JSON.stringify((file as any).content)),
534-
}));
558+
const backendFiles = (result.files ?? []).filter(file => file.name !== 'standalone.jl').map(file => {
559+
const content = (file as GeneratedFile & { content: unknown }).content;
560+
return {
561+
name: file.name,
562+
content: typeof content === 'string'
563+
? content
564+
: (content == null ? '' : JSON.stringify(content)),
565+
};
566+
});
535567
// Put standalone first so users see a guaranteed non-empty file
536568
executionStore.generatedFiles = [frontendStandaloneFile, ...backendFiles];
537569
// Debug log: show file sizes to help diagnose empty content
@@ -569,37 +601,6 @@ const runModel = async () => {
569601
}
570602
};
571603
572-
// Convert a Julia NamedTuple-like string to a JSON-friendly object (best-effort)
573-
function juliaTupleToJsonObject(juliaString: string): Record<string, unknown> {
574-
try {
575-
// Very conservative: if it's already JSON, return parsed
576-
try { return JSON.parse(juliaString); } catch { /* ignore */ }
577-
// Minimal parser for patterns like: (a = 1, b = [1,2], c = [ [..]; [..] ])
578-
// We will transform into JSON by replacing Julia syntax tokens carefully.
579-
let s = juliaString.trim();
580-
if (!s || s === '()') return {};
581-
// Replace tuple parens with braces
582-
s = s.replace(/^\(/, '{').replace(/\)$/, '}');
583-
// Replace "key =" with ""key":"
584-
s = s.replace(/(\w+)\s*=\s*/g, '"$1": ');
585-
// Replace Julia matrix [a b; c d] with array of arrays [[a,b],[c,d]] (best-effort)
586-
s = s.replace(/\[\s*([\s\S]*?)\s*\]/g, (match, content) => {
587-
// If it contains semicolons, treat as rows
588-
if (content.includes(';')) {
589-
const rows = content.split(';').map((row: string) => `[${row.trim().replace(/\s+/g, ', ')}]`);
590-
return `[${rows.join(',')}]`;
591-
}
592-
// Otherwise keep commas
593-
return `[${content.replace(/\s+/g, ' ')}]`;
594-
});
595-
// Remove trailing commas if any
596-
s = s.replace(/,\s*}/g, '}');
597-
return JSON.parse(s);
598-
} catch {
599-
return {};
600-
}
601-
}
602-
603604
const jsonToJulia = (jsonString: string): string => {
604605
try {
605606
const obj = JSON.parse(jsonString);
@@ -711,7 +712,7 @@ const handleGenerateStandalone = () => {
711712
<GraphEditor :is-grid-enabled="isGridEnabled" :grid-size="gridSize" :current-mode="currentMode"
712713
:elements="elements" :current-node-type="currentNodeType" :validation-errors="validationErrors"
713714
@update:current-mode="currentMode = $event" @update:current-node-type="currentNodeType = $event"
714-
@element-selected="handleElementSelected" />
715+
@element-selected="handleElementSelected" @layout-updated="handleLayoutUpdated" />
715716
</main>
716717

717718
<div class="resizer resizer-right" @mousedown.prevent="startResizeRight"></div>

DoodleBUGS/src/stores/graphStore.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useDataStore } from './dataStore';
66
export interface GraphContent {
77
graphId: string;
88
elements: GraphElement[];
9+
lastLayout?: string;
910
}
1011

1112
export const useGraphStore = defineStore('graph', () => {
@@ -41,6 +42,7 @@ export const useGraphStore = defineStore('graph', () => {
4142
const newContent: GraphContent = {
4243
graphId: graphId,
4344
elements: [],
45+
lastLayout: 'dagre', // Default layout for new graphs
4446
};
4547
graphContents.value.set(graphId, newContent);
4648
saveGraph(graphId, newContent);
@@ -55,6 +57,16 @@ export const useGraphStore = defineStore('graph', () => {
5557
}
5658
};
5759

60+
const updateGraphLayout = (graphId: string, layoutName: string) => {
61+
if (graphContents.value.has(graphId)) {
62+
const content = graphContents.value.get(graphId)!;
63+
if (content.lastLayout !== layoutName) {
64+
content.lastLayout = layoutName;
65+
saveGraph(graphId, content);
66+
}
67+
}
68+
};
69+
5870
const deleteGraphContent = (graphId: string) => {
5971
graphContents.value.delete(graphId);
6072
localStorage.removeItem(`doodlebugs-graph-${graphId}`);
@@ -85,6 +97,7 @@ export const useGraphStore = defineStore('graph', () => {
8597
selectGraph,
8698
createNewGraphContent,
8799
updateGraphElements,
100+
updateGraphLayout,
88101
deleteGraphContent,
89102
saveGraph,
90103
loadGraph,

0 commit comments

Comments
 (0)