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:
Joe Pea 2022-04-20 14:28:46 -07:00 committed by GitHub
parent a5580912e3
commit 402cd15726
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 558 additions and 76 deletions

View 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);
}

View File

@ -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",

View File

@ -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"

View File

@ -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);
}
return ticks(range.min, range.max, number);
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() {

View File

@ -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];
}

View File

@ -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);
}

View File

@ -71,6 +71,7 @@ export default class Model extends EventEmitter {
}
/**
* @abstract
* @param {ModelOptions<T, O>} options
*/
initialize(options) {

View File

@ -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: {

View File

@ -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 {
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);
} 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'));
}
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.
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.
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>

View File

@ -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) {

View File

@ -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;

View 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);
}

View File

@ -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', () => {

View File

@ -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.
*/