From 5e07574c92e9c0d2f2665c5f7c25ff77c845e0c8 Mon Sep 17 00:00:00 2001
From: xpetrov4 <xpetrov4@mendelu.cz>
Date: Mon, 5 Feb 2024 14:47:05 +0100
Subject: [PATCH] add cpg island page

---
 src/components/core/app-menu.vue              |   7 +
 src/components/cpg/cpg-form.vue               | 175 +++++++++++
 src/components/cpg/cpg-results.vue            | 288 ++++++++++++++++++
 src/components/cpg/cpg.vue                    | 149 +++++++++
 src/components/cpg/stored-results-details.vue |  55 ++++
 src/components/cpg/stored-results.vue         | 205 +++++++++++++
 src/router.js                                 |  20 ++
 vue.config.js                                 |   2 +-
 8 files changed, 900 insertions(+), 1 deletion(-)
 create mode 100644 src/components/cpg/cpg-form.vue
 create mode 100644 src/components/cpg/cpg-results.vue
 create mode 100644 src/components/cpg/cpg.vue
 create mode 100644 src/components/cpg/stored-results-details.vue
 create mode 100644 src/components/cpg/stored-results.vue

diff --git a/src/components/core/app-menu.vue b/src/components/core/app-menu.vue
index f119c74..2f060ca 100644
--- a/src/components/core/app-menu.vue
+++ b/src/components/core/app-menu.vue
@@ -62,6 +62,13 @@
             <b-dropdown-item :to="{ name: 'results.zdna' }" v-if="config.analysis.zdna">
               Stored results (Z-DNA)
             </b-dropdown-item>
+            <div class="dropdown-divider" v-if="config.analysis.g4killer"></div>
+            <b-dropdown-item :to="{ name: 'analyse.cpg' }" v-if="config.analysis.cpg">
+              CpG
+            </b-dropdown-item>
+            <b-dropdown-item :to="{ name: 'results.cpg' }" v-if="config.analysis.cpg">
+              Stored results (CpG)
+            </b-dropdown-item>
           </b-nav-item-dropdown>
 
           <b-nav-item-dropdown text="Help">
diff --git a/src/components/cpg/cpg-form.vue b/src/components/cpg/cpg-form.vue
new file mode 100644
index 0000000..cf43434
--- /dev/null
+++ b/src/components/cpg/cpg-form.vue
@@ -0,0 +1,175 @@
+<template>
+  <div class="row">
+    <div class="col-md-12">
+      <div class="form-group row">
+        <label class="col-md-4 col-form-label">Minimal window size</label>
+        <div class="col-md-8">
+          <input
+              type="number"
+              class="form-control"
+              v-model="minWindowSize"
+              step="1"
+              min="10"
+              max="10000"
+              @input="updateValue($event.target.value, 'minWindowSize')"
+          />
+        </div>
+      </div>
+
+      <div class="form-group row">
+        <label class="col-md-4 col-form-label">Minimal GC percentage</label>
+        <div class="col-md-8">
+          <input
+              type="number"
+              class="form-control"
+              v-model="minGcPercentage"
+              step="0.001"
+              min="0"
+              max="1"
+              @input="updateValue($event.target.value, 'minGcPercentage')"
+          />
+        </div>
+      </div>
+
+      <div class="form-group row">
+        <label class="col-md-4 col-form-label">Minimal observed/expected CpG</label>
+        <div class="col-md-8">
+          <input
+              type="number"
+              class="form-control"
+              v-model="minObservedToExpectedCpG"
+              step="0.001"
+              min="0"
+              max="1"
+              @input="updateValue($event.target.value, 'minObservedToExpectedCpG')"
+          />
+        </div>
+      </div>
+
+      <div class="form-group row">
+        <label class="col-md-4 col-form-label">Minimal island merge gap</label>
+        <div class="col-md-8">
+          <input
+              type="number"
+              class="form-control"
+              v-model="minIslandMergeGap"
+              step="1"
+              min="10"
+              max="10000"
+              @input="updateValue($event.target.value, 'minIslandMergeGap')"
+          />
+        </div>
+      </div>
+
+      <div class="form-group row">
+        <label class="col-md-4 col-form-label">First nucleotide</label>
+        <div class="col-md-8">
+          <input
+              type="text"
+              class="form-control"
+              v-model="firstNucleotide"
+              @input="updateValue($event.target.value, 'firstNucleotide')"
+          />
+        </div>
+      </div>
+
+      <div class="form-group row">
+        <label class="col-md-4 col-form-label">Second nucleotide</label>
+        <div class="col-md-8">
+          <input
+              type="text"
+              class="form-control"
+              v-model="secondNucleotide"
+              @input="updateValue($event.target.value, 'secondNucleotide')"
+          />
+        </div>
+      </div>
+
+      <div class="alert alert-danger" v-if="$v.$anyError">
+        <div v-if="!$v.minSequenceSize.required">Minimal sequence size is required.</div>
+        <div v-if="!$v.minSequenceSize.numeric">Minimal sequence size must be numeric.</div>
+        <div v-if="!$v.minSequenceSize.minValue">Minimal length of sequence is {{ $v.minSequenceSize.$params.minValue.min }}.</div>
+        <div v-if="!$v.threshold.required">Threshold is required.</div>
+        <div v-if="!$v.threshold.decimal">Threshold must be decimal number.</div>
+        <div v-if="!$v.threshold.minValue">Minimal value of threshold is {{ $v.threshold.$params.minValue.min }}.</div>
+      </div>
+
+      <div class="text-right">
+        <slot name="additionalButtons"></slot>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { required, numeric, minValue, decimal } from 'vuelidate/lib/validators'
+
+export default {
+  props: {
+    minWindowSize: {
+      type: [Number, String],
+      default: 200 
+    },
+    minGcPercentage: {
+      type: [Number, String],
+      default: 0.5
+    },
+    minObservedToExpectedCpG: {
+      type: [Number, String],
+      default: 0.6 
+    },
+    minIslandMergeGap: {
+      type: [Number, String],
+      default: 100
+    },
+    firstNucleotide: {
+      type: [Number, String],
+      default: "C" 
+    },
+    secondNucleotide: {
+      type: [Number, String],
+      default: "G" 
+    },
+  },
+  created() {
+  this.minSequenceSize = Number(this.minSequenceSize);
+  this.threshold = Number(this.threshold);
+  },
+  validations: {
+    minWindowSize: {
+      required,
+      numeric,
+      minValue: minValue(0)
+    },
+    minGcPercentage: {
+      required,
+      decimal,
+      minValue: minValue(0)
+    },
+    minObservedToExpectedCpG: {
+      required,
+      decimal,
+      minValue: minValue(0)
+    },
+    minIslandMergeGap: {
+      required,
+      numeric,
+      minValue: minValue(0)
+    },
+    firstNucleotide: {
+      required,
+    },
+    secondNucleotide: {
+      required,
+    },
+
+  },
+  methods: {
+    updateValue: function(value, type) {
+      this[type] = value; // Aktualizácia lokálnej hodnoty
+      this.$v[type].$touch(); // Aktivuje validáciu
+      this.$emit('checkModel', value, type)
+    },
+  },
+}
+</script>
diff --git a/src/components/cpg/cpg-results.vue b/src/components/cpg/cpg-results.vue
new file mode 100644
index 0000000..ab621e5
--- /dev/null
+++ b/src/components/cpg/cpg-results.vue
@@ -0,0 +1,288 @@
+<template>
+  <div>
+    <h2 v-if="sequence">
+      {{ sequence.name }}
+      <span v-if="sequence.name !== analysisInfo.title">: {{ analysisInfo.title }}</span>
+      <!-- slot for back button -->
+      <slot></slot>
+    </h2>
+
+    <!-- overview -->
+    <div v-show="zdnaList">
+      <heatchart
+          v-if="heatmapData"
+          :data="heatmapData"
+          y-axis-title="Count (Z-DNA)"
+          @dataPointClick="showSegment"
+          @onZoom="zoomHeatchart"
+      ></heatchart>
+      <heatmap v-if="heatmapData" :data="heatmapData" @segmentClick="showSegment" :selected="selectedSegment">
+      </heatmap>
+
+      <table class="table table-striped table-hover">
+        <tbody>
+        <tr>
+          <th>Analysis settings</th>
+          <th>Analysis results</th>
+          <th class="text-center">Export</th>
+          <th v-if="sequence">Sequence info</th>
+        </tr>
+        <tr>
+          <td>
+            Minimal window size: {{ analysisInfo.minWindowSize | number(0) }}
+            <br />
+            Minimal GC percentage: {{ analysisInfo.minGcPercentage }}
+            <br />
+            Minimal observed/expected CpG: {{ analysisInfo.minObservedToExpectedCpG }}
+            <br />
+            Minimal island merge gap: {{ analysisInfo.minIslandMergeGap }}
+            <br />
+            First nucleotide: {{ analysisInfo.firstNucleotide }}
+            <br />
+            Second nucleotide: {{ analysisInfo.secondNucleotide }}
+          </td>
+          <td>
+            Islands found: {{ analysisInfo.resultCount | number(0) }}
+            <br />
+            <span v-if="sequence">Frequency: {{ (analysisInfo.resultCount / sequence.length) * 1000 | number(3) }} / 1000 bp</span>
+          </td>
+          <td class="text-center">
+            <a v-if="downloadToken" :href="downloadCSV()" class="btn btn-primary btn-sm">
+              <span class="fa fa-cube"></span> CSV
+            </a>
+            <a v-if="downloadToken" :href="downloadBED()" class="btn btn-primary btn-sm">
+              <span class="fa fa-bed"></span> BedGraph
+            </a>
+          </td>
+          <td v-if="sequence">
+            {{ sequence.name }}
+            <br />
+            {{ sequence.length | number(0) }} bp
+            <br />
+            GC: {{ GCCount }} ({{ GCRate | number(1) }}%)
+          </td>
+        </tr>
+        </tbody>
+      </table>
+
+      <!-- list of results -->
+      <pagination ref="pagination" @pageChange="reload" :page-size="10">
+        <table class="table table-striped table-hover">
+          <tbody>
+          <tr>
+            <th>
+              <sort-toggle @sort="sortSequences" crit="start" :current="currentSort">Start</sort-toggle>
+            </th>
+            <th>
+              <sort-toggle @sort="sortSequences" crit="end" :current="currentSort">End</sort-toggle>
+            </th>
+            <th>Sequence</th>
+            <th>
+              <sort-toggle @sort="sortSequences" crit="gcPerc" :current="currentSort">
+                GC Percentage
+              </sort-toggle>
+            </th>
+            <th>
+              <sort-toggle @sort="sortSequences" crit="observedToExpectedCpG" :current="currentSort">
+                Observer/expected CpG
+              </sort-toggle>
+            </th>
+            <th class="text-center">
+              <sort-toggle @sort="sortSequences" crit="length" :current="currentSort">Length</sort-toggle>
+            </th>
+          </tr>
+          <template v-for="zdna in zdnaList">
+            <tr>
+              <td>
+                {{ zdna.position | number(0) }}
+              </td>
+              <td>
+                {{ zdna.end }}
+              </td>
+              <td class="sequence text-monospace text-bold">
+                <highlight :sequence="zdna.sequence.slice(0, 20)+'...'"></highlight>
+              </td>
+
+              <td class="sequence text-monospace">{{ zdna.gcPerc  | number(3) }}</td>
+              <td class="sequence text-monospace">{{ zdna.observedToExpectedCpG | number(3) }}</td>
+              <td class="text-right">
+                {{ zdna.length | number }}
+              </td>
+            </tr>
+          </template>
+          </tbody>
+        </table>
+      </pagination>
+
+      <div v-show="zdnaList && zdnaList.length === 0">
+        <p class="alert alert-warning">
+          No CpG islands found.
+        </p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import Formatters from '@/formatters'
+import SortToggle from '@/components/core/sort-toggle'
+import Pagination from '@/components/core/pagination'
+import SequenceStats from '@/components/sequence/helpers/sequence-stats'
+import Helpers from '@/helpers'
+import Heatchart from '@/components/core/visualisation/heatchart'
+import Heatmap from '@/components/core/visualisation/heatmap'
+import Highlight from '@/components/core/visualisation/highlight'
+
+/**
+ * list of results for sequence analysis
+ */
+export default {
+  props: ['analysisInfo', 'downloadToken'],
+  data() {
+    return {
+      sequence: null,
+      zdnaList: null,
+      avgZdnaLen: null,
+      totalZdnaLenM1: null,
+      totalZdnaLenM2: null,
+      heatmapData: null,
+      selectedSegment: undefined,
+      sequenceStart: undefined,
+      sequenceLength: undefined,
+      currentSort: {
+        crit: 'position',
+        asc: true,
+      },
+    }
+  },
+  methods: {
+    getSequenceInfo() {
+      return this.$http.get('sequence/' + this.analysisInfo.sequenceId).then(response => {
+        this.sequence = response.data.payload
+      })
+    },
+    countNucleic() {
+      this.$http.patch('sequence/' + this.analysisInfo.sequenceId + '/nucleic-counts').catch(err => {
+        this.httpError = err
+      })
+    },
+    reload() {
+      return this.$http
+          .get('analyse/cpg/' + this.analysisInfo.id + '/cpg', {
+            params: {
+              sort: this.currentSort.crit,
+              order: this.currentSort.asc ? 'ASC' : 'DESC',
+              sequenceStart: this.sequenceStart,
+              sequenceLength: this.sequenceLength,
+              pageSize: this.$refs.pagination.getPageSize(),
+              pageNumber: this.$refs.pagination.getPageCurrent() - 1,
+            },
+          })
+          .then(response => {
+            this.$refs.pagination.setPageCount(response.data.page.totalElements)
+            Helpers.setUIState(response.data.items, 'showDetails', false)
+            this.zdnaList = response.data.items
+            this.countNucleic()
+          })
+    },
+    toggleDetails(zdna) {
+      zdna.ui.showDetails = !zdna.ui.showDetails
+    },
+    getAvgRizLen() {
+      return this.$http.get('analyse/cpg/' + this.analysisInfo.id + '/average/length').then(response => {
+        this.avgZdnaLen = response.data
+      })
+    },
+    
+    sortSequences(info) {
+      this.currentSort = info
+      this.reload()
+    },
+    async downloadHeatmap() {
+      const total = this.sequence.length
+      const { sequenceFrom: from = 0, sequenceTo: to = total, analysisInfo } = this
+      const segments = 80
+      const { data } = await this.$http.get(`analyse/cpg/${analysisInfo.id}/heatmap`, {
+        params: { segments, from, to },
+      })
+
+      this.heatmapData = { ...data, from, to, total }
+    },
+    /**
+     * click in heatmap
+     *
+     * @param seg
+     */
+    showSegment(pos, size) {
+      if (this.selectedSegment === pos) {
+        this.selectedSegment = undefined
+        this.sequenceStart = undefined
+        this.sequenceLength = undefined
+      } else {
+        this.selectedSegment = pos
+        this.sequenceStart = Math.floor(pos * size)
+        this.sequenceLength = Math.ceil(size)
+      }
+      this.reload()
+    },
+    async zoomHeatchart(from, to, onSuccess) {
+      this.sequenceFrom = from
+      this.sequenceTo = to
+      await this.downloadHeatmap()
+      onSuccess()
+    },
+    downloadCSV() {
+      return `${this.$http.defaults.baseURL}analyse/cpg/${this.analysisInfo.id}/cpg.csv`
+    },
+    downloadBED() {
+      return `${this.$http.defaults.baseURL}analyse/cpg/${this.analysisInfo.id}/cpg.bedgraph`
+    },
+  },
+  mounted() {
+    this.reload()
+    this.getSequenceInfo().then(() => {
+      this.getAvgRizLen().then(() => {
+        this.downloadHeatmap()
+      })
+    })
+  },
+  computed: {
+    GCCount() {
+      return SequenceStats.calcGCCount(this.sequence)
+    },
+    GCRate() {
+      return SequenceStats.calcGCRate(this.sequence)
+    },
+  },
+  components: {
+    highlight: Highlight,
+    'sort-toggle': SortToggle,
+    pagination: Pagination,
+    heatmap: Heatmap,
+    heatchart: Heatchart,
+  },
+  filters: {
+    number: Formatters.number,
+    genePos: Formatters.genePos,
+  },
+}
+</script>
+
+<style scoped>
+td,
+th {
+  padding: 0.5rem;
+}
+td.sequence {
+  max-width: 400px;
+}
+td.details {
+  max-width: 600px;
+}
+td.details .feature {
+  border: 1px solid red;
+}
+td .toggle {
+  cursor: pointer;
+}
+</style>
diff --git a/src/components/cpg/cpg.vue b/src/components/cpg/cpg.vue
new file mode 100644
index 0000000..b872d5a
--- /dev/null
+++ b/src/components/cpg/cpg.vue
@@ -0,0 +1,149 @@
+<template>
+  <div class="container-fluid">
+    <h1>CpG tracker</h1>
+    <div class="row">
+      <div class="col-md-4">
+        <h2>Prediction models</h2>
+        <cpg-form @checkModel="updateModel" />
+      </div>
+      <div class="col-md-8">
+        <h2>Sequences</h2>
+        <sequence-selector ref="sequenceSelector" @analyse="startAnalysis" />
+      </div>
+    </div>
+
+    <div class="alert alert-danger" v-if="serverError">{{ serverError }}</div>
+
+    <div v-if="waitError">
+      <hr />
+      <div class="alert alert-danger">
+        <div class="row">
+          <p class="col-10">
+            Waiting for results took too long.
+          </p>
+          <div class="col-2">
+            <button class="btn btn-primary btn-block" @click="waitForResult(analysisId)">
+              <span class="fa fa-refresh"></span> Keep waiting
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <b-tabs v-if="finishedAnalysis.length > 0">
+      <b-tab v-for="(analysis, i) of finishedAnalysis" :key="analysis.payload.id" :title="'Results ' + (i + 1)">
+        <br />
+        <results :analysis-info="analysis.payload" :download-token="analysis.downloadToken">
+          <b-btn variant="danger" class="float-right" @click="closeResults(analysis.payload)">
+            Close tab
+          </b-btn>
+        </results>
+      </b-tab>
+    </b-tabs>
+
+    <hr />
+    <div class="card bg-light">
+      <div class="card-body">
+        CpG analyser Tokai alg #TODO
+      </div>
+    </div>
+  </div>
+</template>
+
+<script type="text/javascript">
+import SequenceSelector from '@/components/sequence/sequence-selector'
+import BatchWatcher from '@/services/batch-watcher'
+import CpgForm from './cpg-form'
+import Results from './cpg-results'
+
+
+export default {
+  data() {
+    return {
+      minWindowSize: 200,
+      minGcPercentage: 0.5,
+      minObservedToExpectedCpG: 0.6,
+      minIslandMergeGap: 100,
+      firstNucleotide: 'C',
+      secondNucleotide: 'G',
+      config: {},
+      finishedAnalysis: [],
+      analysisId: null,
+      waiting: false,
+      waitError: false,
+      serverError: null,
+    }
+  },
+  methods: {
+    closeResults(analysis) {
+      this.finishedAnalysis = this.finishedAnalysis.filter(info => {
+        return info.payload.id !== analysis.id
+      })
+    },
+    waitForResult(analysisId) {
+      this.waitError = false
+      this.waiting = true
+      var bw = new BatchWatcher(this.$http)
+      this.$store.commit('addWork', { key: analysisId, title: 'CpG analysis' })
+      return bw
+          .wait('analyse/cpg/' + analysisId + '/analysis')
+          .then(response => {
+            this.finishedAnalysis.push(response.data)
+          })
+          .finally(() => {
+            this.$store.commit('workDone', analysisId)
+            this.waiting = false
+          })
+    },
+    updateModel(value, type) {
+      if (type === 'minWindowSize') {
+        this.minWindowSize = value;
+      } else if (type === 'minGcPercentage') {
+        this.minGcPercentage = value;
+      } else if (type === 'minObservedToExpectedCpG') {
+        this.minObservedToExpectedCpG = value;
+      } else if (type === 'minIslandMergeGap') {
+        this.minIslandMergeGap = value;
+      } else if (type === 'firstNucleotide') {
+        this.firstNucleotide = value;
+      } else if (type === 'secondNucleotide') {
+        this.secondNucleotide = value;
+      }
+    },
+    startAnalysis(sequence, callback) {
+      this.$http
+          .post('analyse/cpg', {
+            minWindowSize: this.minWindowSize,
+            minGcPercentage: this.minGcPercentage,
+            minObservedToExpectedCpG: this.minObservedToExpectedCpG,
+            minIslandMergeGap: this.minIslandMergeGap,
+            firstNucleotide: this.firstNucleotide,
+            secondNucleotide: this.secondNucleotide,
+            sequence: sequence.id,
+            tags: sequence.tags,
+          })
+          .then(response => {
+            const item = response.data.payload
+            this.serverError = null
+            this.analysisId = item.id
+            return this.waitForResult(item.id)
+          })
+          .then(() => {
+            callback()
+          })
+          .catch(error => {
+            this.serverError = error?.response?.data?.message
+            callback('FAILED')
+          })
+    },
+    clearAnalysis() {
+      this.cpgAnalysis = []
+    },
+  },
+  components: {
+    'sequence-selector': SequenceSelector,
+    results: Results,
+    'cpg-form': CpgForm,
+  },
+}
+</script>
diff --git a/src/components/cpg/stored-results-details.vue b/src/components/cpg/stored-results-details.vue
new file mode 100644
index 0000000..f407c60
--- /dev/null
+++ b/src/components/cpg/stored-results-details.vue
@@ -0,0 +1,55 @@
+<template>
+  <div class="container-fluid">
+    <div class="row">
+      <div class="col-sm-10">
+        <h1>CpG stored results</h1>
+      </div>
+      <div class="col-sm-2">
+        <br />
+        <router-link class="btn btn-primary btn-block" :to="{ name: 'results.cpg' }">Back</router-link>
+      </div>
+    </div>
+
+    <results v-if="analysis" :analysis-info="analysis" :download-token="downloadToken"></results>
+
+    <http-error :info="httpError"></http-error>
+  </div>
+</template>
+
+<script>
+import Results from './cpg-results'
+import HttpError from '@/components/core/http-error'
+
+/**
+ * display previously stored results
+ */
+export default {
+  data() {
+    return {
+      analysis: null,
+      downloadToken: '',
+      httpError: '',
+    }
+  },
+  methods: {
+    downloadResults() {
+      this.$http.get('analyse/cpg/' + this.$route.params.id + '/analysis').then(
+          response => {
+            this.analysis = response.data.payload
+            this.downloadToken = response.data.downloadToken
+          },
+          err => {
+            this.httpError = err
+          }
+      )
+    },
+  },
+  mounted() {
+    this.downloadResults()
+  },
+  components: {
+    results: Results,
+    'http-error': HttpError,
+  },
+}
+</script>
diff --git a/src/components/cpg/stored-results.vue b/src/components/cpg/stored-results.vue
new file mode 100644
index 0000000..263df6e
--- /dev/null
+++ b/src/components/cpg/stored-results.vue
@@ -0,0 +1,205 @@
+<template>
+  <div class="container">
+    <h1>CpG results</h1>
+
+    <pagination ref="pagination" @pageChange="downloadResults">
+      <checkable-list ref="resultsList">
+        <th slot="header">Sequence/Analyse name</th>
+        <th class="text-center" slot="header">
+          <sort-toggle @sort="sortResults" crit="finished" :current="currentSort">Created</sort-toggle>
+        </th>
+        <th class="text-center" slot="header">
+          <sort-toggle @sort="sortResults" crit="resultCount" :current="currentSort">Results count</sort-toggle>
+        </th>
+        <th class="text-center" slot="header">Tags</th>
+        <th class="text-center" slot="header">Export</th>
+        <th class="text-center" slot="header">Details</th>
+        <th class="text-center" slot="header">Edit</th>
+        <th class="text-center" slot="header">Delete</th>
+
+        <span slot="item-name" slot-scope="props">
+          {{ props.item.title }}
+        </span>
+
+        <template slot-scope="props">
+          <td class="text-right">
+            <span v-if="props.item.finished">{{ props.item.finished | dateTime }}</span>
+            <span v-else class="fa fa-spinner animate"></span>
+          </td>
+          <td class="text-right">
+            <span v-if="props.item.finished">
+              {{ props.item.resultCount | number(0) }}
+            </span>
+          </td>
+          <td>
+            <b-badge v-for="tag in props.item.tags" :key="tag" pill variant="primary">
+              {{ tag }}
+            </b-badge>
+          </td>
+          <td class="text-center">
+            <a :href="downloadCSV(props.item)" v-if="downloadToken" class="btn btn-primary btn-sm">
+              <span class="fa fa-cube"></span>
+            </a>
+            <a :href="downloadBED(props.item)" v-if="downloadToken" class="btn btn-primary btn-sm">
+              <span class="fa fa-bed"></span>
+            </a>
+          </td>
+          <td>
+            <router-link
+                class="btn btn-primary btn-sm"
+                :to="{ name: 'results.cpg.detail', params: { id: props.item.id } }"
+            >
+              <span class="fa fa-search"></span>
+            </router-link>
+          </td>
+          <td class="text-center">
+            <button class="btn btn-primary btn-sm" @click.stop="editResult(props.item)">
+              <span class="fa fa-edit"></span>
+            </button>
+          </td>
+          <td class="text-center">
+            <button class="btn btn-danger btn-sm" @click.stop="deleteResult(props.item)">
+              <span class="fa fa-trash"></span>
+            </button>
+          </td>
+        </template>
+
+        <p slot="no-items" class="alert alert-warning">
+          No results found.
+        </p>
+      </checkable-list>
+    </pagination>
+
+    <button type="button" class="btn btn-default" @click="toggleSelection">
+      <i class="fa fa-refresh"></i> Invert selection
+    </button>
+    <button class="btn btn-danger" @click="deleteResults"><i class="fa fa-trash"></i> Delete selected</button>
+
+    <modal-confirm ref="confirmModal">Really delete results?</modal-confirm>
+    <modal-confirm ref="confirmModalMulti">Really delete selected results?</modal-confirm>
+
+    <modal-confirm ref="editModal">
+      <template slot="header">
+        Modify result
+      </template>
+
+      <result-editor v-if="selectedResult" :result="selectedResult" ref="resultEditor"></result-editor>
+
+      <template slot="buttonConfirm">
+        Save changes
+      </template>
+    </modal-confirm>
+  </div>
+</template>
+<script>
+import SortToggle from '@/components/core/sort-toggle'
+import Formatters from '@/formatters'
+import CheckableList from '@/components/core/checkable-list'
+import Pagination from '@/components/core/pagination'
+import ResultEditor from '@/components/core/result-editor'
+
+/**
+ * list of stored result groups
+ */
+export default {
+  data() {
+    return {
+      selectedResult: null,
+      currentSort: {
+        crit: 'finished',
+        asc: true,
+      },
+      downloadToken: '',
+    }
+  },
+  methods: {
+    downloadResults() {
+      this.$http
+          .get('analyse/cpg', {
+            params: {
+              sort: this.currentSort.crit,
+              order: this.currentSort.asc ? 'ASC' : 'DESC',
+              pageSize: this.$refs.pagination.getPageSize(),
+              pageNumber: this.$refs.pagination.getPageCurrent() - 1,
+            },
+          })
+          .then(response => {
+            this.$refs.pagination.setPageCount(response.data.page.totalElements)
+            this.$refs.resultsList.setItems(response.data.items)
+            this.downloadToken = response.data.downloadToken
+          })
+    },
+    performDelete(result) {
+      return this.$http.delete('analyse/cpg/' + result.id)
+    },
+    deleteResult(result) {
+      this.$refs.confirmModal
+          .display()
+          .then(() => {
+            return this.performDelete(result)
+          })
+          .then(() => {
+            this.downloadResults()
+          })
+    },
+    deleteResults() {
+      this.$refs.confirmModalMulti.display().then(() => {
+        var checked = this.$refs.resultsList.getCheckedItems()
+        var promises = []
+        checked.forEach(result => {
+          if (result.ui.checked) {
+            promises.push(this.performDelete(result))
+          }
+        })
+        Promise.all(promises).then(() => {
+          this.downloadResults()
+        })
+      })
+    },
+    editResult(result) {
+      this.selectedResult = result
+      this.$refs.editModal
+          .display()
+          .then(() => {
+            this.$http
+                .put('analyse/cpg/' + result.id + '/tags', {
+                  tags: this.$refs.resultEditor.getTags(),
+                })
+                .then(() => {
+                  this.downloadResults()
+                })
+          })
+          .finally(() => {
+            this.selectedResult = null
+          })
+    },
+    sortResults(info) {
+      this.currentSort = info
+      this.downloadResults()
+    },
+    toggleSelection() {
+      this.$refs.resultsList.toggleSelection()
+    },
+    downloadCSV(analysisInfo) {
+      return `${this.$http.defaults.baseURL}analyse/cpg/${analysisInfo.id}/cpg.csv`
+    },
+    downloadBED(analysisInfo) {
+      return `${this.$http.defaults.baseURL}analyse/cpg/${analysisInfo.id}/cpg.bedgraph`
+    },
+  },
+  mounted() {
+    this.downloadResults()
+  },
+  components: {
+    'sort-toggle': SortToggle,
+    'checkable-list': CheckableList,
+    pagination: Pagination,
+    'result-editor': ResultEditor,
+  },
+  filters: {
+    dateTime: Formatters.dateTime,
+    number: Formatters.number,
+    sequenceName: Formatters.sequenceName,
+  },
+}
+</script>
diff --git a/src/router.js b/src/router.js
index 63efc56..111a07c 100644
--- a/src/router.js
+++ b/src/router.js
@@ -35,6 +35,10 @@ import Zdna from "./components/zdna/zdna";
 import ZdnaResults from "./components/zdna/stored-results";
 import ZdnaResultsDetail from './components/zdna/stored-results-details';
 
+import Cpg from "./components/cpg/cpg";
+import CpgResults from "./components/cpg/stored-results";
+import CpgResultsDetail from './components/cpg/stored-results-details';
+
 Vue.use(Router)
 
 var router = new Router({
@@ -177,6 +181,22 @@ var router = new Router({
       path: '/results/zdna/:id',
       component: ZdnaResultsDetail,
     },
+    //CpG
+    {
+      name: 'analyse.cpg',
+      path: '/analyse/cpg',
+      component: Cpg,
+    },
+    {
+      name: 'results.cpg',
+      path: '/results/cpg',
+      component: CpgResults,
+    },
+    {
+      name: 'results.cpg.detail',
+      path: '/results/cpg/:id',
+      component: CpgResultsDetail,
+    },
 
     //help routes
     {
diff --git a/vue.config.js b/vue.config.js
index 4d5400c..f90f248 100644
--- a/vue.config.js
+++ b/vue.config.js
@@ -4,7 +4,7 @@ module.exports = {
   devServer: {
     proxy: {
       '/api': {
-        target: 'https://localhost:443',
+        target: 'http://localhost:8080',
         ws: true,
         changeOrigin: true,
       }
-- 
GitLab