diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..14963ae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,21 @@ +name: Release +on: + push: + tags: + - "**" +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - name: Install Dependencies + run: bun install + - name: Build Release + run: bun release + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: dist/index.js + make_latest: "true" diff --git a/.gitignore b/.gitignore index c6bba59..bc1a104 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,7 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Custom + +example/index.js diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..8155350 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +bun 1.1.20 diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..17d6150 Binary files /dev/null and b/bun.lockb differ diff --git a/example/config.json b/example/config.json new file mode 100644 index 0000000..a1e2803 --- /dev/null +++ b/example/config.json @@ -0,0 +1,569 @@ +{ + "date": "2024.06", + "svg_id": "radar", + "colors": { + "background": "#fff0", + "grid": "#dddde0", + "inactive": "#ddd" + }, + "entries": [ + { + "quadrant": 3, + "ring": 1, + "label": "AWS Athena", + "active": true, + "moved": 1 + }, + { + "quadrant": 3, + "ring": 0, + "label": "AWS EMR", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 2, + "label": "AWS Glue", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 2, + "label": "AWS Lake Formation", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "Airflow", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "Databricks", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 1, + "label": "Flink", + "link": "https://engineering.zalando.com/tags/apache-flink.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 1, + "label": "Google BigQuery", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 1, + "label": "Presto", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "Spark", + "link": "https://engineering.zalando.com/tags/apache-spark.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 2, + "label": "Streamlit", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 1, + "label": "dbt", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "AWS DynamoDB", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "AWS S3", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "Amazon ElastiCache", + "link": "https://engineering.zalando.com/tags/redis.html", + "active": true, + "moved": 1 + }, + { + "quadrant": 2, + "ring": 2, + "label": "Amazon MemoryDB", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 1, + "label": "Amazon Redshift", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 1, + "label": "Amazon Feature Store", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Apache Cassandra", + "link": "https://engineering.zalando.com/tags/cassandra.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Consul", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 1, + "label": "Druid", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "Elasticsearch", + "link": "https://engineering.zalando.com/tags/elasticsearch.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "Exasol", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "HBase", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 1, + "label": "HDFS", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Hazelcast", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Memcached", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "MongoDB", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "MySQL", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Oracle DB", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 0, + "label": "PostgreSQL", + "link": "https://engineering.zalando.com/tags/postgresql.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Redis", + "link": "https://engineering.zalando.com/tags/redis.html", + "active": true, + "moved": -1 + }, + { + "quadrant": 2, + "ring": 2, + "label": "RocksDB", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 3, + "label": "Solr", + "active": true, + "moved": 0 + }, + { + "quadrant": 2, + "ring": 2, + "label": "Valkey", + "link": "https://engineering.zalando.com/tags/redis.html", + "active": true, + "moved": 2 + }, + { + "quadrant": 2, + "ring": 3, + "label": "ZooKeeper", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "AWS CloudFormation", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "AWS CloudFront", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 1, + "label": "AWS Elemental MediaConvert", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 1, + "label": "AWS Lambda", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "AWS Service Catalog", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 1, + "label": "AWS Step Functions", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "Amazon Bedrock", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "Amazon Pinpoint", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "Amazon SageMaker", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "Docker", + "link": "https://engineering.zalando.com/tags/docker.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "GraalVM", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "Kotlin Multiplatform", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "Kubernetes", + "link": "https://engineering.zalando.com/tags/kubernetes.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 1, + "label": "Open Policy Agent", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "OpenTelemetry", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "Skipper", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 2, + "label": "Slurm", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 1, + "label": "WebAssembly", + "active": true, + "moved": 0 + }, + { + "quadrant": 1, + "ring": 0, + "label": "ZMON", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 3, + "label": "Clojure", + "link": "https://engineering.zalando.com/tags/clojure.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 1, + "label": "Dart", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Go", + "link": "https://engineering.zalando.com/tags/golang.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "GraphQL", + "link": "https://engineering.zalando.com/tags/graphql.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 3, + "label": "Haskell", + "link": "https://engineering.zalando.com/tags/haskell.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Java", + "link": "https://engineering.zalando.com/tags/java.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "JavaScript", + "link": "https://engineering.zalando.com/tags/javascript.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Kotlin", + "link": "https://engineering.zalando.com/tags/kotlin.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "OpenAPI (Swagger)", + "link": "https://engineering.zalando.com/tags/openapi.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Python", + "link": "https://engineering.zalando.com/tags/python.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 2, + "label": "R", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 3, + "label": "Rust", + "link": "https://engineering.zalando.com/tags/rust.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Scala", + "link": "https://engineering.zalando.com/tags/scala.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "Swift", + "link": "https://engineering.zalando.com/tags/swift.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 0, + "ring": 0, + "label": "TypeScript", + "link": "https://engineering.zalando.com/tags/typescript.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "AWS Kinesis", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "AWS SNS", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "AWS SQS", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "Kafka", + "link": "https://engineering.zalando.com/tags/apache-kafka.html", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 0, + "label": "Nakadi", + "link": "https://nakadi.io", + "active": true, + "moved": 0 + }, + { + "quadrant": 3, + "ring": 1, + "label": "RabbitMQ", + "link": "https://engineering.zalando.com/tags/rabbitmq.html", + "active": true, + "moved": 0 + } + ] +} diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..6f4041e --- /dev/null +++ b/example/index.html @@ -0,0 +1,13 @@ + + + + + + Tech Radar + + + + + + + diff --git a/example/radar.css b/example/radar.css new file mode 100644 index 0000000..201682c --- /dev/null +++ b/example/radar.css @@ -0,0 +1,10 @@ +body { + font-family: "Source Sans Pro", arial, helvetica, sans-serif; + background-color: white; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: black; + } +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..238655f --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..74a2c6f --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "tech-radar", + "version": "1.0.0", + "type": "module", + "author": "sipgate GmbH", + "license": "ISC", + "description": "", + "main": "index.js", + "scripts": { + "dev": "bun build ./src/index.js --outdir ./example --watch & bunx serve example", + "release": "bun build --minify ./src/index.js --outdir ./dist" + }, + "dependencies": { + "d3": "^7.9.0", + "d3v4": "^4.2.2" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..0956664 --- /dev/null +++ b/src/index.js @@ -0,0 +1,50 @@ +import { + drawRadarVisualization, + removeRadarVisualization, +} from "./radar_visualization.js"; + +window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", async () => { + const config = await fetchConfig(); + removeRadarVisualization(config); + renderRadar(config); + }); + +window.addEventListener("load", async () => { + const config = await fetchConfig(); + renderRadar(config); +}); + +const fetchConfig = async () => { + try { + const config = await fetch("./config.json"); + return config.json(); + } catch (error) { + console.error("Error fetching config.json", error); + return {}; + } +}; + +const renderRadar = async (config) => { + drawRadarVisualization({ + repo_url: "https://github.com/zalando/tech-radar", + title: "Zalando Tech Radar", + date: config.date, + quadrants: [ + { name: "Languages" }, + { name: "Infrastructure" }, + { name: "Datastores" }, + { name: "Data Management" }, + ], + rings: [ + { name: "ADOPT", color: "#5ba300" }, + { name: "TRIAL", color: "#009eb0" }, + { name: "ASSESS", color: "#c7ba00" }, + { name: "HOLD", color: "#e09b96" }, + ], + entries: config.entries, + colors: config.colors, + print_ring_descriptions_table: true, + }); +}; diff --git a/src/radar_visualization.js b/src/radar_visualization.js new file mode 100644 index 0000000..bd431f2 --- /dev/null +++ b/src/radar_visualization.js @@ -0,0 +1,560 @@ +import * as d3 from "d3v4"; + +const checkIsDarkSchemePreferred = () => + window.matchMedia("(prefers-color-scheme:dark)").matches; + +const drawRadarVisualization = (config) => { + storedConfig = 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; + + // 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 footer_offset = { x: -155, y: 450 }; + + 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 < 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(" "); + } + + // adjust with config.scale. + config.scale = config.scale || 1; + var scaled_width = config.width * config.scale; + var scaled_height = config.height * config.scale; + + var svg = d3 + .select("svg#" + config.svg_id) + .style("background-color", config.colors.background) + .attr("width", scaled_width) + .attr("height", scaled_height); + + var radar = svg.append("g"); + if ("zoomed_quadrant" in config) { + svg.attr("viewBox", viewbox(config.zoomed_quadrant)); + } else { + radar.attr( + "transform", + translate(scaled_width / 2, scaled_height / 2).concat( + `scale(${config.scale})`, + ), + ); + } + + var grid = radar.append("g"); + + // define default font-family + config.font_family = config.font_family || "Arial, Helvetica"; + + // 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 filterLight = defs + .append("filter") + .attr("x", 0) + .attr("y", 0) + .attr("width", 1) + .attr("height", 1) + .attr("id", "solidLight"); + filterLight.append("feFlood").attr("flood-color", "rgb(0, 0, 0, 0.8)"); + filterLight.append("feComposite").attr("in", "SourceGraphic"); + + var filterDark = defs + .append("filter") + .attr("x", 0) + .attr("y", 0) + .attr("width", 1) + .attr("height", 1) + .attr("id", "solidDark"); + filterDark.append("feFlood").attr("flood-color", "rgb(255, 255, 255)"); + filterDark.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.35) + .style("font-family", config.font_family) + .style("font-size", "42px") + .style("font-weight", "bold") + .style("pointer-events", "none") + .style("user-select", "none"); + } + } + + function legend_transform(quadrant, ring, index = null) { + var dx = ring < 2 ? 0 : 140; + 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("a") + .attr("href", config.repo_url) + .attr("transform", translate(title_offset.x, title_offset.y)) + .append("text") + .attr("class", "hover-underline") // add class for hover effect + .text(config.title) + .style("font-family", config.font_family) + .style("font-size", "30") + .style("font-weight", "bold") + .style("fill", checkIsDarkSchemePreferred() ? "#fff" : "#000"); + // date + radar + .append("text") + .attr("transform", translate(title_offset.x, title_offset.y + 20)) + .text(config.date || "") + .style("font-family", config.font_family) + .style("font-size", "14") + .style("fill", checkIsDarkSchemePreferred() ? "#777" : "#999"); + + // footer + radar + .append("text") + .attr("transform", translate(footer_offset.x, footer_offset.y)) + .text("▲ moved up ▼ moved down ★ new 〇 no change") + .attr("xml:space", "preserve") + .style("font-family", config.font_family) + .style("font-size", "12px") + .style("fill", checkIsDarkSchemePreferred() ? "#fff" : "#000"); + + // 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", config.font_family) + .style("font-size", "18px") + .style("font-weight", "bold") + .style("fill", checkIsDarkSchemePreferred() ? "#fff" : "#000"); + for (var ring = 0; ring < 4; ring++) { + legend + .append("text") + .attr("transform", legend_transform(quadrant, ring)) + .text(config.rings[ring].name) + .style("font-family", config.font_family) + .style("font-size", "12px") + .style("font-weight", "bold") + .style("fill", config.rings[ring].color); + legend + .selectAll(".legend" + quadrant + ring) + .data(segmented[quadrant][ring]) + .enter() + .append("a") + .attr("href", function (d, i) { + return d.link ? d.link : "#"; // stay on same page if no link was provided + }) + // 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("fill", checkIsDarkSchemePreferred() ? "#fff" : "#000") + .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", config.font_family) + .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", config.font_family) + .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", + checkIsDarkSchemePreferred() ? "url(#solidDark)" : "url(#solidLight)", + ); + legendItem.setAttribute( + "fill", + checkIsDarkSchemePreferred() ? "#000" : "#fff", + ); + } + + function unhighlightLegendItem(d) { + var legendItem = document.getElementById("legendItem" + d.id); + legendItem.removeAttribute("filter"); + legendItem.setAttribute( + "fill", + checkIsDarkSchemePreferred() ? "#fff" : "#000", + ); + } + + // 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); +}; + +const removeRadarVisualization = (config) => { + var svg = d3.select("svg#" + config.svg_id); + svg.selectAll("*").remove(); +}; + +module.exports = { + drawRadarVisualization, + removeRadarVisualization, +};