Public
Edited
Jun 27, 2023
11 forks
1 star
Insert cell
Insert cell
Insert cell
Insert cell
ecvs = FileAttachment("ecvs@15.json").json()
Insert cell
Insert cell
chords = {
document.head.innerHTML+=styling;
const width=512;
const height=512;
var canvas=DOM.canvas(width, height);
var ctx=canvas.getContext('2d');

const fadeInTime=250.0; // Time (ms) for linear ramp to full focus
const fadeOutTime=125.0; // Time (ms) for (1/e) exponential decay fade out

var previousnearestecv=-3;

var mystery=true; // Flags whether we're in "mystery mode" (on startup and mouse-out)
var mysteryFade=1.0; // For fade of mystery "look"
const mysteryFadeTime=500.0; // Background will fade linearly

var animating=true; // Flags whether there's anything for animation to actually do, otherwise the canvas can be left as is.
let frame;

// Precompute label centres
var ecvxys=[];
for (var i=0;i<ecvs.length;i++) {
const ecv=ecvs[i];
const a=(2.0*Math.PI*(1+i))/(ecvs.length+1);
const dx= 0.4*width*Math.sin(a);
const dy= -0.4*height*Math.cos(a);
const x=width*0.5+dx;
const y=height*0.5+dy;
ecvxys.push(
[x,y]
);
}

// Names with "\n" line breaks replaced by " "
var ecvnames=[];
for (var i=0;i<ecvs.length;i++) {
ecvnames.push(ecvs[i].name.replaceAll("\n"," "));
}

// Lookup from ecvname to its index
var ecvindices={};
for (var i=0;i<ecvs.length;i++) {
ecvindices[ecvnames[i]]=i;
}
// Precompute enumerated links
var ecvlinks=[];
for (var i=0;i<ecvs.length;i++) {
var row=[];
for (var j=0;j<ecvs[i].links.length;j++) {
if (ecvs[i].links[j] in ecvindices) {
row.push(ecvindices[ecvs[i].links[j]]);
} else {
console.log("Bad ECV link: can't find "+ecvs[i].links[j]+" from "+ecvs[i].name);
}
}
ecvlinks.push(row);
}

// An array of ecv "highlight level"s... [0...1].
// These will fade in or out depending on focus
var ecvattns=new Array(ecvs.length).fill(0.0);

// And the same for the links
var ecvlinkattns=new Array(ecvs.length);
for (var i=0;i<ecvs.length;i++) {
ecvlinkattns[i]=new Array(ecvlinks[i].length).fill(0.0);
}
var nearestecv= -2; // -1 means near centre

var t0=Date.now();
function tick() {

const t1=Date.now();
const dt=t1-t0;

if (animating) {

var stillAnimating=false;

if (mystery) {
if (mysteryFade<1.0) stillAnimating=true;
mysteryFade=Math.min(1.0,mysteryFade+dt/mysteryFadeTime);
} else {
if (mysteryFade>1.0/1024.0) stillAnimating=true;
mysteryFade=Math.max(0.0,mysteryFade-dt/mysteryFadeTime);
}
// Draw the background
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha=mysteryFade;
ctx.fillStyle = "#51477d"; // Purple colour from style guide (actually for Ozone)
ctx.fillRect(0, 0, width, height);
// Draw faint lines for all the possible connections, whether active or not
// Do this first so that these never overdraw the primary lines
ctx.strokeStyle='#eeeeee';
ctx.globalAlpha=0.2+0.8*mysteryFade;
ctx.lineWidth=1;
ctx.shadowOffsetX=0;
ctx.shadowOffsetY=0;
ctx.shadowBlur=mysteryFade;
ctx.shadowColor = '#eeeeee'
for (var i=0;i<ecvs.length;i++) {
const ecv=ecvs[i];
for (var j=0;j<ecvlinks[i].length;j++) {
const t=ecvlinks[i][j];
const x0=width*0.5+0.8*(ecvxys[i][0]-0.5*width);
const y0=height*0.5+0.8*(ecvxys[i][1]-0.5*height)
const x1=width*0.5+0.8*(ecvxys[t][0]-0.5*width);
const y1=height*0.5+0.8*(ecvxys[t][1]-0.5*height);

ctx.beginPath();
ctx.moveTo(x0,y0);
ctx.quadraticCurveTo(width*0.5,height*0.5,x1,y1);
ctx.stroke();
}
}
ctx.globalAlpha=1.0;
ctx.shadowBlur = 0.0;
// Tag which ecvs are "active" with 2 or 1 depending on primary or secondary
var activeecvs=new Array(ecvs.length).fill(0);
for (var i=0;i<ecvs.length;i++) {
if (i==nearestecv || nearestecv== -1) {
activeecvs[i]=2;
for (var j=0;j<ecvlinks[i].length;j++) {
activeecvs[ecvlinks[i][j]]=Math.max(activeecvs[ecvlinks[i][j]],1);
}
}
}
// Update attention levels depending on activeness
for (var i=0;i<ecvattns.length;i++) {
if (activeecvs[i]>0) {
if (ecvattns[i]<1.0) stillAnimating=true;
ecvattns[i]=Math.min(1.0,ecvattns[i]+dt/fadeInTime);
} else {
if (ecvattns[i]>1.0/1024.0) stillAnimating=true;
ecvattns[i]*=Math.exp(-dt/fadeOutTime);
}
}
// And for the links
// Ramp up links from primary to secondary tagged ECVs
for (var i=0;i<ecvlinkattns.length;i++) {
for (var j=0;j<ecvlinkattns[i].length;j++) {
if (activeecvs[i]==2 && activeecvs[ecvlinks[i][j]]>0) {
if (ecvlinkattns[i][j]<1.0) stillAnimating=true;
ecvlinkattns[i][j]=Math.min(1.0,ecvlinkattns[i][j]+dt/fadeInTime);
} else {
if (ecvlinkattns[i][j]>1.0/1024.0) stillAnimating=true;
ecvlinkattns[i][j]*=Math.exp(-dt/fadeOutTime);
}
}
};
// Now draw arcs according to their attention level
for (var i=0;i<ecvs.length;i++) {
const ecv=ecvs[i];
for (var j=0;j<ecvlinks[i].length;j++) {
const t=ecvlinks[i][j];
const x0=width*0.5+0.8*(ecvxys[i][0]-0.5*width);
const y0=height*0.5+0.8*(ecvxys[i][1]-0.5*height)
const x1=width*0.5+0.8*(ecvxys[t][0]-0.5*width);
const y1=height*0.5+0.8*(ecvxys[t][1]-0.5*height);
if (ecvlinkattns[i][j]>1.0/256.0) {
var gradient = ctx.createLinearGradient(x0,y0,x1,y1);
gradient.addColorStop(0,groupcolours[ecvs[i].group]);
gradient.addColorStop(1,groupcolours[ecvs[t].group]);
ctx.globalAlpha=ecvlinkattns[i][j];
ctx.strokeStyle=gradient;
ctx.lineWidth=2;
ctx.beginPath();
ctx.moveTo(x0,y0);
ctx.quadraticCurveTo(width*0.5,height*0.5,x1,y1);
ctx.stroke();
// Draw an arrowhead but only on relevant links
if (nearestecv == i ) {
ctx.globalAlpha=ecvlinkattns[i][j]*ecvlinkattns[i][j];
ctx.fillStyle=groupcolours[ecvs[t].group];
var dx=x1-width*0.5;
var dy=y1-height*0.5;
var d=Math.sqrt(dx*dx+dy*dy);
dx/=d;dy/=d;
var h=4.0; // Arrowhead size
ctx.beginPath();
ctx.moveTo(x1+1.5*h*dx,y1+1.5*h*dy);
ctx.lineTo(x1-h*dy,y1+h*dx);
ctx.lineTo(x1+h*dy,y1-h*dx);
ctx.fill();
}
}
}
ctx.globalAlpha=1.0;
}
// Draw labels
if (!mystery) {
for (var i=0;i<ecvs.length;i++) {
const ecv=ecvs[i];
// Draw label
ctx.font='bold 14px Helvetica Neue,Helvetica,Arial,sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.globalAlpha=0.2+0.8*ecvattns[i];
ctx.fillStyle=groupcolours[ecv.group];
const lines=ecv.name.split('\n');
if (lines.length==1) {
ctx.fillText(ecv.name,ecvxys[i][0],ecvxys[i][1]);
} else {
const lineHeight=ctx.measureText('M').width*1.2 // Doesn't measure height!
for (var line=0;line<lines.length;line++) {
ctx.fillText(lines[line],ecvxys[i][0],ecvxys[i][1]+lineHeight*(-(lines.length-1)/2.0+line));
}
}
ctx.globalAlpha=1.0;
}
}
if (!stillAnimating) {
console.log('Climate Connections animation stopping animation, for now');
}
animating=stillAnimating;
} // if (animating)
frame = requestAnimationFrame(tick);
t0=t1;
}
tick();

function sqr(x) {return x*x;}

canvas.onmouseout = function(e) {
mystery=true;
nearestecv = -2;
animating=true;
var elem = document.getElementById("mystery");
elem.style.visibility='visible';
elem = document.getElementById("about");
elem.textContent="";
}
canvas.onmousemove = function(e) {

animating=true; // Assume any sort of mouse move will trigger something.
const rect = canvas.getBoundingClientRect();
const ex = e.clientX - rect.left;
const ey = e.clientY - rect.top;
nearestecv= -1;
var nearestd=4.0*Math.sqrt(sqr(ex-0.5*width)+sqr(ey-0.5*height));
for (var i=0;i<ecvs.length;i++) {
const x=ecvxys[i][0];
const y=ecvxys[i][1];
const d=Math.sqrt(sqr(x-ex)+sqr(y-ey));
if (d<nearestd) {
nearestecv=i;
nearestd=d;
}

if (nearestecv!=previousnearestecv) {
mystery=false;

// TODO: Could be done a bit more cleanly... avoid hide then un-hide path
var elem = document.getElementById("mystery");
elem.style.visibility='hidden';
elem = document.getElementById("about");
if (nearestecv>=0) {
if ('about' in ecvs[nearestecv])
elem.innerHTML=ecvs[nearestecv].about;
else
elem.innerHTML=ecvs[nearestecv].name;
} else {
elem.textContent="";
elem = document.getElementById("mystery");
elem.style.visibility='visible';
}
previousnearestecv=nearestecv;
}
}
}
invalidation.then(() => cancelAnimationFrame(frame));
return canvas;
}

Insert cell
Insert cell
about=html`<div class="about"><div class="aboutText" id="about"></div><div id="mystery">${mysterytext}</div></div>`
Insert cell
mysterytext=`<div class="mysteryTitle">climate connections</div><div class="mysteryStrapline">essential climate variables</div>`
Insert cell
Insert cell
Insert cell
Insert cell
groupcolours=[{oceanic:'#007D8A',terrestrial:'#FF8F1C',atmospheric:'#009CDE'}][0] // Standard CCI colours for these groups
Insert cell
styling=`<style>
body {
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
}
canvas {
// Actually, this is all controlled from code
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 14px;
}
div.about {
position: relative;
max-width: 512px;
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
background-color: #f1eede; // Sampled from style guide
}
div.aboutText {
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 12px;
height: 84px;
vertical-align: top;
padding: 16px;
//display: table-cell; // Don't think this does anything useful.
}
#mystery {
position: absolute;
top: 0;
left: 0;
}
div.mysteryTitle {
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 24px;
font-weight: bold;
padding: 16px;
}
div.mysteryStrapline {
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 12px;
padding-left: 16px;
}
</style>`
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more