How to build a VueJS frontend for your machine learning prediction input and output
In this tutorial, we build a Vue application for users to interact with our machine learning model. This tutorial assumes you already have a backend hosting a machine learning prediction REST endpoint. To try out the frontend implementation for yourself, you may go to hdbpricer.com
This is part 3 of a 4-part tutorial
1. Building a good prediction model
2. Hosting the model prediction as an API endpoint on Flask
3. Building a simple VueJS frontend for a users to price their HDBs
4. Deploying the entire full stack application to the internet
The Git repository for the implementation can be found here
My hdbpricer app client
Background
Most Machine learning engineers / Data scientists would likely have Python or R as their core programming language and is less likely to delve into front end technologies such as javascript frameworks. Therefore, I believe this tutorial will be helpful for the Machine learning engineers to create a quick intuitive front end application to showcase the value they are delivering.
Getting Started
We will use the Vue CLI to generate a boilerplate.
We will use npm to install the Vue CLI globally.
$ npm install -g @vue/cli@3.7.0
Within the folder directory you are building your app in, run
$ vue create client
You will be prompted to answer some project setting questions. Use the down arrow key to select “Manually select features” and select the features “Babel”, “Router”, and “Linter / Formatter”.
Use history mode for the router. Then, select “ESLint + Airbnb config” for the linter and “Lint on save”. Finally, select the “In package.json” to save our configurations in package.json.
Let’s look at the folder structure.
- The node_modules is where your libraries are installed
- The public folder contains
- index.html : After your Vue application is built, it will be injected into this file
- favicon.ico : The logo that appears on your browser tab when your app is deployed
- the src folder contains
- assets folder : Where we usually save the image files we use in our app
- components folder : As we are building a single page application with multiple components (Tables, graphs, maps etc.), this is where we store all the seperate components we want to see on our application.
- router folder : Includes an index.js file that helps to map the different urls to the components of the application.
- views folder : Some default templates that is generated for homepage and about page for UI components tied to the router.
- App.vue : The base template of the application where the components will be built upon
- main.js : The javascript file that will initialise the app. Usually you will use this to add 3rd party components, import plugins etc.
- package.json contains the configurations for your application
There are some files such as editorconfig, gitignore, babelconfig, procfile, readme are not crucial in this tutorial.
To understand more about Vue components,
Take a peek at the client/src/components/HelloWorld.vue file. This is a Single File component broken up into three different sections:
To run the app :
$ cd client $ npm run serve
You will see the app hosted locally on http://localhost:8080/
Our first component
Install axios to run AJAX calls to the backend app (as seen in the previous tutorial)
$ npm install axios@0.18.0 --save
In the components folder, create a Ping.vue file
Ping.vue
<template><!-- eslint-disable max-len --><div class="container"><button type="button" class="btn btn-primary">{{ msg }}</button></div></template><script>import axios from 'axios';export default {name: 'Ping',data() {return {msg: '',};},methods: {getMessage() {const path = 'http://localhost:5000/ping';//const path = 'https://hdbpricer-fe.herokuapp.com/ping';axios.get(path).then((res) => {this.msg = res.data;}).catch((error) => {// eslint-disable-next-lineconsole.error(error);});},},created() {this.getMessage();},};</script>
To ensure we are able to reach this page, we will need to add /ping to the router
import Vue from 'vue';import VueRouter from 'vue-router';import Ping from '../components/Ping.vue';import HDB from '../components/HDB.vue';Vue.use(VueRouter);const routes = [/*{path: '/',name: 'Home',component: Home,},*/{path: '/about',name: 'About',// route level code-splitting// this generates a separate chunk (about.[hash].js) for this route// which is lazy-loaded when the route is visited.component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),},{path: '/ping',name: 'Ping',component: Ping,},];const router = new VueRouter({mode: 'history',base: process.env.BASE_URL,routes,});export default router;
When you run the Flask backend in another terminal window, you will be able to open localhost:8080/ping and see the message ‘pong!’ from the backend. This is because the Ajax call retrieved the response from the backend, and binded to the Vue Ping component.
Beautifying using Bootstrap
Install
$ npm install bootstrap@4.3.1 --save
Import bootstrap for Vue in main.js
main.js
import 'bootstrap/dist/css/bootstrap.css';import BootstrapVue from 'bootstrap-vue';import Vue from 'vue';import App from './App.vue';import router from './router';Vue.use(BootstrapVue);Vue.config.productionTip = false;new Vue({router,render: (h) => h(App),}).$mount('#app');
You may then use bootstrap in the components you build.
HDB component
As we are building a HDB price predictor, we will create a HDB component here.
HDB.vue
<template><!-- eslint-disable max-len --><!-- eslint-disable no-mixed-spaces-and-tabs --><!-- eslint-disable no-tabs --><div class="container"><div class="row"><div class="col-sm-12"><h1>HDB</h1><hr /><alert :message=message v-if="showMessage"></alert><buttontype="button"class="btn btn-success btn-sm"v-b-modal.HDB-modal>Price new HDB</button><br /><br /><table class="table table-striped table-dark"><thead><tr><th scope="col">Town</th><th scope="col">Flat type</th><th scope="col">Storey range</th><th scope="col">Floor area (sqm)</th><th scope="col">Lease commence date</th><th scope="col">Resale price</th></tr></thead><tbody><tr v-for="(hdb, index) in hdbs" :key="index"><td>{{ hdb.town }}</td><td>{{ hdb.flat_type }}</td><td>{{ hdb.storey_range }}</td><td>{{ hdb.floor_area_sqm }}</td><td>{{ hdb.lease_commence_date }}</td><td>S$ {{ hdb.resale_price }}</td></tr></tbody></table></div><!--<div class="col-sm-6"><mappy></mappy></div><div class="col-sm-6"><rawdata></rawdata></div>--></div><b-modalref="priceHDBModal"id="HDB-modal"title="Price an HDB"header-bg-variant="dark"body-bg-variant="secondary"hide-footer><b-form @submit="onSubmit" @reset="onReset" class="w-100"><b-form-groupid="form-town-group"label="Town:"label-for="form-town-input"><b-form-select v-model="priceHDBForm.town" :options="townoptions" required></b-form-select></b-form-group><b-form-groupid="form-flat_type-group"label="Flat type:"label-for="form-flat_type-input"><b-form-select v-model="priceHDBForm.flat_type" :options="flat_typeoptions" required></b-form-select></b-form-group><b-form-groupid="form-storey_range-group"label="Storey range:"label-for="form-storey_range-input"><b-form-select v-model="priceHDBForm.storey_range" :options="storey_rangeoptions" required></b-form-select></b-form-group><b-form-groupid="form-floor_area_sqm-group"label="Floor area (sqm):"label-for="form-floor_area_sqm-input"><b-form-inputid="form-floor_area_sqm-input"type="range"v-model="priceHDBForm.floor_area_sqm"min="30"max="300"requiredplaceholder="Enter Floor area (sqm)"></b-form-input><div class="mt-2">Value: {{ priceHDBForm.floor_area_sqm }}</div></b-form-group><b-form-groupid="form-lease_commence_date-group"label="Lease commence date (Year):"label-for="form-lease_commence_date-input"><b-form-inputid="form-lease_commence_date-input"type="number"v-model="priceHDBForm.lease_commence_date"min="1965":max="currentYear"requiredplaceholder="Enter Lease commence date (Year)"></b-form-input></b-form-group><!-- <b-form-group id="form-read-group"><b-form-checkbox-group v-model="addBookForm.read" id="form-checks"><b-form-checkbox value="true">Read?</b-form-checkbox></b-form-checkbox-group></b-form-group> --><b-button type="submit" variant="primary">Submit</b-button><b-button type="reset" variant="danger">Reset</b-button></b-form></b-modal></div></template><script>import axios from 'axios';import Alert from './Alert.vue';export default {data() {return {hdbs: [],priceHDBForm: {town: null,flat_type: null,storey_range: null,floor_area_sqm: 120,lease_commence_date: '',},message: '',showMessage: false,variants: ['primary', 'secondary', 'success', 'warning', 'danger', 'info', 'light', 'dark'],headerBgVariant: 'dark',headerTextVariant: 'light',bodyBgVariant: 'light',bodyTextVariant: 'dark',footerBgVariant: 'warning',footerTextVariant: 'dark',currentYear: new Date().getFullYear(),townoptions: [{ value: null, text: 'Please select a town' },{ value: 'ANG MO KIO', text: 'ANG MO KIO' }, { value: 'BEDOK', text: 'BEDOK' }, { value: 'BISHAN', text: 'BISHAN' }, { value: 'BUKIT BATOK', text: 'BUKIT BATOK' }, { value: 'BUKIT MERAH', text: 'BUKIT MERAH' }, { value: 'BUKIT PANJANG', text: 'BUKIT PANJANG' }, { value: 'BUKIT TIMAH', text: 'BUKIT TIMAH' }, { value: 'CENTRAL AREA', text: 'CENTRAL AREA' }, { value: 'CHOA CHU KANG', text: 'CHOA CHU KANG' }, { value: 'CLEMENTI', text: 'CLEMENTI' }, { value: 'GEYLANG', text: 'GEYLANG' }, { value: 'HOUGANG', text: 'HOUGANG' }, { value: 'JURONG EAST', text: 'JURONG EAST' }, { value: 'JURONG WEST', text: 'JURONG WEST' }, { value: 'KALLANG/WHAMPOA', text: 'KALLANG/WHAMPOA' }, { value: 'MARINE PARADE', text: 'MARINE PARADE' }, { value: 'PASIR RIS', text: 'PASIR RIS' }, { value: 'PUNGGOL', text: 'PUNGGOL' }, { value: 'QUEENSTOWN', text: 'QUEENSTOWN' }, { value: 'SEMBAWANG', text: 'SEMBAWANG' }, { value: 'SENGKANG', text: 'SENGKANG' }, { value: 'SERANGOON', text: 'SERANGOON' }, { value: 'TAMPINES', text: 'TAMPINES' }, { value: 'TOA PAYOH', text: 'TOA PAYOH' }, { value: 'WOODLANDS', text: 'WOODLANDS' }, { value: 'YISHUN', text: 'YISHUN' },],flat_typeoptions: [{ value: null, text: 'Please select a Flat type' },{ value: '1 ROOM', text: '1 ROOM' }, { value: '2 ROOM', text: '2 ROOM' }, { value: '3 ROOM', text: '3 ROOM' }, { value: '4 ROOM', text: '4 ROOM' }, { value: '5 ROOM', text: '5 ROOM' }, { value: 'EXECUTIVE', text: 'EXECUTIVE' }, { value: 'MULTI-GENERATION', text: 'MULTI-GENERATION' },],storey_rangeoptions: [{ value: null, text: 'Please select a Storey range' },{ value: '01 TO 03', text: '01 TO 03' }, { value: '04 TO 06', text: '04 TO 06' }, { value: '07 TO 09', text: '07 TO 09' }, { value: '10 TO 12', text: '10 TO 12' }, { value: '13 TO 15', text: '13 TO 15' }, { value: '16 TO 18', text: '16 TO 18' }, { value: '19 TO 21', text: '19 TO 21' }, { value: '22 TO 24', text: '22 TO 24' }, { value: '25 TO 27', text: '25 TO 27' }, { value: '28 TO 30', text: '28 TO 30' }, { value: '31 TO 33', text: '31 TO 33' }, { value: '34 TO 36', text: '34 TO 36' }, { value: '37 TO 39', text: '37 TO 39' }, { value: '40 TO 42', text: '40 TO 42' }, { value: '43 TO 45', text: '43 TO 45' }, { value: '46 TO 48', text: '46 TO 48' }, { value: '49 TO 51', text: '49 TO 51' },],};},components: {alert: Alert,},methods: {getHDBs() {const path = "http://localhost:5000/hdbs";//const path = '/hdbs';axios.get(path).then((res) => {this.hdbs = res.data.hdbs;}).catch((error) => {// eslint-disable-next-lineconsole.error(error);});},priceHDB(payload) {const path = "http://localhost:5000/hdbs";//const path = '/hdbs';axios.post(path, payload).then(() => {this.getHDBs();this.message = 'HDB priced!';this.showMessage = true;}).catch((error) => {// eslint-disable-next-lineconsole.log(error);this.getHDBs();this.message = 'This HDB could not be priced';});},initForm() {this.priceHDBForm.town = null;this.priceHDBForm.flat_type = null;this.priceHDBForm.storey_range = null;this.priceHDBForm.floor_area_sqm = 100;this.priceHDBForm.lease_commence_date = '';},onSubmit(evt) {evt.preventDefault();this.$refs.priceHDBModal.hide();const payload = {town: this.priceHDBForm.town,flat_type: this.priceHDBForm.flat_type,storey_range: this.priceHDBForm.storey_range,floor_area_sqm: this.priceHDBForm.floor_area_sqm,lease_commence_date: this.priceHDBForm.lease_commence_date,};this.priceHDB(payload);this.initForm();},onReset(evt) {evt.preventDefault();this.$refs.priceHDBModal.hide();this.initForm();},},created() {this.getHDBs();this.showMessage = false;},};</script>
We will then update the router to reach this component.
import Vue from 'vue';import VueRouter from 'vue-router';import Ping from '../components/Ping.vue';import HDB from '../components/HDB.vue';Vue.use(VueRouter);const routes = [/*{path: '/',name: 'Home',component: Home,},*/{path: '/about',name: 'About',// route level code-splitting// this generates a separate chunk (about.[hash].js) for this route// which is lazy-loaded when the route is visited.component: () => import(/* webpackChunkName: "about" */ '../views/About.vue'),},{path: '/ping',name: 'Ping',component: Ping,},{path: '/',name: 'HDB',component: HDB,},];const router = new VueRouter({mode: 'history',base: process.env.BASE_URL,routes,});export default router;
As you can see, what we did was
<template>
2. Used bootstrap to define the col size
3. Add an alert message when the HDB is priced
Alert.vue Component
<template><!-- eslint-disable max-len --><!-- eslint-disable no-mixed-spaces-and-tabs --><!-- eslint-disable no-tabs --><div><b-alert variant="success" show dismissible >{{ message }}</b-alert><br></div></template><script>export default {props: ['message'],};</script>
4. Add a button to trigger a form (modal) to price HDB
5. Add a table to list the HDB predictions
6. Add an input form
The form allows the user to submit values such as town, flat type to be submitted to the ML model.
<script>
2. Export data used in this template
3. Link <alert> to Alert component
4. Method getHDBs() — GET
Load table of predicted HDB prices
5. Method priceHDB(payload) — POST
Send payload of form input to backend
6. Initialise form with empty values
7. Event listener for submit function
8. Reset form values
9. When HDB vue component is created
Conclusion
This app will end up looking like this. However, the map and chart components are not part of this tutorial. Feel free to reach out to me if you would like to know how to build those components.
Initial Vue app inspiration was taken from Michael Herman’s post. Huge credits!
Thank you for reading this tutorial blog post. 🙂
Originally published at http://royleekiat.com on November 5, 2020.