From 2b77496cc5c2a59a6a57d9a751127f51d9f4e061 Mon Sep 17 00:00:00 2001
From: Leander <leander.gerwing@gmail.com>
Date: Tue, 10 Sep 2024 20:11:20 +0200
Subject: [PATCH] feat: color

---
 src/charts/bubbleChart.ts |  52 +++++++------
 src/charts/radarChart.ts  | 156 +++++++++++++++++++-------------------
 src/main.ts               |  15 +++-
 src/styles/index.scss     |   1 -
 4 files changed, 117 insertions(+), 107 deletions(-)

diff --git a/src/charts/bubbleChart.ts b/src/charts/bubbleChart.ts
index 85794e4..d398f91 100644
--- a/src/charts/bubbleChart.ts
+++ b/src/charts/bubbleChart.ts
@@ -5,9 +5,10 @@ import SearchableChart from "@/charts/SearchableChart.ts";
 import {debounce} from "@/utils.ts";
 
 export type BubbleChartConfig = ChartConfig & {
-    groupAccessor: (d: any) => string,
+    groupAccessor: (d: any) => string | null,
     sizeAccessor: (d: any) => number,
     colorAccessor: (d: any) => string | number | null,
+    idAccessor: (d: any) => any,
     zoomExtent: [number, number],
     onZoom?: (event: any) => void,
     renderTooltip?: (dataPoint: any, tooltip: d3.Selection<d3.BaseType, unknown, HTMLElement, any>) => void,
@@ -47,10 +48,11 @@ export default class BubbleChart extends SearchableChart {
             containerWidth: _config.containerWidth || 500,
             containerHeight: _config.containerHeight || 140,
             margin: _config.margin || {top: 10, bottom: 30, right: 10, left: 30},
-            tooltipPadding: _config.tooltipPadding || 15,
+            tooltipPadding: _config.tooltipPadding || 30,
             groupAccessor: _config.groupAccessor || (() => null),
             sizeAccessor: _config.sizeAccessor || (() => 5),
             colorAccessor: _config.colorAccessor || (() => null),
+            idAccessor: _config.idAccessor || (() => null),
             zoomExtent: _config.zoomExtent || [0.5, 20],
             renderTooltip: _config.renderTooltip || (() => null),
             onClick: _config.onClick || (() => null),
@@ -106,7 +108,7 @@ export default class BubbleChart extends SearchableChart {
         const node = vis.chart
             .selectAll("g")
             .filter('[bubble]')
-            .data(vis.packRoot?.leaves())
+            .data(vis.packRoot?.leaves(), (d: any) => vis.config.idAccessor(d.data))
             .join(
                 (enter: any) => enter.append("g"),
                 (update: any) => update
@@ -131,7 +133,7 @@ export default class BubbleChart extends SearchableChart {
 
         const node = vis.chart
             .selectAll("g")
-            .data(vis.packRoot?.leaves())
+            .data(vis.packRoot?.leaves(), (d: any) => vis.config.idAccessor(d.data))
             .join("g")
             .attr("bubble", true)
             .attr("transform", (d: any) => `translate(${d.x},${d.y})`)
@@ -140,7 +142,7 @@ export default class BubbleChart extends SearchableChart {
             .append('circle')
             .attr('fill', (d: any) => vis.getFillForNode(d))
             .attr('r', (d: any) => d.r)
-            .attr('data-bubble', (d: any) => d.data.player) // TODO make label accessor
+            .attr('data-bubble', (d: any) => vis.config.idAccessor(d.data))
             .on('mouseover', (_: Event, d: any) => {
                 const element = d3.select('#tooltip')
                     .style('display', 'block')
@@ -164,10 +166,10 @@ export default class BubbleChart extends SearchableChart {
         d3.selectAll('circle[data-bubble]')
             .attr('stroke', 'none')
         for (const node of nodes) {
-            const playerNode = this.getPlayerNode(node.player)
+            const playerNode = this.getNode(node)
             playerNode
-                .attr('stroke', 'black')
-                .attr('stroke-width', 2 / this.zoomLevel)
+                .attr('stroke', node._color || "#333")
+                .attr('stroke-width', 3)
         }
     }
 
@@ -229,7 +231,7 @@ export default class BubbleChart extends SearchableChart {
                 exit => exit.remove()
             )
             .attr('fill', 'white')
-            .attr('stroke', 'black')
+            .attr('stroke', '#333')
             .attr('stroke-width', 1 / this.zoomLevel)
             .attr('rx', 5 / this.zoomLevel)
             .attr('ry', 5 / this.zoomLevel)
@@ -265,9 +267,9 @@ export default class BubbleChart extends SearchableChart {
         }
         const groups = this.packRoot.descendants()
             .filter(group =>
-                group.children &&
-                group.r >= (50 / this.zoomLevel) &&
-                group.r < (300 / (this.zoomLevel * 2.5)) &&
+                group.children && group.value &&
+                group.value >= (50 / this.zoomLevel) &&
+                group.value < (300 / (this.zoomLevel * 2.5)) &&
                 group.children.length > 5
             )
 
@@ -286,14 +288,13 @@ export default class BubbleChart extends SearchableChart {
             this.data,
             (d: any) => this.config.groupAccessor(d)
         )
+        const hierarchy = (d3.hierarchy(groupedData) as HierarchyNode<any>)
+            .sum((d: any) => this.getSizeForNode(d))
+            .sort((a: any, b: any) => d3.descending(a.value, b.value))
         this.packRoot = d3.pack()
             .size([this.width(), this.height()])
             .padding(2)
-            (
-                (d3.hierarchy(groupedData) as HierarchyNode<any>)
-                    .sum((d: any) => this.getSizeForNode(d))
-                    .sort((d: any) => this.getSizeForNode(d))
-            )
+            (hierarchy)
         this.updateGroupsWithLabels()
     }
 
@@ -313,7 +314,7 @@ export default class BubbleChart extends SearchableChart {
         if (!input) return
         if (!input || !this.packRoot) return
         const result = this.packRoot.leaves()
-            .find((d: any) => d.data.player.toLowerCase().includes(input.toLowerCase()))
+            .find((d: any) => this.config.idAccessor(d.data).toLowerCase().includes(input.toLowerCase()))
         if (result) {
             this.zoomToPoint(result)
         }
@@ -323,9 +324,12 @@ export default class BubbleChart extends SearchableChart {
         // The color scale is either a sequential scale or an ordinal scale
         // depending on what type the colorAccessor returns
         const sampleValue = this.config.colorAccessor(this.data[0])
-        if (!sampleValue) return () => 'var(--primary)'
+        if (!sampleValue) {
+            this.colorScale = null
+            return
+        }
         if (typeof sampleValue === 'number') {
-            this.colorScale = d3.scaleSequential(d3.interpolateBlues)
+            this.colorScale = d3.scaleSequential(d3.interpolateCool)
                 .domain(
                     d3.extent(
                         this.data,
@@ -376,12 +380,12 @@ export default class BubbleChart extends SearchableChart {
     zoomToPoint(dataPoint: any) {
         if (dataPoint) {
             if (this.highlightedNode) {
-                this.getPlayerNode(this.highlightedNode.data.player)
+                this.getNode(this.highlightedNode.data)
                     .transition()
                     .duration(700)
                     .attr('fill', (d: any) => this.getFillForNode(d))
             }
-            this.getPlayerNode(dataPoint.data.player)
+            this.getNode(dataPoint.data)
                 .transition()
                 .duration(700)
                 .attr('fill', 'var(--secondary)')
@@ -401,7 +405,7 @@ export default class BubbleChart extends SearchableChart {
         this.updateGroupsWithLabels()
     }
 
-    private getPlayerNode(playerName: string) {
-        return d3.select(`circle[data-bubble="${playerName}"]`);
+    private getNode(data: any) {
+        return d3.select(`circle[data-bubble="${this.config.idAccessor(data)}"]`);
     }
 }
\ No newline at end of file
diff --git a/src/charts/radarChart.ts b/src/charts/radarChart.ts
index b09809f..109cb7f 100644
--- a/src/charts/radarChart.ts
+++ b/src/charts/radarChart.ts
@@ -3,14 +3,19 @@ import {ScaleLinear} from "d3";
 import Chart, {ChartConfig, ChartConfigParam} from "@/charts/chart.ts";
 
 export type RadarChartConfig = ChartConfig & {
-    selectedData: any[],
+    selectedData: RadarChartSelection[],
     renderTooltip?: (dataPoint: any, tooltip: d3.Selection<d3.BaseType, unknown, HTMLElement, any>) => void,
     axisCircles: number,
-    attributes: {key: string, label: string}[] | [],
+    attributes: { key: string, label: string }[] | [],
 }
 
 export type RadarChartConfigParam = ChartConfigParam & Partial<RadarChartConfig>
 
+type RadarChartSelection = {
+    _color: string | null | undefined,
+    [key: string]: any
+}
+
 export default class RadarChart extends Chart {
     chartId: string = 'radarChart';
     chart: any
@@ -34,7 +39,7 @@ export default class RadarChart extends Chart {
         this.config = this.createConfig(_config)
     }
 
-    private createConfig(_config: RadarChartConfigParam) : RadarChartConfig {
+    private createConfig(_config: RadarChartConfigParam): RadarChartConfig {
         return {
             ..._config,
             parentElement: typeof _config.parentElement === 'string' ? document.querySelector(_config.parentElement) as HTMLElement : _config.parentElement,
@@ -79,54 +84,22 @@ export default class RadarChart extends Chart {
         }
     }
 
-    updateVis(selectedData: any[]): void {
-        const vis = this;
-        const dataWrapper = vis.chart.select('.dataWrapper')
+    updateVis(selectedData: RadarChartSelection[]): void {
         this.config.selectedData = selectedData;
 
-        const preparedData = vis.getPreparedData()
-        const data = dataWrapper.selectAll('.data')
-            .data(preparedData)
-            .join(
-                enter => enter.append("g"),
-                update => update,
-                exit => exit.remove()
-            )
-            .attr("class", "data")
-
-        data.append("path")
-            .attr("d", (d: any) => {
-                const data = d.map((d: any) => d.value)
-                return d3.lineRadial()
-                    .angle((_, index) => Math.PI * 2 / vis.axes.length * index)
-                    .radius((value) => value || 0)
-                    .curve(d3.curveCardinalClosed.tension(0.6))
-                    ([...data, data[0]])
-            })
-            .attr('fill', '#69b3a211')
-            .attr('stroke', 'black')
-
-        data.selectAll('.dataPoint')
-            .data((d: any) => d)
-            .join(
-                enter => enter.append("circle"),
-                update => update,
-                exit => exit.remove()
-            )
-            .attr("class", "dataPoint")
-            .attr("r", 3)
-            .attr("cx", (data: {label: string, value: number}, index: number) => Math.sin(2 * Math.PI * (index/vis.axes.length)) * data.value)
-            .attr("cy", (data: {label: string, value: number}, index: number) => -Math.cos(2 * Math.PI * (index/vis.axes.length)) * data.value)
-            .attr('fill', 'black')
+        this.drawData();
     }
 
-    private getPreparedData() {
+    private getPreparedData(): { data: RadarChartSelection, axesValues: { label: string, value: number }[] }[] {
         return this.config.selectedData.map(
-            (d: any) => this.axes.map(axis => {
-                return {
-                    label: axis.label,
-                    value: axis.scale(d[axis.key])
-                }
+            (d: any) => ({
+                data: d,
+                axesValues: this.axes.map(axis => (
+                    {
+                        label: axis.label,
+                        value: axis.scale(d[axis.key])
+                    }
+                ))
             })
         );
     }
@@ -160,52 +133,79 @@ export default class RadarChart extends Chart {
                 ([[0, 0], [Math.PI * 2 * index / vis.axes.length, this.axisLength]])
             )
         axes.append('text')
-            .attr("x", (_: any, index: number) => Math.sin(2 * Math.PI * (index/vis.axes.length)) * (this.axisLength + 10))
-            .attr("y", (_: any, index: number) => -Math.cos(2 * Math.PI * (index/vis.axes.length)) * (this.axisLength + 10))
+            .attr("x", (_: any, index: number) => Math.sin(2 * Math.PI * (index / vis.axes.length)) * (this.axisLength + 10))
+            .attr("y", (_: any, index: number) => -Math.cos(2 * Math.PI * (index / vis.axes.length)) * (this.axisLength + 10))
             .attr('text-anchor', 'middle')
             .attr('alignment-baseline', 'middle')
             .attr('font-size', 12)
             .attr('fill', 'black')
             .text((d: any) => d.label)
 
-        const dataWrapper = vis.chart.append("g")
-            .attr("class", "dataWrapper")
-            .attr('transform', `translate(${vis.chartCenter},${vis.chartCenter})`)
+        this.drawData();
+    }
 
-        const preparedData = vis.getPreparedData()
+    private drawData() {
+        let dataWrapper = this.chart.selectAll(".dataWrapper")
+        if (dataWrapper.empty()) {
+            dataWrapper = this.chart.append("g")
+                .attr("class", "dataWrapper")
+                .attr('transform', `translate(${this.chartCenter},${this.chartCenter})`)
+        }
+
+        const preparedData = this.getPreparedData()
 
         const data = dataWrapper.selectAll('.data')
             .data(preparedData)
             .join(
-                enter => enter.append("g"),
+                (enter: any) => {
+                    const data = enter.append("g")
+                        .attr("class", "data")
+                    data
+                        .append("path")
+                        .attr("d", (d: any) => {
+                            const data = d.axesValues.map((d: any) => d.value)
+                            return d3.lineRadial()
+                                .angle((_, index) => Math.PI * 2 / this.axes.length * index)
+                                .radius((value) => value || 0)
+                                .curve(d3.curveCardinalClosed.tension(0.6))
+                                ([...data, data[0]])
+                        })
+                        .attr('fill', (d: any) => {
+                            const color = d3.color(d.data._color)
+                            if (!color) {
+                                return "rgba(50,50,50,0.1)"
+                            }
+                            return color.copy({opacity: 0.2}).toString()
+                        })
+                        .attr('stroke', (d: any) => d.data._color)
+                        .attr('stroke-width', 3)
+
+                    data.selectAll('.dataPoint')
+                        .data((d: any) => d.axesValues.map((value: any) => ({
+                            ...value,
+                            data: d.data,
+                        })))
+                        .join(
+                            enter => enter.append("circle"),
+                            update => update,
+                            exit => exit.remove()
+                        )
+                        .attr("class", "dataPoint")
+                        .attr("r", 5)
+                        .attr("cx", (data: {
+                            label: string,
+                            value: number
+                        }, index: number) => Math.sin(2 * Math.PI * (index / this.axes.length)) * data.value)
+                        .attr("cy", (data: {
+                            label: string,
+                            value: number
+                        }, index: number) => -Math.cos(2 * Math.PI * (index / this.axes.length)) * data.value)
+                        .attr('fill', (d: any) => d.data._color)
+                },
                 update => update,
                 exit => exit.remove()
             )
-            .attr("class", "data")
-
-        data.append("path")
-            .attr("d", (d: any) => {
-                const data = d.map((d: any) => d.value)
-                return d3.lineRadial()
-                    .angle((_, index) => Math.PI * 2 / vis.axes.length * index)
-                    .radius((value) => value || 0)
-                    .curve(d3.curveCardinalClosed.tension(0.6))
-                    ([...data, data[0]])
-            })
-            .attr('fill', '#69b3a211')
-            .attr('stroke', 'black')
 
-        data.selectAll('.dataPoint')
-            .data((d: any) => d)
-            .join(
-                enter => enter.append("circle"),
-                update => update,
-                exit => exit.remove()
-            )
-            .attr("class", "dataPoint")
-            .attr("r", 3)
-            .attr("cx", (data: {label: string, value: number}, index: number) => Math.sin(2 * Math.PI * (index/vis.axes.length)) * data.value)
-            .attr("cy", (data: {label: string, value: number}, index: number) => -Math.cos(2 * Math.PI * (index/vis.axes.length)) * data.value)
-            .attr('fill', 'black')
+
     }
 }
\ No newline at end of file
diff --git a/src/main.ts b/src/main.ts
index a963861..77956e5 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,6 +6,7 @@ import {countries} from "@/countries.ts";
 import {numericColumns, Player} from "@/player.ts";
 import {getPositionName} from "@/positions.ts";
 import RadarChart from "@/charts/radarChart.ts";
+import {schemeTableau10} from "d3";
 
 const radarChartWrapper = document.querySelector('#radar-chart-wrapper') as HTMLDivElement;
 const bubbleChartWrapper = document.querySelector('#bubble-chart-wrapper') as HTMLDivElement;
@@ -58,7 +59,7 @@ sizeBySetting.oninput = (event) => {
         bubbleChart.updateVisConfig({sizeAccessor: (d: any) => d[event.target?.value] ?? 0} as BubbleChartConfigParam)
     }
 }
-const selectedNodes: Player[] = []
+const selectedNodes: (Player & { _color: string })[] = []
 
 dsv(';', 'data/output.csv').then(data => {
     parsedData = data.map((d: any) => {
@@ -74,7 +75,7 @@ dsv(';', 'data/output.csv').then(data => {
                 nation: countries.find(c => c.code === d.nation)?.name ?? d.nation,
             } as Player
         }
-    )
+    ).splice(0, 1000)
 
     bubbleChart = new BubbleChart(parsedData, {
         parentElement: bubbleChartWrapper,
@@ -82,7 +83,7 @@ dsv(';', 'data/output.csv').then(data => {
         containerHeight: 1000,
         margin: {top: 20, right: 20, bottom: 20, left: 20},
         zoomExtent,
-        selectedNodes,
+        idAccessor: (d: any) => d.player,
         onZoom: (event) => {
             if (sliderBlocked) return
             zoomSlider.value = event.transform.k.toString()
@@ -124,10 +125,16 @@ function updateZoomLevel(to: '+' | '-') {
 }
 
 function updateSelectedNodes(node: Player) {
+    if (selectedNodes.length > 6) {
+        alert('Maximal 6 Spieler können ausgewählt werden')
+    }
     if (selectedNodes.some(n => n.player === node.player)) {
         selectedNodes.splice(selectedNodes.findIndex((n: Player) => n.player === node.player), 1)
     } else {
-        selectedNodes.push(node)
+        selectedNodes.push({
+            ...node,
+            _color: schemeTableau10[selectedNodes.length]
+        })
     }
     bubbleChart?.updateSelectedNodes(selectedNodes)
     if (!radarChart) {
diff --git a/src/styles/index.scss b/src/styles/index.scss
index fc12d89..0254b58 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -81,7 +81,6 @@ h1 {
       position: relative;
       margin: 1rem auto;
       width: fit-content;
-      box-shadow: 0 0 20px rgba(51, 51, 51, 0.5);
       padding: 1rem;
     }
 
-- 
GitLab