зеркало из https://github.com/mozilla/glean.git
Bug 1654384 - Add a specific simulator to each of the distributions metrics on the book (#1150)
This commit is contained in:
Родитель
15bd281d3a
Коммит
eda8c4c686
|
@ -9,7 +9,7 @@ create-missing = false
|
|||
|
||||
[output.html]
|
||||
additional-css = ["glean.css", "mermaid.css"]
|
||||
additional-js = ["tabs.js", "mermaid.min.js", "mermaid-init.js"]
|
||||
additional-js = ["tabs.js", "mermaid.min.js", "mermaid-init.js", "chart.min.js", "chart-distributions.js", "chart-distributions-ui.js"]
|
||||
git-repository-url = "https://github.com/mozilla/glean"
|
||||
git-branch = "main"
|
||||
mathjax-support = true
|
||||
|
|
|
@ -0,0 +1,173 @@
|
|||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
"use strict"
|
||||
|
||||
if (document.getElementById("histogram-chart")) {
|
||||
// Transformation function we may want to apply to each value before plotting
|
||||
//
|
||||
// The current use cases are memory distributions and timing distribution,
|
||||
// which may receive the values in a given unit, but transform them to a base one upon recording
|
||||
let TRANSFORMATION;
|
||||
|
||||
function memoryUnitToByte(unit) {
|
||||
switch(unit) {
|
||||
case "byte":
|
||||
return value => value;
|
||||
case "kilobyte":
|
||||
return value => value * Math.pow(2, 10);
|
||||
case "megabyte":
|
||||
return value => value * Math.pow(2, 20);
|
||||
case "gigabyte":
|
||||
return value => value * Math.pow(2, 30);
|
||||
}
|
||||
}
|
||||
|
||||
const memoryUnitSelect = document.querySelector("#histogram-props select#memory-unit")
|
||||
if (memoryUnitSelect) {
|
||||
setInputValueFromSearchParam(memoryUnitSelect);
|
||||
TRANSFORMATION = memoryUnitToByte(memoryUnitSelect.value);
|
||||
memoryUnitSelect.addEventListener("change", event => {
|
||||
let memoryUnit = event.target.value;
|
||||
TRANSFORMATION = memoryUnitToByte(memoryUnit);
|
||||
|
||||
let input = event.target;
|
||||
setURLSearchParam(input.name, input.value);
|
||||
buildChartFromInputs();
|
||||
})
|
||||
}
|
||||
|
||||
function timeUnitToNanos(unit) {
|
||||
switch(unit) {
|
||||
case "nanoseconds":
|
||||
return value => value;
|
||||
case "microseconds":
|
||||
return value => value * 1000;
|
||||
case "milliseconds":
|
||||
return value => value * 1000 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
const baseMax = 1000 * 1000 * 1000 * 60 * 10;
|
||||
const timeUnitToMaxValue = {
|
||||
"nanoseconds": baseMax,
|
||||
"microseconds": timeUnitToNanos("microseconds")(baseMax),
|
||||
"milliseconds": timeUnitToNanos("milliseconds")(baseMax),
|
||||
};
|
||||
const timeUnitSelect = document.querySelector("#histogram-props select#time-unit");
|
||||
const maxValueInput = document.getElementById("maximum-value");
|
||||
if (timeUnitSelect) {
|
||||
setInputValueFromSearchParam(timeUnitSelect);
|
||||
TRANSFORMATION = timeUnitToNanos(timeUnitSelect.value);
|
||||
timeUnitSelect.addEventListener("change", event => {
|
||||
let timeUnit = event.target.value;
|
||||
maxValueInput.value = timeUnitToMaxValue[timeUnit];
|
||||
TRANSFORMATION = timeUnitToNanos(timeUnit);
|
||||
|
||||
let input = event.target;
|
||||
setURLSearchParam(input.name, input.value);
|
||||
buildChartFromInputs();
|
||||
})
|
||||
}
|
||||
|
||||
// Open custom data modal when custom data option is selected
|
||||
const customDataInput = document.getElementById("custom-data-input-group");
|
||||
customDataInput.addEventListener('click', () => {
|
||||
customDataModalOverlay.style.display = "block";
|
||||
const customDataTextarea = document.querySelector("#custom-data-modal textarea");
|
||||
if (!customDataTextarea.value) fillUpTextareaWithDummyData(customDataTextarea);
|
||||
})
|
||||
|
||||
// Rebuild chart everytime the custom data text is changed
|
||||
const customDataTextarea = document.querySelector("#custom-data-modal textarea");
|
||||
customDataTextarea.addEventListener("change", () => buildChartFromInputs());
|
||||
|
||||
// Close modal when we click the overlay
|
||||
const customDataModalOverlay = document.getElementById("custom-data-modal-overlay");
|
||||
customDataModalOverlay && customDataModalOverlay.addEventListener('click', () => {
|
||||
customDataModalOverlay.style.display = "none";
|
||||
});
|
||||
|
||||
// We need to stop propagation for click events on the actual modal,
|
||||
// so that clicking it doesn't close it
|
||||
const customDataModal = document.getElementById("custom-data-modal");
|
||||
customDataModal.addEventListener("click", event => event.stopPropagation());
|
||||
|
||||
const options = document.querySelectorAll("#data-options input");
|
||||
options.forEach(option => {
|
||||
option.addEventListener("change", event => {
|
||||
event.preventDefault();
|
||||
|
||||
let input = event.target;
|
||||
setURLSearchParam(input.name, input.value);
|
||||
buildChartFromInputs();
|
||||
});
|
||||
|
||||
if (searchParams().get(option.name) == option.value) {
|
||||
option.checked = true;
|
||||
|
||||
// We won't save the custom data in the URL,
|
||||
// if that is the value on load, we create dummy data
|
||||
if (option.value == "custom") {
|
||||
const customDataTextarea = document.querySelector("#custom-data-modal textarea");
|
||||
fillUpTextareaWithDummyData(customDataTextarea);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const inputs = [
|
||||
...document.querySelectorAll("#histogram-props input"),
|
||||
document.querySelector("#histogram-props select#kind")
|
||||
];
|
||||
|
||||
inputs.forEach(input => {
|
||||
setInputValueFromSearchParam(input);
|
||||
input.addEventListener("change", event => {
|
||||
let input = event.target;
|
||||
setURLSearchParam(input.name, input.value);
|
||||
buildChartFromInputs();
|
||||
});
|
||||
});
|
||||
|
||||
buildChartFromInputs();
|
||||
|
||||
/**
|
||||
* Build and replace the previous chart with a new one, based on the page inputs.
|
||||
*/
|
||||
function buildChartFromInputs() {
|
||||
const kind = document.getElementById("kind").value
|
||||
|
||||
let props;
|
||||
if (kind == "functional") {
|
||||
const logBase = Number(document.getElementById("log-base").value);
|
||||
const bucketsPerMagnitude = Number(document.getElementById("buckets-per-magnitude").value);
|
||||
const maximumValue = Number(document.getElementById("maximum-value").value || Number.MAX_SAFE_INTEGER);
|
||||
props = {
|
||||
logBase,
|
||||
bucketsPerMagnitude,
|
||||
maximumValue
|
||||
}
|
||||
} else {
|
||||
const lowerBound = Number(document.getElementById("lower-bound").value);
|
||||
const upperBound = Number(document.getElementById("upper-bound").value);
|
||||
const bucketCount = Number(document.getElementById("bucket-count").value);
|
||||
props = {
|
||||
lowerBound,
|
||||
upperBound,
|
||||
bucketCount
|
||||
}
|
||||
}
|
||||
|
||||
buildChart(
|
||||
kind,
|
||||
props,
|
||||
document.querySelector("#data-options input:checked").value,
|
||||
document.querySelector("#custom-data-modal textarea").value,
|
||||
document.getElementById("histogram-chart-legend"),
|
||||
document.getElementById("histogram-chart"),
|
||||
TRANSFORMATION
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,589 @@
|
|||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
"use strict"
|
||||
|
||||
// Do not show dataset legend for graph,
|
||||
// defining this in the Chart options doesn't seem to work
|
||||
Chart.defaults.global.legend = false;
|
||||
|
||||
const DATA_SAMPLE_COUNT = 20000;
|
||||
|
||||
/**
|
||||
* Build and replace the previous chart with a new one.
|
||||
*
|
||||
* @param {String} kind The kind of histogram that should be build, possible values are "functional", "exponential" or "linear"
|
||||
* @param {Object} props The properties related to the given histogram, keys differ based in the kind
|
||||
* @param {String} dataOption The chosen way to build data, possible values are "normally-distributed", "log-normally-distributed", "uniformly-distributed" or "custom"
|
||||
* @param {String} customData In case `dataOption` is "custom", this should contain a String containing a JSON array of numbers
|
||||
* @param {HTMLElement} legend The HTML element that should contain the text of the chart legend
|
||||
* @param {HTMLElement} chartSpace The HTML element that should contain the chart
|
||||
* @param {function} transformation Option function to be applied to generated values
|
||||
*/
|
||||
function buildChart (kind, props, dataOption, customData, chartLegend, chartSpace, transformation) {
|
||||
const { buckets, data, percentages, mean } = buildData(kind, props, dataOption, customData, transformation);
|
||||
|
||||
if (kind != "functional") {
|
||||
chartLegend.innerHTML = `Using these parameters, the widest bucket's width is <b>${getWidestBucketWidth(buckets)}</b>.`;
|
||||
} else {
|
||||
chartLegend.innerHTML = `
|
||||
Using these parameters, the maximum bucket is <b>${buckets[buckets.length - 1]}</b>.
|
||||
<br /><br />
|
||||
The mean of the recorded data is <b>${formatNumber(mean)}</b>.
|
||||
`;
|
||||
}
|
||||
|
||||
// Clear chart for re-drawing,
|
||||
// here we need to re-create the whole canvas
|
||||
// otherwise we keep rebuilding the new graph on top of the previous
|
||||
// and that causes hover madness
|
||||
const canvas = document.createElement("canvas");
|
||||
chartSpace.innerHTML = "";
|
||||
chartSpace.appendChild(canvas);
|
||||
// Draw the chart
|
||||
const ctx = canvas.getContext("2d");
|
||||
new Chart(ctx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: buckets,
|
||||
datasets: [{
|
||||
barPercentage: .95,
|
||||
categoryPercentage: 1,
|
||||
backgroundColor: "rgba(76, 138, 196, 1)",
|
||||
hoverBackgroundColor: "rgba(0, 89, 171, 1)",
|
||||
data: percentages
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
beginAtZero: true,
|
||||
callback: value => `${value}%`
|
||||
},
|
||||
scaleLabel: {
|
||||
display: true,
|
||||
labelString: "Percentages of samples"
|
||||
}
|
||||
}],
|
||||
xAxes: [{
|
||||
ticks: {
|
||||
autoSkip: false,
|
||||
minRotation: 50,
|
||||
maxRotation: 50,
|
||||
beginAtZero: true,
|
||||
callback: (value, index, values) => {
|
||||
const interval = Math.floor(values.length / 25)
|
||||
if (interval > 0 && index % interval != 0) {
|
||||
return ""
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
},
|
||||
scaleLabel: {
|
||||
display: true,
|
||||
labelString: "Buckets"
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
mode: "index",
|
||||
callbacks: {
|
||||
title: () => null,
|
||||
label: item => {
|
||||
const index = item.index
|
||||
const lastIndex = percentages.length - 1
|
||||
const percentage = percentages[index].toFixed(2)
|
||||
const value = formatNumber(data[index])
|
||||
if (kind == "functional") {
|
||||
return index == lastIndex ? `${value} samples (${percentage}%) where sample value > ${buckets[lastIndex]} (overflow)`
|
||||
: `${value} samples (${percentage}%) where ${buckets[index]} ≤ sample value < ${buckets[index + 1]}`
|
||||
} else {
|
||||
return index == 0 ? `${value} samples (${percentage}%) where sample value < ${buckets[0]} (underflow)`
|
||||
: index == lastIndex ? `${value} samples (${percentage}%) where sample value > ${buckets[lastIndex]} (overflow)`
|
||||
: `${value} samples (${percentage}%) where ${buckets[index]} ≤ sample value < ${buckets[index + 1]}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the data to be rendered in the charts.
|
||||
*
|
||||
* @param {String} kind The kind of histogram that should be build, possible values are "functional", "exponential" or "linear"
|
||||
* @param {Object} props The properties related to the given histogram, keys differ based in the kind
|
||||
* @param {String} dataOption The chosen way to build data, possible values are "normally-distributed", "log-normally-distributed", "uniformly-distributed" or "custom"
|
||||
* @param {String} customData In case `dataOption` is "custom", this should contain a String containing a JSON array of numbers
|
||||
* @param {function} transformation Option function to be applied to generated values
|
||||
*
|
||||
* @returns {Object} An object containing the bucket and values of a histogram
|
||||
*/
|
||||
function buildData (kind, props, dataOption, customData, transformation) {
|
||||
if (kind == "functional") {
|
||||
return buildDataFunctional(props, dataOption, customData, transformation);
|
||||
} else {
|
||||
return buildDataPreComputed(kind, props, dataOption, customData, transformation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build sample data or parse custom data.
|
||||
*
|
||||
* @param {String} dataOption The chosen way to build data, possible values are "normally-distributed", "log-normally-distributed", "uniformly-distributed" or "custom"
|
||||
* @param {String} customData In case `dataOption` is "custom", this should contain a String containing a JSON array of numbers
|
||||
* @param {Number} lower The lowest number the generated values may be, defaults to `1`
|
||||
* @param {Number} upper The highest number the generated values may be, defaults to `100`
|
||||
*
|
||||
* @returns {Array} An array of values, this array has DATA_SAMPLE_COUNT length if not custom
|
||||
*/
|
||||
function buildSampleData (dataOption, customData, lower, upper) {
|
||||
if (!lower) lower = 1;
|
||||
if (!upper) upper = 100;
|
||||
const values =
|
||||
dataOption == "normally-distributed" ? normalRandomValues((lower + upper) / 2, (upper - lower) / 8, DATA_SAMPLE_COUNT)
|
||||
: dataOption == "log-normally-distributed" ? logNormalRandomValues(Math.sqrt(Math.max(lower, 1) * upper), Math.pow(upper / Math.max(lower, 1), 1 / 8), DATA_SAMPLE_COUNT)
|
||||
: dataOption == "uniformly-distributed" ? uniformValues(lower, upper, DATA_SAMPLE_COUNT)
|
||||
: parseJSONString(customData);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the data to be rendered in the charts, in case histogram kind is "exponential" or "linear".
|
||||
*
|
||||
* @param {String} kind The kind of histogram that should be build, possible values are "functional", "exponential" or "linear"
|
||||
* @param {Object} props The properties related to the given histogram, keys differ based in the kind
|
||||
* @param {String} dataOption The chosen way to build data, possible values are "normally-distributed", "log-normally-distributed", "uniformly-distributed" or "custom"
|
||||
* @param {String} customData In case `dataOption` is "custom", this should contain a String containing a JSON array of numbers
|
||||
* @param {function} transformation Option function to be applied to generated values
|
||||
*
|
||||
* @returns {Object} An object containing the bucket and values of a histogram
|
||||
*/
|
||||
function buildDataPreComputed (kind, props, dataOption, customData, transformation) {
|
||||
const { lowerBound, upperBound, bucketCount } = props;
|
||||
const buckets = kind == "exponential"
|
||||
? exponentialRange(lowerBound, upperBound, bucketCount)
|
||||
: linearRange(lowerBound, upperBound, bucketCount);
|
||||
|
||||
const lowerBucket = buckets[0];
|
||||
const upperBucket = buckets[buckets.length - 1];
|
||||
const values = buildSampleData(dataOption, customData, lowerBucket, upperBucket)
|
||||
.map(v => transformation && transformation(v));
|
||||
|
||||
const data = accumulateValuesIntoBucketsPreComputed(buckets, values)
|
||||
return {
|
||||
data,
|
||||
buckets,
|
||||
percentages: data.map(v => v * 100 / values.length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the data to be rendered in the charts, in case histogram kind is "functional".
|
||||
*
|
||||
* @param {String} kind The kind of histogram that should be build, possible values are "functional", "exponential" or "linear"
|
||||
* @param {Object} props The properties related to the given histogram, keys differ based in the kind
|
||||
* @param {String} dataOption The chosen way to build data, possible values are "normally-distributed", "log-normally-distributed", "uniformly-distributed" or "custom"
|
||||
* @param {String} customData In case `dataOption` is "custom", this should contain a String containing a JSON array of numbers
|
||||
* @param {function} transformation Option function to be applied to generated values
|
||||
*
|
||||
* @returns {Object} An object containing the bucket and values of a histogram
|
||||
*/
|
||||
function buildDataFunctional(props, dataOption, customData, transformation) {
|
||||
const { logBase, bucketsPerMagnitude, maximumValue } = props;
|
||||
const values = buildSampleData(dataOption, customData)
|
||||
.map(v => transformation && transformation(v));
|
||||
const acc = accumulateValuesIntoBucketsFunctional(logBase, bucketsPerMagnitude, maximumValue, values);
|
||||
const data = Object.values(acc)
|
||||
return {
|
||||
data,
|
||||
buckets: Object.keys(acc),
|
||||
percentages: data.map(v => v * 100 / values.length),
|
||||
mean: values.reduce((sum, current) => sum + current) / values.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the search params of the current URL.
|
||||
*
|
||||
* @returns {URLSearchParams} The search params object related to the current URL
|
||||
*/
|
||||
function searchParams() {
|
||||
return (new URL(document.location)).searchParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new param to the current pages URL, no relaoding
|
||||
*
|
||||
* @param {String} name The name of the param to set
|
||||
* @param {String} value The value of the param to set
|
||||
*/
|
||||
function setURLSearchParam(name, value) {
|
||||
let params = searchParams();
|
||||
params.set(name, value);
|
||||
history.pushState(null, null, `?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to get a search param in the current pages URL with the same name as a given input,
|
||||
* if such a param exists, set the value of the given input to the same value as the param found.
|
||||
*
|
||||
* @param {HTMLElement} input The input to update
|
||||
*/
|
||||
function setInputValueFromSearchParam(input) {
|
||||
let param = searchParams().get(input.name);
|
||||
if (param) input.value = param;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the widest bucket in a list of buckets.
|
||||
*
|
||||
* The width of a bucket is defined by it's minimum value minus the previous buckets minimum value.
|
||||
*
|
||||
* @param {Array} buckets An array of buckets
|
||||
*
|
||||
* @returns {Number} The length of the widest bucket found
|
||||
*/
|
||||
function getWidestBucketWidth (buckets) {
|
||||
let widest = 0;
|
||||
for (let i = 1; i < buckets.length; i++) {
|
||||
const currentWidth = buckets[i] - buckets[i - 1];
|
||||
if (currentWidth > widest) {
|
||||
widest = currentWidth;
|
||||
}
|
||||
}
|
||||
return widest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attemps to parse a string as JSON, if unsuccesfull returns an empty array.
|
||||
*
|
||||
* @param {String} data A string containing a JSON encoded array
|
||||
*
|
||||
* @returns {Array} The parsed array
|
||||
*/
|
||||
function parseJSONString (data) {
|
||||
let result = [];
|
||||
try {
|
||||
result = JSON.parse(data);
|
||||
} finally {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills up a given textarea with dummy data.
|
||||
*
|
||||
* @param {HTMLElement} textarea The textarea to fill up
|
||||
*/
|
||||
function fillUpTextareaWithDummyData (textarea) {
|
||||
const lower = 1;
|
||||
const upper = 100;
|
||||
const dummyData = logNormalRandomValues(Math.sqrt(Math.max(lower, 1) * upper), Math.pow(upper / Math.max(lower, 1), 1 / 8), DATA_SAMPLE_COUNT);
|
||||
const prettyDummyData = JSON.stringify(dummyData, undefined, 4);
|
||||
textarea.value = prettyDummyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precomputes the buckets for an exponential histogram.
|
||||
*
|
||||
* This is copied and adapted from glean-core/src/histograms/exponential.rs
|
||||
*
|
||||
* @param {Number} min The minimum value that can be recorded on this histogram
|
||||
* @param {Number} max The maximum value that can be recorded on this histogram
|
||||
* @param {Number} bucketCount The number of buckets on this histogram
|
||||
*
|
||||
* @return {Array} The array of calculated buckets
|
||||
*/
|
||||
function exponentialRange (min, max, bucketCount) {
|
||||
let logMax = Math.log(max);
|
||||
|
||||
let ranges = [0];
|
||||
let current = min;
|
||||
if (current == 0) {
|
||||
current = 1;
|
||||
}
|
||||
ranges.push(current);
|
||||
|
||||
for (let i = 2; i < bucketCount; i++) {
|
||||
let logCurrent = Math.log(current);
|
||||
let logRatio = (logMax - logCurrent) / (bucketCount - i);
|
||||
let logNext = logCurrent + logRatio;
|
||||
let nextValue = Math.round(Math.exp(logNext));
|
||||
current = nextValue > current ? nextValue : current + 1;
|
||||
ranges.push(current);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precomputes the buckets for an exponential histogram.
|
||||
*
|
||||
* This is copied and adapted from glean-core/src/histograms/linear.rs
|
||||
*
|
||||
* @param {Number} min The minimum value that can be recorded on this histogram
|
||||
* @param {Number} max The maximum value that can be recorded on this histogram
|
||||
* @param {Number} bucketCount The number of buckets on this histogram
|
||||
*
|
||||
* @return {Array} The array of calculated buckets
|
||||
*/
|
||||
function linearRange (min, max, bucketCount) {
|
||||
let ranges = [0];
|
||||
min = Math.max(1, min);
|
||||
for (let i = 1; i < bucketCount; i++) {
|
||||
let range = Math.round((min * (bucketCount - 1 - i) + max * (i - 1)) / (bucketCount - 2));
|
||||
ranges.push(range);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Accumulate an array of values into buckets for histograms with pre-computed buckets
|
||||
*
|
||||
* @param {Array} buckets An array of buckets for a given histogram
|
||||
* @param {Array} values The values to be recorded on this histogram
|
||||
*
|
||||
* @return {Array} The array of recorded values
|
||||
*/
|
||||
function accumulateValuesIntoBucketsPreComputed (buckets, values) {
|
||||
let result = new Array(buckets.length).fill(0);
|
||||
for (const value of values) {
|
||||
let placed = false;
|
||||
for (let i = 0; i < buckets.length - 1; i++) {
|
||||
if (buckets[i] <= value && value < buckets[i + 1]) {
|
||||
placed = true;
|
||||
result[i]++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If the value was not placed it is after the buckets limit,
|
||||
// thus it fits in the last bucket
|
||||
if (!placed) {
|
||||
result[result.length - 1]++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate an array of values into buckets for histograms with dinamically created buckets.
|
||||
*
|
||||
* For these types of histograms bucketing is performed by a function, rather than pre-computed buckets.
|
||||
* The bucket index of a given sample is determined with the following function:
|
||||
*
|
||||
* i = ⌊n log<sub>base</sub>(𝑥)⌋
|
||||
*
|
||||
* In other words, there are n buckets for each power of `base` magnitude.
|
||||
*
|
||||
* Based on glean-core/src/histograms/functional.rs
|
||||
*
|
||||
* @param {Number} logBase The log base for the bucketing algorithm
|
||||
* @param {Array} bucketsPerMagnitude How many buckets to create per magnitude
|
||||
* @param {Number} maximumValue The maximum value that can be recorded on this histogram
|
||||
* @param {Array} values The values to be recorded on this histogram
|
||||
*
|
||||
* @return {Object} An object mapping buckets and recorded values of this histogram
|
||||
*/
|
||||
function accumulateValuesIntoBucketsFunctional (logBase, bucketsPerMagnitude, maximumValue, values) {
|
||||
const exponent = Math.pow(logBase, 1 / bucketsPerMagnitude);
|
||||
|
||||
const sampleToBucketIndex = sample => Math.floor(log(sample + 1, exponent));;
|
||||
const bucketIndexToBucketMinimum = index => Math.floor(Math.pow(exponent, index));
|
||||
const sampleToBucketMinimum = sample => {
|
||||
let bucketMinimum;
|
||||
if (sample == 0) {
|
||||
bucketMinimum = 0;
|
||||
} else {
|
||||
const bucketIndex = sampleToBucketIndex(sample);
|
||||
bucketMinimum = bucketIndexToBucketMinimum(bucketIndex);
|
||||
}
|
||||
return bucketMinimum;
|
||||
}
|
||||
|
||||
let result = {};
|
||||
let min, max;
|
||||
for (let value of values) {
|
||||
// Cap on the maximum value
|
||||
if (value > maximumValue) {
|
||||
value = maximumValue;
|
||||
}
|
||||
|
||||
const bucketMinimum = String(sampleToBucketMinimum(value));
|
||||
if (!(bucketMinimum in result)) {
|
||||
result[bucketMinimum] = 0;
|
||||
}
|
||||
result[bucketMinimum]++;
|
||||
|
||||
// Keep track of the max and min values accumulated,
|
||||
// we will need them later
|
||||
if (!min || value < min) min = value;
|
||||
if (!max || value > max) max = value;
|
||||
}
|
||||
|
||||
// Fill in missing buckets,
|
||||
// this is based on the snapshot() function
|
||||
const minBucket = sampleToBucketIndex(min);
|
||||
const maxBucket = sampleToBucketIndex(max) + 1;
|
||||
for (let idx = minBucket; idx <= maxBucket; idx++) {
|
||||
let bucketMinimum = String(bucketIndexToBucketMinimum(idx));
|
||||
if (!(bucketMinimum in result)) {
|
||||
result[bucketMinimum] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Box-Muller transform in polar form.
|
||||
*
|
||||
* Values below zero will be truncated to 0.
|
||||
*
|
||||
* Copied over and adapted
|
||||
* from https://github.com/mozilla/telemetry-dashboard/blob/bd7c213391d4118553b9ff1791ed0441bf912c60/histogram-simulator/simulator.js
|
||||
*
|
||||
* @param {Number} mu
|
||||
* @param {Number} sigma
|
||||
* @param {Number} count The length of the generated array
|
||||
*
|
||||
* @return {Array} An array of generated values
|
||||
*/
|
||||
function normalRandomValues (mu, sigma, count) {
|
||||
let values = [];
|
||||
let z0, z1, value;
|
||||
for (let i = 0; values.length < count; i++) {
|
||||
if (i % 2 === 0) {
|
||||
let x1, x2, w;
|
||||
do {
|
||||
x1 = 2 * Math.random() - 1;
|
||||
x2 = 2 * Math.random() - 1;
|
||||
w = x1 * x1 + x2 * x2;
|
||||
} while (w >= 1)
|
||||
w = Math.sqrt((-2 * Math.log(w)) / w);
|
||||
z0 = x1 * w;
|
||||
z1 = x2 * w;
|
||||
value = z0;
|
||||
} else {
|
||||
value = z1;
|
||||
}
|
||||
value = value * sigma + mu;
|
||||
|
||||
values.push(value);
|
||||
}
|
||||
return values.map(value => value >= 0 ? Math.floor(value) : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Box-Muller transform in polar form for log-normal distributions
|
||||
*
|
||||
* Values below zero will be truncated to 0.
|
||||
*
|
||||
* Copied over and adapted
|
||||
* from https://github.com/mozilla/telemetry-dashboard/blob/bd7c213391d4118553b9ff1791ed0441bf912c60/histogram-simulator/simulator.js
|
||||
*
|
||||
* @param {Number} mu
|
||||
* @param {Number} sigma
|
||||
* @param {Number} count The length of the generated array
|
||||
*
|
||||
* @return {Array} An array of generated values
|
||||
*/
|
||||
function logNormalRandomValues (mu, sigma, count) {
|
||||
let values = [];
|
||||
let z0, z1, value;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (i % 2 === 0) {
|
||||
let x1, x2, w;
|
||||
do {
|
||||
x1 = 2 * Math.random() - 1;
|
||||
x2 = 2 * Math.random() - 1;
|
||||
w = x1 * x1 + x2 * x2;
|
||||
} while (w >= 1)
|
||||
w = Math.sqrt((-2 * Math.log(w)) / w);
|
||||
z0 = x1 * w;
|
||||
z1 = x2 * w;
|
||||
value = z0;
|
||||
} else {
|
||||
value = z1;
|
||||
}
|
||||
value = Math.exp(value * Math.log(sigma) + Math.log(mu));
|
||||
|
||||
values.push(value);
|
||||
}
|
||||
return values.map(value => value >= 0 ? Math.floor(value) : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A uniformly distributed array of random values
|
||||
*
|
||||
* @param {Number} min The minimum value this function may generate
|
||||
* @param {Number} max The maximum value this function may generate
|
||||
* @param {Number} count The length of the generated array
|
||||
*
|
||||
* @return {Array} An array of generated values
|
||||
*/
|
||||
function uniformValues (min, max, count) {
|
||||
let values = [];
|
||||
for (var i = 0; i <= count; i++) {
|
||||
values.push(Math.random() * (max - min) + min);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Formats a number as a string.
|
||||
*
|
||||
* Copied over and adapted
|
||||
* from https://github.com/mozilla/telemetry-dashboard/blob/bd7c213391d4118553b9ff1791ed0441bf912c60/histogram-simulator/simulator.js
|
||||
*
|
||||
* @param {Number} number The number to format
|
||||
*
|
||||
* @return {String} The formatted number
|
||||
*/
|
||||
function formatNumber(number) {
|
||||
if (number == Infinity) return "Infinity";
|
||||
if (number == -Infinity) return "-Infinity";
|
||||
if (isNaN(number)) return "NaN";
|
||||
|
||||
const mag = Math.abs(number);
|
||||
const exponent =
|
||||
Math.log10 !== undefined ? Math.floor(Math.log10(mag))
|
||||
: Math.floor(Math.log(mag) / Math.log(10));
|
||||
const interval = Math.pow(10, Math.floor(exponent / 3) * 3);
|
||||
const units = {
|
||||
1000: "k",
|
||||
1000000: "M",
|
||||
1000000000: "B",
|
||||
1000000000000: "T"
|
||||
};
|
||||
|
||||
if (interval in units) {
|
||||
return Math.round(number * 100 / interval) / 100 + units[interval];
|
||||
}
|
||||
|
||||
return Math.round(number * 100) / 100;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Arbitrary base log function, Javascript doesn't have one
|
||||
*
|
||||
* @param {Number} number A numeric expression
|
||||
* @param {base} base The log base
|
||||
*
|
||||
* @return {Number} The calculation result
|
||||
*/
|
||||
function log(number, base) {
|
||||
return Math.log(number) / Math.log(base);
|
||||
}
|
||||
|
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
117
docs/glean.css
117
docs/glean.css
|
@ -26,7 +26,6 @@
|
|||
background-color: #ccc;
|
||||
}
|
||||
|
||||
|
||||
/* The container that holds all of the tab contents */
|
||||
.tabcontents {
|
||||
display: flex;
|
||||
|
@ -47,3 +46,119 @@ footer#open-on-gh {
|
|||
border-top: 1px solid black;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
/* Distribution simulator styles */
|
||||
|
||||
#simulator-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#simulator-container h3 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#simulator-container .input-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#simulator-container .input-group label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
#simulator-container .input-group input,
|
||||
#simulator-container .input-group select,
|
||||
#custom-data-modal textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#histogram-props,
|
||||
#data-options {
|
||||
width: 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#data-options {
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
#data-options .input-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#data-options .input-group:first-of-type {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#data-options .input-group:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#data-options .input-group label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#data-options .input-group input {
|
||||
display: inline;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#histogram-chart-container {
|
||||
width: 100%;
|
||||
padding: 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
margin: 30px 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#histogram-chart {
|
||||
margin-top: 50px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#histogram-chart-legend {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#histogram-functional-props,
|
||||
#histogram-non-functional-props {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#custom-data-modal-overlay {
|
||||
background-color: rgba(0, 0, 0, .5);
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#custom-data-modal {
|
||||
width: 50%;
|
||||
background-color: white;
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
top: 15%;
|
||||
left: 25%;
|
||||
padding: 50px;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
|
@ -21,6 +21,8 @@ Custom distributions have the following required parameters:
|
|||
- `linear`: The buckets are evenly spaced
|
||||
- `exponential`: The buckets follow a natural logarithmic distribution
|
||||
|
||||
> **Note** Check out how these bucketing algorithms would behave on the [Custom distribution simulator](#simulator)
|
||||
|
||||
In addition, the metric should specify:
|
||||
|
||||
- `unit`: (String) The unit of the values in the metric. For documentation purposes only -- does not affect data collection.
|
||||
|
@ -86,3 +88,60 @@ assertEquals(1, Graphics.checkerboardPeak.testGetNumRecordedErrors(ErrorType.Inv
|
|||
## Reference
|
||||
|
||||
* [Kotlin API docs](../../../javadoc/glean/mozilla.telemetry.glean.private/-custom-distribution-metric-type/index.html)
|
||||
|
||||
## Simulator
|
||||
|
||||
<div id="custom-data-modal-overlay">
|
||||
<div id="custom-data-modal">
|
||||
<p>Please, insert your custom data below as a JSON array.</p>
|
||||
<textarea rows="30"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="simulator-container">
|
||||
<div id="histogram-chart-container">
|
||||
<div id="histogram-chart"></div>
|
||||
<p id="histogram-chart-legend"><p>
|
||||
</div>
|
||||
<div id="data-options">
|
||||
<h3>Data options</h3>
|
||||
<div class="input-group">
|
||||
<label for="normally-distributed">Generate normally distributed data</label>
|
||||
<input name="data-options" value="normally-distributed" id="normally-distributed" type="radio" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="log-normally-distributed">Generate log-normally distributed data</label>
|
||||
<input name="data-options" value="log-normally-distributed" id="log-normally-distributed" type="radio" checked />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="uniformly-distributed">Generate uniformly distributed data</label>
|
||||
<input name="data-options" value="uniformly-distributed" id="uniformly-distributed" type="radio" />
|
||||
</div>
|
||||
<div class="input-group" id="custom-data-input-group">
|
||||
<label for="custom">Use custom data</label>
|
||||
<input name="data-options" value="custom" id="custom" type="radio" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="histogram-props">
|
||||
<h3>Properties</h3>
|
||||
<div class="input-group">
|
||||
<label for="kind">Histogram type (<code>histogram_type</code>)</label>
|
||||
<select id="kind" name="kind">
|
||||
<option value="exponential" selected>Exponential</option>
|
||||
<option value="linear">Linear</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="lower-bound">Range minimum (<code>range_min</code>)</label>
|
||||
<input name="lower-bound" id="lower-bound" type="number" value="1" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="upper-bound">Range maximum (<code>range_max</code>)</label>
|
||||
<input name="upper-bound" id="upper-bound" type="number" value="500" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="bucket-count">Bucket count (<code>bucket_count</code>)</label>
|
||||
<input name="bucket-count" id="bucket-count" type="number" value="20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -9,6 +9,8 @@ That is, the function from a value \\( x \\) to a bucket index is:
|
|||
|
||||
This makes them suitable for measuring memory sizes on a number of different scales without any configuration.
|
||||
|
||||
> **Note** Check out how this bucketing algorithm would behave on the [Simulator](#simulator)
|
||||
|
||||
## Configuration
|
||||
|
||||
If you wanted to create a memory distribution to measure the amount of heap memory allocated, first you need to add an entry for it to the `metrics.yaml` file:
|
||||
|
@ -193,3 +195,71 @@ Assert.Equal(1, Memory.heapAllocated.TestGetNumRecordedErrors(ErrorType.InvalidV
|
|||
* [Kotlin API docs](../../../javadoc/glean/mozilla.telemetry.glean.private/-memory-distribution-metric-type/index.html)
|
||||
* [Swift API docs](../../../swift/Classes/MemoryDistributionMetricType.html)
|
||||
* [Python API docs](../../../python/glean/metrics/timing_distribution.html)
|
||||
|
||||
## Simulator
|
||||
|
||||
<div id="custom-data-modal-overlay">
|
||||
<div id="custom-data-modal">
|
||||
<p>Please, insert your custom data below as a JSON array.</p>
|
||||
<textarea rows="30"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="simulator-container">
|
||||
<div id="histogram-chart-container">
|
||||
<div id="histogram-chart"></div>
|
||||
<p id="histogram-chart-legend"><p>
|
||||
</div>
|
||||
<div id="data-options">
|
||||
<h3>Data options</h3>
|
||||
<div class="input-group">
|
||||
<label for="normally-distributed">Generate normally distributed data</label>
|
||||
<input name="data-options" value="normally-distributed" id="normally-distributed" type="radio" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="log-normally-distributed">Generate log-normally distributed data</label>
|
||||
<input name="data-options" value="log-normally-distributed" id="log-normally-distributed" type="radio" checked />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="uniformly-distributed">Generate uniformly distributed data</label>
|
||||
<input name="data-options" value="uniformly-distributed" id="uniformly-distributed" type="radio" />
|
||||
</div>
|
||||
<div class="input-group" id="custom-data-input-group">
|
||||
<label for="custom">Use custom data</label>
|
||||
<input name="data-options" value="custom" id="custom" type="radio" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="histogram-props">
|
||||
<h3>Properties</h3>
|
||||
<div class="input-group hide">
|
||||
<label for="kind">Histogram type</label>
|
||||
<select id="kind" name="kind" disabled>
|
||||
<option value="functional" selected>Functional</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group hide">
|
||||
<label for="log-base">Log base</label>
|
||||
<input id="log-base" name="log-base" type="number" value="2" disabled />
|
||||
</div>
|
||||
<div class="input-group hide">
|
||||
<label for="buckets-per-magnitude">Buckets per magnitude</label>
|
||||
<input id="buckets-per-magnitude" name="buckets-per-magnitude" type="number" value="16" disabled />
|
||||
</div>
|
||||
<div class="input-group hide">
|
||||
<label for="maximum-value">Maximum value</label>
|
||||
<input id="maximum-value" name="maximum-value" type="number" value="1099511627776" disabled />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="memory-unit">Memory unit (<code>memory_unit</code>)</label>
|
||||
<select id="memory-unit" name="memory-unit">
|
||||
<option value="byte" selected>Byte</option>
|
||||
<option value="kilobyte">Kilobyte</option>
|
||||
<option value="megabyte">Megabyte</option>
|
||||
<option value="gigabyte">Gigabyte</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
> **Note** The data _provided_, is assumed to be in the configured memory unit. The data _recorded_, on the other hand, is always in **bytes**.
|
||||
> This means that, if the configured memory unit is not `byte`, the data will be transformed before being recorded. Notice this, by using the select field above to change the memory unit and see the mean of the data recorded changing.
|
||||
|
|
|
@ -11,6 +11,8 @@ That is, the function from a value \\( x \\) to a bucket index is:
|
|||
|
||||
This makes them suitable for measuring timings on a number of time scales without any configuration.
|
||||
|
||||
> **Note** Check out how this bucketing algorithm would behave on the [Simulator](#simulator)
|
||||
|
||||
Timings always span the full length between `start` and `stopAndAccumulate`.
|
||||
If the Glean upload is disabled when calling `start`, the timer is still started.
|
||||
If the Glean upload is disabled at the time `stopAndAccumulate` is called, nothing is recorded.
|
||||
|
@ -310,3 +312,71 @@ Assert.Equal(1, Pages.pageLoad.TestGetNumRecordedErrors(ErrorType.InvalidValue))
|
|||
* [Kotlin API docs](../../../javadoc/glean/mozilla.telemetry.glean.private/-timing-distribution-metric-type/index.html)
|
||||
* [Swift API docs](../../../swift/Classes/TimingDistributionMetricType.html)
|
||||
* [Python API docs](../../../python/glean/metrics/timing_distribution.html)
|
||||
|
||||
## Simulator
|
||||
|
||||
<div id="custom-data-modal-overlay">
|
||||
<div id="custom-data-modal">
|
||||
<p>Please, insert your custom data below as a JSON array.</p>
|
||||
<textarea rows="30"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="simulator-container">
|
||||
<div id="histogram-chart-container">
|
||||
<div id="histogram-chart"></div>
|
||||
<p id="histogram-chart-legend"><p>
|
||||
</div>
|
||||
<div id="data-options">
|
||||
<h3>Data options</h3>
|
||||
<div class="input-group">
|
||||
<label for="normally-distributed">Generate normally distributed data</label>
|
||||
<input name="data-options" value="normally-distributed" id="normally-distributed" type="radio" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="log-normally-distributed">Generate log-normally distributed data</label>
|
||||
<input name="data-options" value="log-normally-distributed" id="log-normally-distributed" type="radio" checked />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="uniformly-distributed">Generate uniformly distributed data</label>
|
||||
<input name="data-options" value="uniformly-distributed" id="uniformly-distributed" type="radio" />
|
||||
</div>
|
||||
<div class="input-group" id="custom-data-input-group">
|
||||
<label for="custom">Use custom data</label>
|
||||
<input name="data-options" value="custom" id="custom" type="radio" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="histogram-props">
|
||||
<h3>Properties</h3>
|
||||
<div class="input-group hide">
|
||||
<label for="kind">Histogram type</label>
|
||||
<select id="kind" name="kind" disabled>
|
||||
<option value="functional" selected>Functional</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group hide">
|
||||
<label for="log-base">Log base</label>
|
||||
<input id="log-base" name="log-base" type="number" value="2" disabled />
|
||||
</div>
|
||||
<div class="input-group hide">
|
||||
<label for="buckets-per-magnitude">Buckets per magnitude</label>
|
||||
<input id="buckets-per-magnitude" name="buckets-per-magnitude" type="number" value="8" disabled />
|
||||
</div>
|
||||
<div class="input-group hide">
|
||||
<label for="maximum-value">Maximum value</label>
|
||||
<input id="maximum-value" name="maximum-value" type="number" value="600000000000" disabled />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="time-unit">Time unit (<code>time_unit</code>)</label>
|
||||
<select id="time-unit" name="time-unit">
|
||||
<option value="nanoseconds" selected>Nanoseconds</option>
|
||||
<option value="microseconds">Microseconds</option>
|
||||
<option value="milliseconds">Milliseconds</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
> **Note** The data _provided_, is assumed to be in the configured time unit. The data _recorded_, on the other hand, is always in **nanoseconds**.
|
||||
> This means that, if the configured time unit is not `nanoseconds`, the data will be transformed before being recorded. Notice this, by using the select field above to change the time unit and see the mean of the data recorded changing.
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче