From 6e411107953776bd88344b54264af8a40b9077c8 Mon Sep 17 00:00:00 2001
From: Leander <leander.gerwing@gmail.com>
Date: Mon, 19 Aug 2024 02:26:52 +0200
Subject: [PATCH] feat: dynamic group labels

---
 index.html                |   3 +-
 src/charts/bubbleChart.ts | 125 ++++++++++++++++++++-
 src/countries.ts          | 222 +++++++++++++++++++++++++++++++++++---
 src/main.ts               |   7 +-
 src/positions.ts          |  25 +++++
 src/styles/index.scss     |  44 +++-----
 src/utils.ts              |   7 ++
 7 files changed, 384 insertions(+), 49 deletions(-)
 create mode 100644 src/positions.ts
 create mode 100644 src/utils.ts

diff --git a/index.html b/index.html
index 4e87918..fe881ee 100644
--- a/index.html
+++ b/index.html
@@ -41,7 +41,8 @@
                 <label for="group-by-setting">Gruppieren nach:</label>
 
                 <select id="group-by-setting" name="group-by-setting">
-                    <option value="team" selected>Team</option>
+                    <option value="default" selected>---</option>
+                    <option value="team">Team</option>
                     <option value="nation">Nation</option>
                     <option value="pos">Position</option>
                     <option value="league">Liga</option>
diff --git a/src/charts/bubbleChart.ts b/src/charts/bubbleChart.ts
index d024527..f560ab4 100644
--- a/src/charts/bubbleChart.ts
+++ b/src/charts/bubbleChart.ts
@@ -2,6 +2,7 @@ import * as d3 from "d3";
 import {HierarchyNode, NumberValue, ScaleLinear, ScaleOrdinal, ScaleSequential} from "d3";
 import {ChartConfig, ChartConfigParam} from "@/charts/chart.ts";
 import SearchableChart from "@/charts/SearchableChart.ts";
+import {debounce} from "@/utils.ts";
 
 export type BubbleChartConfig = ChartConfig & {
     groupAccessor: (d: any) => string,
@@ -23,6 +24,8 @@ export default class BubbleChart extends SearchableChart {
     highlightedNode: HierarchyNode<any> | null = null
     colorScale: ScaleSequential<any> | ScaleOrdinal<any, any> | null = null
     sizeScale: ScaleLinear<any, number> | null = null
+    groupLabels: any | null = null
+    groupsWithLabels: any[] = []
 
     constructor(data: any[], _config: BubbleChartConfigParam) {
         super(data, _config as ChartConfigParam)
@@ -37,14 +40,14 @@ export default class BubbleChart extends SearchableChart {
     }
 
     private createConfig(_config: BubbleChartConfigParam) {
-        return  {
+        return {
             ..._config,
             parentElement: typeof _config.parentElement === 'string' ? document.querySelector(_config.parentElement) as HTMLElement : _config.parentElement,
             containerWidth: _config.containerWidth || 500,
             containerHeight: _config.containerHeight || 140,
             margin: _config.margin || {top: 10, bottom: 30, right: 10, left: 30},
             tooltipPadding: _config.tooltipPadding || 15,
-            groupAccessor: _config.groupAccessor || (() => 'default'),
+            groupAccessor: _config.groupAccessor || (() => null),
             sizeAccessor: _config.sizeAccessor || (() => 5),
             colorAccessor: _config.colorAccessor || (() => null),
             zoomExtent: _config.zoomExtent || [0.5, 20],
@@ -99,6 +102,7 @@ export default class BubbleChart extends SearchableChart {
 
         const node = vis.chart
             .selectAll("g")
+            .filter('[bubble]')
             .data(vis.packRoot?.leaves())
             .join(
                 (enter: any) => enter.append("g"),
@@ -126,6 +130,7 @@ export default class BubbleChart extends SearchableChart {
             .selectAll("g")
             .data(vis.packRoot?.leaves())
             .join("g")
+            .attr("bubble", true)
             .attr("transform", (d: any) => `translate(${d.x},${d.y})`)
 
         node
@@ -149,6 +154,111 @@ export default class BubbleChart extends SearchableChart {
             })
     }
 
+    private renderGroupLabels() {
+        this.groupLabels = this.chart.selectAll('g')
+            .filter('[group]')
+            .data(this.groupsWithLabels)
+            .join(
+                (enter: any) => {
+                    const enterGroup = enter.append("g")
+                        .attr('opacity', 0)
+                    enterGroup
+                        .transition()
+                        .delay(1000)
+                        .duration(1000)
+                        .attr('opacity', 1)
+                    return enterGroup
+                },
+                (update: any) => {
+                    update.selectAll('*').remove()
+                    return update
+                },
+                (exit: any) => exit.transition(300)
+                    .attr('opacity', 0)
+                    .remove()
+            )
+            .attr('group', true)
+            .attr("transform", (d: any) => `translate(${d.x},${d.y})`)
+            .on('mouseover', (event: Event, d: any) => {
+                // hide label
+                d3.select(event.target.closest('g'))
+                    .selectAll("*")
+                    .transition()
+                    .duration(100)
+                    .attr('opacity', 0)
+            })
+            .on('mouseleave', (event: Event) => {
+                // show label
+                d3.select(event.target.closest('g'))
+                    .selectAll("*")
+                    .transition()
+                    .duration(100)
+                    .attr('opacity', 1)
+                    .attr('width', (d: any) => d.bbox.width + 10)
+            })
+
+        this.groupLabels.transition()
+            .delay(1000)
+            .duration(1000)
+            .attr('opacity', 1)
+
+        const rects = this.groupLabels
+            .selectAll('rect')
+            .filter((d: any) => d.data[0])
+            .data((d: any) => [d])
+            .join(
+                enter => enter.append("rect"),
+                update => update,
+                exit => exit.remove()
+            )
+            .attr('fill', 'white')
+            .attr('stroke', 'black')
+            .attr('stroke-width', 1 / this.zoomLevel)
+            .attr('rx', 5 / this.zoomLevel)
+            .attr('ry', 5 / this.zoomLevel)
+
+        this.groupLabels
+            .selectAll('text')
+            .data((d: any) => [d])
+            .join(
+                enter => enter.append("text"),
+                update => update,
+                exit => exit.remove()
+            )
+            .attr('text-anchor', 'middle')
+            .attr('alignment-baseline', 'middle')
+            .attr('font-size', 12 / this.zoomLevel)
+            .attr('fill', 'black')
+            .text(d => d.data[0])
+            .call((selection: any) => selection.each(
+                function (d: any) {
+                    d.bbox = this.getBBox()
+                }
+            ))
+        rects.attr('x', (d: any) => d.bbox.x - 5)
+            .attr('y', (d: any) => d.bbox.y - 5)
+            .attr('width', (d: any) => d.bbox.width + 10)
+            .attr('height', (d: any) => d.bbox.height + 10)
+    }
+
+    private updateGroupsWithLabels = debounce(() => {
+        if (!this.packRoot) {
+            this.groupsWithLabels = []
+            return
+        }
+        const groups = this.packRoot.descendants()
+            .filter(group =>
+                group.children &&
+                group.r >= (50 / this.zoomLevel) &&
+                group.r < (300 / (this.zoomLevel * 2.5)) &&
+                group.children.length > 5
+            )
+
+        this.groupsWithLabels = groups.length > 1 ? groups : []
+        this.renderGroupLabels()
+    }, 300)
+
+
     /**
      * Updates the d3 hierachy pack root with the current data.
      * Uses the groupAccessor and sizeAccessor to group the data.
@@ -167,6 +277,7 @@ export default class BubbleChart extends SearchableChart {
                     .sum((d: any) => this.getSizeForNode(d))
                     .sort((d: any) => this.getSizeForNode(d))
             )
+        this.updateGroupsWithLabels()
     }
 
     private getFillForNode(node: HierarchyNode<any>) {
@@ -175,7 +286,7 @@ export default class BubbleChart extends SearchableChart {
         return this.colorScale(this.config.colorAccessor(node.data) as NumberValue)
     }
 
-    private getSizeForNode(dataPoint: HierarchyNode<any>) : number {
+    private getSizeForNode(dataPoint: HierarchyNode<any>): number {
         if (!dataPoint || !this.config.sizeAccessor(dataPoint)) return 0.1
         if (!this.sizeScale) return 5
         return this.sizeScale(this.config.sizeAccessor(dataPoint) as NumberValue)
@@ -218,28 +329,31 @@ export default class BubbleChart extends SearchableChart {
                 this.data,
                 this.config.sizeAccessor as (d: any) => number | null
             ) as [number, number],
-            [0,10]
+            [0, 10]
         )
     }
 
     get zoomLevel() {
-        return this.zoom.scale()
+        return d3.zoomTransform(this.chart.node()).k
     }
 
     zoomIn() {
         this.chart.transition()
             .duration(300)
             .call(this.zoom.scaleBy, 1.3)
+        this.updateGroupsWithLabels()
     }
 
     zoomOut() {
         this.chart.transition()
             .duration(300)
             .call(this.zoom.scaleBy, 0.75)
+        this.updateGroupsWithLabels()
     }
 
     zoomTo(zoomLevel: number) {
         this.chart.call(this.zoom.scaleTo, zoomLevel)
+        this.updateGroupsWithLabels()
     }
 
     zoomToPoint(dataPoint: any) {
@@ -267,5 +381,6 @@ export default class BubbleChart extends SearchableChart {
                         )
                 )
         }
+        this.updateGroupsWithLabels()
     }
 }
\ No newline at end of file
diff --git a/src/countries.ts b/src/countries.ts
index 187ee3c..07ad61e 100644
--- a/src/countries.ts
+++ b/src/countries.ts
@@ -62,7 +62,7 @@ export const countries = [
         "name": "Argentinien",
     },
     {
-        "code": "ZAF",
+        "code": "RSA",
         "name": "Südafrika",
     },
     {
@@ -86,7 +86,7 @@ export const countries = [
         "name": "Norwegen",
     },
     {
-        "code": "DNK",
+        "code": "DEN",
         "name": "Dänemark",
     },
     {
@@ -94,7 +94,7 @@ export const countries = [
         "name": "Finnland",
     },
     {
-        "code": "CHE",
+        "code": "SUI",
         "name": "Schweiz",
     },
     {
@@ -106,7 +106,7 @@ export const countries = [
         "name": "Belgien",
     },
     {
-        "code": "NLD",
+        "code": "NED",
         "name": "Niederlande",
     },
     {
@@ -114,7 +114,7 @@ export const countries = [
         "name": "Polen",
     },
     {
-        "code": "GRC",
+        "code": "GRE",
         "name": "Griechenland",
     },
     {
@@ -130,7 +130,7 @@ export const countries = [
         "name": "Slowakei",
     },
     {
-        "code": "PRT",
+        "code": "POR",
         "name": "Portugal",
     },
     {
@@ -213,10 +213,6 @@ export const countries = [
         "code": "GHA",
         "name": "Ghana",
     },
-    {
-        "code": "DZA",
-        "name": "Algerien",
-    },
     {
         "code": "MAR",
         "name": "Marokko",
@@ -334,7 +330,7 @@ export const countries = [
         "name": "Serbien",
     },
     {
-        "code": "HRV",
+        "code": "CRO",
         "name": "Kroatien",
     },
     {
@@ -358,7 +354,7 @@ export const countries = [
         "name": "Montenegro",
     },
     {
-        "code": "KOS",
+        "code": "KVX",
         "name": "Kosovo",
     },
     {
@@ -397,4 +393,204 @@ export const countries = [
         "code": "GNB",
         "name": "Guinea-Bissau",
     },
-]
+    {
+        "code": "CIV",
+        "name": "Elfenbeinküste",
+    },
+    {
+        "code": "GAB",
+        "name": "Gabun",
+    },
+    {
+        "code": "CMR",
+        "name": "Kamerun",
+    },
+    {
+        "code": "CGO",
+        "name": "Kongo",
+    },
+    {
+        "code": "ZIM",
+        "name": "Simbabwe",
+    },
+    {
+        "code": "ZAM",
+        "name": "Sambia",
+    },
+    {
+        "code": "MOZ",
+        "name": "Mosambik",
+    },
+    {
+        "code": "MWI",
+        "name": "Malawi",
+    },
+    {
+        "code": "ANG",
+        "name": "Angola",
+    },
+    {
+        "code": "NAM",
+        "name": "Namibia",
+    },
+    {
+        "code": "BWA",
+        "name": "Botswana",
+    },
+    {
+        "code": "SWZ",
+        "name": "Swasiland",
+    },
+    {
+        "code": "LSO",
+        "name": "Lesotho",
+    },
+    {
+        "code": "LBY",
+        "name": "Libyen",
+    },
+    {
+        "code": "SDN",
+        "name": "Sudan",
+    },
+    {
+        "code": "SSD",
+        "name": "Südsudan",
+    },
+    {
+        "code": "ERI",
+        "name": "Eritrea",
+    },
+    {
+        "code": "DJI",
+        "name": "Dschibuti",
+    },
+    {
+        "code": "SOM",
+        "name": "Somalia",
+    },
+    {
+        "code": "SEN",
+        "name": "Senegal",
+    },
+    {
+        "code": "ALG",
+        "name": "Algerien",
+    },
+    {
+        "code": "URU",
+        "name": "Uruguay",
+    },
+    {
+        "code": "COL",
+        "name": "Kolumbien",
+    },
+    {
+        "code": "PER",
+        "name": "Peru",
+    },
+    {
+        "code": "VEN",
+        "name": "Venezuela",
+    },
+    {
+        "code": "ECU",
+        "name": "Ecuador",
+    },
+    {
+        "code": "BOL",
+        "name": "Bolivien",
+    },
+    {
+        "code": "PAR",
+        "name": "Paraguay",
+    },
+    {
+        "code": "GUY",
+        "name": "Guyana",
+    },
+    {
+        "code": "SUR",
+        "name": "Suriname",
+    },
+    {
+        "code": "GUF",
+        "name": "Französisch-Guayana",
+    },
+    {
+        "code": "PAN",
+        "name": "Panama",
+    },
+    {
+        "code": "ROU",
+        "name": "Rumänien",
+    },
+    {
+        "code": "LUX",
+        "name": "Luxemburg",
+    },
+    {
+        "code": "JAM",
+        "name": "Jamaika",
+    },
+    {
+        "code": "SCO",
+        "name": "Schottland",
+    },
+    {
+        "code": "MLI",
+        "name": "Mali",
+    },
+    {
+        "code": "WAL",
+        "name": "Wales",
+    },
+    {
+        "code": "BUL",
+        "name": "Bulgarien",
+    },
+    {
+        "code": "CRC",
+        "name": "Costa Rica",
+    },
+    {
+        "code": "COD",
+        "name": "Demokratische Republik Kongo",
+    },
+    {
+        "code": "NIR",
+        "name": "Nordirland",
+    },
+    {
+        "code": "CPV",
+        "name": "Kap Verde",
+    },
+    {
+        "code": "CUW",
+        "name": "Curaçao",
+    },
+    {
+        "code": "HAI",
+        "name": "Haiti",
+    },
+    {
+        "code": "GUI",
+        "name": "Guinea",
+    },
+    {
+        "code": "TOG",
+        "name": "Togo",
+    },
+    {
+        "code": "DOM",
+        "name": "Dominikanische Republik",
+    },
+    {
+        "code": "CHI",
+        "name": "Chile",
+    },
+    {
+        "code": "BFA",
+        "name": "Burkina Faso",
+    }
+]
\ No newline at end of file
diff --git a/src/main.ts b/src/main.ts
index 543ed52..02e32ef 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -4,6 +4,7 @@ import BubbleChart, {BubbleChartConfigParam} from "@/charts/bubbleChart.ts";
 import Search from "@/search.ts";
 import {countries} from "@/countries.ts";
 import {numericColumns, Player} from "@/player.ts";
+import {getPositionName, positions} from "@/positions.ts";
 
 // const radarChartWrapper = document.querySelector('#radar-chart-wrapper') as HTMLDivElement;
 const bubbleChartWrapper = document.querySelector('#bubble-chart-wrapper') as HTMLDivElement;
@@ -12,7 +13,7 @@ let bubbleChart: BubbleChart | null = null
 
 let sliderBlocked = false
 const zoomSlider = document.querySelector('#zoom-slider') as HTMLInputElement
-zoomSlider.min = zoomExtent[0].toString()
+zoomSlider.min = zoomSlider.value = zoomExtent[0].toString()
 zoomSlider.max = zoomExtent[1].toString()
 zoomSlider.step = '0.1'
 zoomSlider.oninput = (event) => {
@@ -55,8 +56,12 @@ dsv(';', 'data/output.csv').then(data => {
             for (const column of Object.keys(numericColumns)) {
                 d[column] = parseFloat(d[column])
             }
+            if (countries.findIndex(c => c.code === d.nation) === -1) {
+                console.warn('Unknown country code:', d.nation)
+            }
             return {
                 ...d,
+                pos: getPositionName(d.pos),
                 nation: countries.find(c => c.code === d.nation)?.name ?? d.nation,
             } as Player
         }
diff --git a/src/positions.ts b/src/positions.ts
new file mode 100644
index 0000000..cf93df4
--- /dev/null
+++ b/src/positions.ts
@@ -0,0 +1,25 @@
+export const positions = [
+    {
+        "pos": "FW",
+        "name": "Stürmer"
+    },
+    {
+        "pos": "MF",
+        "name": "Mittelfeldspieler"
+    },
+    {
+        "pos": "DF",
+        "name": "Verteidiger"
+    },
+    {
+        "pos": "GK",
+        "name": "Torwart"
+    }
+]
+
+export const getPositionName = (pos: string) => {
+    const splitPositions = pos.split(',')
+    return splitPositions
+        .map(p => positions.find(c => c.pos === p)?.name ?? p)
+        .join(', ')
+}
\ No newline at end of file
diff --git a/src/styles/index.scss b/src/styles/index.scss
index dd493ad..8649011 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -14,36 +14,16 @@
   -20px -20px 60px #ffffff;
 }
 
+@mixin glass {
+  background: rgba(255, 255, 255, 0.5);
+  box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
+  backdrop-filter: blur(10px);
+}
+
 body {
   font-family: Segoe UI, sans-serif;
   background-color: var(--quaternary);
   color: var(--text);
-
-  /*&::before {
-    content: '';
-    position: fixed;
-    top: 10%;
-    right: 0;
-    width: 10rem;
-    height: 10rem;
-    background-image: radial-gradient(circle, var(--tertiary), var(--secondary));
-    filter: blur(125px);
-    z-index: -1;
-    animation: shimmer 10s infinite;
-  }
-
-  &::after {
-    content: '';
-    position: fixed;
-    bottom: 10%;
-    left: 0;
-    width: 10rem;
-    height: 10rem;
-    background-image: radial-gradient(circle, var(--tertiary), var(--secondary));
-    filter: blur(125px);
-    z-index: -1;
-    animation: shimmer 10s infinite;
-  }*/
 }
 
 h1 {
@@ -75,6 +55,14 @@ h1 {
   z-index: 10;
 }
 
+.groupLabel {
+  font-size: 1rem;
+  font-weight: bold;
+  text-align: center;
+  padding: .25rem;
+  @include glass;
+}
+
 #app {
   display: flex;
   gap: 1rem;
@@ -106,9 +94,7 @@ h1 {
       align-items: flex-end;
       justify-content: flex-end;
       gap: 1rem;
-      background: rgba(255, 255, 255, 0.5);
-      box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
-      backdrop-filter: blur(10px);
+      @include glass;
 
       > div {
         align-items: center;
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..f55009a
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,7 @@
+export function debounce(func: Function, timeout = 300){
+    let timer: number | undefined;
+    return (...args: any[]) => {
+        clearTimeout(timer);
+        timer = setTimeout(() => { func.apply(this, args); }, timeout);
+    };
+}
\ No newline at end of file
-- 
GitLab