diff --git a/README.md b/README.md index 0cb35c1c..410dc606 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Feel free to use and adapt it for your own purposes. ```html - + ``` 2. insert an empty `svg` tag: @@ -31,6 +31,7 @@ radar_visualization({ svg_id: "radar", width: 1450, height: 1000, + scale: 1.0, colors: { background: "#fff", grid: "#bbb", @@ -65,7 +66,8 @@ radar_visualization({ }); ``` -Entries are positioned automatically so that they don't overlap. +Entries are positioned automatically so that they don't overlap. The "scale" parameter can help +in adjusting the size of the radar. As a working example, you can check out `docs/index.html` — the source of our [public Tech Radar](http://zalando.github.io/tech-radar/). @@ -99,7 +101,7 @@ http://localhost:3000/ ``` The MIT License (MIT) -Copyright (c) 2017-2022 Zalando SE +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 diff --git a/docs/radar.js b/docs/radar.js index 1fbda6f8..f157ad3d 100644 --- a/docs/radar.js +++ b/docs/radar.js @@ -1,6 +1,6 @@ // The MIT License (MIT) -// Copyright (c) 2017 Zalando SE +// 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 diff --git a/docs/release/radar-0.8.js b/docs/release/radar-0.8.js new file mode 100644 index 00000000..f157ad3d --- /dev/null +++ b/docs/release/radar-0.8.js @@ -0,0 +1,474 @@ +// 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); +}