// The MIT License (MIT) // Copyright (c) 2017-2024 Zalando SE // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. function radar_visualization(config) { // custom random number generator, to make random sequence reproducible // source: https://stackoverflow.com/questions/521295 var seed = 42; function random() { var x = Math.sin(seed++) * 10000; return x - Math.floor(x); } function random_between(min, max) { return min + random() * (max - min); } function normal_between(min, max) { return min + (random() + random()) * 0.5 * (max - min); } // radial_min / radial_max are multiples of PI const quadrants = [ { radial_min: 0, radial_max: 0.5, factor_x: 1, factor_y: 1 }, { radial_min: 0.5, radial_max: 1, factor_x: -1, factor_y: 1 }, { radial_min: -1, radial_max: -0.5, factor_x: -1, factor_y: -1 }, { radial_min: -0.5, radial_max: 0, factor_x: 1, factor_y: -1 } ]; const rings = [ { radius: 130 }, { radius: 220 }, { radius: 310 }, { radius: 400 } ]; const title_offset = { x: -675, y: -420 }; const footer_offset = { x: -675, y: 420 }; const legend_offset = [ { x: 450, y: 90 }, { x: -675, y: 90 }, { x: -675, y: -310 }, { x: 450, y: -310 } ]; function polar(cartesian) { var x = cartesian.x; var y = cartesian.y; return { t: Math.atan2(y, x), r: Math.sqrt(x * x + y * y) } } function cartesian(polar) { return { x: polar.r * Math.cos(polar.t), y: polar.r * Math.sin(polar.t) } } function bounded_interval(value, min, max) { var low = Math.min(min, max); var high = Math.max(min, max); return Math.min(Math.max(value, low), high); } function bounded_ring(polar, r_min, r_max) { return { t: polar.t, r: bounded_interval(polar.r, r_min, r_max) } } function bounded_box(point, min, max) { return { x: bounded_interval(point.x, min.x, max.x), y: bounded_interval(point.y, min.y, max.y) } } function segment(quadrant, ring) { var polar_min = { t: quadrants[quadrant].radial_min * Math.PI, r: ring === 0 ? 30 : rings[ring - 1].radius }; var polar_max = { t: quadrants[quadrant].radial_max * Math.PI, r: rings[ring].radius }; var cartesian_min = { x: 15 * quadrants[quadrant].factor_x, y: 15 * quadrants[quadrant].factor_y }; var cartesian_max = { x: rings[3].radius * quadrants[quadrant].factor_x, y: rings[3].radius * quadrants[quadrant].factor_y }; return { clipx: function(d) { var c = bounded_box(d, cartesian_min, cartesian_max); var p = bounded_ring(polar(c), polar_min.r + 15, polar_max.r - 15); d.x = cartesian(p).x; // adjust data too! return d.x; }, clipy: function(d) { var c = bounded_box(d, cartesian_min, cartesian_max); var p = bounded_ring(polar(c), polar_min.r + 15, polar_max.r - 15); d.y = cartesian(p).y; // adjust data too! return d.y; }, random: function() { return cartesian({ t: random_between(polar_min.t, polar_max.t), r: normal_between(polar_min.r, polar_max.r) }); } } } // position each entry randomly in its segment for (var i = 0; i < config.entries.length; i++) { var entry = config.entries[i]; entry.segment = segment(entry.quadrant, entry.ring); var point = entry.segment.random(); entry.x = point.x; entry.y = point.y; entry.color = entry.active || config.print_layout ? config.rings[entry.ring].color : config.colors.inactive; } // partition entries according to segments var segmented = new Array(4); for (var quadrant = 0; quadrant < 4; quadrant++) { segmented[quadrant] = new Array(4); for (var ring = 0; ring < 4; ring++) { segmented[quadrant][ring] = []; } } for (var i=0; i 0) { blip.append("path") .attr("d", "M -11,5 11,5 0,-13 z") // triangle pointing up .style("fill", d.color); } else if (d.moved < 0) { blip.append("path") .attr("d", "M -11,-5 11,-5 0,13 z") // triangle pointing down .style("fill", d.color); } else { blip.append("circle") .attr("r", 9) .attr("fill", d.color); } // blip text if (d.active || config.print_layout) { var blip_text = config.print_layout ? d.id : d.label.match(/[a-z]/i); blip.append("text") .text(blip_text) .attr("y", 3) .attr("text-anchor", "middle") .style("fill", "#fff") .style("font-family", "Arial, Helvetica") .style("font-size", function(d) { return blip_text.length > 2 ? "8px" : "9px"; }) .style("pointer-events", "none") .style("user-select", "none"); } }); // make sure that blips stay inside their segment function ticked() { blips.attr("transform", function(d) { return translate(d.segment.clipx(d), d.segment.clipy(d)); }) } // distribute blips, while avoiding collisions d3.forceSimulation() .nodes(config.entries) .velocityDecay(0.19) // magic number (found by experimentation) .force("collision", d3.forceCollide().radius(12).strength(0.85)) .on("tick", ticked); }