This application was developed with the assistance of AI pair programming tools, including DeepSeek Chat and Claude Chat. These tools were invaluable in brainstorming solutions, debugging code, and refining the architecture of the application. Their contributions helped streamline the development process and ensured the implementation of best practices.

Following my previous post, Building a Flexible Optimizer Framework with Micronaut, I embarked on creating a frontend application with Vue.js. This app serves as a visual interface for designing and managing optimization workflows, seamlessly connecting to the Micronaut backend service. The primary goal was to create an intuitive drag-and-drop UI where users could define optimization problems by connecting inputs, transformations, and outputs. This approach eliminates the need for manual API testing tools and provides a user-friendly way to visualize and compare the efficiency of backend optimization algorithms.

In this blog, I’ll guide you through the setup, project structure, and key features of this Vue.js application.


Setup

To get started, ensure you have Node.js installed. Verify the installation by running:

node --version
# v22.12.0

npm --version
# 10.9.0

Next, install the Vue CLI globally and create the Vue project within the Micronaut-Optimizer repository:

npm install -g @vue/cli

vue create vue-app

Project Structure

The project is organized into a modular structure to ensure scalability and maintainability. Below is an overview of the directory layout:

vue-app/
├── src/
│   ├── assets/
│   ├── components/
│   │   ├── inputs/
│   │   │   ├── KeyInput.vue
│   │   │   ├── SubkeyInput.vue
│   │   │   └── TextInput.vue
│   │   ├── managers/
│   │   │   ├── DragManager.vue
│   │   │   ├── InputManager.vue
│   │   │   ├── LinkManager.vue
│   │   │   ├── NodeManager.vue
│   │   │   └── OutputManager.vue
│   │   ├── outputs/
│   │   │   ├── TextOutput.vue
│   │   │   └── TSPChartOutput.vue
│   │   ├── ConnectionLine.vue
│   │   ├── Inport.vue
│   │   ├── LeftPanel.vue
│   │   ├── LocalPersistent.vue
│   │   ├── MainPanel.vue
│   │   ├── OutPort.vue
│   │   └── WorkflowNode.vue
│   ├── models/
│   │   └── Node.js
│   ├── store/
│   │   └── index.js
│   ├── utils/
│   │   ├── lineConnectionUtils.vue
│   │   ├── nodeUtils.vue
│   │   └── transformUtils.js
│   ├── App.vue
│   └── main.js
├── babel.config.js
├── jsconfig.json
├── package.json
├── README.md
└── vue.config.js

This structure groups components, utilities, and managers logically, ensuring the project remains scalable as it evolves.


Key Concepts and Features

1. Component-Based Architecture

The Vue.js app follows a modular, component-based architecture. Components are grouped into subfolders such as inputs, managers and outputs to promote reusability and maintainability.

Below are the template sections for two essential components, TextInput.vue and TextOutput.vue:

TextInput.vue:

<template>
    <textarea v-model="text" placeholder="Text input..." spellcheck="false" @mousedown.stop @mouseup.stop
        @mousemove.stop @input="onInputChange"
        :style="{ width: initialWidth + 'px', height: initialHeight + 'px' }"></textarea>
</template>

TextOutput.vue:

<template>
    <textarea :value="formattedOutput" placeholder="Output text..." spellcheck="false" @mousedown.stop readonly
        :style="{ width: initialWidth + 'px', height: initialHeight + 'px' }"></textarea>
</template>

These components allow users to drag Workflow Nodes from the Left Panel, link outputs to inputs, and visually design optimization workflows.

vue-text-input-output-components

2. Drag-and-Drop Functionality

The drag-and-drop feature is managed by DragManager.vue. Below is a snippet illustrating how nodes are handled during the drag process:

<script>
export default {    
    methods: {
        onStartDrag(nodeId, event) {
            this.draggingNodeId = nodeId;
            const node = this.nodes.find((n) => n.id === nodeId);

            // Get the container's position
            const container = this.$el.parentElement;
            const containerRect = container.getBoundingClientRect();

            // Calculate offset relative to the container
            this.offset.x = event.clientX - containerRect.left - node.x;
            this.offset.y = event.clientY - containerRect.top - node.y;

            document.addEventListener("mousemove", this.onMouseMove);
            document.addEventListener("mouseup", this.onStopDrag);
        },
        onStopDrag() {
            this.draggingNodeId = null;
            document.removeEventListener("mousemove", this.onMouseMove);
            document.removeEventListener("mouseup", this.onStopDrag);
        },
    },
};
</script>

3. Connection Validation

The LinkManager.vue component handles connection validation between nodes, ensuring that connections adhere to predefined rules. These rules are defined in lineConnectionUtils.js.

Example of Line Linking:

<script>
export default {
  mounted() {
    window.addEventListener("keydown", this.onKeydown);
  },
  beforeUnmount() {
    window.removeEventListener("keydown", this.onKeydown);
  },
  methods: {
    onKeydown(event) {
      if (event.key === "Escape") {
        this.resetLinkingState();
      }
    },

    onStartLink(nodeId, type) {
      if (!this.linking.isDrawing) {
        this.startLink(nodeId, type);
      } else {
        this.endLink(nodeId, type);
      }
    },

    startLink(nodeId, type) {
      // Determine if the port is an input or output port
      const isInputPort = type === "input";

      // For input ports, check if they already have a line
      if (isInputPort && isInputPortConnected(nodeId)) {
        toast.warning(`This ${type} port already has a line.`);
        return;
      }

      // Start the linking process
      this.$emit("update-linking", {
        sourceId: nodeId,
        sourceType: type,
        targetId: null,
        targetType: null,
        isDrawing: true,
      });
    },

    resetLinkingState() {
      this.$emit("update-linking", {
        sourceId: null,
        sourceType: null,
        targetId: null,
        isDrawing: false,
      });
    },
  }
};
</script>

Example of Line Validation:

// src/utils/lineConnectionUtils.js
import store from '@/store';
import { useToast } from "vue-toastification";
import { findNodeById } from "@/utils/nodeUtils";

const toast = useToast();

const nodeTypes = {
    TextInput: {
        inputTypes: [""],
        outputTypes: ["textInput"],
    },
    KeyInput: {
        inputTypes: [""],
        outputTypes: ["keyInput"],
    },
    SubkeyInput: {
        inputTypes: [""],
        outputTypes: ["subkeyInput"],
    },
    TextOutput: {
        inputTypes: ["any"],
        outputTypes: [""],
    },
    DistanceMatrixConstraint: {
        inputTypes: ["double[][]"],
        outputTypes: ["distanceMatrixConstraint"],
    },
    SolveTimeConstraint: {
        inputTypes: ["string"],
        outputTypes: ["solveTimeConstraint"],
    },
    TSPInput: {
        inputTypes: ["distanceMatrixConstraint", "solveTimeConstraint"],
        outputTypes: ["TSPInput"],
    },
    TSPProblem: {
        inputTypes: ["TSPInput"],
        outputTypes: ["TSPOutput"],
    },
};

const typeCompatibility = {
    // Primitive type compatibility rules
    double: ["double", "double[]", "double[][]"],
    "double[]": ["double[]", "double[][]"],
    "double[][]": ["double[][]"],
    int: ["int", "int[]", "int[][]"],
    "int[]": ["int[]", "int[][]"],
    "int[][]": ["int[][]"],
    boolean: ["boolean", "boolean[]", "boolean[][]"],
    "boolean[]": ["boolean[]", "boolean[][]"],
    "boolean[][]": ["boolean[][]"],
    string: ["string", "string[]", "string[][]"],
    "string[]": ["string[]", "string[][]"],
    "string[][]": ["string[][]"],
    any: ["any"],
};

4. Consistent Line Rendering

The ConnectionLine.vue component ensures consistent rendering of lines between nodes. It includes logic to switch between straight lines and curves based on thresholds and adjusts source and target positions for perpendicularity.

<template>
    <path :d="curvePath" :stroke="strokeColor" :stroke-width="strokeWidth" fill="none"
        :stroke-dasharray="isDotted ? '3' : undefined" />
</template>

<script>
export default {
    name: "ConnectionLine",
    props: {
        sourcePointId: { type: String, required: true },
        targetPointId: { type: String, default: null },
        mousePosition: { type: Object, default: null },
        strokeColor: { type: String, default: "#42b983" },
        strokeWidth: { type: Number, default: 1 },
        isDotted: { type: Boolean, default: false },
    },
    computed: {
        sourcePosition() {
            return this.getPosition(this.sourcePointId);
        },
        targetPosition() {
            if (this.mousePosition) {
                return {
                    x: this.mousePosition.x,
                    y: this.mousePosition.y,
                };
            }
            return this.getPosition(this.targetPointId);
        },
        curvePath() {
            let { x: x1, y: y1 } = this.sourcePosition;
            let { x: x2, y: y2 } = this.targetPosition;

            // Calculate the distance between the source and target points
            const dx = x2 - x1;
            const dy = y2 - y1;
            const distance = Math.sqrt(dx * dx + dy * dy);

            // Define thresholds for switching between straight lines and curves
            const distanceThreshold = 50; // Use straight lines if nodes are within 10 pixels
            const alignmentThreshold = 10; // Use straight lines if nodes are aligned within 5 pixels

            // Adjust source and target positions for perpendicularity
            if (Math.abs(dx) < alignmentThreshold) {
                // Nodes are vertically aligned: adjust x positions to be the same
                x1 = x2 = (x1 + x2) / 2;
            } else if (Math.abs(dy) < alignmentThreshold) {
                // Nodes are horizontally aligned: adjust y positions to be the same
                y1 = y2 = (y1 + y2) / 2;
            }

            // Ensure the line stays within the 5-pixel bounding circles
            const radius = 5; // Radius of the bounding circle
            if (distance > 0) {
                const directionX = dx / distance; // Normalized direction vector (x)
                const directionY = dy / distance; // Normalized direction vector (y)

                // Adjust source position to stay within its bounding circle
                x1 = x1 + directionX * radius;
                y1 = y1 + directionY * radius;

                // Adjust target position to stay within its bounding circle
                x2 = x2 - directionX * radius;
                y2 = y2 - directionY * radius;
            }

            // Check if nodes are close or aligned
            if (
                distance < distanceThreshold || // Nodes are close
                Math.abs(dx) < alignmentThreshold || // Nodes are vertically aligned
                Math.abs(dy) < alignmentThreshold // Nodes are horizontally aligned
            ) {
                // Use a straight line
                return `M ${x1} ${y1} L ${x2} ${y2}`;
            } else {
                // Use a quadratic Bézier curve
                const cpX = (x1 + x2) / 2; // Midpoint between x1 and x2
                const cpY = y1 - 30; // Adjust this value to control the curve's height
                return `M ${x1} ${y1} Q ${cpX} ${cpY}, ${x2} ${y2}`;
            }
        },
    },
    mounted() {
        window.addEventListener('resize', this.handleViewportChange);
        window.addEventListener('scroll', this.handleViewportChange);
    },
    beforeUnmount() {
        window.removeEventListener('resize', this.handleViewportChange);
        window.removeEventListener('scroll', this.handleViewportChange);
    },
    methods: {
        handleViewportChange() {
            // Trigger a re-render of the lines
            this.$forceUpdate();
        },

        getPosition(pointId) {
            const element = document.querySelector(`[data-point-id="${pointId}"]`);
            const svgContainer = document.querySelector('.permanent-lines'); // Reference to the SVG container
            const elementRect = element.getBoundingClientRect();
            const svgRect = svgContainer.getBoundingClientRect();

            // Calculate positions relative to the SVG container
            const x = elementRect.left - svgRect.left + elementRect.width / 2;
            const y = elementRect.top - svgRect.top + elementRect.height / 2;

            return { x, y };
        },
    },
};
</script>

5. Dynamic Node Management

The WorkflowNode.vue component represents individual nodes in the workflow. Each node can be dynamically positioned and interacted with, allowing users to create flexible workflows. Below is the implementation:

<template>
  <div class="node" :style="{ left: node.x + 'px', top: node.y + 'px' }" :data-node-id="node.id"
    @mousedown="onStartDrag">
    <!-- Node Content -->
    <div class="node-rectangle">
      <div class="node-name" :style="{ fontSize: fontSize + 'px' }" :title="node.name">
        {{ truncatedName }}
      </div>
      <div class="left-section">
      </div>
      <div class="right-section">
        <button v-if="node.triggerAction !== 'A'" class="trigger-button" :title="actionTitle" @click="onTrigger">
          {{ node.triggerAction }}
        </button>
      </div>
    </div>

    <!-- Input Manager -->
    <InputManager :id="node.id" :hasTextInput="node.hasTextInput" :hasKeyInput="node.hasKeyInput"
      :hasSubkeyInput="node.hasSubkeyInput" :initialData="node.outputData" :initialWidth="node.width"
      :initialHeight="node.height" @input-change="onInputChange" @resize="onResize" />

    <!-- Output Manager -->
    <OutputManager :hasTextOutput="node.hasTextOutput" :hasTSPChart="node.hasChartOutput" :outputData="node.outputData"
      :solverId="solverId" :initialWidth="node.width" :initialHeight="node.height" @resize="onResize" />

    <!-- Input Ports -->
    <InPort v-for="(input, index) in node.inputTypes" :key="'input-' + index"
      :id="generatePortId(node.id, 'input', index)" :nodeWidth="200"
      :offsetY="getPortPosition(index, node.inputTypes.length)" :label="input" :inPortData="portInputData[index]"
      @start-link="onStartLink" />

    <!-- Output Ports -->
    <OutPort v-for="(output, index) in node.outputTypes" :key="'output-' + index"
      :id="generatePortId(node.id, 'output', index)" :nodeWidth="200"
      :offsetY="getPortPosition(index, node.outputTypes.length)" :label="output" :outPortData="portOutputData"
      @start-link="onStartLink" />
  </div>
</template>

There are three types of trigger actions: Submit, Auto, and Optimize. Each input node has a Submit button (S button) in its top-right corner, which propagates data to the next node in the workflow. Nodes with the Auto trigger action automatically transform their inputs into outputs. Optimization problems feature an Optimize button (O button), which invokes an API call to the Micronaut backend and streams data via Server-Sent Events (SSE) whenever a better solution is found.

vue-optimization-problem-nodes

The TSPChartOutput.vue component provides a resizable chart for visualizing optimization results:

<template>
  <div class="chart-output" :style="{ width: width + 'px', height: height + 'px' }" @mousedown="startDrag">
    <canvas ref="chartCanvas"></canvas>
    <div class="resize-handle" @mousedown="startResize"></div>
  </div>
</template>

6. Data Persistence with IndexedDB

The LocalPersistent.vue component uses IndexedDB to save and load the application state. This ensures that users can save their workflows and resume work later without losing progress, even with large datasets. For simplicity, I’ve implemented a save slot system that currently supports up to five slots. Data is compressed using the pako library before saving to reduce storage size.

<script>
import { useToast } from "vue-toastification";
import pako from 'pako';

const toast = useToast();

// IndexedDB utility functions
const openDB = () => {
    return new Promise((resolve, reject) => {
        const request = indexedDB.open('ProblemSolverDB', 1);

        request.onupgradeneeded = (event) => {
            const db = event.target.result;
            if (!db.objectStoreNames.contains('savedProblems')) {
                db.createObjectStore('savedProblems', { keyPath: 'slot' });
            }
        };

        request.onsuccess = () => resolve(request.result);
        request.onerror = () => reject(request.error);
    });
};

const saveToIndexedDB = async (slot, data) => {
    const db = await openDB();
    const transaction = db.transaction('savedProblems', 'readwrite');
    const store = transaction.objectStore('savedProblems');
    store.put({ slot, data });
};

const loadFromIndexedDB = async (slot) => {
    const db = await openDB();
    const transaction = db.transaction('savedProblems', 'readonly');
    const store = transaction.objectStore('savedProblems');
    const request = store.get(slot);
    return new Promise((resolve, reject) => {
        request.onsuccess = () => resolve(request.result?.data);
        request.onerror = () => reject(request.error);
    });
};
</script>

Running the Application

To run the application, execute the following commands:

# Clone the repo
git clone https://github.com/seehiong/micronaut-optimizer
cd micronaut-optimizer/vue-app

# Installs dependencies
yarn install

# Starts the development server
yarn serve

# Starts the backend Micronaut server
gradlew run

The frontend is configured to run on port 8081 and can be accessed at http://localhost:8081.

vue-tsp-problem-optimization


Conclusion

This Vue.js application provides an intuitive and powerful frontend for defining, managing, and troubleshooting optimization problems. It bridges the gap between complex backend algorithms and user-friendly workflows, making it easier to analyze and compare different optimization techniques.

Future enhancements include dynamic configuration retrieval from the backend, improved validation, and expanded visualization capabilities. Stay tuned for updates!