@qiwi/tech-radar
Version:
Fully automated tech-radar generator
500 lines (450 loc) • 14.6 kB
JavaScript
// The MIT License (MIT)
// Copyright (c) 2017 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: 150 },
{ radius: 230 },
{ radius: 320 },
{ radius: 410 },
]
const title_offset = { x: -675, y: -475 }
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: -410 },
]
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 < 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]) {
for (var ring = 0; ring < 4; 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 grid lines
grid
.append('line')
.attr('x1', 0)
.attr('y1', -400)
.attr('x2', 0)
.attr('y2', 400)
.style('stroke', config.colors.grid)
.style('stroke-width', 1)
grid
.append('line')
.attr('x1', -400)
.attr('y1', 0)
.attr('x2', 400)
.attr('y2', 0)
.style('stroke', config.colors.grid)
.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')
// draw rings
for (var i = 0; i < rings.length; i++) {
grid
.append('circle')
.attr('cx', 0)
.attr('cy', 0)
.attr('r', rings[i].radius)
.style('fill', 'none')
.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.3)
// .style('fill', '#eeeef0')
.style('font-family', 'Helvetica, Arial')
.style('font-size', 42)
.style('font-weight', 'bold')
.style('pointer-events', 'none')
.style('user-select', 'none')
}
}
function legend_transform(quadrant, ring, index = null) {
var dx = ring < 1 ? 0 : 140
var dy = index == null ? -16 : index * 12
if (ring === 2 ) {
dy = dy + 36 + segmented[quadrant][1].length * 12
}
if (ring === 3 ) {
dy = dy + 36 * 2 + (segmented[quadrant][1].length + segmented[quadrant][2].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', 'Helvetica, Arial')
.style('font-size', '26')
.style('font-weight', 'bolder')
// date
radar
.append('text')
.attr('transform', translate(title_offset.x, title_offset.y + 20))
.text(config.date)
.style('font-family', 'Helvetica, Arial')
.style('font-size', '12')
.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', 'Helvetica, Arial')
.style('font-size', '10')
// legend
var legend = radar.append('g')
for (var quadrant = 0; quadrant < 4; quadrant++) {
legend
.append('text')
.attr(
'transform',
translate(legend_offset[quadrant].x, legend_offset[quadrant].y - 45),
)
.text(config.quadrants[quadrant].name)
.style('font-family', 'Helvetica, Arial')
.style('font-size', '16')
.style('font-weight', 'bold')
for (var ring = 0; ring < 4; ring++) {
legend
.append('text')
.attr('transform', legend_transform(quadrant, ring))
.text(config.rings[ring].name)
.style('font-family', 'Helvetica, Arial')
.style('font-size', '12')
.style('font-weight', 'bold')
.style('fill', config.rings[ring].color)
legend
.selectAll('.legend' + quadrant + ring)
.data(segmented[quadrant][ring])
.enter()
.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', 'Helvetica, Arial')
.style('font-size', '11')
.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.hasOwnProperty('link')) {
blip = blip
.append('a')
.attr('target', '_blank')
.attr('xlink:href', d.link)
}
// 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 -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', 'Helvetica, Arial')
.style('font-size', function (d) {
return blip_text.length > 2 ? '8' : '9'
})
.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)
}