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

feat: add zooming to bubble chart

parent 6f31f2e8
Branches
No related tags found
No related merge requests found
......@@ -13,6 +13,11 @@
<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>
</div>
</div>
<div class="right">
<h2>Vergleichen</h2>
......
......@@ -59,6 +59,7 @@ export default class AreaChart extends AxisChart<Date, number> {
vis.chart.append('g')
.attr('class', 'axis y-axis')
.call(vis.yAxis)
.call(vis.yAxis)
}
updateVis(data: any[]): void {
......
import * as d3 from "d3";
import Chart, {ChartConfigParam} from "@/charts/chart.ts";
import Chart, {ChartConfig, ChartConfigParam} from "@/charts/chart.ts";
export type BubbleChartConfig = ChartConfig & {
groupAccessor: (d: any) => string,
sizeAccessor: (d: any) => number,
colorAccessor: (d: any) => number | null,
zoomExtent: [number, number],
onZoom?: (event: any) => void
}
export type BubbleChartConfigParam = ChartConfigParam & Partial<BubbleChartConfig>
export type BubbleChartConfigParam = ChartConfigParam
export default class BubbleChart extends Chart {
groupAccessor: (d: any) => string = () => 'default';
sizeAccessor: (d: any) => number = () => 10;
colorAccessor: (d: any) => number | null = () => null;
chartId: string = 'bubbleChart';
chart: any
zoom: any;
config: BubbleChartConfig
constructor(data: any[], _config: BubbleChartConfigParam) {
super(data, _config)
super(data, _config as ChartConfigParam)
this.config = {
..._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'),
sizeAccessor: _config.sizeAccessor || (() => 5),
colorAccessor: _config.colorAccessor || (() => null),
zoomExtent: _config.zoomExtent || [0.5, 20],
}
this.initVis()
}
initVis() {
......@@ -30,6 +54,17 @@ export default class BubbleChart extends Chart {
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])
.on('zoom', (event) => {
vis.chart.attr('transform', event.transform);
vis.config.onZoom?.(event)
});
vis.chart.append('input')
svg.call(vis.zoom);
}
updateVis(data: any[]): void {
......@@ -39,22 +74,114 @@ export default class BubbleChart extends Chart {
renderVis(): void {
let vis: BubbleChart = this;
vis.config.groupAccessor = (d: any) => {
return d['league']
}
const chart = vis.chart
const parsedData = vis.isUngrouped(vis.data) ? [vis.data] : vis.data.reduce((acc: {[key: string]: any;}, data: any) => {
(acc[data[vis.groupAccessor(data)]] = acc[data[vis.groupAccessor(data)]] || []).push(data)
}, {})
const groupedData = vis.getGroupedData()
for (const groupIndex in groupedData)
{
const group = groupedData[groupIndex]
for (const group of parsedData) {
chart.append('circle')
.data(group)
.attr('fill', 'rgba(62,187,228,0.3)')
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);
}
}
}
get zoomLevel() {
return this.zoom.scale()
}
zoomIn() {
this.chart.transition()
.duration(300)
.call(this.zoom.scaleBy, 1.3)
}
zoomOut() {
this.chart.transition()
.duration(300)
.call(this.zoom.scaleBy, 0.75)
}
zoomTo(zoomLevel: number) {
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]
})
}
return acc
}, [])
}
isUngrouped(data: any[]): boolean {
return data.every((d: any) => this.groupAccessor(d) === 'default')
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 * as d3 from "d3-fetch";
import {dsv} from "d3-fetch";
import BubbleChart from "@/charts/bubbleChart.ts";
// const radarChartWrapper = document.querySelector('#radar-chart-wrapper') as HTMLDivElement;
const bubbleChartWrapper = document.querySelector('#bubble-chart-wrapper') as HTMLDivElement;
const zoomExtent: [number, number] = [1, 5]
let bubbleChart: BubbleChart | null = null
d3.csv('data/Out_2.csv').then(data => {
const bubbleChart = new BubbleChart(data, {
let sliderBlocked = false
const zoomSlider = document.querySelector('#zoom-slider') as HTMLInputElement
zoomSlider.min = zoomExtent[0].toString()
zoomSlider.max = zoomExtent[1].toString()
zoomSlider.step = '0.1'
zoomSlider.oninput = (event) => {
if (bubbleChart) {
const newZoom = parseFloat((event.target as HTMLInputElement).value)
bubbleChart.zoomTo(newZoom)
}
}
dsv(';', 'data/Out_2.csv').then(data => {
const filteredData = data.slice(0,1300)
bubbleChart = new BubbleChart(filteredData, {
parentElement: bubbleChartWrapper,
containerWidth: 800,
containerHeight: 600,
margin: {top: 20, right: 20, bottom: 20, left: 20}
containerHeight: 800,
margin: {top: 20, right: 20, bottom: 20, left: 20},
zoomExtent,
onZoom: (event) => {
if (sliderBlocked) return
zoomSlider.value = event.transform.k.toString()
}
})
bubbleChart.renderVis()
})
const zoomInButton = document.querySelector('#zoom-in') as HTMLButtonElement
zoomInButton.onclick = () => updateZoomLevel('+')
const zoomOutButton = document.querySelector('#zoom-out') as HTMLButtonElement
zoomOutButton.onclick = () => updateZoomLevel('-')
function updateZoomLevel(to: '+' | '-') {
if (bubbleChart) {
to === '+' ? bubbleChart.zoomIn() : bubbleChart.zoomOut()
}
}
\ No newline at end of file
......@@ -78,24 +78,40 @@ h1 {
gap: 1rem;
.left, .right {
position: relative;
flex: 1;
text-align: center;
#zoom-wrapper {
position: absolute;
bottom: 1rem;
right: 0;
width: 400px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
}
}
}
button {
@include neumorphism;
padding: 0.5rem 1rem;
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;
min-width: 3rem;
&:hover {
transform: scale(1.05);
}
}
input {
input[type="text"] {
border-radius: .5rem;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
padding: 0.5rem;
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment