diff --git a/src/charts/bubbleChart.ts b/src/charts/bubbleChart.ts
index d398f919f704fa1056c3ba8cb95c3776bde8da99..18c7adf9fe66c613f38f5fb2d213e69ef3bec428 100644
--- a/src/charts/bubbleChart.ts
+++ b/src/charts/bubbleChart.ts
@@ -3,6 +3,7 @@ import {HierarchyNode, NumberValue, ScaleLinear, ScaleOrdinal, ScaleSequential}
 import {ChartConfig, ChartConfigParam} from "@/charts/chart.ts";
 import SearchableChart from "@/charts/SearchableChart.ts";
 import {debounce} from "@/utils.ts";
+import Legend from "@/charts/utils/legendColor.js";
 
 export type BubbleChartConfig = ChartConfig & {
     groupAccessor: (d: any) => string | null,
@@ -67,6 +68,9 @@ export default class BubbleChart extends SearchableChart {
         vis.config.parentElement.innerHTML += `
             <div id="tooltip"></div>
         `;
+        vis.config.parentElement.innerHTML += `
+            <div id="color-legend"></div>
+        `;
 
         const svg = d3.select(`#${this.chartId}`)
             .attr('width', vis.config.containerWidth)
@@ -198,7 +202,7 @@ export default class BubbleChart extends SearchableChart {
             )
             .attr('group', true)
             .attr("transform", (d: any) => `translate(${d.x},${d.y})`)
-            .on('mouseover', (event: Event, d: any) => {
+            .on('mouseover', (event: Event) => {
                 // hide label
                 d3.select(event.target.closest('g'))
                     .selectAll("*")
@@ -326,9 +330,7 @@ export default class BubbleChart extends SearchableChart {
         const sampleValue = this.config.colorAccessor(this.data[0])
         if (!sampleValue) {
             this.colorScale = null
-            return
-        }
-        if (typeof sampleValue === 'number') {
+        } else if (typeof sampleValue === 'number') {
             this.colorScale = d3.scaleSequential(d3.interpolateCool)
                 .domain(
                     d3.extent(
@@ -336,12 +338,18 @@ export default class BubbleChart extends SearchableChart {
                         this.config.colorAccessor as (d: any) => number | null
                     ) as [number, number]
                 )
+
+            const colorLegend = document.getElementById('color-legend')
+            if (colorLegend) {
+                colorLegend.replaceChildren(Legend(this.colorScale, d3.interpolateCool))
+            }
         } else {
             this.colorScale = d3.scaleOrdinal(
                 new Set(this.data.map((d: any) => this.config.colorAccessor(d))),
                 d3.schemeCategory10,
             )
         }
+
     }
 
     updateSizeScale() {
diff --git a/src/charts/radarChart.ts b/src/charts/radarChart.ts
index 109cb7f3abea2b691d4b33715299a5d6e091fe93..1762401fe86c513dac0431b059c277d0547b7e0d 100644
--- a/src/charts/radarChart.ts
+++ b/src/charts/radarChart.ts
@@ -154,7 +154,7 @@ export default class RadarChart extends Chart {
 
         const preparedData = this.getPreparedData()
 
-        const data = dataWrapper.selectAll('.data')
+        dataWrapper.selectAll('.data')
             .data(preparedData)
             .join(
                 (enter: any) => {
diff --git a/src/charts/utils/legendCircle.js b/src/charts/utils/legendCircle.js
new file mode 100644
index 0000000000000000000000000000000000000000..179ea2484fa84786cfacb92c2c1fe7fea749ff9b
--- /dev/null
+++ b/src/charts/utils/legendCircle.js
@@ -0,0 +1,69 @@
+// Circle legend, made by Harry Stevens
+// https://observablehq.com/@harrystevens/circle-legend
+
+export default function legendCircle(context){
+    let scale,
+        tickValues,
+        tickFormat = d => d,
+        tickSize = 5;
+
+    function legend(context){
+        let g = context.select("g");
+        if (!g._groups[0][0]){
+            g = context.append("g");
+        }
+        g.attr("transform", `translate(${[1, 1]})`);
+
+        const ticks = tickValues || scale.ticks();
+
+        const max = ticks[ticks.length - 1];
+
+        g.selectAll("circle")
+            .data(ticks.slice().reverse())
+            .enter().append("circle")
+            .attr("fill", "none")
+            .attr("stroke", "currentColor")
+            .attr("cx", scale(max))
+            .attr("cy", scale)
+            .attr("r", scale);
+
+        g.selectAll("line")
+            .data(ticks)
+            .enter().append("line")
+            .attr("stroke", "currentColor")
+            .attr("stroke-dasharray", "4, 2")
+            .attr("x1", scale(max))
+            .attr("x2", tickSize + scale(max) * 2)
+            .attr("y1", d => scale(d) * 2)
+            .attr("y2", d => scale(d) * 2);
+
+        g.selectAll("text")
+            .data(ticks)
+            .enter().append("text")
+            .attr("font-family", "'Helvetica Neue', sans-serif")
+            .attr("font-size", 11)
+            .attr("dx", 3)
+            .attr("dy", 4)
+            .attr("x", tickSize + scale(max) * 2)
+            .attr("y", d => scale(d) * 2)
+            .text(tickFormat);
+    }
+
+    legend.tickSize = function(_){
+        return arguments.length ? (tickSize = +_, legend) : tickSize;
+    }
+
+    legend.scale = function(_){
+        return arguments.length ? (scale = _, legend) : scale;
+    }
+
+    legend.tickFormat = function(_){
+        return arguments.length ? (tickFormat = _, legend) : tickFormat;
+    }
+
+    legend.tickValues = function(_){
+        return arguments.length ? (tickValues = _, legend) : tickValues;
+    }
+
+    return legend;
+}
\ No newline at end of file
diff --git a/src/charts/utils/legendColor.js b/src/charts/utils/legendColor.js
new file mode 100644
index 0000000000000000000000000000000000000000..1d6cb594b60881a24816f29576928a9daff54479
--- /dev/null
+++ b/src/charts/utils/legendColor.js
@@ -0,0 +1,151 @@
+import * as d3 from "d3";
+
+// Copyright 2021, Observable Inc.
+// Released under the ISC license.
+// https://observablehq.com/@d3/color-legend
+export default function Legend(color, {
+    title,
+    tickSize = 6,
+    width = 320,
+    height = 44 + tickSize,
+    marginTop = 18,
+    marginRight = 0,
+    marginBottom = 16 + tickSize,
+    marginLeft = 0,
+    ticks = width / 64,
+    tickFormat,
+    tickValues
+} = {}) {
+
+    function ramp(color, n = 256) {
+        const canvas = document.createElement("canvas");
+        canvas.width = n;
+        canvas.height = 1;
+        const context = canvas.getContext("2d");
+        for (let i = 0; i < n; ++i) {
+            context.fillStyle = color(i / (n - 1));
+            context.fillRect(i, 0, 1, 1);
+        }
+        return canvas;
+    }
+
+    const svg = d3.create("svg")
+        .attr("width", width)
+        .attr("height", height)
+        .attr("viewBox", [0, 0, width, height])
+        .style("overflow", "visible")
+        .style("display", "block");
+
+    let tickAdjust = g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height);
+    let x;
+
+    // Continuous
+    if (color.interpolate) {
+        const n = Math.min(color.domain().length, color.range().length);
+
+        x = color.copy().rangeRound(d3.quantize(d3.interpolate(marginLeft, width - marginRight), n));
+
+        svg.append("image")
+            .attr("x", marginLeft)
+            .attr("y", marginTop)
+            .attr("width", width - marginLeft - marginRight)
+            .attr("height", height - marginTop - marginBottom)
+            .attr("preserveAspectRatio", "none")
+            .attr("xlink:href", ramp(color.copy().domain(d3.quantize(d3.interpolate(0, 1), n))).toDataURL());
+    }
+
+    // Sequential
+    else if (color.interpolator) {
+        x = Object.assign(color.copy()
+                .interpolator(d3.interpolateRound(marginLeft, width - marginRight)),
+            {range() { return [marginLeft, width - marginRight]; }});
+
+        svg.append("image")
+            .attr("x", marginLeft)
+            .attr("y", marginTop)
+            .attr("width", width - marginLeft - marginRight)
+            .attr("height", height - marginTop - marginBottom)
+            .attr("preserveAspectRatio", "none")
+            .attr("xlink:href", ramp(color.interpolator()).toDataURL());
+
+        // scaleSequentialQuantile doesn’t implement ticks or tickFormat.
+        if (!x.ticks) {
+            if (tickValues === undefined) {
+                const n = Math.round(ticks + 1);
+                tickValues = d3.range(n).map(i => d3.quantile(color.domain(), i / (n - 1)));
+            }
+            if (typeof tickFormat !== "function") {
+                tickFormat = d3.format(tickFormat === undefined ? ",f" : tickFormat);
+            }
+        }
+    }
+
+    // Threshold
+    else if (color.invertExtent) {
+        const thresholds
+            = color.thresholds ? color.thresholds() // scaleQuantize
+            : color.quantiles ? color.quantiles() // scaleQuantile
+                : color.domain(); // scaleThreshold
+
+        const thresholdFormat
+            = tickFormat === undefined ? d => d
+            : typeof tickFormat === "string" ? d3.format(tickFormat)
+                : tickFormat;
+
+        x = d3.scaleLinear()
+            .domain([-1, color.range().length - 1])
+            .rangeRound([marginLeft, width - marginRight]);
+
+        svg.append("g")
+            .selectAll("rect")
+            .data(color.range())
+            .join("rect")
+            .attr("x", (d, i) => x(i - 1))
+            .attr("y", marginTop)
+            .attr("width", (d, i) => x(i) - x(i - 1))
+            .attr("height", height - marginTop - marginBottom)
+            .attr("fill", d => d);
+
+        tickValues = d3.range(thresholds.length);
+        tickFormat = i => thresholdFormat(thresholds[i], i);
+    }
+
+    // Ordinal
+    else {
+        x = d3.scaleBand()
+            .domain(color.domain())
+            .rangeRound([marginLeft, width - marginRight]);
+
+        svg.append("g")
+            .selectAll("rect")
+            .data(color.domain())
+            .join("rect")
+            .attr("x", x)
+            .attr("y", marginTop)
+            .attr("width", Math.max(0, x.bandwidth() - 1))
+            .attr("height", height - marginTop - marginBottom)
+            .attr("fill", color);
+
+        tickAdjust = () => {};
+    }
+
+    svg.append("g")
+        .attr("transform", `translate(0,${height - marginBottom})`)
+        .call(d3.axisBottom(x)
+            .ticks(ticks, typeof tickFormat === "string" ? tickFormat : undefined)
+            .tickFormat(typeof tickFormat === "function" ? tickFormat : undefined)
+            .tickSize(tickSize)
+            .tickValues(tickValues))
+        .call(tickAdjust)
+        .call(g => g.select(".domain").remove())
+        .call(g => g.append("text")
+            .attr("x", marginLeft)
+            .attr("y", marginTop + marginBottom - height - 6)
+            .attr("fill", "currentColor")
+            .attr("text-anchor", "start")
+            .attr("font-weight", "bold")
+            .attr("class", "title")
+            .text(title));
+
+    return svg.node();
+}
\ No newline at end of file
diff --git a/src/main.ts b/src/main.ts
index 77956e5a9ea734085ba500fde3e457468c3b2ad2..ee7c2944ac51aecd0fdf1866e6bd9e90052e915a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -103,7 +103,7 @@ dsv(';', 'data/output.csv').then(data => {
         },
         onClick: (dataPoint: any) => {
             updateSelectedNodes(dataPoint)
-        }
+        },
     })
 
     bubbleChart.renderVis()