Skip to content

Instantly share code, notes, and snippets.

@emeeks
Last active September 14, 2020 16:49
Show Gist options
  • Save emeeks/e9d64d27f286e61493c9 to your computer and use it in GitHub Desktop.
Save emeeks/e9d64d27f286e61493c9 to your computer and use it in GitHub Desktop.

Revisions

  1. emeeks revised this gist Aug 1, 2019. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion index.html
    Original file line number Diff line number Diff line change
    @@ -66,7 +66,6 @@
    "#7e0050",
    "#f98b10",
    "#9c1bad",
    "#ffffe5",
    "#ae1daf"
    ]

    @@ -130,6 +129,7 @@
    .attr("height", function(d) { return d.dy; })
    .attr("width", sankey.nodeWidth())
    .style("fill", function(d,i) { return d.color; })
    .style("rx", 5)
    .style("stroke", "none");

    function dragmove(d) {
  2. emeeks revised this gist Aug 1, 2019. 1 changed file with 5 additions and 1 deletion.
    6 changes: 5 additions & 1 deletion index.html
    Original file line number Diff line number Diff line change
    @@ -95,6 +95,10 @@
    d.o_value = d.value;
    d.value = 1;
    })

    energy.nodes.forEach(function (d,i) {
    d.color = colors[i%colors.length]
    })

    sankey
    .nodes(energy.nodes)
    @@ -125,7 +129,7 @@
    node.append("rect")
    .attr("height", function(d) { return d.dy; })
    .attr("width", sankey.nodeWidth())
    .style("fill", function(d,i) { return colors[i]; })
    .style("fill", function(d,i) { return d.color; })
    .style("stroke", "none");

    function dragmove(d) {
  3. emeeks revised this gist Aug 1, 2019. 1 changed file with 2 additions and 3 deletions.
    5 changes: 2 additions & 3 deletions index.html
    Original file line number Diff line number Diff line change
    @@ -71,8 +71,7 @@
    ]

    var formatNumber = d3.format(",.0f"),
    format = function(d) { return formatNumber(d) + " TWh"; },
    color = function(d) { return colors[d] };
    format = function(d) { return formatNumber(d) + " TWh"; }

    var svg = d3.select("svg")
    .attr("width", width + margin.left + margin.right)
    @@ -126,7 +125,7 @@
    node.append("rect")
    .attr("height", function(d) { return d.dy; })
    .attr("width", sankey.nodeWidth())
    .style("fill", function(d) { return d.color = color(d.name.replace(/ .*/, "")); })
    .style("fill", function(d,i) { return colors[i]; })
    .style("stroke", "none");

    function dragmove(d) {
  4. emeeks revised this gist Aug 1, 2019. 1 changed file with 21 additions and 15 deletions.
    36 changes: 21 additions & 15 deletions index.html
    Original file line number Diff line number Diff line change
    @@ -50,10 +50,29 @@
    var margin = {top: 1, right: 1, bottom: 6, left: 1},
    width = 960 - margin.left - margin.right,
    height = 500 - margin.top - margin.bottom;

    var colors = [
    "#01ffff",
    "#f6a7ab",
    "#0b2934",
    "#fcba74",
    "#49d811",
    "#ff0000",
    "#cea9cf",
    "#0137d0",
    "#008fe1",
    "#330120",
    "#00dce6",
    "#7e0050",
    "#f98b10",
    "#9c1bad",
    "#ffffe5",
    "#ae1daf"
    ]

    var formatNumber = d3.format(",.0f"),
    format = function(d) { return formatNumber(d) + " TWh"; },
    color = d3.scale.category20();
    color = function(d) { return colors[d] };

    var svg = d3.select("svg")
    .attr("width", width + margin.left + margin.right)
    @@ -108,20 +127,7 @@
    .attr("height", function(d) { return d.dy; })
    .attr("width", sankey.nodeWidth())
    .style("fill", function(d) { return d.color = color(d.name.replace(/ .*/, "")); })
    .style("stroke", "none")
    .append("title")
    .text(function(d) { return d.name + "\n" + format(d.o_value); });

    node.append("text")
    .attr("x", -6)
    .attr("y", function(d) { return d.dy / 2; })
    .attr("dy", ".35em")
    .attr("text-anchor", "end")
    .attr("transform", null)
    .text(function(d) { return d.name; })
    .filter(function(d) { return d.x < width / 2; })
    .attr("x", 6 + sankey.nodeWidth())
    .attr("text-anchor", "start");
    .style("stroke", "none");

    function dragmove(d) {
    d3.select(this).attr("transform", "translate(" + d.x + "," + (d.y = Math.max(0, Math.min(height - d.dy, d3.event.y))) + ")");
  5. emeeks revised this gist Mar 8, 2016. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion index.html
    Original file line number Diff line number Diff line change
    @@ -42,7 +42,7 @@
    <canvas width="1000" height="1000" ></canvas>
    <svg width="1000" height="1000" ></svg>

    <script src="http://d3js.org/d3.v3.min.js" charset="utf-8" type="text/javascript"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.16/d3.min.js" charset="utf-8" type="text/javascript"></script>
    <script src="d3.sankey.js" charset="utf-8" type="text/javascript"></script>

    <script type="text/javascript">
  6. emeeks revised this gist Nov 24, 2015. No changes.
  7. Elijah Meeks created this gist Nov 24, 2015.
    294 changes: 294 additions & 0 deletions d3.sankey.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,294 @@
    d3.sankey = function() {
    var sankey = {},
    nodeWidth = 24,
    nodePadding = 8,
    size = [1, 1],
    nodes = [],
    links = [];

    sankey.nodeWidth = function(_) {
    if (!arguments.length) return nodeWidth;
    nodeWidth = +_;
    return sankey;
    };

    sankey.nodePadding = function(_) {
    if (!arguments.length) return nodePadding;
    nodePadding = +_;
    return sankey;
    };

    sankey.nodes = function(_) {
    if (!arguments.length) return nodes;
    nodes = _;
    return sankey;
    };

    sankey.links = function(_) {
    if (!arguments.length) return links;
    links = _;
    return sankey;
    };

    sankey.size = function(_) {
    if (!arguments.length) return size;
    size = _;
    return sankey;
    };

    sankey.layout = function(iterations) {
    computeNodeLinks();
    computeNodeValues();
    computeNodeBreadths();
    computeNodeDepths(iterations);
    computeLinkDepths();
    return sankey;
    };

    sankey.relayout = function() {
    computeLinkDepths();
    return sankey;
    };

    sankey.link = function() {
    var curvature = .5;

    function link(d) {
    var x0 = d.source.x + d.source.dx,
    x1 = d.target.x,
    xi = d3.interpolateNumber(x0, x1),
    x2 = xi(curvature),
    x3 = xi(1 - curvature),
    y0 = d.source.y + d.sy + d.dy / 2,
    y1 = d.target.y + d.ty + d.dy / 2;
    return "M" + x0 + "," + y0
    + "C" + x2 + "," + y0
    + " " + x3 + "," + y1
    + " " + x1 + "," + y1;
    }

    link.curvature = function(_) {
    if (!arguments.length) return curvature;
    curvature = +_;
    return link;
    };

    return link;
    };

    // Populate the sourceLinks and targetLinks for each node.
    // Also, if the source and target are not objects, assume they are indices.
    function computeNodeLinks() {
    nodes.forEach(function(node) {
    node.sourceLinks = [];
    node.targetLinks = [];
    });
    links.forEach(function(link) {
    var source = link.source,
    target = link.target;
    if (typeof source === "number") source = link.source = nodes[link.source];
    if (typeof target === "number") target = link.target = nodes[link.target];
    source.sourceLinks.push(link);
    target.targetLinks.push(link);
    });
    }

    // Compute the value (size) of each node by summing the associated links.
    function computeNodeValues() {
    nodes.forEach(function(node) {
    node.value = Math.max(
    d3.sum(node.sourceLinks, value),
    d3.sum(node.targetLinks, value)
    );
    });
    }

    // Iteratively assign the breadth (x-position) for each node.
    // Nodes are assigned the maximum breadth of incoming neighbors plus one;
    // nodes with no incoming links are assigned breadth zero, while
    // nodes with no outgoing links are assigned the maximum breadth.
    function computeNodeBreadths() {
    var remainingNodes = nodes,
    nextNodes,
    x = 0;

    while (remainingNodes.length) {
    nextNodes = [];
    remainingNodes.forEach(function(node) {
    node.x = x;
    node.dx = nodeWidth;
    node.sourceLinks.forEach(function(link) {
    if (nextNodes.indexOf(link.target) < 0) {
    nextNodes.push(link.target);
    }
    });
    });
    remainingNodes = nextNodes;
    ++x;
    }

    //
    moveSinksRight(x);
    scaleNodeBreadths((size[0] - nodeWidth) / (x - 1));
    }

    function moveSourcesRight() {
    nodes.forEach(function(node) {
    if (!node.targetLinks.length) {
    node.x = d3.min(node.sourceLinks, function(d) { return d.target.x; }) - 1;
    }
    });
    }

    function moveSinksRight(x) {
    nodes.forEach(function(node) {
    if (!node.sourceLinks.length) {
    node.x = x - 1;
    }
    });
    }

    function scaleNodeBreadths(kx) {
    nodes.forEach(function(node) {
    node.x *= kx;
    });
    }

    function computeNodeDepths(iterations) {
    var nodesByBreadth = d3.nest()
    .key(function(d) { return d.x; })
    .sortKeys(d3.ascending)
    .entries(nodes)
    .map(function(d) { return d.values; });

    //
    initializeNodeDepth();
    resolveCollisions();
    for (var alpha = 1; iterations > 0; --iterations) {
    relaxRightToLeft(alpha *= .99);
    resolveCollisions();
    relaxLeftToRight(alpha);
    resolveCollisions();
    }

    function initializeNodeDepth() {
    var ky = d3.min(nodesByBreadth, function(nodes) {
    return (size[1] - (nodes.length - 1) * nodePadding) / d3.sum(nodes, value);
    });

    nodesByBreadth.forEach(function(nodes) {
    nodes.forEach(function(node, i) {
    node.y = i;
    node.dy = node.value * ky;
    });
    });

    links.forEach(function(link) {
    link.dy = link.value * ky;
    });
    }

    function relaxLeftToRight(alpha) {
    nodesByBreadth.forEach(function(nodes, breadth) {
    nodes.forEach(function(node) {
    if (node.targetLinks.length) {
    var y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value);
    node.y += (y - center(node)) * alpha;
    }
    });
    });

    function weightedSource(link) {
    return center(link.source) * link.value;
    }
    }

    function relaxRightToLeft(alpha) {
    nodesByBreadth.slice().reverse().forEach(function(nodes) {
    nodes.forEach(function(node) {
    if (node.sourceLinks.length) {
    var y = d3.sum(node.sourceLinks, weightedTarget) / d3.sum(node.sourceLinks, value);
    node.y += (y - center(node)) * alpha;
    }
    });
    });

    function weightedTarget(link) {
    return center(link.target) * link.value;
    }
    }

    function resolveCollisions() {
    nodesByBreadth.forEach(function(nodes) {
    var node,
    dy,
    y0 = 0,
    n = nodes.length,
    i;

    // Push any overlapping nodes down.
    nodes.sort(ascendingDepth);
    for (i = 0; i < n; ++i) {
    node = nodes[i];
    dy = y0 - node.y;
    if (dy > 0) node.y += dy;
    y0 = node.y + node.dy + nodePadding;
    }

    // If the bottommost node goes outside the bounds, push it back up.
    dy = y0 - nodePadding - size[1];
    if (dy > 0) {
    y0 = node.y -= dy;

    // Push any overlapping nodes back up.
    for (i = n - 2; i >= 0; --i) {
    node = nodes[i];
    dy = node.y + node.dy + nodePadding - y0;
    if (dy > 0) node.y -= dy;
    y0 = node.y;
    }
    }
    });
    }

    function ascendingDepth(a, b) {
    return a.y - b.y;
    }
    }

    function computeLinkDepths() {
    nodes.forEach(function(node) {
    node.sourceLinks.sort(ascendingTargetDepth);
    node.targetLinks.sort(ascendingSourceDepth);
    });
    nodes.forEach(function(node) {
    var sy = 0, ty = 0;
    node.sourceLinks.forEach(function(link) {
    link.sy = sy;
    sy += link.dy;
    });
    node.targetLinks.forEach(function(link) {
    link.ty = ty;
    ty += link.dy;
    });
    });

    function ascendingSourceDepth(a, b) {
    return a.source.y - b.source.y;
    }

    function ascendingTargetDepth(a, b) {
    return a.target.y - b.target.y;
    }
    }

    function center(node) {
    return node.y + node.dy / 2;
    }

    function value(link) {
    return link.value;
    }

    return sankey;
    };
    120 changes: 120 additions & 0 deletions energy.json
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,120 @@
    {"nodes":[
    {"name":"Agricultural 'waste'"},
    {"name":"Bio-conversion"},
    {"name":"Liquid"},
    {"name":"Losses"},
    {"name":"Solid"},
    {"name":"Gas"},
    {"name":"Biofuel imports"},
    {"name":"Biomass imports"},
    {"name":"Coal imports"},
    {"name":"Coal"},
    {"name":"Coal reserves"},
    {"name":"District heating"},
    {"name":"Industry"},
    {"name":"Heating and cooling - commercial"},
    {"name":"Heating and cooling - homes"},
    {"name":"Electricity grid"},
    {"name":"Over generation / exports"},
    {"name":"H2 conversion"},
    {"name":"Road transport"},
    {"name":"Agriculture"},
    {"name":"Rail transport"},
    {"name":"Lighting & appliances - commercial"},
    {"name":"Lighting & appliances - homes"},
    {"name":"Gas imports"},
    {"name":"Ngas"},
    {"name":"Gas reserves"},
    {"name":"Thermal generation"},
    {"name":"Geothermal"},
    {"name":"H2"},
    {"name":"Hydro"},
    {"name":"International shipping"},
    {"name":"Domestic aviation"},
    {"name":"International aviation"},
    {"name":"National navigation"},
    {"name":"Marine algae"},
    {"name":"Nuclear"},
    {"name":"Oil imports"},
    {"name":"Oil"},
    {"name":"Oil reserves"},
    {"name":"Other waste"},
    {"name":"Pumped heat"},
    {"name":"Solar PV"},
    {"name":"Solar Thermal"},
    {"name":"Solar"},
    {"name":"Tidal"},
    {"name":"UK land based bioenergy"},
    {"name":"Wave"},
    {"name":"Wind"}
    ],
    "links":[
    {"source":0,"target":1,"value":124.729},
    {"source":1,"target":2,"value":0.597},
    {"source":1,"target":3,"value":26.862},
    {"source":1,"target":4,"value":280.322},
    {"source":1,"target":5,"value":81.144},
    {"source":6,"target":2,"value":35},
    {"source":7,"target":4,"value":35},
    {"source":8,"target":9,"value":11.606},
    {"source":10,"target":9,"value":63.965},
    {"source":9,"target":4,"value":75.571},
    {"source":11,"target":12,"value":10.639},
    {"source":11,"target":13,"value":22.505},
    {"source":11,"target":14,"value":46.184},
    {"source":15,"target":16,"value":104.453},
    {"source":15,"target":14,"value":113.726},
    {"source":15,"target":17,"value":27.14},
    {"source":15,"target":12,"value":342.165},
    {"source":15,"target":18,"value":37.797},
    {"source":15,"target":19,"value":4.412},
    {"source":15,"target":13,"value":40.858},
    {"source":15,"target":3,"value":56.691},
    {"source":15,"target":20,"value":7.863},
    {"source":15,"target":21,"value":90.008},
    {"source":15,"target":22,"value":93.494},
    {"source":23,"target":24,"value":40.719},
    {"source":25,"target":24,"value":82.233},
    {"source":5,"target":13,"value":0.129},
    {"source":5,"target":3,"value":1.401},
    {"source":5,"target":26,"value":151.891},
    {"source":5,"target":19,"value":2.096},
    {"source":5,"target":12,"value":48.58},
    {"source":27,"target":15,"value":7.013},
    {"source":17,"target":28,"value":20.897},
    {"source":17,"target":3,"value":6.242},
    {"source":28,"target":18,"value":20.897},
    {"source":29,"target":15,"value":6.995},
    {"source":2,"target":12,"value":121.066},
    {"source":2,"target":30,"value":128.69},
    {"source":2,"target":18,"value":135.835},
    {"source":2,"target":31,"value":14.458},
    {"source":2,"target":32,"value":206.267},
    {"source":2,"target":19,"value":3.64},
    {"source":2,"target":33,"value":33.218},
    {"source":2,"target":20,"value":4.413},
    {"source":34,"target":1,"value":4.375},
    {"source":24,"target":5,"value":122.952},
    {"source":35,"target":26,"value":839.978},
    {"source":36,"target":37,"value":504.287},
    {"source":38,"target":37,"value":107.703},
    {"source":37,"target":2,"value":611.99},
    {"source":39,"target":4,"value":56.587},
    {"source":39,"target":1,"value":77.81},
    {"source":40,"target":14,"value":193.026},
    {"source":40,"target":13,"value":70.672},
    {"source":41,"target":15,"value":59.901},
    {"source":42,"target":14,"value":19.263},
    {"source":43,"target":42,"value":19.263},
    {"source":43,"target":41,"value":59.901},
    {"source":4,"target":19,"value":0.882},
    {"source":4,"target":26,"value":400.12},
    {"source":4,"target":12,"value":46.477},
    {"source":26,"target":15,"value":525.531},
    {"source":26,"target":3,"value":787.129},
    {"source":26,"target":11,"value":79.329},
    {"source":44,"target":15,"value":9.452},
    {"source":45,"target":1,"value":182.01},
    {"source":46,"target":15,"value":19.013},
    {"source":47,"target":15,"value":289.366}
    ]}
    199 changes: 199 additions & 0 deletions index.html
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,199 @@

    <!DOCTYPE html>

    <html lang="en">
    <head>
    <meta charset="utf-8" />
    <title>Sankey Particles</title>
    <style>
    .node rect {
    cursor: move;
    fill-opacity: .9;
    shape-rendering: crispEdges;
    }

    .node text {
    pointer-events: none;
    text-shadow: 0 1px 0 #fff;
    }

    .link {
    fill: none;
    stroke: #000;
    stroke-opacity: .15;
    }

    .link:hover {
    stroke-opacity: .25;
    }

    svg {
    position: absolute;
    }

    canvas {
    position: absolute;
    }


    </style>
    </head>
    <body>
    <canvas width="1000" height="1000" ></canvas>
    <svg width="1000" height="1000" ></svg>

    <script src="http://d3js.org/d3.v3.min.js" charset="utf-8" type="text/javascript"></script>
    <script src="d3.sankey.js" charset="utf-8" type="text/javascript"></script>

    <script type="text/javascript">

    var margin = {top: 1, right: 1, bottom: 6, left: 1},
    width = 960 - margin.left - margin.right,
    height = 500 - margin.top - margin.bottom;

    var formatNumber = d3.format(",.0f"),
    format = function(d) { return formatNumber(d) + " TWh"; },
    color = d3.scale.category20();

    var svg = d3.select("svg")
    .attr("width", width + margin.left + margin.right)
    .attr("height", height + margin.top + margin.bottom)
    .append("g")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    var sankey = d3.sankey()
    .nodeWidth(15)
    .nodePadding(10)
    .size([width, height]);

    var path = sankey.link();

    var freqCounter = 1;


    d3.json("energy.json", function(energy) {

    energy.links.forEach(function (d) {
    d.o_value = d.value;
    d.value = 1;
    })

    sankey
    .nodes(energy.nodes)
    .links(energy.links)
    .layout(32);

    var link = svg.append("g").selectAll(".link")
    .data(energy.links)
    .enter().append("path")
    .attr("class", "link")
    .attr("d", path)
    .style("stroke-width", function(d) { return Math.max(1, d.dy); })
    .sort(function(a, b) { return b.dy - a.dy; });

    link.append("title")
    .text(function(d) { return d.source.name + " → " + d.target.name + "\n" + format(d.o_value); });

    var node = svg.append("g").selectAll(".node")
    .data(energy.nodes)
    .enter().append("g")
    .attr("class", "node")
    .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; })
    .call(d3.behavior.drag()
    .origin(function(d) { return d; })
    .on("dragstart", function() { this.parentNode.appendChild(this); })
    .on("drag", dragmove));

    node.append("rect")
    .attr("height", function(d) { return d.dy; })
    .attr("width", sankey.nodeWidth())
    .style("fill", function(d) { return d.color = color(d.name.replace(/ .*/, "")); })
    .style("stroke", "none")
    .append("title")
    .text(function(d) { return d.name + "\n" + format(d.o_value); });

    node.append("text")
    .attr("x", -6)
    .attr("y", function(d) { return d.dy / 2; })
    .attr("dy", ".35em")
    .attr("text-anchor", "end")
    .attr("transform", null)
    .text(function(d) { return d.name; })
    .filter(function(d) { return d.x < width / 2; })
    .attr("x", 6 + sankey.nodeWidth())
    .attr("text-anchor", "start");

    function dragmove(d) {
    d3.select(this).attr("transform", "translate(" + d.x + "," + (d.y = Math.max(0, Math.min(height - d.dy, d3.event.y))) + ")");
    sankey.relayout();
    link.attr("d", path);
    }

    var linkExtent = d3.extent(energy.links, function (d) {return d.o_value});
    var frequencyScale = d3.scale.linear().domain(linkExtent).range([0.05,1]);
    var particleSize = d3.scale.linear().domain(linkExtent).range([1,5]);


    energy.links.forEach(function (link) {
    link.freq = frequencyScale(link.o_value);
    link.particleSize = 2;
    link.particleColor = d3.scale.linear().domain([0,1])
    .range([link.source.color, link.target.color]);
    })

    var t = d3.timer(tick, 1000);
    var particles = [];

    function tick(elapsed, time) {

    particles = particles.filter(function (d) {return d.current < d.path.getTotalLength()});

    d3.selectAll("path.link")
    .each(
    function (d) {
    // if (d.freq < 1) {
    for (var x = 0;x<2;x++) {
    var offset = (Math.random() - .5) * (d.dy - 4);
    if (Math.random() < d.freq) {
    var length = this.getTotalLength();
    particles.push({link: d, time: elapsed, offset: offset, path: this, length: length, animateTime: length, speed: 0.5 + (Math.random())})
    }
    }

    // }
    /* else {
    for (var x = 0; x<d.freq; x++) {
    var offset = (Math.random() - .5) * d.dy;
    particles.push({link: d, time: elapsed, offset: offset, path: this})
    }
    } */
    });

    particleEdgeCanvasPath(elapsed);
    }

    function particleEdgeCanvasPath(elapsed) {
    var context = d3.select("canvas").node().getContext("2d")

    context.clearRect(0,0,1000,1000);

    context.fillStyle = "gray";
    context.lineWidth = "1px";
    for (var x in particles) {
    var currentTime = elapsed - particles[x].time;
    // var currentPercent = currentTime / 1000 * particles[x].path.getTotalLength();
    particles[x].current = currentTime * 0.15 * particles[x].speed;
    var currentPos = particles[x].path.getPointAtLength(particles[x].current);
    context.beginPath();
    context.fillStyle = particles[x].link.particleColor(0);
    context.arc(currentPos.x,currentPos.y + particles[x].offset,particles[x].link.particleSize,0,2*Math.PI);
    context.fill();
    }
    }


    });

    </script>
    </body>
    </html>
    10 changes: 10 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,10 @@
    Fixed width edges in Sankey where flow value is indicated by number of particles. Typically a sankey diagram uses the width of the edges to indicate value of flow but here it's done entirely with particles. Drag the nodes to adjust the paths of the particles.


    Other examples of sankeys with particles:

    * [Slightly variable speed particles with fixed color based on edge source.](http://bl.ocks.org/emeeks/e749224c89f82788cb18)

    * [Fixed speed particles transitioning in color from source node color to target node color.](http://bl.ocks.org/emeeks/9673c96a682fe3948379)

    * [Particles of differing sizes and differing speeds moving in "bursts" between nodes.](http://bl.ocks.org/emeeks/21f99959d48dd0d0c746)
    Binary file added thumbnail.png
    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.