Spaces:
Running
Running
/* Copyright 2020 Google LLC. All Rights Reserved. | |
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. | |
==============================================================================*/ | |
console.clear() | |
var ttSel = d3.select('body').selectAppend('div.tooltip.tooltip-hidden') | |
window.renderFns = [] | |
window.m = (function(){ | |
var rv = {b: .7, tpr: .8, fnr: .5, update, str: 'kids', titleStr: 'Children',} | |
function update(obj={}){ | |
Object.assign(rv, obj) | |
window.renderFns.forEach(d => d()) | |
} | |
return rv | |
})() | |
window.f = (function(){ | |
var rv = {b: .3, tpr: .8, fnr: .5, update, str: 'adults', titleStr: 'Adults'} | |
function update(obj={}){ | |
window.renderFns.forEach(d => d()) | |
} | |
return rv | |
})() | |
var wLarge = d3.clamp(0, innerWidth/2 - 30, 300) | |
d3.select('#big-matrix').html('') | |
.appendMany('div.big-container', [{w: wLarge, s: f, isText: 1}, {w: wLarge, s: m, isText: 1}]) | |
.each(drawMatrix) | |
addPattern(10, `pattern-${wLarge}-`) | |
addPattern(5, 'pattern-50-') | |
function addPattern(s, str){ | |
var cColors = [colors.sick, colors.sick, colors.well, colors.well, lcolors.sick, lcolors.sick, lcolors.well, lcolors.well] | |
var rColors = [lcolors.sick, lcolors.well, lcolors.sick, lcolors.well, llcolors.sick, llcolors.well, llcolors.sick, llcolors.well] | |
d3.select('#big-matrix') | |
.append('svg') | |
.st({height: 0, position: 'absolute'}) | |
.append('defs').appendMany('pattern', d3.range(8)) | |
.at({ id: i => str + i, width: s, height: s}) | |
.attr('patternUnits', 'userSpaceOnUse') | |
.append('rect') | |
.at({width: s, height: s, fill: i => rColors[i]}) | |
.parent().append('circle') | |
.at({r: s == 10 ? 2.5 : 1.5, cx: s/2, cy: s/2, fill: i => cColors[i]}) | |
} | |
var scale = d3.clamp(0, ((innerWidth - 50) / 3)/280, 1) | |
var isScaled = scale != 1 | |
d3.select('#metrics').html('').st({height: 350*scale + 30}) | |
.appendMany('div', [0, 1, 2]) | |
.st({width: 280*scale, display: 'inline-block'}) | |
.append('div') | |
.st({transform: `scale(${scale})`, transformOrigin: '0% 0%'}) | |
.append('div.metrics-container').st({width: 280}) | |
.each(drawMetric) | |
d3.selectAll('rect.drag') | |
.on('mouseover.style', d => d3.selectAll('rect.' + d).st({strokeWidth: 3, stroke: '#000'})) | |
.on('mouseout.style', d => d3.selectAll('rect.' + d).st({strokeWidth: 0})) | |
function drawMetric(i){ | |
var sel = d3.select(this) | |
var text = [ | |
// 'Percentage of <span style="background: #fcf">sick people</span><br> who <span style="background: #f0f">test positive<span>', | |
'Percentage of sick people<br> who test positive', | |
'Percentage of positive tests<br> who are actually sick', | |
'Percentage of well people <br>who test negative', | |
][i] | |
var percentFn = [ | |
s => s.tpr, | |
s => s.b*s.tpr/(s.b*s.tpr + (1 - s.b)*(s.fnr)), | |
s => 1 - s.fnr, | |
][i] | |
var colors = [ | |
['#f0f', '#fcf', '#fff', '#fff'], | |
['#f0f', '#fff', '#fcf', '#fff'], | |
['#fff', '#fff', '#fcf', '#f0f'], | |
][i] | |
sel.append('h3').st({marginBottom: 20, fontSize: isScaled ? 30 : 20}).html(isScaled ? text.replace('<br>', '') : text) | |
var h = 200 | |
var width = 100 | |
var fDiv = sel.append('div').st({position: 'relative', top: -h + 7}) | |
.datum({w: 50, s: f, isText: 0, colors}).each(drawMatrix) | |
var svg = sel.append('svg') | |
.at({width, height: h}) | |
.st({fontSize: 14, fontFamily: 'monospace'}) | |
svg.append('path').at({stroke: '#ccc', d: `M ${width/2 + .5} 0 V ${h}`}) | |
var errorSel = svg.append('path') | |
.translate(width/2 + .5, 0) | |
.at({stroke: 'orange', strokeWidth: 3}) | |
var fSel = svg.append('g') | |
var mSel = svg.append('g') | |
mSel.append('circle').at({r: 4, cx: width/2 + .5, fill: 'none', stroke: '#000'}) | |
fSel.append('circle').at({r: 4, cx: width/2 + .5, fill: 'none', stroke: '#000'}) | |
var fTextSel = fSel.append('text').text('23%') | |
.at({dy: '.33em', textAnchor: 'middle', x: width/4 - 3, fontSize: isScaled ? 20 : 16}) | |
var mTextSel = mSel.append('text').text('23%') | |
.at({dy: '.33em', textAnchor: 'middle', x: width/4*3 + 5, fontSize: isScaled ? 20 : 16}) | |
fSel.append('text').text('Adults').st({fontSize: isScaled ? 18 : 12}) | |
.at({textAnchor: 'middle', x: -23, y: -30}) | |
mSel.append('text').text('Children').st({fontSize: isScaled ? 18 : 12}) | |
.at({textAnchor: 'middle', x: 124, y: -30}) | |
var mDiv = sel.append('div').st({position: 'relative', top: -h + 7}) | |
.datum({w: 50, s: m, isText: 0, colors}).each(drawMatrix) | |
renderFns.push(() => { | |
var fPercent = percentFn(f) | |
fSel.translate(h - h*fPercent, 1) | |
fTextSel.text(d3.format('.0%')(fPercent)) | |
var mPercent = percentFn(m) | |
mSel.translate(h - h*mPercent, 1) | |
mTextSel.text(d3.format('.0%')(mPercent)) | |
fDiv.translate(h - h*fPercent, 1) | |
mDiv.translate(h - h*mPercent, 1) | |
errorSel.at({d: 'M 0 ' + (h - h*fPercent) + ' V ' + (h - h*mPercent) }) | |
}) | |
} | |
function drawMatrix({s, w, isText, colors}){ | |
var svg = d3.select(this).append('svg') | |
.at({width: w, height: w}) | |
svg.append('rect').at({width: w + 1, height: w + 1}) | |
if (!colors) colors = ['#000', '#000', '#000', '#000'] | |
var rects = [ | |
{n: 'tp', x: 0, y: 0, width: _ => s.b*w, height: _ => s.tpr*w}, | |
{n: 'fn', x: 0, y: _ => 1 + s.tpr*w, width: _ => s.b*w, height: _ => w - s.tpr*w}, | |
{n: 'fp', x: _ => 1 + s.b*w, y: 0, width: _ => w - s.b*w, height: _ => s.fnr*w}, | |
{n: 'tn', x: _ => 1 + s.b*w, y: _ => 1 + s.fnr*w, width: _ => w - s.b*w, height: _ => w - s.fnr*w}, | |
] | |
rects.forEach((d, i) => d.i = i) | |
var rectSel = svg.appendMany('rect', rects) | |
.at({fill: d => `url(#pattern-${w}-${d.i}`}) | |
// .at({opacity: d => colors[d.i] == '#fff' ? .5 : 1}) | |
// .at({fill: d => `url(#pattern-${w}-${d.i + (colors[d.i] == '#ccc' ? 4 : 0)})`}) | |
// .at({fill: d => colors[d.i] == '#ccc' ? '#000' : `url(#pattern-${w}-${d.i + (colors[d.i] == '#ccc' ? 4 : 0)})`}) | |
.each(function(d){ d.sel = d3.select(this) }) | |
rectSel.filter(d => colors[d.i] == '#fff').at({fill: '#eee'}) | |
var bh = .5 | |
svg.append('rect.tpr').at({height: bh}).translate(-bh/2, 1) | |
.datum('tpr') | |
svg.append('rect.fnr').at({height: bh}).translate(-bh/2, 1) | |
.datum('fnr') | |
svg.append('rect.b').at({width: bh, height: w}).translate(-bh/2, 0) | |
.datum('b') | |
var bh = 20 | |
svg.append('rect.drag.tpr').at({height: bh}).translate(-bh/2, 1) | |
.call(makeDrag('tpr', 1)).datum('tpr').call(d3.attachTooltip).on('mouseover', ttFormat) | |
svg.append('rect.drag.fnr').at({height: bh}).translate(-bh/2, 1) | |
.call(makeDrag('fnr', 1)).datum('fnr').call(d3.attachTooltip).on('mouseover', ttFormat) | |
svg.append('rect.drag.b').at({width: bh, height: w}).translate(-bh/2, 0) | |
.call(makeDrag('b', 0)).datum('b').call(d3.attachTooltip).on('mouseover', ttFormat) | |
var tprRect = svg.selectAll('rect.tpr') | |
var fnrRect = svg.selectAll('rect.fnr') | |
var bRect = svg.selectAll('rect.b') | |
function ttFormat(str){ | |
var html = '' | |
if (str == 'tpr') html = `${d3.format('.0%')(s.tpr)} of sick ${s.titleStr.toLowerCase()} test positive` | |
if (str == 'fnr') html = `${d3.format('.0%')(s.fnr)} of well ${s.titleStr.toLowerCase()} test negative` | |
if (str == 'b') html = `${d3.format('.0%')(s.b)} of ${s.titleStr.toLowerCase()} are sick` | |
ttSel.html(html) | |
} | |
function makeDrag(str, index){ | |
return d3.drag() | |
.on('drag', function(){ | |
var percent = d3.mouse(this)[index]/w | |
s[str] = d3.clamp(.15, percent, .85) | |
window.basetimer.stop() | |
s.update() | |
ttMove() | |
ttFormat(str) | |
}) | |
.on('start', _ => svg.classed('dragging', 1)) | |
.on('end', _ => svg.classed('dragging', 0)) | |
} | |
renderFns.push(() => { | |
rectSel.each(d => d.sel.at(d)) | |
tprRect.at({width: w*s.b, y: w*s.tpr}) | |
fnrRect.at({x: w*s.b, width: w - w*s.b, y: w*s.fnr}) | |
bRect.at({x: w*s.b}) | |
// s => s.tpr, | |
// s => s.b*s.tpr/(s.b*s.tpr + (1 - s.b)*(s.fnr)), | |
// s => 1 - s.fnr, | |
if (!isText) return | |
}) | |
if (!isText) return | |
svg.append('text').text(s.titleStr).at({textAnchor: 'middle', x: w/2, y: -8, fontSize: 20}) | |
if (innerWidth < 800) return | |
// if (true) | |
svg.appendMany('text', d3.range(4)).each(function(i){ | |
var isSick = i < 2 | |
var isPos = i % 2 | |
var pad = 5 | |
d3.select(this) | |
.translate([isSick ? pad : w - pad, isPos ? 13 : w - 23]) | |
.at({ | |
textAnchor: isSick ? 'start' : 'end', | |
fill: '#000', | |
fontSize: 12, | |
fontFamily: 'monospace', | |
pointerEvents: 'none', | |
}) | |
.tspans([ | |
' test : ' + (isPos ? 'sick' : 'well'), | |
'truth: ' + (isSick ? 'sick' : 'well')]) | |
}) | |
} | |
if (window.basetimer) window.basetimer.stop() | |
window.basetimer = d3.timer(t => { | |
var val = t/1000 % (Math.PI*4) | |
if (val < Math.PI*2){ | |
m.b = (Math.sin(val + Math.PI/2))/4 + .4 | |
} else if (Math.PI*3 < val && val < Math.PI*5 || true){ | |
f.tpr = (Math.sin(val + Math.PI/2))/4 + .4 | |
} | |
m.update() | |
}) | |
m.update() | |
function ttMove(d){ | |
if (!ttSel.size()) return; | |
var e = d3.event.sourceEvent, | |
x = e.clientX, | |
y = e.clientY, | |
bb = ttSel.node().getBoundingClientRect(), | |
left = d3.clamp(20, (x-bb.width/2), window.innerWidth - bb.width - 20), | |
top = innerHeight > y + 20 + bb.height ? y + 20 : y - bb.height - 20; | |
ttSel | |
.style('left', left +'px') | |
.style('top', top + 'px'); | |
} | |