src/components/explore/AggregationsOverTimeGraph.svelte (386 lines of code) (raw):
<script>
import _ from 'lodash';
import { fly } from 'svelte/transition';
import { cubicOut as easing } from 'svelte/easing';
import { Axis } from '@graph-paper/guides';
import { Line } from '@graph-paper/elements';
import DataGraphic from '../datagraphic/DataGraphic.svelte';
import Tweenable from '../Tweenable.svelte';
import Springable from '../Springable.svelte';
import BuildIDRollover from './BuildIDRollover.svelte';
import ReleaseVersionMarkers from '../ReleaseVersionMarkers.svelte';
import { aggregationsOverTimeGraph } from '../../utils/constants';
import ReferenceSymbol from '../ReferenceSymbol.svelte';
import TrackingLine from './TrackingLine.svelte';
import TrackingLabel from './TrackingLabel.svelte';
import ChartTitle from './ChartTitle.svelte';
import ChartContextMenu from '../ChartContextMenu.svelte';
import {
getActiveProductConfig,
store,
showContextMenu,
toQueryString,
} from '../../state/store';
import {
getPercentileName,
getTransformedPercentileName,
getProportionName,
getCountName,
} from '../../config/shared';
export let title;
export let description;
export let aggregationLevel;
export let xDomain;
export let yDomain;
export let key;
export let xScaleType;
export let yScaleType;
export let data;
export let lineColorMap = () => 'gray';
export let hovered = {};
export let ref = {};
export let yTickFormatter;
export let metricKeys;
export let yAccessor;
export let distViewButtonId;
// set default reference point to be used in distribution sql
let defaultRef = data[data.length - 1].build_id;
store.setField('defaultRef', defaultRef);
const pushlogUrlTemplate = _.template(
// eslint-disable-next-line no-template-curly-in-string
'https://hg.mozilla.org/mozilla-central/pushloghtml?fromchange=${from}&tochange=${to}'
);
function createTimeSeries(d, actives, accessor) {
return actives.map((a) => ({
bin: a,
series: d.map((di) => {
const value = di[accessor] && di[accessor][a];
return {
y: value,
x: di.label,
};
}),
}));
}
function plotValues(xValue, bins, actives, x, y) {
return actives.map((b) => ({ x: x(xValue), y: y(bins[b]), bin: b }));
}
export let hoverValue = {};
let menuPos = { x: 0, y: 0 };
let zoomUrl;
let pushlogUrl;
function generateQueryString(paramsToUpdate) {
const activeProductConfig = getActiveProductConfig();
return activeProductConfig
? toQueryString({
...activeProductConfig.getParamsForQueryString($store),
...paramsToUpdate,
})
: '';
}
function getDefaultReferencePoint() {
if ($store.ref) {
const found = data.find((d) => d[aggregationLevel] === $store.ref);
if (found) return found;
}
return data[data.length - 1];
}
// Values we pass to context menu for zoom events.
let clickedRef;
let clickedHov;
let revisionMap = new Map();
function onRightClick(e) {
// If the context menu is already up, disable it and return.
if ($showContextMenu) {
$showContextMenu = false;
return;
}
// Only show context menu when aggregation is build_id.
if (aggregationLevel !== 'build_id') {
return;
}
menuPos = { x: e.clientX, y: e.clientY };
let referencePoint = getDefaultReferencePoint();
clickedHov = hovered.datum.build_id;
clickedRef = referencePoint.build_id;
// Make sure `hov` is on the left of the range.
[clickedRef, clickedHov] = [
_.max([clickedRef, clickedHov]),
_.min([clickedRef, clickedHov]),
];
zoomUrl = generateQueryString({
clickedHov,
timeHorizon: 'ZOOM',
});
if (referencePoint.revision) {
revisionMap.set(referencePoint.build_id, referencePoint.revision);
}
if (hovered.datum.revision) {
revisionMap.set(hovered.datum.build_id, hovered.datum.revision);
}
if (revisionMap.has(clickedHov) && revisionMap.has(clickedRef)) {
pushlogUrl = pushlogUrlTemplate({
from: revisionMap.get(clickedHov),
to: revisionMap.get(clickedRef),
});
}
// Finally, set the flag to open the context menu.
$showContextMenu = true; // eslint-disable-line no-unused-vars
}
const linearMetrics = [
'histogram-linear',
'keyed-scalar',
'scalar',
'quantity',
'counter',
];
const desktopLinearMetrics = [
'histogram-linear',
'keyed-scalar',
'scalar',
'labeled_counter',
];
const getYTicks = (ranges) => {
// exponential and linear graphs
if (
linearMetrics.includes(data[0].metric_type) ||
yScaleType === 'scalePoint'
) {
// when the range is too small we need to custom set it
if (ranges[ranges.length - 1] <= 5) return [0, 1, 2, 3, 4, 5];
if (yScaleType === 'scalePoint' && ranges.length < 5) return yValues;
return undefined;
}
// categorical graphs
if (
$store.activeBuckets.length &&
$store.proportionMetricType === 'proportions'
) {
// when the percentage gets too small we need to manually set the ticks
if (ranges[ranges.length - 1] - ranges[0] < 0.05)
return [0, ranges[ranges.length - 1]];
}
if (
$store.activeBuckets.length &&
$store.proportionMetricType === 'counts'
) {
if (ranges[ranges.length - 1] < 5) return [0, 5];
}
return undefined;
};
const getYDomain = (visiblePercentiles, buckets, normType) => {
let yData = [];
let yDomainValues = [];
let percentileName = getPercentileName(normType);
// get percentile data of linear and log graphs
if (
linearMetrics.includes(data[0].metric_type) ||
yScaleType === 'scalePoint'
) {
visiblePercentiles.forEach((p) => {
yData = yData.concat([
...data.map((arr) => arr[percentileName] && arr[percentileName][p]),
]);
});
// use transformed data for Android metrics
if (data[data.length - 1].transformedPercentiles) {
visiblePercentiles.forEach((p) => {
yData = yData.concat([
...data.map(
(arr) =>
arr[getTransformedPercentileName(normType)] &&
arr[getTransformedPercentileName(normType)][p]
),
]);
});
}
}
// get proportion and count data of categorical graphs
if (
buckets.length &&
!desktopLinearMetrics.includes(data[0].metric_type) &&
yScaleType !== 'scalePoint'
) {
// do not change yDomain when all categories are selected
if ($store.activeBuckets.length === 10) return yDomain;
if ($store.proportionMetricType === 'proportions') {
buckets.forEach((bucket) => {
yData = yData.concat([
...data.map(
(arr) =>
arr[
getProportionName($store.productDimensions.normalizationType)
][bucket]
),
]);
});
}
if ($store.proportionMetricType === 'counts') {
buckets.forEach((bucket) => {
yData = yData.concat([
...data.map(
(arr) =>
arr[getCountName($store.productDimensions.normalizationType)][
bucket
]
),
]);
});
}
}
yDomainValues = _.uniq(yData).sort((a, b) => a - b);
yDomainValues = yDomainValues.filter(
(a) => !Number.isNaN(a) && Number.isFinite(a)
);
// set the range for each graph type based on graph-paper setting
if (yScaleType === 'linear' && linearMetrics.includes(data[0].metric_type))
return yDomainValues[yDomainValues.length - 1]
? [yDomainValues[0], yDomainValues[yDomainValues.length - 1]]
: [0, 1];
if (
!desktopLinearMetrics.includes(data[0].metric_type) &&
yScaleType !== 'scalePoint' &&
buckets.length
) {
if ($store.proportionMetricType === 'proportions') {
yDomainValues = yDomainValues.filter((a) => a < 1);
}
return [yDomainValues[0], yDomainValues[yDomainValues.length - 1]];
}
return yDomainValues.length ? yDomainValues : yDomain;
};
$: yValues = getYDomain(
$store.visiblePercentiles,
$store.activeBuckets,
$store.productDimensions.normalizationType
);
</script>
<style>
.chart-title {
flex: 1;
display: flex;
justify-content: space-between;
padding: 0 1rem;
}
</style>
{#if showContextMenu}
<ChartContextMenu
{...menuPos}
{zoomUrl}
{pushlogUrl}
{data}
{clickedRef}
{clickedHov}
{distViewButtonId}
/>
{/if}
<div on:contextmenu|preventDefault={onRightClick}>
<div style="display: flex;">
<div class="chart-title">
<ChartTitle
{description}
left={aggregationsOverTimeGraph.left}
right={aggregationsOverTimeGraph.right}
>
{title}
</ChartTitle>
</div>
<slot name="smoother" />
</div>
<DataGraphic
{xDomain}
yDomain={yValues}
yType={yScaleType}
xType={xScaleType}
height={aggregationsOverTimeGraph.height}
bottom={aggregationsOverTimeGraph.bottom}
top={aggregationsOverTimeGraph.top}
left={aggregationsOverTimeGraph.left}
right={aggregationsOverTimeGraph.right}
{key}
bind:mousePosition={hoverValue}
on:click
>
<g slot="background">
<Axis
side="left"
lineStyle="short"
tickFormatter={yTickFormatter}
ticks={getYTicks(yValues)}
/>
{#if aggregationLevel === 'build_id'}
<Axis side="bottom" />
{:else if xDomain.length <= 5}
<Axis side="bottom" ticks={xDomain} />
{:else}
<Axis side="bottom" />
{/if}
</g>
<g slot="body">
{#each createTimeSeries(data, metricKeys, yAccessor) as { bin, series }, i (bin)}
<Line
scaling={false}
data={series}
x="x"
y="y"
color={lineColorMap(bin)}
curve="curveLinear"
lineDrawAnimation={{ duration: 500 }}
/>
{/each}
</g>
<g slot="annotation" let:xScale let:yScale>
{#if ref}
<Tweenable value={xScale(ref.label)} let:tweenValue={tv1}>
<TrackingLine xr={tv1} />
<TrackingLabel
yOffset={16}
xr={tv1}
align="top"
background="white"
label="Ref."
/>
</Tweenable>
{/if}
{#if hovered.datum}
<TrackingLine x={hovered.datum.label} />
<TrackingLabel
x={hovered.datum.label}
align="top"
background="white"
label="Hov."
/>
{#each plotValues(hovered.datum.label, hovered.datum[yAccessor], metricKeys, xScale, yScale) as { x, y, bin }, i (bin)}
<Springable value={[x, y]} let:springValue>
<circle
cx={x}
cy={y}
r="3"
stroke="none"
fill={lineColorMap(bin)}
/>
</Springable>
{/each}
{#if aggregationLevel === 'build_id'}
<BuildIDRollover
x={hovered.datum.label}
label={hovered.datum.label}
/>
{/if}
{/if}
{#each plotValues(ref.label, ref[yAccessor], metricKeys, xScale, yScale) as { x, y, bin }, i (bin)}
<g in:fly={{ duration: 150, y: 100, easing }}>
<Springable value={[x, y]} let:springValue>
<ReferenceSymbol
size={25}
xLocation={springValue[0]}
yLocation={springValue[1]}
color={lineColorMap(bin)}
/>
</Springable>
</g>
{/each}
<ReleaseVersionMarkers />
</g>
</DataGraphic>
</div>