diff --git a/index.html b/index.html index 5ab93f82a7de017dcd363d9f472d7af071fcef7b..599c69fa9c64fc36f2fb78da7546f7a2d726f148 100644 --- a/index.html +++ b/index.html @@ -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> diff --git a/src/charts/areaChart.ts b/src/charts/areaChart.ts index fcc871d195c716a5151b527e108175882ff7a3b4..cbd4a01ad4dfdec99b03791b30630a1ee4731ca6 100644 --- a/src/charts/areaChart.ts +++ b/src/charts/areaChart.ts @@ -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 { diff --git a/src/charts/bubbleChart.ts b/src/charts/bubbleChart.ts index 32f93ab7e609ff6ba9184028b7b9bf7fede3b221..dfc0e3af48e1b0ac53fcdb863779d56e0da5d69d 100644 --- a/src/charts/bubbleChart.ts +++ b/src/charts/bubbleChart.ts @@ -1,18 +1,42 @@ 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 } } - isUngrouped(data: any[]): boolean { - return data.every((d: any) => this.groupAccessor(d) === 'default') + 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 + }, []) + } + + private isUngrouped(data: any[]): boolean { + return data.every((d: any) => this.config.groupAccessor(d) === 'default') } } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 9abdd749b7a40d360d2f6d6566d6f6dc67040f23..4f6434152e7b4ee385bb8c4e669d4cd082c5bff1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,17 +1,49 @@ 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 diff --git a/src/styles/index.scss b/src/styles/index.scss index c577690af561e39683a72e3daabb41d88da67a54..631fe69689e0c8c95a5d20db695e365785b14eb9 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -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;