// 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) { config.svg_id = config.svg || "radar"; config.width = config.width || 1450; config.height = config.height || 1000; config.colors = ("colors" in config) ? config.colors : { background: "#fff", grid: '#dddde0', inactive: "#ddd" }; config.print_layout = ("print_layout" in config) ? config.print_layout : true; config.links_in_new_tabs = ("links_in_new_tabs" in config) ? config.links_in_new_tabs : true; config.repo_url = config.repo_url || '#'; config.print_ring_descriptions_table = ("print_ring_descriptions_table" in config) ? config.print_ring_descriptions_table : false; config.footer_offset = config.footer_offset || { x: -155, y: 450 }; // 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 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 element var lines = [ `${d.label}` ]; if (d.description) { lines = [ ...(`${d.description || ''}`.split("\n").reverse()), ...lines ]; } lines.forEach((line, index) => { tooltip .append("tspan") .text(line) .attr("x", 0) // Align all lines horizontally .attr("dy", index === 0 ? "0" : "-1.2em") .attr("font-size", index === lines.length-1 ? "1.2em" : "") .attr("font-weight", index === lines.length-1 ? "bold" : ""); // Vertical offset for each line }); var bbox = tooltip.node().getBBox(); d3.select("#bubble") .attr("transform", translate(d.x - bbox.width / 2, d.y - 16)) .style("opacity", 0.8); d3.select("#bubble rect") .attr("x", -5) .attr("y", -bbox.height) .attr("width", bbox.width + 10) .attr("height", bbox.height + 4); d3.select("#bubble path") .attr("transform", translate(bbox.width / 2 - 5, 3)); } } function hideBubble(d) { var bubble = d3.select("#bubble") .attr("transform", translate(0,0)) .style("opacity", 0); } function highlightLegendItem(d) { var legendItem = document.getElementById("legendItem" + d.id); legendItem.setAttribute("filter", "url(#solid)"); legendItem.setAttribute("fill", "white"); } function unhighlightLegendItem(d) { var legendItem = document.getElementById("legendItem" + d.id); legendItem.removeAttribute("filter"); legendItem.removeAttribute("fill"); } // draw blips on radar var blips = rink.selectAll(".blip") .data(config.entries) .enter() .append("g") .attr("class", "blip") .attr("transform", function(d, i) { return legend_transform(d.quadrant, d.ring, i); }) .on("mouseover", function(d) { showBubble(d); highlightLegendItem(d); }) .on("mouseout", function(d) { hideBubble(d); unhighlightLegendItem(d); }); // configure each blip blips.each(function(d) { var blip = d3.select(this); // blip link if (d.active && Object.prototype.hasOwnProperty.call(d, "link") && d.link) { blip = blip.append("a") .attr("xlink:href", d.link); if (config.links_in_new_tabs) { blip.attr("target", "_blank"); } } // blip shape if (d.moved == 1) { blip.append("path") .attr("d", "M -11,5 11,5 0,-13 z") // triangle pointing up .style("fill", d.color); } else if (d.moved == -1) { blip.append("path") .attr("d", "M -11,-5 11,-5 0,13 z") // triangle pointing down .style("fill", d.color); } else if (d.moved == 2) { blip.append("path") .attr("d", d3.symbol().type(d3.symbolStar).size(200)) .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", config.font_family) .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); function ringDescriptionsTable() { var table = d3.select("body").append("table") .attr("class", "radar-table") .style("border-collapse", "collapse") .style("position", "relative") .style("top", "-70px") // Adjust this value to move the table closer vertically .style("margin-left", "50px") .style("margin-right", "50px") .style("font-family", config.font_family) .style("font-size", "13px") .style("text-align", "left"); var thead = table.append("thead"); var tbody = table.append("tbody"); // define fixed width for each column var columnWidth = `${100 / config.rings.length}%`; // create table header row with ring names var headerRow = thead.append("tr") .style("border", "1px solid #ddd"); headerRow.selectAll("th") .data(config.rings) .enter() .append("th") .style("padding", "8px") .style("border", "1px solid #ddd") .style("background-color", d => d.color) .style("color", "#fff") .style("width", columnWidth) .text(d => d.name); // create table body row with descriptions var descriptionRow = tbody.append("tr") .style("border", "1px solid #ddd"); descriptionRow.selectAll("td") .data(config.rings) .enter() .append("td") .style("padding", "8px") .style("border", "1px solid #ddd") .style("width", columnWidth) .text(d => d.description); } if (config.print_ring_descriptions_table) { ringDescriptionsTable(); } }