Skip to content

Commit

Permalink
add desktop support, refactor scoring data (#10)
Browse files Browse the repository at this point in the history
  • Loading branch information
connorjclark authored May 28, 2020
1 parent 2880e44 commit 5b2d729
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 81 deletions.
87 changes: 45 additions & 42 deletions script/main.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
import { h, render, createRef, Component } from 'preact';
import { QUANTILE_AT_VALUE, VALUE_AT_QUANTILE } from './math.js';
import { $, NBSP, numberFormatter, calculateRating, arithmeticMean } from './util.js';
import { weights as WEIGHTS, scoring } from './metrics.js';
import { metrics, scoringGuides } from './metrics.js';
import { updateGauge } from './gauge.js';

const params = new URLSearchParams(location.hash.substr(1));

function determineMinMax(metricId) {
const metricScoring = scoring[metricId];

const valueAtScore100 = VALUE_AT_QUANTILE(
metricScoring.median,
metricScoring.falloff,
0.995
);
const valueAtScore5 = VALUE_AT_QUANTILE(
metricScoring.median,
metricScoring.falloff,
0.05
);
function determineMinMax(metricScoring) {
const valueAtScore100 = VALUE_AT_QUANTILE(metricScoring, 0.995);
const valueAtScore5 = VALUE_AT_QUANTILE(metricScoring, 0.05);

let min = Math.floor(valueAtScore100 / 1000) * 1000;
let max = Math.ceil(valueAtScore5 / 1000) * 1000;
Expand Down Expand Up @@ -46,29 +36,36 @@ function getMajorVersion(version) {
}

class Metric extends Component {
onValueChange(e, id) {
onValueChange(e) {
const {id} = this.props;

this.props.app.setState({
[id]: e.target.valueAsNumber,
});
}

onScoreChange(e, id) {
onScoreChange(e) {
const {id, metricScoring} = this.props;

const score = e.target.valueAsNumber;
const metricScoring = scoring[id];
let computedValue = VALUE_AT_QUANTILE(metricScoring.median, metricScoring.falloff, score / 100);
let computedValue = VALUE_AT_QUANTILE(metricScoring, score / 100);

// Clamp because we can end up with Infinity
const { min, max } = determineMinMax(id);
const { min, max } = determineMinMax(metricScoring);
computedValue = Math.max(Math.min(computedValue, max), min);

if (metricScoring.units !== 'unitless') {
computedValue = Math.round(computedValue);
}

this.props.app.setState({
[id]: Math.round(computedValue),
[id]: computedValue,
});
}

render({ id, value, weight, maxWeight, score }) {
const { min, max, step } = determineMinMax(id);
const metricScoring = scoring[id];
render({ id, value, score, weightMax, metricScoring }) {
const { min, max, step } = determineMinMax(metricScoring, id);
const weight = metricScoring.weight;
const valueFormatted = metricScoring.units === 'unitless' ?
value.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) :
// TODO: Use https://github.com/tc39/proposal-unified-intl-numberformat#i-units when Safari/FF support it
Expand All @@ -81,13 +78,13 @@ class Metric extends Component {
</td>
<td>{`${id} (${metricScoring.name})`}</td>
<td>
<input type="range" min={min} value={value} max={max} step={step} class={`${id} metric-value`} onInput={(e) => this.onValueChange(e, id)} />
<input type="range" min={min} value={value} max={max} step={step} class={`${id} metric-value`} onInput={(e) => this.onValueChange(e)} />
<output class="${id} value-output">{valueFormatted}</output>
</td>
<td></td>

<td>
<input type="range" class={`${id} metric-score`} style={`width: ${weight / maxWeight * 100}%`} value={score} onInput={(e) => this.onScoreChange(e, id)} />
<input type="range" class={`${id} metric-score`} style={`width: ${weight / weightMax * 100}%`} value={score} onInput={(e) => this.onScoreChange(e)} />
<output class={`${id} score-output`}>{score}</output>
</td>

Expand Down Expand Up @@ -143,25 +140,27 @@ class Gauge extends Component {
}

class ScoringGuide extends Component {
render({ app, name, values, weights }) {
render({ app, name, values, scoring }) {
// Make sure weights total to 1
const weightSum = Object.values(weights).reduce((agg, val) => (agg += val));
const weights = Object.values(scoring).map(metricScoring => metricScoring.weight);
const weightSum = weights.reduce((agg, val) => (agg += val));
const weightMax = Math.max(...Object.values(weights));
console.assert(weightSum > 0.999 && weightSum < 1.0001); // lol rounding is hard.

const metrics = Object.keys(weights).map(id => {
const metricsData = Object.keys(scoring).map(id => {
const metricScoring = scoring[id];
return {
id,
weight: weights[id],
metricScoring,
value: values[id],
score: Math.round(QUANTILE_AT_VALUE(metricScoring.median, metricScoring.falloff, values[id]) * 100),
score: Math.round(QUANTILE_AT_VALUE(metricScoring, values[id]) * 100),
};
});

const auditRefs = metrics.map(metric => {
const auditRefs = metricsData.map(metric => {
return {
id: metric.id,
weight: metric.weight,
weight: metric.metricScoring.weight,
group: 'metrics',
result: {
score: metric.score / 100,
Expand All @@ -170,7 +169,6 @@ class ScoringGuide extends Component {
});

const score = arithmeticMean(auditRefs);
const maxWeight = Math.max(...Object.values(weights));

let title = <h2>{name}</h2>;
if (name === 'v6') {
Expand All @@ -191,8 +189,8 @@ class ScoringGuide extends Component {
</tr>
</thead>
<tbody>
{metrics.map(metric => {
return <Metric app={app} maxWeight={maxWeight} {...metric}></Metric>
{metricsData.map(metric => {
return <Metric app={app} weightMax={weightMax} metricScoring={metric.metricScoring} {...metric}></Metric>
})}
</tbody>
</table>
Expand Down Expand Up @@ -220,7 +218,9 @@ class App extends Component {
// debounce just a tad, as its noisy
debounce(_ => {
const url = new URL(location.href);
const auditIdValuePairs = Object.entries(this.state).map(([id, value]) => [scoring[id].auditId,value]);
const auditIdValuePairs = Object.entries(this.state).map(([id, value]) => {
return [metrics[id].auditId, value];
});
const params = new URLSearchParams(auditIdValuePairs);
url.hash = params.toString();
history.replaceState(this.state, '', url.toString());
Expand All @@ -232,11 +232,13 @@ class App extends Component {
const versions = params.has('version') ?
params.getAll('version').map(getMajorVersion) :
['6', '5'];
const scoringGuides = versions.map(version => {
return <ScoringGuide app={this} name={`v${version}`} values={this.state} weights={WEIGHTS[`v${version}`]}></ScoringGuide>;
const device = params.get('device') || 'mobile';
const scoringGuideEls = versions.map(version => {
const key = `v${version}`;
return <ScoringGuide app={this} name={key} values={this.state} scoring={scoringGuides[key][device]}></ScoringGuide>;
});
return <div>
{scoringGuides}
{scoringGuideEls}
</div>
}
}
Expand All @@ -245,12 +247,13 @@ function getInitialState() {
const state = {};

// Set defaults as median.
for (const id in scoring) {
state[id] = scoring[id].median;
const metricScorings = {...scoringGuides.v6.desktop, ...scoringGuides.v5.desktop};
for (const id in metricScorings) {
state[id] = metricScorings[id].median;
}

// Load from query string.
for (const [id, metric] of Object.entries(scoring)) {
for (const [id, metric] of Object.entries(metrics)) {
if (!params.has(metric.auditId)) continue;
const value = Number(params.get(metric.auditId));
state[id] = value;
Expand Down
36 changes: 25 additions & 11 deletions script/math.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,23 @@ function internalErf_(x) {
* quantile (1-percentile) of that distribution at value. All
* arguments should be in the same units (e.g. milliseconds).
*
* @param {number} median
* @param {number} falloff
* @param {{median: number, podr?: number, p10?: number}} curve
* @param {number} value
* @return The complement of the quantile at value.
* @customfunction
*/
export function QUANTILE_AT_VALUE(median, falloff, value) {
export function QUANTILE_AT_VALUE({median, podr, p10}, value) {
if (!podr) {
podr = derivePodrFromP10(median, p10);
}

var location = Math.log(median);

// The "falloff" value specified the location of the smaller of the positive
// The "podr" value specified the location of the smaller of the positive
// roots of the third derivative of the log-normal CDF. Calculate the shape
// parameter in terms of that value and the median.
var logRatio = Math.log(falloff / median);
// See https://www.desmos.com/calculator/2t1ugwykrl
var logRatio = Math.log(podr / median);
var shape = Math.sqrt(1 - 3 * logRatio - Math.sqrt((logRatio - 3) * (logRatio - 3) - 8)) / 2;

var standardizedX = (Math.log(value) - location) / (Math.SQRT2 * shape);
Expand All @@ -63,21 +67,31 @@ function internalErfInv_(x) {
}

/**
* Calculates the value at the given quantile. Median, falloff, and
* Calculates the value at the given quantile. Median, podr, and
* expected value should all be in the same units (e.g. milliseconds).
* quantile should be within [0,1].
*
* @param {number} median
* @param {number} falloff
* @param {number} quantile
* @param {{median: number, podr?: number, p10?: number}} curve
* @return The value at this quantile.
* @customfunction
*/
export function VALUE_AT_QUANTILE(median, falloff, quantile) {
export function VALUE_AT_QUANTILE({median, podr, p10}, quantile) {
if (!podr) {
podr = derivePodrFromP10(median, p10);
}

var location = Math.log(median);
var logRatio = Math.log(falloff / median);
var logRatio = Math.log(podr / median);
var shape = Math.sqrt(1 - 3 * logRatio - Math.sqrt((logRatio - 3) * (logRatio - 3) - 8)) / 2;

return Math.exp(location + shape * Math.SQRT2 * internalErfInv_(1 - 2 * quantile));
}

// https://www.desmos.com/calculator/oqlvmezbze
function derivePodrFromP10(median, p10) {
const u = Math.log(median);
const shape = Math.abs(Math.log(p10) - u) / (Math.SQRT2 * 0.9061938024368232);
const inner1 = -3 * shape - Math.sqrt(4 + shape * shape);
const podr = Math.exp(u + shape/2 * inner1)
return podr;
}
80 changes: 52 additions & 28 deletions script/metrics.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,60 @@
export const metrics = {
FCP: {auditId: 'first-contentful-paint', name: 'First Contentful Paint'},
SI: {auditId: 'speed-index', name: 'Speed Index'},
LCP: {auditId: 'largest-contentful-paint', name: 'Largest Contentful Paint'},
TTI: {auditId: 'interactive', name: 'Time to Interactive'},
TBT: {auditId: 'total-blocking-time', name: 'Total Blocking Time'},
CLS: {auditId: 'cumulative-layout-shift', name: 'Cumulative Layout Shift', units: 'unitless'},
FMP: {auditId: 'first-meaningful-paint', name: 'First Meaningful Paint'},
FCI: {auditId: 'first-cpu-idle', name: 'First CPU Idle'},
};

/**
* V6 weights
*/

export const weights = {
v5: {
FCP: 0.2,
SI: 0.26666,
FMP: 0.066666,
TTI: 0.33333,
FCI: 0.133333,
},
export const curves = {
v6: {
FCP: 0.15,
SI: 0.15,
LCP: 0.25,
TTI: 0.15,
TBT: 0.25,
CLS: 0.05
mobile: {
FCP: {weight: 0.15, median: 4000, p10: 2336},
SI: {weight: 0.15, median: 5800, p10: 3387},
LCP: {weight: 0.25, median: 4000, p10: 2500},
TTI: {weight: 0.15, median: 7300, p10: 3785},
TBT: {weight: 0.25, median: 600, p10: 287},
CLS: {weight: 0.05, median: 0.25, p10: 0.1},
},
desktop: {
FCP: {weight: 0.15, median: 1600, p10: 934},
SI: {weight: 0.15, median: 2300, p10: 1311},
LCP: {weight: 0.25, median: 2400, p10: 1200},
TTI: {weight: 0.15, median: 4500, p10: 2468},
TBT: {weight: 0.25, median: 350, p10: 150},
CLS: {weight: 0.05, median: 0, p10: 0.1},
},
},
v5: {
FCP: {weight: 0.2, median: 4000, podr: 2000},
SI: {weight: 0.26666, median: 5800, podr: 2900},
FMP: {weight: 0.066666, median: 4000, podr: 2000},
TTI: {weight: 0.33333, median: 7300, podr: 2900},
FCI: {weight: 0.133333, median: 6500, podr: 2900},
},
};

/**
* V5/v6 scoring curves
* @param {Record<string, {weight: number, median: number, podr: number}>} curves
*/
export const scoring = {
FCP: {median: 4000, falloff: 2000, auditId: 'first-contentful-paint', name: 'First Contentful Paint'},
FMP: {median: 4000, falloff: 2000, auditId: 'first-meaningful-paint', name: 'First Meaningful Paint'},
SI: {median: 5800, falloff: 2900, auditId: 'speed-index', name: 'Speed Index'},
TTI: {median: 7300, falloff: 2900, auditId: 'interactive', name: 'Time to Interactive'},
FCI: {median: 6500, falloff: 2900, auditId: 'first-cpu-idle', name: 'First CPU Idle'},
TBT: {median: 600, falloff: 200, auditId: 'total-blocking-time', name: 'Total Blocking Time'},
LCP: {median: 4000, falloff: 2000, auditId: 'largest-contentful-paint', name: 'Largest Contentful Paint'},
CLS: {median: 0.25, falloff: 0.054, auditId: 'cumulative-layout-shift', name: 'Cumulative Layout Shift', units: 'unitless'},
function makeScoringGuide(curves) {
const scoringGuide = {};
for (const [key, curve] of Object.entries(curves)) {
scoringGuide[key] = {...metrics[key], ...curve};
}
return scoringGuide;
}

export const scoringGuides = {
v6: {
mobile: makeScoringGuide(curves.v6.mobile),
desktop: makeScoringGuide(curves.v6.desktop),
},
v5: {
mobile: makeScoringGuide(curves.v5),
desktop: makeScoringGuide(curves.v5),
},
};

0 comments on commit 5b2d729

Please sign in to comment.