Skip to content
Snippets Groups Projects
Commit abc03393 authored by Leander's avatar Leander
Browse files

feat: use pack for bubble chart creation, add search and highlight

parent 1c8909b6
No related branches found
No related tags found
No related merge requests found
...@@ -9,7 +9,12 @@ ...@@ -9,7 +9,12 @@
<h1>Scouting tool</h1> <h1>Scouting tool</h1>
<div id="app"> <div id="app">
<div class="left"> <div class="left">
<input type="text" placeholder="Spieler suchen" /> <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 id="bubble-chart-wrapper">
</div> </div>
......
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
import * as d3 from "d3"; 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 & { export type BubbleChartConfig = ChartConfig & {
...@@ -12,13 +14,13 @@ export type BubbleChartConfig = ChartConfig & { ...@@ -12,13 +14,13 @@ export type BubbleChartConfig = ChartConfig & {
export type BubbleChartConfigParam = ChartConfigParam & Partial<BubbleChartConfig> export type BubbleChartConfigParam = ChartConfigParam & Partial<BubbleChartConfig>
export default class BubbleChart extends SearchableChart {
export default class BubbleChart extends Chart {
chartId: string = 'bubbleChart'; chartId: string = 'bubbleChart';
chart: any chart: any
zoom: any; zoom: any;
config: BubbleChartConfig config: BubbleChartConfig
packRoot: HierarchyNode<any> | null = null
highlightedNode: HierarchyNode<any> | null = null
constructor(data: any[], _config: BubbleChartConfigParam) { constructor(data: any[], _config: BubbleChartConfigParam) {
super(data, _config as ChartConfigParam) super(data, _config as ChartConfigParam)
...@@ -51,20 +53,18 @@ export default class BubbleChart extends Chart { ...@@ -51,20 +53,18 @@ export default class BubbleChart extends Chart {
const svg = d3.select(`#${this.chartId}`) const svg = d3.select(`#${this.chartId}`)
.attr('width', vis.config.containerWidth) .attr('width', vis.config.containerWidth)
.attr('height', vis.config.containerHeight) .attr('height', vis.config.containerHeight)
.attr('transform', `translate(${vis.config.margin.left},${vis.config.margin.top})`)
vis.chart = svg.append('g') vis.chart = svg.append('g')
.attr('transform', `translate(${vis.config.margin.left},${vis.config.margin.top})`)
vis.zoom = d3.zoom() vis.zoom = d3.zoom()
.scaleExtent(vis.config.zoomExtent ?? [0.5, 20]) .scaleExtent(vis.config.zoomExtent)
.on('zoom', (event) => { .on('zoom', (event) => {
vis.chart.attr('transform', event.transform); vis.chart.attr('transform', event.transform);
vis.config.onZoom?.(event) vis.config.onZoom?.(event)
}); });
vis.chart.append('input') vis.chart.call(vis.zoom)
svg.call(vis.zoom);
} }
updateVis(data: any[]): void { updateVis(data: any[]): void {
...@@ -74,58 +74,73 @@ export default class BubbleChart extends Chart { ...@@ -74,58 +74,73 @@ export default class BubbleChart extends Chart {
renderVis(): void { renderVis(): void {
let vis: BubbleChart = this; let vis: BubbleChart = this;
vis.config.groupAccessor = (d: any) => { /*vis.config.groupAccessor = (d: any) => {
return d['league'] return d['nation']
} }*/
const chart = vis.chart const chart = vis.chart
const groupedData = vis.getGroupedData() const groupedData = d3.group(
vis.data,
(d: any) => vis.config.groupAccessor(d)
)
for (const groupIndex in groupedData) const pack = d3.pack()
{ .size([vis.width(), vis.height()])
const group = groupedData[groupIndex] .padding(2)
d3.forceSimulation(group.children) vis.packRoot = pack((d3.hierarchy(groupedData) as HierarchyNode<any>)
.force("charge", d3.forceManyBody().strength(5)) .sum((d: any) => vis.config.sizeAccessor(d)))
.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') const node = chart
.data(group.children) .selectAll("g")
.enter() .data(vis.packRoot.descendants())
.join("g")
.attr("transform", (d: any) => `translate(${d.x},${d.y})`)
node
.append('circle') .append('circle')
.attr('fill', 'var(--primary)') .attr('fill', (d:any) => d.children ? "transparent" : 'var(--primary)')
.attr('stroke', 'none') .attr('stroke', 'none')
.attr('stroke-width', 2) .attr('stroke-width', 2)
.attr('r', (d: any) => vis.config.sizeAccessor(d)) .attr('r', (d: any) => d.r)
.attr('cx', vis.width() / 2) .attr('data-leaf', (d: any) => d.children ? 'false' : 'true')
.attr('cy', vis.height() / 2) .attr('data-player', (d: any) => d.data.player)
.on('mouseover', (_: Event, d: any) => { .on('mouseover', (_: Event, d: any) => {
d3.select('#tooltip') vis.renderTooltip(d.data)
.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) => { .on('mousemove', (event: any) => {
d3.select('#tooltip') d3.select('#tooltip')
.style('left', (event.pageX + vis.config.tooltipPadding) + 'px') .style('left', (event.layerX + vis.config.tooltipPadding) + 'px')
.style('top', (event.pageY + vis.config.tooltipPadding) + 'px') .style('top', (event.layerY + vis.config.tooltipPadding) + 'px')
}) })
.on('mouseleave', () => { .on('mouseleave', () => {
d3.select('#tooltip').style('display', 'none'); d3.select('#tooltip').style('display', 'none');
}) })
}
function ticked() { renderTooltip(dataPoint: { player: string; league: string; pos: string; nation: string; team: string;}) {
nodes.attr("cx", (d: any) => d.x) d3.select('#tooltip')
.attr("cy", (d: any) => d.y); .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 { ...@@ -149,39 +164,30 @@ export default class BubbleChart extends Chart {
this.chart.call(this.zoom.scaleTo, zoomLevel) this.chart.call(this.zoom.scaleTo, zoomLevel)
} }
private getGroupCenter(index: number, totalGroupLength: number) { zoomToPoint(dataPoint: any) {
const angle = index * (2 * Math.PI / totalGroupLength) if (dataPoint) {
return { if (this.highlightedNode) {
x: Math.cos(angle) * 100, d3.select(`circle[data-player="${this.highlightedNode.data.player}"]`)
y: Math.sin(angle) * 100 .transition()
} .duration(700)
} .attr('fill', 'var(--primary)')
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]
})
} }
return acc 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]))
)
)
} }
private isUngrouped(data: any[]): boolean {
return data.every((d: any) => this.config.groupAccessor(d) === 'default')
} }
} }
\ No newline at end of file
import "@/styles/index.scss"; // imports the default styles import "@/styles/index.scss"; // imports the default styles
import {dsv} from "d3-fetch"; import {dsv} from "d3-fetch";
import BubbleChart from "@/charts/bubbleChart.ts"; import BubbleChart from "@/charts/bubbleChart.ts";
import Search from "@/search.ts";
// const radarChartWrapper = document.querySelector('#radar-chart-wrapper') as HTMLDivElement; // const radarChartWrapper = document.querySelector('#radar-chart-wrapper') as HTMLDivElement;
const bubbleChartWrapper = document.querySelector('#bubble-chart-wrapper') as HTMLDivElement; const bubbleChartWrapper = document.querySelector('#bubble-chart-wrapper') as HTMLDivElement;
...@@ -20,11 +21,10 @@ zoomSlider.oninput = (event) => { ...@@ -20,11 +21,10 @@ zoomSlider.oninput = (event) => {
} }
dsv(';', 'data/Out_2.csv').then(data => { dsv(';', 'data/Out_2.csv').then(data => {
const filteredData = data.slice(0,1300) bubbleChart = new BubbleChart(data, {
bubbleChart = new BubbleChart(filteredData, {
parentElement: bubbleChartWrapper, parentElement: bubbleChartWrapper,
containerWidth: 800, containerWidth: 1000,
containerHeight: 800, containerHeight: 1000,
margin: {top: 20, right: 20, bottom: 20, left: 20}, margin: {top: 20, right: 20, bottom: 20, left: 20},
zoomExtent, zoomExtent,
onZoom: (event) => { onZoom: (event) => {
...@@ -34,6 +34,9 @@ dsv(';', 'data/Out_2.csv').then(data => { ...@@ -34,6 +34,9 @@ dsv(';', 'data/Out_2.csv').then(data => {
}) })
bubbleChart.renderVis() bubbleChart.renderVis()
const search = new Search(bubbleChart)
search.initOptions(data)
}) })
const zoomInButton = document.querySelector('#zoom-in') as HTMLButtonElement const zoomInButton = document.querySelector('#zoom-in') as HTMLButtonElement
......
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
...@@ -93,14 +93,21 @@ h1 { ...@@ -93,14 +93,21 @@ h1 {
gap: 1rem; gap: 1rem;
} }
} }
.left {
flex: 2;
}
.right {
flex: 1;
}
} }
button { button {
padding: 0.5rem 1rem; padding: 0.5rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: transform 0.2s; transition: transform 0.2s;
font-size: 1.5rem;
background: linear-gradient(145deg, var(--primary), var(--secondary)); background: linear-gradient(145deg, var(--primary), var(--secondary));
color: white; color: white;
border-radius: 0.5rem; border-radius: 0.5rem;
...@@ -118,6 +125,16 @@ input[type="text"] { ...@@ -118,6 +125,16 @@ input[type="text"] {
border: 1px solid var(--text); border: 1px solid var(--text);
} }
.search-wrapper {
margin-bottom: 1rem;
}
@media (max-width: 1536px) {
#app {
flex-direction: column;
}
}
@keyframes shimmer { @keyframes shimmer {
0% { 0% {
filter: blur(70px); filter: blur(70px);
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment