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