mirror of
https://github.com/nasa/openmct.git
synced 2025-01-18 02:39:56 +00:00
Log plots 2 / custom ticks (#4931)
* add some types to XAxisModel * Add UI to toggle log mode. * handle autoscale toggle for logMode * add log plot tests * test log ticks work after refresh * add an initial manually-created visual snapshot test of log plot chart * update plot unit tests for log mode * remove scale variable for now * make v-for keys unique per template to avoid a small performance hazard of v-for markup in the same subtree of a template having clashing keys (Vue quirk) Co-authored-by: unlikelyzero <jchill2@gmail.com> Co-authored-by: John Hill <john.c.hill@nasa.gov> Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
This commit is contained in:
parent
a5580912e3
commit
402cd15726
279
e2e/tests/plugins/plot/log-plot.e2e.spec.js
Normal file
279
e2e/tests/plugins/plot/log-plot.e2e.spec.js
Normal file
@ -0,0 +1,279 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2022, United States Government
|
||||
* as represented by the Administrator of the National Aeronautics and Space
|
||||
* Administration. All rights reserved.
|
||||
*
|
||||
* Open MCT is licensed under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*
|
||||
* Open MCT includes source code licensed under additional open source
|
||||
* licenses. See the Open Source Licenses file (LICENSES.md) included with
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
/*
|
||||
Tests to verify log plot functionality.
|
||||
*/
|
||||
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Log plot tests', () => {
|
||||
test.only('Can create a log plot.', async ({ page }) => {
|
||||
await makeOverlayPlot(page);
|
||||
await testRegularTicks(page);
|
||||
await enableEditMode(page);
|
||||
await enableLogMode(page);
|
||||
await testLogTicks(page);
|
||||
await disableLogMode(page);
|
||||
await testRegularTicks(page);
|
||||
await enableLogMode(page);
|
||||
await testLogTicks(page);
|
||||
await saveOverlayPlot(page);
|
||||
await testLogTicks(page);
|
||||
await testLogPlotPixels(page);
|
||||
|
||||
// refresh page
|
||||
await page.reload();
|
||||
|
||||
// test log ticks hold up after refresh
|
||||
await testLogTicks(page);
|
||||
await testLogPlotPixels(page);
|
||||
});
|
||||
|
||||
test.only('Verify that log mode option is reflected in import/export JSON', async ({ page }) => {
|
||||
await makeOverlayPlot(page);
|
||||
await enableEditMode(page);
|
||||
await enableLogMode(page);
|
||||
await saveOverlayPlot(page);
|
||||
|
||||
// TODO ...export, delete the overlay, then import it...
|
||||
|
||||
await testLogTicks(page);
|
||||
|
||||
// TODO, the plot is slightly at different position that in the other test, so this fails.
|
||||
// ...We can fix it by copying all steps from the first test...
|
||||
// await testLogPlotPixels(page);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Makes an overlay plot with a sine wave generator and clicks on the overlay plot in the sidebar so it is the active thing displayed.
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function makeOverlayPlot(page) {
|
||||
// fresh page with time range from 2022-03-29 22:00:00.000Z to 2022-03-29 22:00:30.000Z
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
|
||||
// Set a specific time range for consistency, otherwise it will change
|
||||
// on every test to a range based on the current time.
|
||||
|
||||
const timeInputs = page.locator('input.c-input--datetime');
|
||||
await timeInputs.first().click();
|
||||
await timeInputs.first().fill('2022-03-29 22:00:00.000Z');
|
||||
|
||||
await timeInputs.nth(1).click();
|
||||
await timeInputs.nth(1).fill('2022-03-29 22:00:30.000Z');
|
||||
|
||||
// create overlay plot
|
||||
|
||||
await page.locator('button.c-create-button').click();
|
||||
await page.locator('li:has-text("Overlay Plot")').click();
|
||||
await Promise.all([
|
||||
page.waitForNavigation(/*{ url: 'http://localhost:8080/#/browse/mine/8caf7072-535b-4af6-8394-edd86e3ea35f?tc.mode=fixed&tc.startBound=1648590633191&tc.endBound=1648592433191&tc.timeSystem=utc&view=plot-overlay' }*/),
|
||||
page.locator('text=OK').click()
|
||||
]);
|
||||
|
||||
// save the overlay plot
|
||||
|
||||
await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click();
|
||||
await page.locator('text=Save and Finish Editing').click();
|
||||
|
||||
// create a sinewave generator
|
||||
|
||||
await page.locator('button.c-create-button').click();
|
||||
await page.locator('li:has-text("Sine Wave Generator")').click();
|
||||
|
||||
// set amplitude to 6, offset 4, period 2
|
||||
|
||||
await page.locator('div:nth-child(5) .form-row .c-form-row__controls .form-control .field input').click();
|
||||
await page.locator('div:nth-child(5) .form-row .c-form-row__controls .form-control .field input').fill('6');
|
||||
|
||||
await page.locator('div:nth-child(6) .form-row .c-form-row__controls .form-control .field input').click();
|
||||
await page.locator('div:nth-child(6) .form-row .c-form-row__controls .form-control .field input').fill('4');
|
||||
|
||||
await page.locator('div:nth-child(7) .form-row .c-form-row__controls .form-control .field input').click();
|
||||
await page.locator('div:nth-child(7) .form-row .c-form-row__controls .form-control .field input').fill('2');
|
||||
|
||||
// Click OK to make generator
|
||||
|
||||
await Promise.all([
|
||||
page.waitForNavigation(/*{ url: 'http://localhost:8080/#/browse/mine/8caf7072-535b-4af6-8394-edd86e3ea35f/6e58b26a-8a73-4df6-b3a6-918decc0bbfa?tc.mode=fixed&tc.startBound=1648590633191&tc.endBound=1648592433191&tc.timeSystem=utc&view=plot-single' }*/),
|
||||
page.locator('text=OK').click()
|
||||
]);
|
||||
|
||||
// click on overlay plot
|
||||
|
||||
await page.locator('text=Open MCT My Items >> span').nth(3).click();
|
||||
await Promise.all([
|
||||
page.waitForNavigation(/*{ url: 'http://localhost:8080/#/browse/mine/8caf7072-535b-4af6-8394-edd86e3ea35f?tc.mode=fixed&tc.startBound=1648590633191&tc.endBound=1648592433191&tc.timeSystem=utc&view=plot-overlay' }*/),
|
||||
page.locator('text=Unnamed Overlay Plot').first().click()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function testRegularTicks(page) {
|
||||
const yTicks = page.locator('.gl-plot-y-tick-label');
|
||||
expect(await yTicks.count()).toBe(7);
|
||||
await expect(yTicks.nth(0)).toHaveText('-2');
|
||||
await expect(yTicks.nth(1)).toHaveText('0');
|
||||
await expect(yTicks.nth(2)).toHaveText('2');
|
||||
await expect(yTicks.nth(3)).toHaveText('4');
|
||||
await expect(yTicks.nth(4)).toHaveText('6');
|
||||
await expect(yTicks.nth(5)).toHaveText('8');
|
||||
await expect(yTicks.nth(6)).toHaveText('10');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function testLogTicks(page) {
|
||||
const yTicks = page.locator('.gl-plot-y-tick-label');
|
||||
expect(await yTicks.count()).toBe(28);
|
||||
await expect(yTicks.nth(0)).toHaveText('-2.98');
|
||||
await expect(yTicks.nth(1)).toHaveText('-2.50');
|
||||
await expect(yTicks.nth(2)).toHaveText('-2.00');
|
||||
await expect(yTicks.nth(3)).toHaveText('-1.51');
|
||||
await expect(yTicks.nth(4)).toHaveText('-1.20');
|
||||
await expect(yTicks.nth(5)).toHaveText('-1.00');
|
||||
await expect(yTicks.nth(6)).toHaveText('-0.80');
|
||||
await expect(yTicks.nth(7)).toHaveText('-0.58');
|
||||
await expect(yTicks.nth(8)).toHaveText('-0.40');
|
||||
await expect(yTicks.nth(9)).toHaveText('-0.20');
|
||||
await expect(yTicks.nth(10)).toHaveText('-0.00');
|
||||
await expect(yTicks.nth(11)).toHaveText('0.20');
|
||||
await expect(yTicks.nth(12)).toHaveText('0.40');
|
||||
await expect(yTicks.nth(13)).toHaveText('0.58');
|
||||
await expect(yTicks.nth(14)).toHaveText('0.80');
|
||||
await expect(yTicks.nth(15)).toHaveText('1.00');
|
||||
await expect(yTicks.nth(16)).toHaveText('1.20');
|
||||
await expect(yTicks.nth(17)).toHaveText('1.51');
|
||||
await expect(yTicks.nth(18)).toHaveText('2.00');
|
||||
await expect(yTicks.nth(19)).toHaveText('2.50');
|
||||
await expect(yTicks.nth(20)).toHaveText('2.98');
|
||||
await expect(yTicks.nth(21)).toHaveText('3.50');
|
||||
await expect(yTicks.nth(22)).toHaveText('4.00');
|
||||
await expect(yTicks.nth(23)).toHaveText('4.50');
|
||||
await expect(yTicks.nth(24)).toHaveText('5.31');
|
||||
await expect(yTicks.nth(25)).toHaveText('7.00');
|
||||
await expect(yTicks.nth(26)).toHaveText('8.00');
|
||||
await expect(yTicks.nth(27)).toHaveText('9.00');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function enableEditMode(page) {
|
||||
// turn on edit mode
|
||||
await page.locator('text=Unnamed Overlay Plot Snapshot >> button').nth(3).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function enableLogMode(page) {
|
||||
// turn on log mode
|
||||
await page.locator('text=Y Axis Label Log mode Auto scale Padding >> input[type="checkbox"]').first().check();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function disableLogMode(page) {
|
||||
// turn off log mode
|
||||
await page.locator('text=Y Axis Label Log mode Auto scale Padding >> input[type="checkbox"]').first().uncheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function saveOverlayPlot(page) {
|
||||
// save overlay plot
|
||||
await page.locator('text=Snapshot Save and Finish Editing Save and Continue Editing >> button').nth(1).click();
|
||||
await page.locator('text=Save and Finish Editing').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function testLogPlotPixels(page) {
|
||||
const pixelsMatch = await page.evaluate(async () => {
|
||||
// TODO get canvas pixels at a few locations to make sure they're the correct color, to test that the plot comes out as expected.
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// These are some pixels that should be blue points in the log plot.
|
||||
// If the plot changes shape to an unexpected shape, this will
|
||||
// likely fail, which is what we want.
|
||||
//
|
||||
// I found these pixels by pausing playwright in debug mode at this
|
||||
// point, and using similar code as below to output the pixel data, then
|
||||
// I logged those pixels here.
|
||||
const expectedBluePixels = [
|
||||
// TODO these pixel sets only work with the first test, but not the second test.
|
||||
|
||||
// [60, 35],
|
||||
// [121, 125],
|
||||
// [156, 377],
|
||||
// [264, 73],
|
||||
// [372, 186],
|
||||
// [576, 73],
|
||||
// [659, 439],
|
||||
// [675, 423]
|
||||
|
||||
[60, 35],
|
||||
[120, 125],
|
||||
[156, 375],
|
||||
[264, 73],
|
||||
[372, 185],
|
||||
[575, 72],
|
||||
[659, 437],
|
||||
[675, 421]
|
||||
];
|
||||
|
||||
// The first canvas in the DOM is the one that has the plot point
|
||||
// icons (canvas 2d), which is the one we are testing. The second
|
||||
// one in the DOM is the WebGL canvas with the line. (Why aren't
|
||||
// they both WebGL?)
|
||||
const canvas = document.querySelector('canvas');
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
for (const pixel of expectedBluePixels) {
|
||||
// XXX Possible optimization: call getImageData only once with
|
||||
// area including all pixels to be tested.
|
||||
const data = ctx.getImageData(pixel[0], pixel[1], 1, 1).data;
|
||||
|
||||
// #43b0ffff <-- openmct cyanish-blue with 100% opacity
|
||||
// if (data[0] !== 0x43 || data[1] !== 0xb0 || data[2] !== 0xff || data[3] !== 0xff) {
|
||||
if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 0) {
|
||||
// If any pixel is empty, it means we didn't hit a plot point.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(pixelsMatch).toBe(true);
|
||||
}
|
@ -94,6 +94,7 @@
|
||||
"test:debug": "cross-env NODE_ENV=debug karma start --no-single-run",
|
||||
"test:e2e:ci": "npx playwright test --config=e2e/playwright-ci.config.js --project=chrome smoke default condition timeConductor",
|
||||
"test:e2e:local": "npx playwright test --config=e2e/playwright-local.config.js --project=chrome",
|
||||
"test:e2e:debug": "npm run test:e2e:local -- --debug",
|
||||
"test:e2e:visual": "percy exec --config ./e2e/.percy.yml -- npx playwright test --config=e2e/playwright-visual.config.js default",
|
||||
"test:e2e:full": "npx playwright test --config=e2e/playwright-ci.config.js",
|
||||
"test:watch": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" karma start --no-single-run",
|
||||
|
@ -57,7 +57,7 @@
|
||||
/>
|
||||
|
||||
<drop-hint
|
||||
:key="i"
|
||||
:key="'hint-' + i"
|
||||
class="c-fl-frame__drop-hint"
|
||||
:index="i"
|
||||
:allow-drop="allowDrop"
|
||||
@ -66,7 +66,7 @@
|
||||
|
||||
<resize-handle
|
||||
v-if="(i !== frames.length - 1)"
|
||||
:key="i"
|
||||
:key="'handle-' + i"
|
||||
:index="i"
|
||||
:orientation="rowsLayout ? 'horizontal' : 'vertical'"
|
||||
:is-editing="isEditing"
|
||||
|
@ -30,8 +30,8 @@
|
||||
class="gl-plot-tick-wrapper"
|
||||
>
|
||||
<div
|
||||
v-for="tick in ticks"
|
||||
:key="tick.value"
|
||||
v-for="(tick, i) in ticks"
|
||||
:key="'tick-left' + i"
|
||||
class="gl-plot-tick gl-plot-x-tick-label"
|
||||
:style="{
|
||||
left: (100 * (tick.value - min) / interval) + '%'
|
||||
@ -46,8 +46,8 @@
|
||||
class="gl-plot-tick-wrapper"
|
||||
>
|
||||
<div
|
||||
v-for="tick in ticks"
|
||||
:key="tick.value"
|
||||
v-for="(tick, i) in ticks"
|
||||
:key="'tick-top' + i"
|
||||
class="gl-plot-tick gl-plot-y-tick-label"
|
||||
:style="{ top: (100 * (max - tick.value) / interval) + '%' }"
|
||||
:title="tick.fullText || tick.text"
|
||||
@ -59,8 +59,8 @@
|
||||
<!-- grid lines follow -->
|
||||
<template v-if="position === 'right'">
|
||||
<div
|
||||
v-for="tick in ticks"
|
||||
:key="tick.value"
|
||||
v-for="(tick, i) in ticks"
|
||||
:key="'tick-right' + i"
|
||||
class="gl-plot-hash hash-v"
|
||||
:style="{
|
||||
right: (100 * (max - tick.value) / interval) + '%',
|
||||
@ -71,8 +71,8 @@
|
||||
</template>
|
||||
<template v-if="position === 'bottom'">
|
||||
<div
|
||||
v-for="tick in ticks"
|
||||
:key="tick.value"
|
||||
v-for="(tick, i) in ticks"
|
||||
:key="'tick-bottom' + i"
|
||||
class="gl-plot-hash hash-h"
|
||||
:style="{ bottom: (100 * (tick.value - min) / interval) + '%', width: '100%' }"
|
||||
>
|
||||
@ -83,7 +83,7 @@
|
||||
|
||||
<script>
|
||||
import eventHelpers from "./lib/eventHelpers";
|
||||
import { ticks, getFormattedTicks } from "./tickUtils";
|
||||
import { ticks, getLogTicks, getFormattedTicks } from "./tickUtils";
|
||||
import configStore from "./configuration/ConfigStore";
|
||||
|
||||
export default {
|
||||
@ -96,6 +96,13 @@ export default {
|
||||
},
|
||||
required: true
|
||||
},
|
||||
// Make it a prop, then later we can allow user to change it via UI input
|
||||
tickCount: {
|
||||
type: Number,
|
||||
default() {
|
||||
return 6;
|
||||
}
|
||||
},
|
||||
position: {
|
||||
required: true,
|
||||
type: String,
|
||||
@ -118,7 +125,6 @@ export default {
|
||||
|
||||
this.axis = this.getAxisFromConfig();
|
||||
|
||||
this.tickCount = 4;
|
||||
this.tickUpdate = false;
|
||||
this.listenTo(this.axis, 'change:displayRange', this.updateTicks, this);
|
||||
this.listenTo(this.axis, 'change:format', this.updateTicks, this);
|
||||
@ -184,7 +190,12 @@ export default {
|
||||
}, this);
|
||||
}
|
||||
|
||||
if (this.axisType === 'yAxis' && this.axis.get('logMode')) {
|
||||
return getLogTicks(range.min, range.max, number, 4);
|
||||
// return getLogTicks2(range.min, range.max, number);
|
||||
} else {
|
||||
return ticks(range.min, range.max, number);
|
||||
}
|
||||
},
|
||||
|
||||
updateTicksForceRegeneration() {
|
||||
|
@ -23,7 +23,7 @@
|
||||
import MCTChartSeriesElement from './MCTChartSeriesElement';
|
||||
|
||||
export default class MCTChartLineStepAfter extends MCTChartSeriesElement {
|
||||
removePoint(point, index, count) {
|
||||
removePoint(index) {
|
||||
if (index > 0 && index / 2 < this.count) {
|
||||
this.buffer[index + 1] = this.buffer[index - 1];
|
||||
}
|
||||
|
@ -85,11 +85,10 @@ export default class MCTChartSeriesElement {
|
||||
|
||||
this.removeSegments(removalPoint, vertexCount);
|
||||
|
||||
this.removePoint(
|
||||
this.makePoint(point, series),
|
||||
removalPoint,
|
||||
vertexCount
|
||||
);
|
||||
// TODO useless makePoint call?
|
||||
this.makePoint(point, series);
|
||||
this.removePoint(removalPoint);
|
||||
|
||||
this.count -= (vertexCount / 2);
|
||||
}
|
||||
|
||||
@ -109,11 +108,7 @@ export default class MCTChartSeriesElement {
|
||||
const insertionPoint = this.startIndexForPointAtIndex(index);
|
||||
this.growIfNeeded(pointsRequired);
|
||||
this.makeInsertionPoint(insertionPoint, pointsRequired);
|
||||
this.addPoint(
|
||||
this.makePoint(point, series),
|
||||
insertionPoint,
|
||||
pointsRequired
|
||||
);
|
||||
this.addPoint(this.makePoint(point, series), insertionPoint);
|
||||
this.count += (pointsRequired / 2);
|
||||
}
|
||||
|
||||
|
@ -71,6 +71,7 @@ export default class Model extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @param {ModelOptions<T, O>} options
|
||||
*/
|
||||
initialize(options) {
|
||||
|
@ -23,6 +23,7 @@ import _ from 'lodash';
|
||||
import Model from "./Model";
|
||||
import { MARKER_SHAPES } from '../draw/MarkerShapes';
|
||||
import configStore from "../configuration/ConfigStore";
|
||||
import { symlog } from '../mathUtils';
|
||||
|
||||
/**
|
||||
* Plot series handle interpreting telemetry metadata for a single telemetry
|
||||
@ -63,6 +64,8 @@ import configStore from "../configuration/ConfigStore";
|
||||
* @extends {Model<PlotSeriesModelType, PlotSeriesModelOptions>}
|
||||
*/
|
||||
export default class PlotSeries extends Model {
|
||||
logMode = false;
|
||||
|
||||
/**
|
||||
@param {import('./Model').ModelOptions<PlotSeriesModelType, PlotSeriesModelOptions>} options
|
||||
*/
|
||||
@ -70,6 +73,8 @@ export default class PlotSeries extends Model {
|
||||
|
||||
super(options);
|
||||
|
||||
this.logMode = options.collection.plot.model.yAxis.logMode;
|
||||
|
||||
this.listenTo(this, 'change:xKey', this.onXKeyChange, this);
|
||||
this.listenTo(this, 'change:yKey', this.onYKeyChange, this);
|
||||
this.persistedConfig = options.persistedConfig;
|
||||
@ -229,6 +234,7 @@ export default class PlotSeries extends Model {
|
||||
this.getXVal = format.parse.bind(format);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update y formatter on change, default to stepAfter interpolation if
|
||||
* y range is an enumeration.
|
||||
@ -252,7 +258,11 @@ export default class PlotSeries extends Model {
|
||||
}.bind(this);
|
||||
this.set('unit', valueMetadata.unit);
|
||||
const format = this.formats[newKey];
|
||||
this.getYVal = format.parse.bind(format);
|
||||
this.getYVal = (value) => {
|
||||
const y = format.parse(value);
|
||||
|
||||
return this.logMode ? symlog(y, 10) : y;
|
||||
};
|
||||
}
|
||||
|
||||
formatX(point) {
|
||||
@ -520,7 +530,8 @@ export default class PlotSeries extends Model {
|
||||
|
||||
/**
|
||||
* Update the series data with the given value.
|
||||
* @returns {Array<{
|
||||
* This return type definition is totally wrong, only covers sinwave generator. It needs to be generic.
|
||||
* @return-example {Array<{
|
||||
cos: number
|
||||
sin: number
|
||||
mctLimitState: {
|
||||
|
@ -19,6 +19,7 @@
|
||||
* this source code distribution or the Licensing information page available
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
import { antisymlog, symlog } from '../mathUtils';
|
||||
import Model from './Model';
|
||||
|
||||
/**
|
||||
@ -30,7 +31,7 @@ import Model from './Model';
|
||||
*
|
||||
* `autoscale`: boolean, whether or not to autoscale.
|
||||
* `autoscalePadding`: float, percent of padding to display in plots.
|
||||
* `displayRange`: the current display range for the x Axis.
|
||||
* `displayRange`: the current display range for the axis.
|
||||
* `format`: the formatter for the axis.
|
||||
* `frozen`: boolean, if true, displayRange will not be updated automatically.
|
||||
* Used to temporarily disable automatic updates during user interaction.
|
||||
@ -53,6 +54,7 @@ export default class YAxisModel extends Model {
|
||||
this.listenTo(this, 'change:stats', this.calculateAutoscaleExtents, this);
|
||||
this.listenTo(this, 'change:autoscale', this.toggleAutoscale, this);
|
||||
this.listenTo(this, 'change:autoscalePadding', this.updatePadding, this);
|
||||
this.listenTo(this, 'change:logMode', this.onLogModeChange, this);
|
||||
this.listenTo(this, 'change:frozen', this.toggleFreeze, this);
|
||||
this.listenTo(this, 'change:range', this.updateDisplayRange, this);
|
||||
this.updateDisplayRange(this.get('range'));
|
||||
@ -73,11 +75,6 @@ export default class YAxisModel extends Model {
|
||||
this.seriesCollection.forEach(this.trackSeries, this);
|
||||
this.updateFromSeries(this.seriesCollection);
|
||||
}
|
||||
updateDisplayRange(range) {
|
||||
if (!this.get('autoscale')) {
|
||||
this.set('displayRange', range);
|
||||
}
|
||||
}
|
||||
toggleFreeze(frozen) {
|
||||
if (!frozen) {
|
||||
this.toggleAutoscale(this.get('autoscale'));
|
||||
@ -165,23 +162,91 @@ export default class YAxisModel extends Model {
|
||||
this.resetStats();
|
||||
this.updateFromSeries(this.seriesCollection);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called in order to map the user-provided `range` to the
|
||||
* `displayRange` that we actually use for plot display.
|
||||
*
|
||||
* @param {import('./XAxisModel').NumberRange} range
|
||||
*/
|
||||
updateDisplayRange(range) {
|
||||
if (this.get('autoscale')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const _range = { ...range };
|
||||
|
||||
if (this.get('logMode')) {
|
||||
_range.min = symlog(range.min, 10);
|
||||
_range.max = symlog(range.max, 10);
|
||||
}
|
||||
|
||||
this.set('displayRange', _range);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} autoscale
|
||||
*/
|
||||
toggleAutoscale(autoscale) {
|
||||
if (autoscale && this.has('stats')) {
|
||||
this.set('displayRange', this.applyPadding(this.get('stats')));
|
||||
} else {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const range = this.get('range');
|
||||
|
||||
if (range) {
|
||||
// If we already have a user-defined range, make sure it maps to the
|
||||
// range we'll actually use for the ticks.
|
||||
this.set('displayRange', range);
|
||||
|
||||
const _range = { ...range };
|
||||
|
||||
if (this.get('logMode')) {
|
||||
_range.min = symlog(range.min, 10);
|
||||
_range.max = symlog(range.max, 10);
|
||||
}
|
||||
|
||||
this.set('displayRange', _range);
|
||||
} else {
|
||||
// Otherwise use the last known displayRange as the initial
|
||||
// values for the user-defined range, so that we don't end up
|
||||
// with any error from an undefined user range.
|
||||
this.set('range', this.get('displayRange'));
|
||||
|
||||
const _range = this.get('displayRange');
|
||||
|
||||
if (this.get('logMode')) {
|
||||
_range.min = antisymlog(_range.min, 10);
|
||||
_range.max = antisymlog(_range.max, 10);
|
||||
}
|
||||
|
||||
this.set('range', _range);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {boolean} logMode */
|
||||
onLogModeChange(logMode) {
|
||||
const range = this.get('displayRange');
|
||||
|
||||
if (logMode) {
|
||||
range.min = symlog(range.min, 10);
|
||||
range.max = symlog(range.max, 10);
|
||||
} else {
|
||||
range.min = antisymlog(range.min, 10);
|
||||
range.max = antisymlog(range.max, 10);
|
||||
}
|
||||
|
||||
this.set('displayRange', range);
|
||||
|
||||
this.resetSeries();
|
||||
}
|
||||
resetSeries() {
|
||||
this.plot.series.forEach((plotSeries) => {
|
||||
plotSeries.logMode = this.get('logMode');
|
||||
plotSeries.reset(plotSeries.getSeriesData());
|
||||
});
|
||||
// Update the series collection labels and formatting
|
||||
this.updateFromSeries(this.seriesCollection);
|
||||
}
|
||||
/**
|
||||
* Update yAxis format, values, and label from known series.
|
||||
@ -189,7 +254,7 @@ export default class YAxisModel extends Model {
|
||||
*/
|
||||
updateFromSeries(seriesCollection) {
|
||||
const plotModel = this.plot.get('domainObject');
|
||||
const label = plotModel?.configuration?.yAxis?.label;
|
||||
const label = plotModel.configuration?.yAxis?.label;
|
||||
const sampleSeries = seriesCollection.first();
|
||||
if (!sampleSeries) {
|
||||
if (!label) {
|
||||
@ -202,7 +267,13 @@ export default class YAxisModel extends Model {
|
||||
const yKey = sampleSeries.get('yKey');
|
||||
const yMetadata = sampleSeries.metadata.value(yKey);
|
||||
const yFormat = sampleSeries.formats[yKey];
|
||||
this.set('format', yFormat.format.bind(yFormat));
|
||||
|
||||
if (this.get('logMode')) {
|
||||
this.set('format', (n) => yFormat.format(antisymlog(n, 10)));
|
||||
} else {
|
||||
this.set('format', (n) => yFormat.format(n));
|
||||
}
|
||||
|
||||
this.set('values', yMetadata.values);
|
||||
if (!label) {
|
||||
const labelName = seriesCollection
|
||||
@ -255,6 +326,7 @@ export default class YAxisModel extends Model {
|
||||
return {
|
||||
frozen: false,
|
||||
autoscale: true,
|
||||
logMode: options.model?.logMode ?? false,
|
||||
autoscalePadding: 0.1
|
||||
|
||||
// 'range' is not specified here, it is undefined at first. When the
|
||||
@ -269,6 +341,7 @@ export default class YAxisModel extends Model {
|
||||
/**
|
||||
@typedef {import('./XAxisModel').AxisModelType & {
|
||||
autoscale: boolean
|
||||
logMode: boolean
|
||||
autoscalePadding: number
|
||||
stats?: import('./XAxisModel').NumberRange
|
||||
values: Array<TODO>
|
||||
|
@ -48,11 +48,19 @@
|
||||
<li class="grid-row">
|
||||
<div
|
||||
class="grid-cell label"
|
||||
title="Automatically scale the Y axis to keep all values in view."
|
||||
>Autoscale</div>
|
||||
title="Enable log mode."
|
||||
>Log mode</div>
|
||||
<div class="grid-cell value">
|
||||
{{ autoscale ? "Enabled: " : "Disabled" }}
|
||||
{{ autoscale ? autoscalePadding : "" }}
|
||||
{{ logMode ? "Enabled" : "Disabled" }}
|
||||
</div>
|
||||
</li>
|
||||
<li class="grid-row">
|
||||
<div
|
||||
class="grid-cell label"
|
||||
title="Automatically scale the Y axis to keep all values in view."
|
||||
>Auto scale</div>
|
||||
<div class="grid-cell value">
|
||||
{{ autoscale ? "Enabled: " + autoscalePadding : "Disabled" }}
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
@ -142,6 +150,7 @@ export default {
|
||||
config: {},
|
||||
label: '',
|
||||
autoscale: '',
|
||||
logMode: false,
|
||||
autoscalePadding: '',
|
||||
rangeMin: '',
|
||||
rangeMax: '',
|
||||
@ -172,6 +181,7 @@ export default {
|
||||
initConfiguration() {
|
||||
this.label = this.config.yAxis.get('label');
|
||||
this.autoscale = this.config.yAxis.get('autoscale');
|
||||
this.logMode = this.config.yAxis.get('logMode');
|
||||
this.autoscalePadding = this.config.yAxis.get('autoscalePadding');
|
||||
const range = this.config.yAxis.get('range');
|
||||
if (range) {
|
||||
|
@ -14,9 +14,22 @@
|
||||
@change="updateForm('label')"
|
||||
></div>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="l-inspector-part">
|
||||
<h2>Y Axis Scaling</h2>
|
||||
<li class="grid-row">
|
||||
<div
|
||||
class="grid-cell label"
|
||||
title="Enable log mode."
|
||||
>
|
||||
Log mode
|
||||
</div>
|
||||
<div class="grid-cell value">
|
||||
<!-- eslint-disable-next-line vue/html-self-closing -->
|
||||
<input
|
||||
v-model="logMode"
|
||||
type="checkbox"
|
||||
@change="updateForm('logMode')"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li class="grid-row">
|
||||
<div
|
||||
class="grid-cell label"
|
||||
@ -105,6 +118,7 @@ export default {
|
||||
return {
|
||||
label: '',
|
||||
autoscale: '',
|
||||
logMode: false,
|
||||
autoscalePadding: '',
|
||||
rangeMin: '',
|
||||
rangeMax: '',
|
||||
@ -117,38 +131,35 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
initialize: function () {
|
||||
this.fields = [
|
||||
{
|
||||
modelProp: 'label',
|
||||
this.fields = {
|
||||
label: {
|
||||
objectPath: 'configuration.yAxis.label'
|
||||
},
|
||||
{
|
||||
modelProp: 'autoscale',
|
||||
autoscale: {
|
||||
coerce: Boolean,
|
||||
objectPath: 'configuration.yAxis.autoscale'
|
||||
},
|
||||
{
|
||||
modelProp: 'autoscalePadding',
|
||||
autoscalePadding: {
|
||||
coerce: Number,
|
||||
objectPath: 'configuration.yAxis.autoscalePadding'
|
||||
},
|
||||
{
|
||||
modelProp: 'range',
|
||||
logMode: {
|
||||
coerce: Boolean,
|
||||
objectPath: 'configuration.yAxis.logMode'
|
||||
},
|
||||
range: {
|
||||
objectPath: 'configuration.yAxis.range',
|
||||
coerce: function coerceRange(range) {
|
||||
if (!range) {
|
||||
return {
|
||||
min: 0,
|
||||
max: 0
|
||||
const newRange = {
|
||||
min: -1,
|
||||
max: 1
|
||||
};
|
||||
}
|
||||
|
||||
const newRange = {};
|
||||
if (typeof range.min !== 'undefined' && range.min !== null) {
|
||||
if (range && typeof range.min !== 'undefined' && range.min !== null) {
|
||||
newRange.min = Number(range.min);
|
||||
}
|
||||
|
||||
if (typeof range.max !== 'undefined' && range.max !== null) {
|
||||
if (range && typeof range.max !== 'undefined' && range.max !== null) {
|
||||
newRange.max = Number(range.max);
|
||||
}
|
||||
|
||||
@ -180,11 +191,12 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
},
|
||||
initFormValues() {
|
||||
this.label = this.yAxis.get('label');
|
||||
this.autoscale = this.yAxis.get('autoscale');
|
||||
this.logMode = this.yAxis.get('logMode');
|
||||
this.autoscalePadding = this.yAxis.get('autoscalePadding');
|
||||
const range = this.yAxis.get('range') ?? this.yAxis.get('displayRange');
|
||||
this.rangeMin = range?.min;
|
||||
@ -202,7 +214,7 @@ export default {
|
||||
}
|
||||
|
||||
let oldVal = this.yAxis.get(formKey);
|
||||
const formField = this.fields.find((field) => field.modelProp === formKey);
|
||||
const formField = this.fields[formKey];
|
||||
|
||||
const validationError = formField.validate?.(newVal, this.yAxis);
|
||||
this.validationErrors[formKey] = validationError;
|
||||
|
44
src/plugins/plot/mathUtils.js
Normal file
44
src/plugins/plot/mathUtils.js
Normal file
@ -0,0 +1,44 @@
|
||||
/** The natural number `e`. */
|
||||
export const e = Math.exp(1);
|
||||
|
||||
/**
|
||||
Returns the logarithm of a number, using the given base or the natural number
|
||||
`e` as base if not specified.
|
||||
@param {number} n
|
||||
@param {number=} base log base, defaults to e
|
||||
*/
|
||||
export function log(n, base = e) {
|
||||
if (base === e) {
|
||||
return Math.log(n);
|
||||
}
|
||||
|
||||
return Math.log(n) / Math.log(base);
|
||||
}
|
||||
|
||||
/**
|
||||
Returns the inverse of the logarithm of a number, using the given base or the
|
||||
natural number `e` as base if not specified.
|
||||
@param {number} n
|
||||
@param {number=} base log base, defaults to e
|
||||
*/
|
||||
export function antilog(n, base = e) {
|
||||
return Math.pow(base, n);
|
||||
}
|
||||
|
||||
/**
|
||||
A symmetric logarithm function. See https://github.com/nasa/openmct/issues/2297#issuecomment-1032914258
|
||||
@param {number} n
|
||||
@param {number=} base log base, defaults to e
|
||||
*/
|
||||
export function symlog(n, base = e) {
|
||||
return Math.sign(n) * log(Math.abs(n) + 1, base);
|
||||
}
|
||||
|
||||
/**
|
||||
An inverse symmetric logarithm function. See https://github.com/nasa/openmct/issues/2297#issuecomment-1032914258
|
||||
@param {number} n
|
||||
@param {number=} base log base, defaults to e
|
||||
*/
|
||||
export function antisymlog(n, base = e) {
|
||||
return Math.sign(n) * (antilog(Math.abs(n), base) - 1);
|
||||
}
|
@ -389,7 +389,7 @@ describe("the plugin", function () {
|
||||
expect(xAxisElement.length).toBe(1);
|
||||
|
||||
let ticks = xAxisElement[0].querySelectorAll(".gl-plot-tick");
|
||||
expect(ticks.length).toBe(5);
|
||||
expect(ticks.length).toBe(9);
|
||||
|
||||
done();
|
||||
});
|
||||
@ -694,7 +694,7 @@ describe("the plugin", function () {
|
||||
|
||||
Vue.nextTick(() => {
|
||||
let ticks = xAxisElement[0].querySelectorAll(".gl-plot-tick");
|
||||
expect(ticks.length).toBe(5);
|
||||
expect(ticks.length).toBe(9);
|
||||
|
||||
done();
|
||||
});
|
||||
@ -1086,7 +1086,9 @@ describe("the plugin", function () {
|
||||
expandControl.dispatchEvent(clickEvent);
|
||||
|
||||
const yAxisProperties = editOptionsEl.querySelectorAll("div.grid-properties:first-of-type .l-inspector-part");
|
||||
expect(yAxisProperties.length).toEqual(3);
|
||||
|
||||
// TODO better test
|
||||
expect(yAxisProperties.length).toEqual(2);
|
||||
});
|
||||
|
||||
it('renders color palette options', () => {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { antisymlog, symlog } from "./mathUtils";
|
||||
|
||||
const e10 = Math.sqrt(50);
|
||||
const e5 = Math.sqrt(10);
|
||||
const e2 = Math.sqrt(2);
|
||||
@ -40,6 +42,47 @@ function getPrecision(step) {
|
||||
return precision;
|
||||
}
|
||||
|
||||
export function getLogTicks(start, stop, mainTickCount = 8, secondaryTickCount = 6) {
|
||||
// log()'ed values
|
||||
const mainLogTicks = ticks(start, stop, mainTickCount);
|
||||
|
||||
// original values
|
||||
const mainTicks = mainLogTicks.map(n => antisymlog(n, 10));
|
||||
|
||||
const result = [];
|
||||
|
||||
let i = 0;
|
||||
for (const logTick of mainLogTicks) {
|
||||
result.push(logTick);
|
||||
|
||||
if (i === mainLogTicks.length - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const tick = mainTicks[i];
|
||||
const nextTick = mainTicks[i + 1];
|
||||
const rangeBetweenMainTicks = nextTick - tick;
|
||||
|
||||
const secondaryLogTicks = ticks(
|
||||
tick + rangeBetweenMainTicks / (secondaryTickCount + 1),
|
||||
nextTick - rangeBetweenMainTicks / (secondaryTickCount + 1),
|
||||
secondaryTickCount - 2
|
||||
)
|
||||
.map(n => symlog(n, 10));
|
||||
|
||||
result.push(...secondaryLogTicks);
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getLogTicks2(start, stop, count = 8) {
|
||||
return ticks(antisymlog(start, 10), antisymlog(stop, 10), count)
|
||||
.map(n => symlog(n, 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear tick generation from d3-array.
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user