diff --git a/src/charts/bubbleChart.ts b/src/charts/bubbleChart.ts index 85794e434548c1e6d85cf54da0c8a0d94f3426bd..d398f919f704fa1056c3ba8cb95c3776bde8da99 100644 --- a/src/charts/bubbleChart.ts +++ b/src/charts/bubbleChart.ts @@ -5,9 +5,10 @@ import SearchableChart from "@/charts/SearchableChart.ts"; import {debounce} from "@/utils.ts"; export type BubbleChartConfig = ChartConfig & { - groupAccessor: (d: any) => string, + groupAccessor: (d: any) => string | null, sizeAccessor: (d: any) => number, colorAccessor: (d: any) => string | number | null, + idAccessor: (d: any) => any, zoomExtent: [number, number], onZoom?: (event: any) => void, renderTooltip?: (dataPoint: any, tooltip: d3.Selection<d3.BaseType, unknown, HTMLElement, any>) => void, @@ -47,10 +48,11 @@ export default class BubbleChart extends SearchableChart { containerWidth: _config.containerWidth || 500, containerHeight: _config.containerHeight || 140, margin: _config.margin || {top: 10, bottom: 30, right: 10, left: 30}, - tooltipPadding: _config.tooltipPadding || 15, + tooltipPadding: _config.tooltipPadding || 30, groupAccessor: _config.groupAccessor || (() => null), sizeAccessor: _config.sizeAccessor || (() => 5), colorAccessor: _config.colorAccessor || (() => null), + idAccessor: _config.idAccessor || (() => null), zoomExtent: _config.zoomExtent || [0.5, 20], renderTooltip: _config.renderTooltip || (() => null), onClick: _config.onClick || (() => null), @@ -106,7 +108,7 @@ export default class BubbleChart extends SearchableChart { const node = vis.chart .selectAll("g") .filter('[bubble]') - .data(vis.packRoot?.leaves()) + .data(vis.packRoot?.leaves(), (d: any) => vis.config.idAccessor(d.data)) .join( (enter: any) => enter.append("g"), (update: any) => update @@ -131,7 +133,7 @@ export default class BubbleChart extends SearchableChart { const node = vis.chart .selectAll("g") - .data(vis.packRoot?.leaves()) + .data(vis.packRoot?.leaves(), (d: any) => vis.config.idAccessor(d.data)) .join("g") .attr("bubble", true) .attr("transform", (d: any) => `translate(${d.x},${d.y})`) @@ -140,7 +142,7 @@ export default class BubbleChart extends SearchableChart { .append('circle') .attr('fill', (d: any) => vis.getFillForNode(d)) .attr('r', (d: any) => d.r) - .attr('data-bubble', (d: any) => d.data.player) // TODO make label accessor + .attr('data-bubble', (d: any) => vis.config.idAccessor(d.data)) .on('mouseover', (_: Event, d: any) => { const element = d3.select('#tooltip') .style('display', 'block') @@ -164,10 +166,10 @@ export default class BubbleChart extends SearchableChart { d3.selectAll('circle[data-bubble]') .attr('stroke', 'none') for (const node of nodes) { - const playerNode = this.getPlayerNode(node.player) + const playerNode = this.getNode(node) playerNode - .attr('stroke', 'black') - .attr('stroke-width', 2 / this.zoomLevel) + .attr('stroke', node._color || "#333") + .attr('stroke-width', 3) } } @@ -229,7 +231,7 @@ export default class BubbleChart extends SearchableChart { exit => exit.remove() ) .attr('fill', 'white') - .attr('stroke', 'black') + .attr('stroke', '#333') .attr('stroke-width', 1 / this.zoomLevel) .attr('rx', 5 / this.zoomLevel) .attr('ry', 5 / this.zoomLevel) @@ -265,9 +267,9 @@ export default class BubbleChart extends SearchableChart { } const groups = this.packRoot.descendants() .filter(group => - group.children && - group.r >= (50 / this.zoomLevel) && - group.r < (300 / (this.zoomLevel * 2.5)) && + group.children && group.value && + group.value >= (50 / this.zoomLevel) && + group.value < (300 / (this.zoomLevel * 2.5)) && group.children.length > 5 ) @@ -286,14 +288,13 @@ export default class BubbleChart extends SearchableChart { this.data, (d: any) => this.config.groupAccessor(d) ) + const hierarchy = (d3.hierarchy(groupedData) as HierarchyNode<any>) + .sum((d: any) => this.getSizeForNode(d)) + .sort((a: any, b: any) => d3.descending(a.value, b.value)) this.packRoot = d3.pack() .size([this.width(), this.height()]) .padding(2) - ( - (d3.hierarchy(groupedData) as HierarchyNode<any>) - .sum((d: any) => this.getSizeForNode(d)) - .sort((d: any) => this.getSizeForNode(d)) - ) + (hierarchy) this.updateGroupsWithLabels() } @@ -313,7 +314,7 @@ export default class BubbleChart extends SearchableChart { if (!input) return if (!input || !this.packRoot) return const result = this.packRoot.leaves() - .find((d: any) => d.data.player.toLowerCase().includes(input.toLowerCase())) + .find((d: any) => this.config.idAccessor(d.data).toLowerCase().includes(input.toLowerCase())) if (result) { this.zoomToPoint(result) } @@ -323,9 +324,12 @@ export default class BubbleChart extends SearchableChart { // The color scale is either a sequential scale or an ordinal scale // depending on what type the colorAccessor returns const sampleValue = this.config.colorAccessor(this.data[0]) - if (!sampleValue) return () => 'var(--primary)' + if (!sampleValue) { + this.colorScale = null + return + } if (typeof sampleValue === 'number') { - this.colorScale = d3.scaleSequential(d3.interpolateBlues) + this.colorScale = d3.scaleSequential(d3.interpolateCool) .domain( d3.extent( this.data, @@ -376,12 +380,12 @@ export default class BubbleChart extends SearchableChart { zoomToPoint(dataPoint: any) { if (dataPoint) { if (this.highlightedNode) { - this.getPlayerNode(this.highlightedNode.data.player) + this.getNode(this.highlightedNode.data) .transition() .duration(700) .attr('fill', (d: any) => this.getFillForNode(d)) } - this.getPlayerNode(dataPoint.data.player) + this.getNode(dataPoint.data) .transition() .duration(700) .attr('fill', 'var(--secondary)') @@ -401,7 +405,7 @@ export default class BubbleChart extends SearchableChart { this.updateGroupsWithLabels() } - private getPlayerNode(playerName: string) { - return d3.select(`circle[data-bubble="${playerName}"]`); + private getNode(data: any) { + return d3.select(`circle[data-bubble="${this.config.idAccessor(data)}"]`); } } \ No newline at end of file diff --git a/src/charts/radarChart.ts b/src/charts/radarChart.ts index b09809fb0415c67cac4dfa81103d56e17bf61ccb..109cb7f3abea2b691d4b33715299a5d6e091fe93 100644 --- a/src/charts/radarChart.ts +++ b/src/charts/radarChart.ts @@ -3,14 +3,19 @@ import {ScaleLinear} from "d3"; import Chart, {ChartConfig, ChartConfigParam} from "@/charts/chart.ts"; export type RadarChartConfig = ChartConfig & { - selectedData: any[], + selectedData: RadarChartSelection[], renderTooltip?: (dataPoint: any, tooltip: d3.Selection<d3.BaseType, unknown, HTMLElement, any>) => void, axisCircles: number, - attributes: {key: string, label: string}[] | [], + attributes: { key: string, label: string }[] | [], } export type RadarChartConfigParam = ChartConfigParam & Partial<RadarChartConfig> +type RadarChartSelection = { + _color: string | null | undefined, + [key: string]: any +} + export default class RadarChart extends Chart { chartId: string = 'radarChart'; chart: any @@ -34,7 +39,7 @@ export default class RadarChart extends Chart { this.config = this.createConfig(_config) } - private createConfig(_config: RadarChartConfigParam) : RadarChartConfig { + private createConfig(_config: RadarChartConfigParam): RadarChartConfig { return { ..._config, parentElement: typeof _config.parentElement === 'string' ? document.querySelector(_config.parentElement) as HTMLElement : _config.parentElement, @@ -79,54 +84,22 @@ export default class RadarChart extends Chart { } } - updateVis(selectedData: any[]): void { - const vis = this; - const dataWrapper = vis.chart.select('.dataWrapper') + updateVis(selectedData: RadarChartSelection[]): void { this.config.selectedData = selectedData; - const preparedData = vis.getPreparedData() - const data = dataWrapper.selectAll('.data') - .data(preparedData) - .join( - enter => enter.append("g"), - update => update, - exit => exit.remove() - ) - .attr("class", "data") - - data.append("path") - .attr("d", (d: any) => { - const data = d.map((d: any) => d.value) - return d3.lineRadial() - .angle((_, index) => Math.PI * 2 / vis.axes.length * index) - .radius((value) => value || 0) - .curve(d3.curveCardinalClosed.tension(0.6)) - ([...data, data[0]]) - }) - .attr('fill', '#69b3a211') - .attr('stroke', 'black') - - data.selectAll('.dataPoint') - .data((d: any) => d) - .join( - enter => enter.append("circle"), - update => update, - exit => exit.remove() - ) - .attr("class", "dataPoint") - .attr("r", 3) - .attr("cx", (data: {label: string, value: number}, index: number) => Math.sin(2 * Math.PI * (index/vis.axes.length)) * data.value) - .attr("cy", (data: {label: string, value: number}, index: number) => -Math.cos(2 * Math.PI * (index/vis.axes.length)) * data.value) - .attr('fill', 'black') + this.drawData(); } - private getPreparedData() { + private getPreparedData(): { data: RadarChartSelection, axesValues: { label: string, value: number }[] }[] { return this.config.selectedData.map( - (d: any) => this.axes.map(axis => { - return { - label: axis.label, - value: axis.scale(d[axis.key]) - } + (d: any) => ({ + data: d, + axesValues: this.axes.map(axis => ( + { + label: axis.label, + value: axis.scale(d[axis.key]) + } + )) }) ); } @@ -160,52 +133,79 @@ export default class RadarChart extends Chart { ([[0, 0], [Math.PI * 2 * index / vis.axes.length, this.axisLength]]) ) axes.append('text') - .attr("x", (_: any, index: number) => Math.sin(2 * Math.PI * (index/vis.axes.length)) * (this.axisLength + 10)) - .attr("y", (_: any, index: number) => -Math.cos(2 * Math.PI * (index/vis.axes.length)) * (this.axisLength + 10)) + .attr("x", (_: any, index: number) => Math.sin(2 * Math.PI * (index / vis.axes.length)) * (this.axisLength + 10)) + .attr("y", (_: any, index: number) => -Math.cos(2 * Math.PI * (index / vis.axes.length)) * (this.axisLength + 10)) .attr('text-anchor', 'middle') .attr('alignment-baseline', 'middle') .attr('font-size', 12) .attr('fill', 'black') .text((d: any) => d.label) - const dataWrapper = vis.chart.append("g") - .attr("class", "dataWrapper") - .attr('transform', `translate(${vis.chartCenter},${vis.chartCenter})`) + this.drawData(); + } - const preparedData = vis.getPreparedData() + private drawData() { + let dataWrapper = this.chart.selectAll(".dataWrapper") + if (dataWrapper.empty()) { + dataWrapper = this.chart.append("g") + .attr("class", "dataWrapper") + .attr('transform', `translate(${this.chartCenter},${this.chartCenter})`) + } + + const preparedData = this.getPreparedData() const data = dataWrapper.selectAll('.data') .data(preparedData) .join( - enter => enter.append("g"), + (enter: any) => { + const data = enter.append("g") + .attr("class", "data") + data + .append("path") + .attr("d", (d: any) => { + const data = d.axesValues.map((d: any) => d.value) + return d3.lineRadial() + .angle((_, index) => Math.PI * 2 / this.axes.length * index) + .radius((value) => value || 0) + .curve(d3.curveCardinalClosed.tension(0.6)) + ([...data, data[0]]) + }) + .attr('fill', (d: any) => { + const color = d3.color(d.data._color) + if (!color) { + return "rgba(50,50,50,0.1)" + } + return color.copy({opacity: 0.2}).toString() + }) + .attr('stroke', (d: any) => d.data._color) + .attr('stroke-width', 3) + + data.selectAll('.dataPoint') + .data((d: any) => d.axesValues.map((value: any) => ({ + ...value, + data: d.data, + }))) + .join( + enter => enter.append("circle"), + update => update, + exit => exit.remove() + ) + .attr("class", "dataPoint") + .attr("r", 5) + .attr("cx", (data: { + label: string, + value: number + }, index: number) => Math.sin(2 * Math.PI * (index / this.axes.length)) * data.value) + .attr("cy", (data: { + label: string, + value: number + }, index: number) => -Math.cos(2 * Math.PI * (index / this.axes.length)) * data.value) + .attr('fill', (d: any) => d.data._color) + }, update => update, exit => exit.remove() ) - .attr("class", "data") - - data.append("path") - .attr("d", (d: any) => { - const data = d.map((d: any) => d.value) - return d3.lineRadial() - .angle((_, index) => Math.PI * 2 / vis.axes.length * index) - .radius((value) => value || 0) - .curve(d3.curveCardinalClosed.tension(0.6)) - ([...data, data[0]]) - }) - .attr('fill', '#69b3a211') - .attr('stroke', 'black') - data.selectAll('.dataPoint') - .data((d: any) => d) - .join( - enter => enter.append("circle"), - update => update, - exit => exit.remove() - ) - .attr("class", "dataPoint") - .attr("r", 3) - .attr("cx", (data: {label: string, value: number}, index: number) => Math.sin(2 * Math.PI * (index/vis.axes.length)) * data.value) - .attr("cy", (data: {label: string, value: number}, index: number) => -Math.cos(2 * Math.PI * (index/vis.axes.length)) * data.value) - .attr('fill', 'black') + } } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index a96386121f2fe98af3ccdfaaeaf324f82b804251..77956e5a9ea734085ba500fde3e457468c3b2ad2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import {countries} from "@/countries.ts"; import {numericColumns, Player} from "@/player.ts"; import {getPositionName} from "@/positions.ts"; import RadarChart from "@/charts/radarChart.ts"; +import {schemeTableau10} from "d3"; const radarChartWrapper = document.querySelector('#radar-chart-wrapper') as HTMLDivElement; const bubbleChartWrapper = document.querySelector('#bubble-chart-wrapper') as HTMLDivElement; @@ -58,7 +59,7 @@ sizeBySetting.oninput = (event) => { bubbleChart.updateVisConfig({sizeAccessor: (d: any) => d[event.target?.value] ?? 0} as BubbleChartConfigParam) } } -const selectedNodes: Player[] = [] +const selectedNodes: (Player & { _color: string })[] = [] dsv(';', 'data/output.csv').then(data => { parsedData = data.map((d: any) => { @@ -74,7 +75,7 @@ dsv(';', 'data/output.csv').then(data => { nation: countries.find(c => c.code === d.nation)?.name ?? d.nation, } as Player } - ) + ).splice(0, 1000) bubbleChart = new BubbleChart(parsedData, { parentElement: bubbleChartWrapper, @@ -82,7 +83,7 @@ dsv(';', 'data/output.csv').then(data => { containerHeight: 1000, margin: {top: 20, right: 20, bottom: 20, left: 20}, zoomExtent, - selectedNodes, + idAccessor: (d: any) => d.player, onZoom: (event) => { if (sliderBlocked) return zoomSlider.value = event.transform.k.toString() @@ -124,10 +125,16 @@ function updateZoomLevel(to: '+' | '-') { } function updateSelectedNodes(node: Player) { + if (selectedNodes.length > 6) { + alert('Maximal 6 Spieler können ausgewählt werden') + } if (selectedNodes.some(n => n.player === node.player)) { selectedNodes.splice(selectedNodes.findIndex((n: Player) => n.player === node.player), 1) } else { - selectedNodes.push(node) + selectedNodes.push({ + ...node, + _color: schemeTableau10[selectedNodes.length] + }) } bubbleChart?.updateSelectedNodes(selectedNodes) if (!radarChart) { diff --git a/src/styles/index.scss b/src/styles/index.scss index fc12d8935c9736bc85021206a9ea4c7812dff6bd..0254b58452d19f6a760451c4709fd4c4743ac122 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -81,7 +81,6 @@ h1 { position: relative; margin: 1rem auto; width: fit-content; - box-shadow: 0 0 20px rgba(51, 51, 51, 0.5); padding: 1rem; }