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,
+};