diff --git a/DoodleBUGS/package-lock.json b/DoodleBUGS/package-lock.json index 8de86b47a..9f54a3b71 100644 --- a/DoodleBUGS/package-lock.json +++ b/DoodleBUGS/package-lock.json @@ -18,6 +18,7 @@ "cytoscape-grid-guide": "^2.3.3", "cytoscape-klay": "^3.1.4", "cytoscape-no-overlap": "^1.0.1", + "cytoscape-panzoom": "^2.5.3", "cytoscape-snap-to-grid": "^2.0.0", "cytoscape-svg": "^0.4.0", "pinia": "^3.0.2", @@ -2713,6 +2714,18 @@ "cytoscape": "^3.2.0" } }, + "node_modules/cytoscape-panzoom": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/cytoscape-panzoom/-/cytoscape-panzoom-2.5.3.tgz", + "integrity": "sha512-//qLOqbbFUCGddarNKHDZArItOJHgnkQ1TvxI9nV2/8aOOl/5wuEOHmra3fL/aWSjB4AYpYTG4LX7w96uWfRTQ==", + "license": "MIT", + "dependencies": { + "jquery": "^1.4 || ^2.0 || ^3.0" + }, + "peerDependencies": { + "cytoscape": "^2.0.0 || ^3.0.0" + } + }, "node_modules/cytoscape-snap-to-grid": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/cytoscape-snap-to-grid/-/cytoscape-snap-to-grid-2.0.0.tgz", @@ -3691,6 +3704,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/DoodleBUGS/package.json b/DoodleBUGS/package.json index 98284a908..8dee3c238 100644 --- a/DoodleBUGS/package.json +++ b/DoodleBUGS/package.json @@ -22,6 +22,7 @@ "cytoscape-grid-guide": "^2.3.3", "cytoscape-klay": "^3.1.4", "cytoscape-no-overlap": "^1.0.1", + "cytoscape-panzoom": "^2.5.3", "cytoscape-snap-to-grid": "^2.0.0", "cytoscape-svg": "^0.4.0", "pinia": "^3.0.2", diff --git a/DoodleBUGS/src/assets/styles/global.css b/DoodleBUGS/src/assets/styles/global.css index a484d829d..c8b82810d 100644 --- a/DoodleBUGS/src/assets/styles/global.css +++ b/DoodleBUGS/src/assets/styles/global.css @@ -1,4 +1,5 @@ @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css'); +@import url('https://unpkg.com/cytoscape-panzoom/cytoscape.js-panzoom.css'); :root { --color-background: #f8f9fa; diff --git a/DoodleBUGS/src/components/canvas/GraphCanvas.vue b/DoodleBUGS/src/components/canvas/GraphCanvas.vue index 6adb9e2b4..7f43b0263 100644 --- a/DoodleBUGS/src/components/canvas/GraphCanvas.vue +++ b/DoodleBUGS/src/components/canvas/GraphCanvas.vue @@ -11,6 +11,7 @@ const props = defineProps<{ gridSize: number; currentMode: string; validationErrors: Map; + zoomControlsPosition?: string; }>(); const emit = defineEmits<{ @@ -29,6 +30,53 @@ const { enableGridSnapping, disableGridSnapping, setGridSize } = useGridSnapping const validNodeTypes: NodeType[] = ['stochastic', 'deterministic', 'constant', 'observed', 'plate']; +// Zoom state +const currentZoom = ref(1); +const minZoom = 0.1; +const maxZoom =2; + +// Panzoom control functions +const zoomIn = () => { + if (cy) { + const newZoom = Math.min(cy.zoom() * 1.2, maxZoom); + cy.zoom({ + level: newZoom, + renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } + }); + currentZoom.value = newZoom; + } +}; + +const zoomOut = () => { + if (cy) { + const newZoom = Math.max(cy.zoom() / 1.2, minZoom); + cy.zoom({ + level: newZoom, + renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } + }); + currentZoom.value = newZoom; + } +}; + +const resetView = () => { + if (cy) { + cy.fit(); + currentZoom.value = 1; + } +}; + +const setZoomLevel = (event: Event) => { + if (cy) { + const target = event.target as HTMLInputElement; + const zoomLevel = Number(target.value); + cy.zoom({ + level: zoomLevel, + renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } + }); + currentZoom.value = zoomLevel; + } +}; + interface CompoundDropPayload { node: NodeSingular; newParent: NodeSingular | null; @@ -208,23 +256,72 @@ watch(() => props.gridSize, (newValue: number) => { watch([() => props.elements, () => props.validationErrors], ([newElements, newErrors]) => { syncGraphWithProps(newElements, newErrors); }, { deep: true }); + +// Sync zoom level with graph +watch(() => cy, (newCy) => { + if (newCy) { + // Set initial zoom level + currentZoom.value = newCy.zoom(); + + // Listen for zoom events from other sources (mouse wheel, etc.) + newCy.on('zoom', () => { + currentZoom.value = newCy.zoom(); + }); + } +}, { immediate: true }); + +/* Custom Panzoom Controls Container */ +.panzoom-controls { + position: absolute; + z-index: 10; + display: flex; + flex-direction: column; + gap: 4px; + background: rgba(255, 255, 255, 0.9); + border-radius: 8px; + padding: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + backdrop-filter: blur(4px); +} + +/* Position classes */ +.panzoom-position-default, +.panzoom-position-bottom-left { + left: 12px; + bottom: 12px; +} + +.panzoom-position-top-left { + left: 12px; + top: 12px; +} + +.panzoom-position-top-right { + right: 12px; + top: 12px; +} + +.panzoom-position-bottom-right { + right: 12px; + bottom: 12px; +} + +.panzoom-button { + width: 32px; + height: 32px; + background: #ffffff; + border: 1px solid #d0d7de; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + color: #222; + font-size: 12px; +} + +.panzoom-button:hover { + background: #f6f8fa; + border-color: #c2c8d0; + transform: scale(1.05); +} + +.panzoom-button:active { + transform: scale(0.95); +} + +.panzoom-slider-container { + + align-items: center; +} +/* Custom Panzoom Slider vertically */ +.panzoom-slider { + margin-left: 8px; + writing-mode: vertical-lr; + direction: rtl; +} + \ No newline at end of file diff --git a/DoodleBUGS/src/components/canvas/GraphEditor.vue b/DoodleBUGS/src/components/canvas/GraphEditor.vue index 586a3edaa..df56fdd7b 100644 --- a/DoodleBUGS/src/components/canvas/GraphEditor.vue +++ b/DoodleBUGS/src/components/canvas/GraphEditor.vue @@ -15,6 +15,7 @@ const props = defineProps<{ currentNodeType: NodeType; elements: GraphElement[]; validationErrors: Map; + zoomControlsPosition?: string; }>(); const emit = defineEmits<{ @@ -246,6 +247,7 @@ watch(() => props.currentMode, (newMode) => { :grid-size="gridSize" :current-mode="props.currentMode" :validation-errors="props.validationErrors" + :zoom-controls-position="props.zoomControlsPosition" @canvas-tap="handleCanvasTap" @node-moved="handleNodeMoved" @node-dropped="handleNodeDropped" diff --git a/DoodleBUGS/src/components/layouts/MainLayout.vue b/DoodleBUGS/src/components/layouts/MainLayout.vue index 02fb4c5c0..b4e4c6460 100644 --- a/DoodleBUGS/src/components/layouts/MainLayout.vue +++ b/DoodleBUGS/src/components/layouts/MainLayout.vue @@ -50,6 +50,7 @@ const currentMode = ref('select'); const currentNodeType = ref('stochastic'); const isGridEnabled = ref(true); const gridSize = ref(20); +const zoomControlsPosition = ref('default'); const isResizingLeft = ref(false); const isResizingRight = ref(false); @@ -377,7 +378,8 @@ const isModelValid = computed(() => validationErrors.value.size === 0); @new-graph="showNewGraphModal = true" @save-current-graph="saveCurrentGraph" @open-about-modal="showAboutModal = true" @export-json="handleExportJson" @open-export-modal="openExportModal" @apply-layout="handleGraphLayout" @load-example="handleLoadExample" @validate-model="validateGraph" - :is-model-valid="isModelValid" @show-validation-issues="showValidationModal = true" /> + :is-model-valid="isModelValid" @show-validation-issues="showValidationModal = true" + @update:zoom-controls-position="zoomControlsPosition = $event" />
+ + @@ -279,7 +294,8 @@ const handleGridSizeInput = (event: Event) => { } .dropdown-checkbox, -.dropdown-input-group { +.dropdown-input-group, +.panzoom-checkbox { display: flex; align-items: center; gap: 8px; @@ -370,4 +386,62 @@ const handleGridSizeInput = (event: Event) => { .validation-status.invalid { color: var(--color-danger); } + +/* Submenu styling */ +.dropdown-submenu { + position: relative; +} + +.dropdown-submenu-trigger { + padding: 10px 15px; + color: var(--color-text); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9em; + cursor: pointer; + border-radius: 10px; +} + +.dropdown-submenu-trigger:hover { + background-color: var(--color-background-mute); +} + +.dropdown-submenu-trigger i { + font-size: 0.8em; + opacity: 0.6; + transition: transform 0.2s ease; +} + +.dropdown-submenu:hover .dropdown-submenu-trigger i { + transform: translateX(2px); +} + +.dropdown-submenu-content { + position: absolute; + left: 100%; + top: 0; + background: white; + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 120px; + opacity: 0; + visibility: hidden; + transform: translateX(-10px); + transition: all 0.2s ease; + z-index: 1000; +} + +.dropdown-submenu:hover .dropdown-submenu-content { + opacity: 1; + visibility: visible; + transform: translateX(0); + +} + +.dropdown-submenu-content a:hover { + background-color: var(--color-primary); + color: white; +} diff --git a/DoodleBUGS/src/composables/useGraphInstance.ts b/DoodleBUGS/src/composables/useGraphInstance.ts index 90665cfb4..e0ff4a3e8 100644 --- a/DoodleBUGS/src/composables/useGraphInstance.ts +++ b/DoodleBUGS/src/composables/useGraphInstance.ts @@ -24,7 +24,7 @@ export function useGraphInstance() { if (cyInstance) { cyInstance.destroy(); cyInstance = null; - } + } const options: cytoscape.CytoscapeOptions = { container: container, diff --git a/DoodleBUGS/src/types/cytoscape-extensions.d.ts b/DoodleBUGS/src/types/cytoscape-extensions.d.ts index f9af9c1ae..898d95531 100644 --- a/DoodleBUGS/src/types/cytoscape-extensions.d.ts +++ b/DoodleBUGS/src/types/cytoscape-extensions.d.ts @@ -5,3 +5,4 @@ declare module 'cytoscape-fcose'; declare module 'cytoscape-svg'; declare module 'cytoscape-cola'; declare module 'cytoscape-klay'; +declare module 'cytoscape-panzoom';