From 90fe6ca668b31c3fa4bd2947f008519414c24274 Mon Sep 17 00:00:00 2001 From: Brenda Date: Wed, 14 Apr 2021 06:22:49 -0400 Subject: [PATCH] Add an undo/redo stack --- CHANGELOG.md | 20 +---- README.md | 3 +- package.json | 1 + src/App.vue | 14 +++ src/components/Charts.vue | 21 +++-- src/components/DistanceChart.vue | 45 ++++++---- src/components/Legend.vue | 17 ++-- src/components/SelectionChart.vue | 11 ++- src/components/Toolbar.vue | 1 + src/router.js | 6 ++ src/store/store.js | 114 ++++++++++++++++------- src/utilities/generateUUID.js | 6 ++ src/views/About.vue | 13 ++- src/views/GpxSmoother.vue | 37 +++++--- src/views/History.vue | 145 ++++++++++++++++++++++++++++++ yarn.lock | 5 ++ 16 files changed, 354 insertions(+), 105 deletions(-) create mode 100644 src/utilities/generateUUID.js create mode 100644 src/views/History.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 84640bd..d4701f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,22 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] -- input validation -- browser compatibility check -- accept more than one file type (fit, tcx, kml) -- try some different UI alternatives for less scrolling -- editing the route and elevation from the route map -- mini elevation chart on the route map -- draggable points on the elevation charts -- undo/redo stack -- charts code clean-up to get rid of repeated code -- Vue 3 migration -- typescript -- snip the route -- reverse the route -- zoom and pan on the graphs -- zoom to route on the map +## [1.8.0] - 2021-05-04 +### Added +- added an undo/redo stack +- added an overlay while loading or processing ## [1.7.1] - 2021-01-17 ### Changed diff --git a/README.md b/README.md index ce301c2..22942fb 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ This is a work in progress... - [ ] editing the route and elevation from the route map - [ ] mini elevation chart on the route map - [ ] draggable points on the elevation charts -- [ ] undo/redo stack +- [ ] edit and delete on the undo/redo stack - [ ] charts code clean-up to get rid of repeated code - [ ] Vue 3 migration - [ ] typescript @@ -31,6 +31,7 @@ This is a work in progress... - [ ] reverse the route - [ ] zoom and pan on the graphs - [ ] zoom to route on the map +- [ ] web worker for long-running processes ## Project setup ``` diff --git a/package.json b/package.json index fd79234..f5c1d30 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "kalmanjs": "^1.1.0", "leaflet": "^1.6.0", "ml-savitzky-golay-generalized": "^1.1.1", + "ramda": "^0.27.1", "vue": "^2.6.10", "vue-router": "^3.0.3", "vuetify": "^2.3.8", diff --git a/src/App.vue b/src/App.vue index 2ccc285..87e8d4f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,17 +6,27 @@ + +
PROCESSING...
+
@@ -31,4 +41,8 @@ export default { html { overflow-y: auto; } + .wait-text { + font-size: 60px; + color: white; + } diff --git a/src/components/Charts.vue b/src/components/Charts.vue index f171048..c97922d 100644 --- a/src/components/Charts.vue +++ b/src/components/Charts.vue @@ -13,12 +13,15 @@ Slope Chart Elevation Profile - +
+ +
diff --git a/src/components/DistanceChart.vue b/src/components/DistanceChart.vue index 350595f..6769642 100644 --- a/src/components/DistanceChart.vue +++ b/src/components/DistanceChart.vue @@ -1,7 +1,5 @@ @@ -11,13 +9,13 @@ import {GraphType, LineTypes, UnitType} from './chartModel'; import {convertDistance, convertElevation, kmToMiles, metresToFeet} from '@/utilities/unitConversion'; - // noinspection JSUnusedGlobalSymbols export default { name: 'DistanceChart', props: { graphType: String, colorScale: Function, - graphUnits: UnitType + graphUnits: UnitType, + selection: Array }, data: () => ({ defaultDistance: 100000, @@ -49,8 +47,11 @@ }, beforeDestroy() { window.removeEventListener('resize', this.resize); + if (this.tooltip) { + this.tooltip.remove(); + } }, - computed: mapState(['rawValues', 'selection', 'totalDistance', 'smoothedValues']), + computed: mapState(['rawValues', 'totalDistance', 'smoothedValues']), watch: { rawValues(newValue) { if (!this.svg) { @@ -99,7 +100,7 @@ if (!this.svg) { return; } - this.onSelectionUpdate(newValue); + this.redrawSelection(newValue); } }, methods: { @@ -163,6 +164,8 @@ .attr('width', this.width) .attr('y', -this.margin.top) .attr('height', this.height + this.margin.top + this.margin.bottom); // Leave some room at the top and bottom + + this.redrawSelection(this.selection); }, draw() { this.focus.select('.x.axis').call(this.xAxis); @@ -191,12 +194,11 @@ + ' ' + this.xScale(datum.previous.totalDistance) + ',' + this.yScale(this.yScale.domain()[0]); }); }, - onSelectionUpdate(selection) { - if (!selection) { - return; + redrawSelection(selection) { + if (selection) { + this.xScale.domain(selection); + this.xScaleImperial.domain(selection.map(distance => kmToMiles(distance))); } - this.xScale.domain(selection); - this.xScaleImperial.domain(selection.map(distance => kmToMiles(distance))); this.focus.select('.x.axis').call(this.xAxis); this.focus.selectAll('path.elevation').attr('d', this.elevationLine); this.focus.selectAll('path.slope').attr('d', this.slopeLine); @@ -487,11 +489,17 @@ const extents = this.getExtents(); this.xScale = d3.scaleLinear() - .range([0, this.width]) - .domain(extents.xExtent); + .range([0, this.width]); this.xScaleImperial = d3.scaleLinear() - .range([0, this.width]) - .domain(extents.xExtent.map(distance => kmToMiles(distance))); + .range([0, this.width]); + + if (this.selection) { + this.xScale.domain(this.selection); + this.xScaleImperial.domain(this.selection.map(distance => kmToMiles(distance))); + } else { + this.xScale.domain(extents.xExtent); + this.xScaleImperial.domain(extents.xExtent.map(distance => kmToMiles(distance))); + } this.yScale = d3.scaleLinear() .range([this.height, 0]) @@ -543,8 +551,7 @@ this.focus.append('g') .attr('clip-path', 'url(#clip)'); - // Get the position of the graph so we can set the - // the offset of the tooltip + // Get the position of the graph element so we can set the offset of the tooltip this.graphElement = this.svg.node(); this.tooltip = d3.select('body').append('div') @@ -586,7 +593,7 @@ #chart font: 10px sans-serif width: 100% - height: 500px + height: 100% .axis path, .axis line diff --git a/src/components/Legend.vue b/src/components/Legend.vue index c80ce18..d07f59b 100644 --- a/src/components/Legend.vue +++ b/src/components/Legend.vue @@ -40,20 +40,13 @@ colorScale: Function }, data: () => ({ - graphTypes: GraphType, - formattedRawAverage: '', - formattedSmoothedAverage: '' + graphTypes: GraphType }), computed: { - ...mapState(['rawAverageSlope', 'smoothedAverageSlope']), - }, - watch: { - rawAverageSlope() { - this.formattedRawAverage = this.rawAverageSlope ? `${this.rawAverageSlope}%` : ''; - }, - smoothedAverageSlope() { - this.formattedSmoothedAverage = this.smoothedAverageSlope ? `${this.smoothedAverageSlope}%` : ''; - } + ...mapState({ + formattedRawAverage: state => state.rawAverageSlope ? `${state.rawAverageSlope}%` : '', + formattedSmoothedAverage: state => state.smoothedAverageSlope ? `${state.smoothedAverageSlope}%` : '', + }), }, mounted() { this.$nextTick(() => { diff --git a/src/components/SelectionChart.vue b/src/components/SelectionChart.vue index 4948b5b..38a3850 100644 --- a/src/components/SelectionChart.vue +++ b/src/components/SelectionChart.vue @@ -10,6 +10,7 @@ import {mapState} from 'vuex'; import {GraphType, LineTypes, UnitType} from './chartModel'; import store from '../store/store'; import {convertElevation, kmToMiles, metresToFeet} from '@/utilities/unitConversion'; +import {equals} from 'ramda'; export default { name: 'SelectionChart', @@ -220,7 +221,11 @@ export default { brushed() { if (d3.event && d3.event.selection) { const newSelection = d3.event.selection.map(this.miniXScale.invert); - store.dispatch('select', newSelection); + // This gets called on a resize to redraw the brush, so we need to check + // if the selection has actually changed before updating the selection + if (!equals(this.selection, newSelection)) { + store.dispatch('select', newSelection); + } this.drawSelectionHandles(newSelection); } }, @@ -429,7 +434,6 @@ export default { }) .curve(d3.curveStepBefore); - store.dispatch('select', this.miniXScale.domain()); this.brush = d3 .brushX() .extent([ @@ -465,7 +469,8 @@ export default { .attr('cursor', 'ew-resize') .attr('d', triangleShape); - this.gBrush.call(this.brush.move, this.xScale.range()); + const selection = (this.selection === null) ? this.xScale.range() : this.selection.map(this.miniXScale); + this.gBrush.call(this.brush.move, selection); } } }; diff --git a/src/components/Toolbar.vue b/src/components/Toolbar.vue index 58d89c0..63958bd 100644 --- a/src/components/Toolbar.vue +++ b/src/components/Toolbar.vue @@ -2,6 +2,7 @@ GPX Smoother Version {{ appVersion }} + Smoothing History Route Map diff --git a/src/router.js b/src/router.js index 931633d..cb8eb81 100644 --- a/src/router.js +++ b/src/router.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import Router from 'vue-router'; import GpxSmoother from '@/views/GpxSmoother'; import RouteMap from '@/views/RouteMap'; +import History from '@/views/History'; Vue.use(Router); @@ -17,6 +18,11 @@ export default new Router({ name: 'route-map', component: RouteMap }, + { + path: '/history', + name: 'history', + component: History + }, { path: '/about', name: 'about', diff --git a/src/store/store.js b/src/store/store.js index bd30d82..50be5cf 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -14,9 +14,11 @@ Vue.use(Vuex); const getDefaultState = () => { return { + redoStack: [], appVersion: process.env.VUE_APP_VERSION || 0, selectedGpxFile: null, isLoading: false, + isSmoothingInProgress: false, loadError: null, fileJson: null, outputName: null, @@ -79,45 +81,68 @@ export default new Vuex.Store({ select(context, selection) { context.commit('setSelection', selection); }, - smooth(context, numberOfPoints) { - const toSmooth = context.state.smoothedValues ? context.state.smoothedValues : context.state.rawValues; - const smoothedValues = boxSmoothing(toSmooth, numberOfPoints, context.state.selection); - context.commit('setSmoothedValues', smoothedValues); - }, - smoothSlope(context, numberOfPoints) { - const toSmooth = context.state.smoothedValues ? context.state.smoothedValues : context.state.rawValues; - const smoothedValues = slopeSmoothing(toSmooth, numberOfPoints, context.state.selection); - context.commit('setSmoothedValues', smoothedValues); - }, - savitzkyGolay(context, options) { - const toSmooth = context.state.smoothedValues ? context.state.smoothedValues : context.state.rawValues; - const smoothedValues = savitzkyGolay(toSmooth, options, context.state.selection); - context.commit('setSmoothedValues', smoothedValues); - }, - kalmanFilter(context, options) { - const toSmooth = context.state.smoothedValues ? context.state.smoothedValues : context.state.rawValues; - const smoothedValues = kalmanFilter(toSmooth, options, context.state.selection); - context.commit('setSmoothedValues', smoothedValues); + addOperation(context, operation) { + context.commit('addOperation', operation); + context.dispatch('doOperation', operation); }, - slopeRange(context, range) { + async doOperation(context, operation) { + if (!operation.enabled) { + return; + } const toSmooth = context.state.smoothedValues ? context.state.smoothedValues : context.state.rawValues; - const smoothedValues = setSlopeRange(toSmooth, range, context.state.selection); + let smoothedValues; + switch (operation.name) { + case 'smooth': { + smoothedValues = boxSmoothing(toSmooth, operation.numberOfPoints, operation.selection); + break; + } + case 'smoothSlope': { + smoothedValues = slopeSmoothing(toSmooth, operation.numberOfPoints, operation.selection); + break; + } + case 'savitzkyGolay': { + smoothedValues = savitzkyGolay(toSmooth, operation, operation.selection); + break; + } + case 'kalmanFilter': { + smoothedValues = kalmanFilter(toSmooth, operation, operation.selection); + break; + } + case 'slopeRange': { + smoothedValues = setSlopeRange(toSmooth, operation.range, operation.selection); + break; + } + case 'flatten': { + smoothedValues = flattenPoints(toSmooth, operation.slopeDelta, operation.selection); + break; + } + case 'slopePercentage': { + smoothedValues = shiftSlope(toSmooth, operation.slopeShift, operation.selection); + break; + } + case 'elevate': { + smoothedValues = elevatePoints(toSmooth, operation.metres, operation.selection); + break; + } + } context.commit('setSmoothedValues', smoothedValues); }, - flatten(context, slopeDelta) { - const toSmooth = context.state.smoothedValues ? context.state.smoothedValues : context.state.rawValues; - const smoothedValues = flattenPoints(toSmooth, slopeDelta, context.state.selection); - context.commit('setSmoothedValues', smoothedValues); + async doOperations(context) { + context.commit('resetSmoothingData'); + for (let index = 0; index < context.state.redoStack.length; index++) { + await context.dispatch('doOperation', context.state.redoStack[index]); + } + context.commit('endSmoothing'); }, - slopePercentage(context, slopeShift) { - const toSmooth = context.state.smoothedValues ? context.state.smoothedValues : context.state.rawValues; - const smoothedValues = shiftSlope(toSmooth, slopeShift, context.state.selection); - context.commit('setSmoothedValues', smoothedValues); + async redoOperations(context) { + context.commit('startSmoothing'); + // A web-worker would be an improvement over a set-timeout here + setTimeout(() => { + context.dispatch('doOperations'); + }); }, - elevate(context, metres) { - const toElevate = context.state.smoothedValues ? context.state.smoothedValues : context.state.rawValues; - const smoothedValues = elevatePoints(toElevate, metres, context.state.selection); - context.commit('setSmoothedValues', smoothedValues); + toggleOperation(context, operation) { + context.commit('toggleOperation', operation); }, resetSmoothing(context) { context.commit('resetSmoothing'); @@ -159,6 +184,29 @@ export default new Vuex.Store({ state.selection = null; state.smoothedAverageSlope = null; state.smoothedValues = null; + state.redoStack = []; + }, + resetSmoothingData(state) { + state.smoothedAverageSlope = null; + state.smoothedValues = null; + }, + addOperation(state, operation) { + state.redoStack.push(operation); + }, + toggleOperation(state, operation) { + // find the operation in the redoStack + const operationIndex = state.redoStack.findIndex(element => element.id === operation.id); + const toggled = { + ...operation, + enabled: !operation.enabled + }; + Vue.set(state.redoStack, operationIndex, toggled); + }, + startSmoothing(state) { + state.isSmoothingInProgress = true; + }, + endSmoothing(state) { + state.isSmoothingInProgress = false; } } }); diff --git a/src/utilities/generateUUID.js b/src/utilities/generateUUID.js new file mode 100644 index 0000000..f22847e --- /dev/null +++ b/src/utilities/generateUUID.js @@ -0,0 +1,6 @@ +// Generate a psuedo-UUID +export function generateUUID() { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +} diff --git a/src/views/About.vue b/src/views/About.vue index 6e94ea2..f694b28 100644 --- a/src/views/About.vue +++ b/src/views/About.vue @@ -32,6 +32,9 @@ currently only work on GPX files with one track containing one track segment.

+

Smoothing History

+

Use the checkboxes to add or remove a smoothing operation.

+

Route Map

Hover over the layers icon in the top-right corner of the map, and select "Route Markers" so see the points on @@ -47,7 +50,7 @@

  • Editing the route and elevation from the route map
  • Mini elevation chart on the route map
  • Draggable points on the elevation charts
  • -
  • Undo/Redo stack
  • +
  • Edit and delete on the undo/redo stack
  • Charts code clean-up to get rid of repeated code
  • Vue 3 migration
  • Typescript
  • @@ -55,9 +58,17 @@
  • Reverse the route
  • Zoom and pan on the graphs
  • Zoom to the route on the map
  • +
  • Web worker for long-running processes for proper wait cursor
  • Change Log

    + +

    Version 1.8.0 - 2021-05-04

    +
      +
    • Added an undo/redo stack.
    • +
    • Added an overlay while loading or processing.
    • +
    +

    Version 1.7.1 - 2021-01-17

    • Updates to the change log.
    • diff --git a/src/views/GpxSmoother.vue b/src/views/GpxSmoother.vue index b2e0707..b7ed5eb 100644 --- a/src/views/GpxSmoother.vue +++ b/src/views/GpxSmoother.vue @@ -124,7 +124,7 @@ + :disabled="!canSmooth"> Reset the Data @@ -155,7 +155,7 @@ + :disabled="!canSmooth"> Download @@ -168,6 +168,7 @@ import {mapState} from 'vuex'; import Charts from '../components/Charts'; import {updateJson} from '@/utilities/gpxFile'; import * as xml2js from 'xml2js'; +import {generateUUID} from '@/utilities/generateUUID'; export default { name: 'GpxSmoother', @@ -194,12 +195,11 @@ export default { numLaps: 1 }), computed: { - ...mapState(['selectedGpxFile', 'outputName', 'description', 'fileJson', 'smoothedValues']), + ...mapState(['selectedGpxFile', 'outputName', 'description', 'fileJson', 'selection', 'smoothedValues']), ...mapState({ isLoading: state => state.isLoading, loadError: state => state.loadError, canSmooth: state => (state.rawValues !== null && state.rawValues.length > 0), - haveSmoothedValues: state => (state.rawValues !== null && state.smoothedValues !== null) }), }, watch: { @@ -220,30 +220,39 @@ export default { onFileChange() { store.dispatch('load', this.gpxFile); }, + addOperation(name, parameters) { + store.dispatch('addOperation', { + name, + ...parameters, + selection: this.selection, + enabled: true, + id: generateUUID() + }); + }, onSlopeSmoothing() { - store.dispatch('smoothSlope', this.numSlopeSmoothingPoints); + this.addOperation('smoothSlope', {numberOfPoints: this.numSlopeSmoothingPoints}); }, onSmoothValues() { - store.dispatch('smooth', this.numSmoothingPoints); + this.addOperation('smooth', {numberOfPoints: this.numSmoothingPoints}); }, onSavitzyGolay() { - store.dispatch('savitzkyGolay', - {windowSize: +this.windowSize, derivative: +this.derivative, polynomial: +this.polynomial}); + this.addOperation('savitzkyGolay', {windowSize: +this.windowSize, derivative: +this.derivative, + polynomial: +this.polynomial}); }, onKalmanFilter() { - store.dispatch('kalmanFilter', {R: this.kalmanR, Q: this.kalmanQ, useDeltaSlope: this.useDeltaSlope}); + this.addOperation('kalmanFilter', {R: this.kalmanR, Q: this.kalmanQ, useDeltaSlope: this.useDeltaSlope}); }, onSetSlopeRange() { - store.dispatch('slopeRange', {minSlope: this.minSlope, maxSlope: this.maxSlope}); + this.addOperation('slopeRange', {range: {minSlope: this.minSlope, maxSlope: this.maxSlope}}); }, onFlattenValues() { - store.dispatch('flatten', this.slopeDelta); - }, + this.addOperation('flatten', {slopeDelta: this.slopeDelta}); + }, onUpdateSlopePercentages() { - store.dispatch('slopePercentage', this.slopeShift); + this.addOperation('slopePercentage', {slopeShift: this.slopeShift}); }, onElevateValues() { - store.dispatch('elevate', this.metresShift); + this.addOperation('elevate', {metres: this.metresShift}); }, onResetData() { store.dispatch('resetSmoothing'); diff --git a/src/views/History.vue b/src/views/History.vue new file mode 100644 index 0000000..b0f522b --- /dev/null +++ b/src/views/History.vue @@ -0,0 +1,145 @@ + + + diff --git a/yarn.lock b/yarn.lock index 5649a9c..59483be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6894,6 +6894,11 @@ querystringify@^2.1.1: resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e" integrity sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA== +ramda@^0.27.1: + version "0.27.1" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.1.tgz#66fc2df3ef873874ffc2da6aa8984658abacf5c9" + integrity sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw== + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"