From abc03393dd81224218c054c23ccb5634541f9544 Mon Sep 17 00:00:00 2001
From: Leander <leander.gerwing@gmail.com>
Date: Sun, 7 Jul 2024 03:56:48 +0200
Subject: [PATCH] feat: use pack for bubble chart creation, add search and
 highlight

---
 index.html                    |  39 ++++---
 src/charts/SearchableChart.ts |   9 ++
 src/charts/bubbleChart.ts     | 190 ++++++++++++++++++----------------
 src/main.ts                   |  11 +-
 src/search.ts                 |  22 ++++
 src/styles/index.scss         |  21 +++-
 6 files changed, 177 insertions(+), 115 deletions(-)
 create mode 100644 src/charts/SearchableChart.ts
 create mode 100644 src/search.ts

diff --git a/index.html b/index.html
index 599c69f..d8e6cf3 100644
--- a/index.html
+++ b/index.html
@@ -1,31 +1,36 @@
 <!doctype html>
 <html lang="en">
-  <head>
-    <meta charset="UTF-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<head>
+    <meta charset="UTF-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
     <title>Scouting tool</title>
-  </head>
-  <body>
-    <h1>Scouting tool</h1>
-    <div id="app">
-      <div class="left">
-        <input type="text" placeholder="Spieler suchen" />
+</head>
+<body>
+<h1>Scouting tool</h1>
+<div id="app">
+    <div class="left">
+        <div class="search-wrapper">
+            <label for="search">Spieler suchen:</label>
+            <input id="search" type="text" autoComplete="on" list="player-datalist"/>
+            <button id="search-button">🔎︎</button>
+            <datalist id="player-datalist"></datalist>
+        </div>
         <div id="bubble-chart-wrapper">
 
         </div>
         <div id="zoom-wrapper">
-        <button id="zoom-out">-</button>
-        <input type="range" min="1" max="100" value="50" class="slider" id="zoom-slider" />
-        <button id="zoom-in">+</button>
+            <button id="zoom-out">-</button>
+            <input type="range" min="1" max="100" value="50" class="slider" id="zoom-slider"/>
+            <button id="zoom-in">+</button>
         </div>
-      </div>
-      <div class="right">
+    </div>
+    <div class="right">
         <h2>Vergleichen</h2>
         <div id="radar-chart-wrapper">
 
         </div>
-      </div>
     </div>
-    <script type="module" src="/src/main.ts"></script>
-  </body>
+</div>
+<script type="module" src="/src/main.ts"></script>
+</body>
 </html>
diff --git a/src/charts/SearchableChart.ts b/src/charts/SearchableChart.ts
new file mode 100644
index 0000000..bef6421
--- /dev/null
+++ b/src/charts/SearchableChart.ts
@@ -0,0 +1,9 @@
+import Chart from "@/charts/chart.ts";
+
+export default abstract class SearchableChart extends Chart {
+    constructor(data: any, _config: any) {
+        super(data, _config);
+    }
+
+    abstract search(input: string): void
+}
\ No newline at end of file
diff --git a/src/charts/bubbleChart.ts b/src/charts/bubbleChart.ts
index dfc0e3a..e5e8b08 100644
--- a/src/charts/bubbleChart.ts
+++ b/src/charts/bubbleChart.ts
@@ -1,5 +1,7 @@
 import * as d3 from "d3";
-import Chart, {ChartConfig, ChartConfigParam} from "@/charts/chart.ts";
+import {HierarchyNode} from "d3";
+import {ChartConfig, ChartConfigParam} from "@/charts/chart.ts";
+import SearchableChart from "@/charts/SearchableChart.ts";
 
 
 export type BubbleChartConfig = ChartConfig & {
@@ -12,13 +14,13 @@ export type BubbleChartConfig = ChartConfig & {
 
 export type BubbleChartConfigParam = ChartConfigParam & Partial<BubbleChartConfig>
 
-
-
-export default class BubbleChart extends Chart {
+export default class BubbleChart extends SearchableChart {
     chartId: string = 'bubbleChart';
     chart: any
     zoom: any;
     config: BubbleChartConfig
+    packRoot: HierarchyNode<any> | null = null
+    highlightedNode: HierarchyNode<any> | null = null
 
     constructor(data: any[], _config: BubbleChartConfigParam) {
         super(data, _config as ChartConfigParam)
@@ -28,7 +30,7 @@ export default class BubbleChart extends Chart {
             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 },
+            margin: _config.margin || {top: 10, bottom: 30, right: 10, left: 30},
             tooltipPadding: _config.tooltipPadding || 15,
             groupAccessor: _config.groupAccessor || (() => 'default'),
             sizeAccessor: _config.sizeAccessor || (() => 5),
@@ -51,20 +53,18 @@ export default class BubbleChart extends Chart {
         const svg = d3.select(`#${this.chartId}`)
             .attr('width', vis.config.containerWidth)
             .attr('height', vis.config.containerHeight)
+            .attr('transform', `translate(${vis.config.margin.left},${vis.config.margin.top})`)
 
         vis.chart = svg.append('g')
-            .attr('transform', `translate(${vis.config.margin.left},${vis.config.margin.top})`)
 
         vis.zoom = d3.zoom()
-            .scaleExtent(vis.config.zoomExtent ?? [0.5, 20])
+            .scaleExtent(vis.config.zoomExtent)
             .on('zoom', (event) => {
                 vis.chart.attr('transform', event.transform);
                 vis.config.onZoom?.(event)
             });
 
-        vis.chart.append('input')
-
-        svg.call(vis.zoom);
+        vis.chart.call(vis.zoom)
     }
 
     updateVis(data: any[]): void {
@@ -74,58 +74,73 @@ export default class BubbleChart extends Chart {
     renderVis(): void {
         let vis: BubbleChart = this;
 
-        vis.config.groupAccessor = (d: any) => {
-            return d['league']
-        }
+        /*vis.config.groupAccessor = (d: any) => {
+            return d['nation']
+        }*/
 
         const chart = vis.chart
 
-        const groupedData = vis.getGroupedData()
-
-        for (const groupIndex in groupedData)
-        {
-            const group = groupedData[groupIndex]
-
-            d3.forceSimulation(group.children)
-                .force("charge", d3.forceManyBody().strength(5))
-                .force("center", d3.forceCenter(vis.width() / 2, vis.height() / 2))
-                .force("collision", d3.forceCollide().radius(d => vis.config.sizeAccessor(d) + 1))
-                .on("tick", ticked);
-
-            const nodes = chart.selectAll('circle')
-                .data(group.children)
-                .enter()
-                .append('circle')
-                .attr('fill', 'var(--primary)')
-                .attr('stroke', 'none')
-                .attr('stroke-width', 2)
-                .attr('r', (d: any) => vis.config.sizeAccessor(d))
-                .attr('cx', vis.width() / 2)
-                .attr('cy', vis.height() / 2)
-                .on('mouseover', (_: Event, d: any) => {
-                    d3.select('#tooltip')
-                        .style('display', 'block')
-                        .html(`<table>
-                            <tr><th>Name</th><td>${d['player']}</td></tr>
-                            <tr><th>Liga</th><td>${d['league']}</td></tr>
-                            <tr><th>Position</th><td>${d['pos']}</td></tr>
-                            <tr><th>Nationalität</th><td>${d['nation']}</td></tr>
-                            <tr><th>Team</th><td>${d['team']}</td></tr>
-                            </table>`);
-                })
-                .on('mousemove', (event: any) => {
-                    d3.select('#tooltip')
-                        .style('left', (event.pageX + vis.config.tooltipPadding) + 'px')
-                        .style('top', (event.pageY + vis.config.tooltipPadding) + 'px')
-                })
-                .on('mouseleave', () => {
-                    d3.select('#tooltip').style('display', 'none');
-                })
-
-            function ticked() {
-                nodes.attr("cx", (d: any) => d.x)
-                    .attr("cy", (d: any) => d.y);
-            }
+        const groupedData = d3.group(
+            vis.data,
+            (d: any) => vis.config.groupAccessor(d)
+        )
+
+
+        const pack = d3.pack()
+            .size([vis.width(), vis.height()])
+            .padding(2)
+
+        vis.packRoot = pack((d3.hierarchy(groupedData) as HierarchyNode<any>)
+            .sum((d: any) => vis.config.sizeAccessor(d)))
+
+        const node = chart
+            .selectAll("g")
+            .data(vis.packRoot.descendants())
+            .join("g")
+            .attr("transform", (d: any) => `translate(${d.x},${d.y})`)
+
+        node
+            .append('circle')
+            .attr('fill', (d:any) => d.children ? "transparent" : 'var(--primary)')
+            .attr('stroke', 'none')
+            .attr('stroke-width', 2)
+            .attr('r', (d: any) => d.r)
+            .attr('data-leaf', (d: any) => d.children ? 'false' : 'true')
+            .attr('data-player', (d: any) => d.data.player)
+            .on('mouseover', (_: Event, d: any) => {
+                vis.renderTooltip(d.data)
+            })
+            .on('mousemove', (event: any) => {
+                d3.select('#tooltip')
+                    .style('left', (event.layerX + vis.config.tooltipPadding) + 'px')
+                    .style('top', (event.layerY + vis.config.tooltipPadding) + 'px')
+            })
+            .on('mouseleave', () => {
+                d3.select('#tooltip').style('display', 'none');
+            })
+    }
+
+    renderTooltip(dataPoint: { player: string; league: string; pos: string; nation: string; team: string;}) {
+        d3.select('#tooltip')
+            .style('display', 'block')
+            .html(`
+                <table>
+                    <tr><th>Name</th><td>${dataPoint['player']}</td></tr>
+                    <tr><th>Liga</th><td>${dataPoint['league']}</td></tr>
+                    <tr><th>Position</th><td>${dataPoint['pos']}</td></tr>
+                    <tr><th>Nationalität</th><td>${dataPoint['nation']}</td></tr>
+                    <tr><th>Team</th><td>${dataPoint['team']}</td></tr>
+                </table>
+        `)
+    }
+
+    search(input: string): void {
+        if (!input) return
+        if (!input || !this.packRoot) return
+        const result = this.packRoot.leaves()
+            .find((d: any) => d.data.player.toLowerCase().includes(input.toLowerCase()))
+        if (result) {
+            this.zoomToPoint(result)
         }
     }
 
@@ -149,39 +164,30 @@ export default class BubbleChart extends Chart {
         this.chart.call(this.zoom.scaleTo, zoomLevel)
     }
 
-    private getGroupCenter(index: number, totalGroupLength: number) {
-        const angle = index * (2 * Math.PI / totalGroupLength)
-        return {
-            x: Math.cos(angle) * 100,
-            y: Math.sin(angle) * 100
-        }
-    }
-
-    private getGroupedData() : {label: string, children: []}[] {
-        if (this.isUngrouped(this.data)) {
-            return [{
-                label: '',
-                children: this.data
-            }]
-        }
-
-        return this.data.reduce((acc: [{label: string, children: any[]}], data: any) => {
-            const groupLabel = this.config.groupAccessor(data)
-
-            const groupIndex = acc.findIndex(it => it.label === data[groupLabel])
-            if (groupIndex >= 0) {
-                acc[groupIndex].children.push(data)
-            } else {
-                acc.push({
-                    label: data[this.config.groupAccessor(groupLabel)],
-                    children: [data]
-                })
+    zoomToPoint(dataPoint: any) {
+        if (dataPoint) {
+            if (this.highlightedNode) {
+                d3.select(`circle[data-player="${this.highlightedNode.data.player}"]`)
+                    .transition()
+                    .duration(700)
+                    .attr('fill', 'var(--primary)')
             }
-            return acc
-        }, [])
-    }
-
-    private isUngrouped(data: any[]): boolean {
-        return data.every((d: any) => this.config.groupAccessor(d) === 'default')
+            d3.select(`circle[data-player="${dataPoint.data.player}"]`)
+                .transition()
+                .duration(700)
+                .attr('fill', 'var(--secondary)')
+            this.highlightedNode = dataPoint
+            this.chart.transition()
+                .duration(300)
+                .call(
+                    this.zoom.transform,
+                    d3.zoomIdentity
+                        .scale(Math.max(this.config.zoomExtent[1], this.config.zoomExtent[0] / dataPoint.r))
+                        .translate(
+                            -dataPoint.x + (this.width() / (2 * this.config.zoomExtent[1])),
+                            -dataPoint.y + (this.height() / (2 * this.config.zoomExtent[1]))
+                        )
+                )
+        }
     }
 }
\ No newline at end of file
diff --git a/src/main.ts b/src/main.ts
index 4f64341..d643496 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,7 @@
 import "@/styles/index.scss"; // imports the default styles
 import {dsv} from "d3-fetch";
 import BubbleChart from "@/charts/bubbleChart.ts";
+import Search from "@/search.ts";
 
 // const radarChartWrapper = document.querySelector('#radar-chart-wrapper') as HTMLDivElement;
 const bubbleChartWrapper = document.querySelector('#bubble-chart-wrapper') as HTMLDivElement;
@@ -20,11 +21,10 @@ zoomSlider.oninput = (event) => {
 }
 
 dsv(';', 'data/Out_2.csv').then(data => {
-    const filteredData = data.slice(0,1300)
-    bubbleChart = new BubbleChart(filteredData, {
+    bubbleChart = new BubbleChart(data, {
         parentElement: bubbleChartWrapper,
-        containerWidth: 800,
-        containerHeight: 800,
+        containerWidth: 1000,
+        containerHeight: 1000,
         margin: {top: 20, right: 20, bottom: 20, left: 20},
         zoomExtent,
         onZoom: (event) => {
@@ -34,6 +34,9 @@ dsv(';', 'data/Out_2.csv').then(data => {
     })
 
     bubbleChart.renderVis()
+    const search = new Search(bubbleChart)
+
+    search.initOptions(data)
 })
 
 const zoomInButton = document.querySelector('#zoom-in') as HTMLButtonElement
diff --git a/src/search.ts b/src/search.ts
new file mode 100644
index 0000000..7fcf2e3
--- /dev/null
+++ b/src/search.ts
@@ -0,0 +1,22 @@
+import SearchableChart from "@/charts/SearchableChart.ts";
+
+export default class Search {
+    dataList: HTMLSelectElement
+    constructor(chart: SearchableChart) {
+        this.dataList = document.querySelector('#player-datalist') as HTMLSelectElement
+        const searchInput = document.querySelector('#search') as HTMLInputElement
+        searchInput.onkeydown = (e) =>
+            e.key === 'Enter' ? chart.search(searchInput.value) : null
+
+        const searchButton = document.querySelector('#search-button') as HTMLButtonElement
+        searchButton.onclick = () => chart.search(searchInput.value)
+    }
+
+    initOptions (data: any) {
+        for (const player of data) {
+            const option = document.createElement('option')
+            option.value = player['player']
+            this.dataList.appendChild(option)
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/styles/index.scss b/src/styles/index.scss
index 631fe69..063909e 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -93,14 +93,21 @@ h1 {
       gap: 1rem;
     }
   }
+
+  .left {
+    flex: 2;
+  }
+
+  .right {
+    flex: 1;
+  }
 }
 
 button {
-  padding: 0.5rem 1rem;
+  padding: 0.5rem;
   border: none;
   cursor: pointer;
   transition: transform 0.2s;
-  font-size: 1.5rem;
   background: linear-gradient(145deg, var(--primary), var(--secondary));
   color: white;
   border-radius: 0.5rem;
@@ -118,6 +125,16 @@ input[type="text"] {
   border: 1px solid var(--text);
 }
 
+.search-wrapper {
+    margin-bottom: 1rem;
+}
+
+@media (max-width: 1536px) {
+  #app {
+    flex-direction: column;
+  }
+}
+
 @keyframes shimmer {
   0% {
     filter: blur(70px);
-- 
GitLab