(function() { if (window.most === undefined) return; if (window.Promise === undefined) return; if (window.requestAnimationFrame === undefined) return; // Wire up all SVG diagrams on the page. var $diagrams = document.querySelectorAll('svg'); for (var i = 0; i < $diagrams.length; i++) { connect($diagrams[i]); } // Add the 'scriptable' class to the body element, which signifies that // JavaScript is available and reveals the inputs attached to certain // diagrams. document.getElementsByTagName('body')[0].classList.add('scriptable'); // connect breathes life into a diagram by connecting its sibling input // elements to functions that modify the diagram shapes. function connect($diagram) { // params holds a variety of values needed by the transformation functions, // such as the upper-left and lower-right coordinates of the diagram bounds // and references to the sampling and metaball functions. var params = { bounds: { min: { x: 0, y: 0 }, max: { x: parseInt($diagram.getAttribute('width')), y: parseInt($diagram.getAttribute('height')) } }, sample: { fn: $diagram.getAttribute('ess:sample_fn') === 'sum' ? sum : max, fill: $diagram.getAttribute('ess:sample_fill') || 'binary' }, metaball: { fn: $diagram.getAttribute('ess:metaball_fn') === 'linear' ? linear : $diagram.getAttribute('ess:metaball_fn') === 'refine' ? refine : midpoint, threshold: 1.0 } }; // If the diagram has a viewBox attribute, parse the bounds coordinates // from the attribute value instead of using the diagram's width and // height. if ($diagram.getAttribute('viewBox')) { var parts = $diagram.getAttribute('viewBox').split(' '); params.bounds.min.x = parseInt(parts[0]); params.bounds.min.y = parseInt(parts[1]); params.bounds.max.x = params.bounds.min.x + parseInt(parts[2]); params.bounds.max.y = params.bounds.min.y + parseInt(parts[3]); } // Sibling inputs are expected to be wrapped in an element with the "input" // class. For each such input, we turn its value into a stream of numbers. var $inputs = $diagram.parentElement.querySelectorAll('.input > input'); if ($inputs.length === 0) return; var input$s = []; for (var i = 0; i < $inputs.length; i++) { input$s.push(inputValue$($inputs[i])); } // Create the transformation functions for this diagram. var step = stepper($diagram); var resize = resizer($diagram); var triangle = triangler($diagram); var sample = sampler($diagram, $diagram.querySelector('g.samples')); var metaball = metaballer($diagram, $diagram.querySelector('path.metaball')); // Each time an input changes, we take the latest values of all inputs, // store them in params, and pass params to each transformation function in // turn. The order in which the transformation functions are called // matters, as certain functions modify the location or size of the // circles parsed from the diagram. most .combineArray(function() { params.inputs = {}; for (var i = 0; i < arguments.length; i++) { for (var key in arguments[i]) { params.inputs[key] = arguments[i][key]; } } return params; }, input$s) .observe(function(params) { window.requestAnimationFrame(function() { // Parse the circles here. Transformation functions could parse // circles on their own, but they would not see changes applied by // preceding transformation functions in the same animation frame, // since those changes are not reflected in the DOM until the // animation frame ends. params.circles = parseCircles($diagram); step(params); resize(params); triangle(params); sample(params); metaball(params); // Update the circle elements with any changes. renderCircles($diagram, params.circles); }); }); } // sum and max are sampling functions that reduce an array of numbers to a // single value. function sum(numbers) { var x = 0; for (var i = 0; i < numbers.length; i++) { x += numbers[i]; } return x; } function max(numbers) { var x = 0; for (var i = 0; i < numbers.length; i++) { if (numbers[i] > x) x = numbers[i]; } return x; } // sampleValue calculates the sample value at the given point (sx, sy) using // the circles in the given circles array and the given sample function fn. function sampleValue(sx, sy, circles, fn) { var values = []; for (var i = 0; i < circles.length; i++) { var circle = circles[i]; var x = sx - circle.center.x; var y = sy - circle.center.y; var r = circle.radius; values[i] = (r * r) / ((x * x) + (y * y)); } return fn(values); } // midpoint returns the point exactly halfway between samples a and b. function midpoint(a, b) { return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; } // linear uses linear interpolation to returns the point where the sample // value should equal the metaball threshold based on the locations and // values of sample points a and b. function linear(a, b, params) { var ratio = (params.metaball.threshold - a.value) / (b.value - a.value); return { x: a.x + ((b.x - a.x) * ratio), y: a.y + ((b.y - a.y) * ratio) }; } // refine uses repeated midpoint selection and range narrowing to try to get // as close as possible to the metaball threshold within a small number of // iterations. function refine(a, b, params) { var lesser = a.value < b.value ? a : b; var greater = a.value < b.value ? b : a; if (lesser.value >= params.metaball.threshold) return lesser; if (greater.value <= params.metaball.threshold) return greater; var point = midpoint(lesser, greater, params); point.value = sampleValue(point.x, point.y, params.circles, params.sample.fn); var delta = point.value - params.metaball.threshold; var iterations = 0; while (iterations < 5 && (delta > 0.0001 || delta < -0.0001)) { if (delta < 0) lesser = point; else greater = point; point = midpoint(lesser, greater, params); point.value = sampleValue(point.x, point.y, params.circles, params.sample.fn); delta = point.value - params.metaball.threshold; iterations++; } return point; } // inputValue$ turns an input element into a stream of values that starts // with the element's current value. Starting the stream with the current // value ensures the stream produces at least one event when the page is // loaded, which has the side effect of synchronizing the diagram with the // user-selected input state after a page refresh. function inputValue$($input) { var className = $input.getAttribute('class'); return most .fromEvent('input', $input) .map(function() { return parseInt($input.value); }) .startWith(parseInt($input.value)) .map(function(value) { var obj = {}; obj[className] = value; return obj; }); } // parseCircles creates and returns an array of circle objects whose values // are parsed from the actual circle elements inside the diagram. function parseCircles($diagram) { var circles = []; var $circles = $diagram.querySelectorAll('g.circles circle'); for (var i = 0; i < $circles.length; i++) { var $circle = $circles[i]; circles.push({ radius: parseFloat($circle.getAttribute('r')) || 0, center: { x: parseFloat($circle.getAttribute('cx')) || 0, y: parseFloat($circle.getAttribute('cy')) || 0 }, velocity: { x: parseFloat($circle.getAttribute('ess:vx')) || 0, y: parseFloat($circle.getAttribute('ess:vy')) || 0 } }); } return circles; } // renderCircles updates the circle elements in the diagram with the values // in the given circles array. function renderCircles($diagram, circles) { var $circles = $diagram.querySelectorAll('g.circles circle'); for (var i = 0; i < $circles.length && i < circles.length; i++) { var $circle = $circles[i]; var circle = circles[i]; $circle.setAttribute('r', circle.radius); $circle.setAttribute('cx', circle.center.x); $circle.setAttribute('cy', circle.center.y); $circle.setAttribute('ess:vx', circle.velocity.x); $circle.setAttribute('ess:vy', circle.velocity.y); } } // stepper creates a transformation function that moves each circle according // to its velocity. When a circle reaches one of the diagram's edges, it // bounces off according to the reflection of its velocity vector over the // edge. function stepper($diagram) { var frames = [[]]; var maxFrame = 0; var circles = parseCircles($diagram); for (var i = 0; i < circles.length; i++) { frames[0].push(circles[i]); } return function(params) { if (params.inputs.frame === undefined) return; while (frames.length <= params.inputs.frame) { var prevFrame = frames[frames.length - 1]; var nextFrame = []; for (var i = 0; i < params.circles.length; i++) { var radius = params.circles[i].radius; var center = { x: prevFrame[i].center.x + prevFrame[i].velocity.x, y: prevFrame[i].center.y + prevFrame[i].velocity.y }; var velocity = { x: prevFrame[i].velocity.x, y: prevFrame[i].velocity.y }; if (center.x - radius < params.bounds.min.x) { center.x += params.bounds.min.x - (center.x - radius); velocity.x = -velocity.x; } else if (center.x + radius > params.bounds.max.x) { velocity.x = -velocity.x; center.x -= (center.x + radius) - params.bounds.max.x; } if (center.y - radius < params.bounds.min.y) { velocity.y = -velocity.y; center.y += params.bounds.min.y - (center.y - radius); } else if (center.y + radius >= params.bounds.max.y) { velocity.y = -velocity.y; center.y -= (center.y + radius) - params.bounds.max.y; } nextFrame.push({ radius: radius, center: center, velocity: velocity }); } frames.push(nextFrame); } params.circles = frames[params.inputs.frame]; }; } // resizer creates a transformation function that sets each circle's radius // to the value of the radius input. function resizer($diagram) { return function(params) { if (params.inputs.radius === undefined) return; for (var i = 0; i < params.circles.length; i++) { params.circles[i].radius = params.inputs.radius; } }; } // triangler creates a transformation function that draws the Pythagorean // squares for the first circle according to the angle and radius inputs. function triangler($diagram) { var $tpath = $diagram.querySelector('path.t'); var $rpath = $diagram.querySelector('path.r'); var $rtext = $diagram.querySelector('text.r'); var $ypath = $diagram.querySelector('path.y'); var $ytext = $diagram.querySelector('text.y'); var $xpath = $diagram.querySelector('path.x'); var $xtext = $diagram.querySelector('text.x'); return function(params) { if (params.inputs.angle === undefined) return; if (params.circles === undefined || params.circles.length === 0) return; var fontSize = parseFloat($rtext.getAttribute('font-size')); var radians = (params.inputs.angle / 360) * (2 * Math.PI); var point = { x: params.circles[0].radius * Math.cos(radians), y: params.circles[0].radius * Math.sin(radians) }; $tpath.setAttribute('d', 'M0,0' + ' l' + point.x.toFixed(2) + ',' + (-point.y).toFixed(2) + ' v' + point.y.toFixed(2) + ' h' + (-point.x).toFixed(2)); if (point.y >= 0) { if (point.x >= 0) { $rpath.setAttribute('d', 'M0,0' + ' l' + point.x.toFixed(2) + ',' + (-point.y).toFixed(2) + ' l' + (-point.y.toFixed(2)) + ',' + (-point.x).toFixed(2) + ' l' + (-point.x.toFixed(2)) + ',' + point.y.toFixed(2) + ' l' + point.y.toFixed(2) + ',' + point.x.toFixed(2)); $ypath.setAttribute('d', 'M' + point.x.toFixed(2) + ',0' + ' v' + (-point.y).toFixed(2) + ' h' + point.y.toFixed(2) + ' v' + point.y.toFixed(2) + ' h' + (-point.y).toFixed(2)); $xpath.setAttribute('d', 'M0,0' + ' h' + point.x.toFixed(2) + ' v' + point.x.toFixed(2) + ' h' + (-point.x).toFixed(2) + ' v' + (-point.x).toFixed(2)); } else { $rpath.setAttribute('d', 'M0,0' + ' l' + point.x.toFixed(2) + ',' + (-point.y).toFixed(2) + ' l' + point.y.toFixed(2) + ',' + point.x.toFixed(2) + ' l' + (-point.x).toFixed(2) + ',' + point.y.toFixed(2) + ' l' + (-point.y).toFixed(2) + ',' + (-point.x).toFixed(2)); $ypath.setAttribute('d', 'M' + point.x + ',0' + ' v' + (-point.y).toFixed(2) + ' h' + (-point.y).toFixed(2) + ' v' + point.y.toFixed(2) + ' h' + point.y.toFixed(2)); $xpath.setAttribute('d', 'M0,0' + ' h' + point.x.toFixed(2) + ' v' + (-point.x.toFixed(2)) + ' h' + (-point.x.toFixed(2)) + ' v' + point.x.toFixed(2)); } } else { if (point.x >= 0) { $rpath.setAttribute('d', 'M0,0' + ' l' + point.x.toFixed(2) + ',' + (-point.y).toFixed(2) + ' l' + point.y.toFixed(2) + ',' + point.x.toFixed(2) + ' l' + (-point.x).toFixed(2) + ',' + point.y.toFixed(2) + ' l' + (-point.y).toFixed(2) + ',' + (-point.x).toFixed(2)); $ypath.setAttribute('d', 'M' + point.x.toFixed(2) + ',0' + ' v' + (-point.y).toFixed(2) + ' h' + (-point.y).toFixed(2) + ' v' + point.y.toFixed(2) + ' h' + point.y.toFixed(2)); $xpath.setAttribute('d', 'M0,0' + ' h' + point.x.toFixed(2) + ' v' + (-point.x).toFixed(2) + ' h' + (-point.x).toFixed(2) + ' v' + point.x.toFixed(2)); } else { $rpath.setAttribute('d', 'M0,0' + ' l' + point.x.toFixed(2) + ',' + (-point.y).toFixed(2) + ' l' + (-point.y).toFixed(2) + ',' + (-point.x).toFixed(2) + ' l' + (-point.x).toFixed(2) + ',' + point.y.toFixed(2) + ' l' + point.y.toFixed(2) + ',' + point.x.toFixed(2)); $ypath.setAttribute('d', 'M' + point.x.toFixed(2) + ',0' + ' v' + (-point.y).toFixed(2) + ' h' + point.y.toFixed(2) + ' v' + point.y.toFixed(2) + ' h' + (-point.y).toFixed(2)); $xpath.setAttribute('d', 'M0,0' + ' h' + point.x.toFixed(2) + ' v' + point.x.toFixed(2) + ' h' + (-point.x).toFixed(2) + ' v' + (-point.x).toFixed(2)); } } var labels = { r: { x: 0, y: 0 }, x: { x: 0, y: 0 }, y: { x: 0, y: 0 } }; labels.x.x = point.x / 2; labels.y.y = (point.y / -2) + (fontSize / 2); if (point.x >= 0) { labels.r.x = -Math.abs(point.y) + ((point.x + Math.abs(point.y)) / 2); labels.y.x = point.x + Math.abs(point.y / 2); } else { labels.r.x = point.x + (((-point.x) + Math.abs(point.y)) / 2); labels.y.x = point.x - Math.abs(point.y / 2); } if (point.y >= 0) { labels.r.y = ((point.y + Math.abs(point.x)) / -2) + (fontSize / 2); labels.x.y = (Math.abs(point.x) / 2) + (fontSize / 2); } else { labels.r.y = (((-point.y) + Math.abs(point.x)) / 2) + (fontSize / 2); labels.x.y = (Math.abs(point.x) / -2) + (fontSize / 2); } $rtext.setAttribute('x', labels.r.x.toFixed(2)); $rtext.setAttribute('y', labels.r.y.toFixed(2)); $xtext.setAttribute('x', labels.x.x.toFixed(2)); $xtext.setAttribute('y', labels.x.y.toFixed(2)); $ytext.setAttribute('x', labels.y.x.toFixed(2)); $ytext.setAttribute('y', labels.y.y.toFixed(2)); }; } // sampler creates a transformation function that samples the diagram at // intervals determined by the resolution input and optionally draws // rectangles representing those samples if a value for $samples is provided. // The calculated samples are assigned to params for use by other functions. function sampler($diagram, $samples) { var samples = []; var $rects = []; var lastResolution = 0; return function(params) { if (params.inputs.resolution === undefined) return; // Optimization: the number of samples will not change unless the // resolution changes. Therefore we can avoid unnecessary GC calls by // reusing the space already allocated for the samples and $rects arrays // and just overwrite element values. if (params.inputs.resolution !== lastResolution) { samples = []; if ($samples) { $samples.innerHTML = ''; $rects = []; } lastResolution = params.inputs.resolution; } // Find the top-left corner of the first sample by stepping back from the // origin. We do this to ensure that sample boundaries line up with the // x- and y-axes even on diagrams where the origin is translated to the // center of the diagram and the quadrant width or height is not evenly // divisible by the sample resolution. var min = { x: 0, y: 0 }; for (var x = -params.inputs.resolution; x > params.bounds.min.x; x -= params.inputs.resolution) min.x = x; for (var y = -params.inputs.resolution; y > params.bounds.min.y; y -= params.inputs.resolution) min.y = y; var samplesPerRow = Math .ceil((params.bounds.max.x - min.x) / params.inputs.resolution); var si = 0; var halfRes = params.inputs.resolution / 2; // Loop left-to-right, top-to-bottom. for (var y = min.y; y < params.bounds.max.y; y += params.inputs.resolution) { for (var x = min.x; x < params.bounds.max.x; x += params.inputs.resolution) { var sx = x + halfRes; var sy = y + halfRes; var sample = samples[si]; // If we haven't previously recorded a sample at this spot, create // the sample object now. Each sample stores the point where it was // taken, the value of the sample function, and references to its // four neighbors. On the edges, the neighbor references may point // to a fake zero-value sample so the metaball function doesn't have // to worry about missing neighbors. Each sample also has a "visited" // map which is used only by the metaball function. Every run through // this function resets the visited map to all false. if (sample === undefined) { sample = { x: sx, y: sy, value: 0, left: x > min.x ? samples[si - 1] : { x: sx - params.inputs.resolution, y: sy, value: 0 }, up: y > min.y ? samples[si - samplesPerRow] : { x: sx, y: sy - params.inputs.resolution, value: 0 }, right: { x: sx + params.inputs.resolution, y: sy, value: 0 }, down: { x: sx, y: sy + params.inputs.resolution, value: 0 }, visited: { left: false, up: false, right: false, down: false } }; sample.left.right = sample; sample.up.down = sample; samples.push(sample); } sample.value = sampleValue(sx, sy, params.circles, params.sample.fn); sample.visited.left = false; sample.visited.up = false; sample.visited.right = false; sample.visited.down = false; // If we were given an element in which to place the sample // rectangles ($samples), create or update a element. if ($samples) { var $rect = $rects[si]; if ($rect === undefined) { $rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); $rect.setAttribute('x', sx - halfRes - 0.5); $rect.setAttribute('y', sy - halfRes - 0.5); $rect.setAttribute('width', params.inputs.resolution + 1); $rect.setAttribute('height', params.inputs.resolution + 1); $rects.push($rect); } switch (params.sample.fill) { case 'opacity': $rect.setAttribute('fill-opacity', Math.max(0, Math.min(1, sample.value)).toFixed(2)); break; default: if (sample.value >= 1) { $rect.removeAttribute('fill'); } else { $rect.setAttribute('fill', 'none') } break; } } si++; } } if ($samples && $samples.children.length === 0) { for (si = 0; si < $rects.length; si++) { $samples.appendChild($rects[si]); } } params.samples = samples; }; } // metaballer creates a transformation function that creates a metaball path // in SVG path language and assigns it to the d attribute of the given // $metaball element. This function must be called after the sampler function // so that it has access to the sample values in the params object. function metaballer($diagram, $metaball) { var turnClockwise = { left: 'up', up: 'right', right: 'down', down: 'left' }; var turnAnticlockwise = { left: 'down', up: 'left', right: 'up', down: 'right' }; return function(params) { if (!$metaball) return; if (params.inputs.resolution === undefined) return; if (params.samples === undefined) return; var res = params.inputs.resolution; var d = []; var path; for (var i = 0; i < params.samples.length; i++) { var sample = params.samples[i]; if (sample.value < params.metaball.threshold) continue; if (sample.left.value >= params.metaball.threshold) continue; if (sample.visited.left) continue; path = []; var cmd = 'M'; var dir = 'left'; while (!sample.visited[dir]) { sample.visited[dir] = true; if (sample[dir].value < params.metaball.threshold) { var point = params.metaball.fn(sample[dir], sample, params); path.push(cmd + point.x.toFixed(2) + ',' + point.y.toFixed(2)); dir = turnClockwise[dir]; } else { sample = sample[dir]; dir = turnAnticlockwise[dir]; } cmd = 'L'; } d.push(path.join(' ') + ' Z') } $metaball.setAttribute('d', d.join(' ')); }; } })();