zalando / tech-radar

Visualizing our technology choices
https://opensource.zalando.com/tech-radar/
MIT License
1.6k stars 622 forks source link

Radar with 5 Quadrants #129

Open dev-marcoC opened 11 months ago

dev-marcoC commented 11 months ago

Hello, I've modified the code for your radar to accommodate 5 quadrants and 3 rings. I've successfully drawn everything and adjusted the legend, but I'm struggling to position the blips correctly in the right quadrant. Could you possibly lend me a hand to understand where I'm going wrong?

` function radar_visualization(config) {

var seed = 45;
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.4, factor_x: 1, factor_y: 1 },
    { radial_min: 0.4, radial_max: 0.8, factor_x: -1, factor_y: 1 },
    { radial_min: 0.8, radial_max: 1.2, factor_x: -1, factor_y: -1 },
    { radial_min: -1.2, radial_max: -0.8, factor_x: 1, factor_y: -1 },
    { radial_min: -0.8, radial_max: -0.4, factor_x: 1, factor_y: 1 }
];

const rings = [
    { radius: 130 },
    { radius: 220 },
    { radius: 310 },
];

const title_offset =
    { x: -675, y: -420 };

const footer_offset =
    { x: -675, y: 420 };

const legend_offset = [
    { x: 400, y: -200 },    // Quadrant 0
    { x: 400, y: 90 },   // Quadrant 1
    { x: -60, y: 400 },  // Quadrant 2
    { x: -550, y: 90 },   // Quadrant 3
    { x: -550, y: -200 }      // Quadrant 4
];

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[2].radius * quadrants[quadrant].factor_x,
        y: rings[2].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(5);
for (var quadrant = 0; quadrant < 5; quadrant++) {
    segmented[quadrant] = new Array(3);
    for (var ring = 0; ring < 3; ring++) {
        segmented[quadrant][ring] = [];
    }
}
for (var i = 0; i < config.entries.length; i++) {
    var entry = config.entries[i];
    segmented[entry.quadrant][entry.ring].push(entry);
}

// assign unique sequential id to each entry
var id = 1;
for (var quadrant of [2, 3, 1, 0, 4]) {
    for (var ring = 0; ring < 3; ring++) {
        var entries = segmented[quadrant][ring];
        entries.sort(function (a, b) { return a.label.localeCompare(b.label); })
        for (var i = 0; i < entries.length; i++) {
            entries[i].id = "" + id++;
        }
    }
}

function translate(x, y) {
    return "translate(" + x + "," + y + ")";
}

function viewbox(quadrant) {
    return [
        Math.max(0, quadrants[quadrant].factor_x * 400) - 420,
        Math.max(0, quadrants[quadrant].factor_y * 400) - 420,
        440,
        440
    ].join(" ");
}

var svg = d3.select("svg#" + config.svg_id)
    .style("background-color", config.colors.background)
    .attr("width", config.width)
    .attr("height", config.height);

var radar = svg.append("g");
if ("zoomed_quadrant" in config) {
    svg.attr("viewBox", viewbox(config.zoomed_quadrant));
} else {
    radar.attr("transform", translate(config.width / 2, config.height / 2));
}

var grid = radar.append("g");

// draw rings
for (var i = rings.length - 1; i >= 0; i--) {
    const bgColor = i === 2 ? "#d5cfcf" : i === 1 ? "#989292" : "#5f5b5b"
    grid.append("circle")
        .attr("cx", 0)
        .attr("cy", 0)
        .attr("r", rings[i].radius)
        .style("fill", bgColor)
        .style("stroke", config.colors.grid)
        .style("stroke-width", 1);

    if (config.print_layout) {
        grid.append("text")
            .text(config.rings[i].name)
            .attr("y", -rings[i].radius + 62)
            .attr("text-anchor", "middle")
            .style("fill", config.rings[i].color)
            .style("opacity", 0.60)
            .style("font-family", "Arial, Helvetica")
            .style("font-size", "42px")
            .style("font-weight", "bold")
            .style("pointer-events", "none")
            .style("user-select", "none");
    }
}
// draw grid lines
const quadrantSuddivisionNumber = 360 / quadrants.length;
for (let q = 0; q < quadrants.length; q++) {

    const angle = quadrantSuddivisionNumber * q;
    const textAngle = angle + quadrantSuddivisionNumber / 2; 

    const xText = 0; 
    const yText = -310;
    grid.append("line")
        .attr("x1", 0).attr("y1", -310)
        .attr("x2", 0).attr("y2", 0)
        .style("stroke", config.colors.grid)
        .attr('transform', `rotate(${angle} 0 0)`)
        .style("stroke-width", 1);

}

// background color. Usage `.attr("filter", "url(#solid)")`
// SOURCE: https://stackoverflow.com/a/31013492/2609980
var defs = grid.append("defs");
var filter = defs.append("filter")
    .attr("x", 0)
    .attr("y", 0)
    .attr("width", 1)
    .attr("height", 1)
    .attr("id", "solid");
filter.append("feFlood")
    .attr("flood-color", "rgb(0, 0, 0, 0.8)");
filter.append("feComposite")
    .attr("in", "SourceGraphic");

function legend_transform(quadrant, ring, index = null) {
    var dx = ring < 2 ? 0 : 120;
    var dy = (index == null ? -16 : index * 12);
    if (ring % 2 === 1) {
        dy = dy + 36 + segmented[quadrant][ring - 1].length * 12;
    }
    return translate(
        legend_offset[quadrant].x + dx,
        legend_offset[quadrant].y + dy
    );
}

// draw title and legend (only in print layout)
if (config.print_layout) {

    // title
    radar.append("text")
        .attr("transform", translate(title_offset.x, title_offset.y))
        .text(config.title)
        .style("font-family", "Arial, Helvetica")
        .style("font-size", "30")
        .style("font-weight", "bold")

    // date
    radar
        .append("text")
        .attr("transform", translate(title_offset.x, title_offset.y + 20))
        .text(config.date || "")
        .style("font-family", "Arial, Helvetica")
        .style("font-size", "14")
        .style("fill", "#999")

    // footer
    /* radar.append("text")
        .attr("transform", translate(footer_offset.x, footer_offset.y))
        .text("▲ moved up     ▼ moved down")
        .attr("xml:space", "preserve")
        .style("font-family", "Arial, Helvetica")
        .style("font-size", "10px"); */

    // legend
    var legend = radar.append("g");
    for (var quadrant = 0; quadrant < 5; quadrant++) {

        legend.append("text")
            .attr("transform", translate(
                legend_offset[quadrant].x,
                legend_offset[quadrant].y - 45
            ))
            .text(config.quadrants[quadrant].name)
            .style("font-family", "Arial, Helvetica")
            .style("font-size", "18px")
            .style("font-weight", "bold");
        for (var ring = 0; ring < 3; ring++) {
            legend.append("text")
                .attr("transform", legend_transform(quadrant, ring))
                .text(config.rings[ring].name)
                .style("font-family", "Arial, Helvetica")
                .style("font-weight", "bold")
                .style("fill", config.rings[ring].color);
            legend.selectAll(".legend" + quadrant + ring)
                .data(segmented[quadrant][ring])
                .enter()
                .append("a")
                // Add an href if (and only if) there is a link
                .attr("href", function (d, i) {
                    return d.link ? d.link : null;
                })
                // Add a target if (and only if) there is a link and we want new tabs
                .attr("target", function (d, i) {
                    return (d.link && config.links_in_new_tabs) ? "_blank" : null;
                })
                .append("text")
                .attr("transform", function (d, i) { return legend_transform(quadrant, ring, i); })
                .attr("class", "legend" + quadrant + ring)
                .attr("id", function (d, i) { return "legendItem" + d.id; })
                .text(function (d, i) { return d.id + ". " + d.label; })
                .style("font-family", "Arial, Helvetica")
                .style("font-size", "11px")
                .on("mouseover", function (d) { showBubble(d); highlightLegendItem(d); })
                .on("mouseout", function (d) { hideBubble(d); unhighlightLegendItem(d); });
        }
    }
}

// layer for entries
var rink = radar.append("g")
    .attr("id", "rink");

// rollover bubble (on top of everything else)
var bubble = radar.append("g")
    .attr("id", "bubble")
    .attr("x", 0)
    .attr("y", 0)
    .style("opacity", 0)
    .style("pointer-events", "none")
    .style("user-select", "none");
bubble.append("rect")
    .attr("rx", 4)
    .attr("ry", 4)
    .style("fill", "#333");
bubble.append("text")
    .style("font-family", "sans-serif")
    .style("font-size", "10px")
    .style("fill", "#fff");
bubble.append("path")
    .attr("d", "M 0,0 10,0 5,8 z")
    .style("fill", "#333");

function showBubble(d) {
    if (d.active || config.print_layout) {
        var tooltip = d3.select("#bubble text")
            .text(d.label);
        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 && d.hasOwnProperty("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 > 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 -9,-9 9,-9 9,9 -9,9 Z") // quadrato
            .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);

} `

chrishrb commented 6 months ago

I created an own version of the techradar that's very similar to this one from Zalando - but you can adjust the number of slices (quadrants) and rings dynamically. Check it out: https://github.com/chrishrb/techradar