Skip to content

Radar with 5 Quadrants #129

@dev-marcoC

Description

@dev-marcoC

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);

}
`

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions