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