Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Radar with 5 Quadrants #129

Open
dev-marcoC opened this issue Oct 11, 2023 · 1 comment
Open

Radar with 5 Quadrants #129

dev-marcoC opened this issue Oct 11, 2023 · 1 comment

Comments

@dev-marcoC
Copy link

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
Copy link

chrishrb commented Mar 6, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants