From 6e411107953776bd88344b54264af8a40b9077c8 Mon Sep 17 00:00:00 2001 From: Leander <leander.gerwing@gmail.com> Date: Mon, 19 Aug 2024 02:26:52 +0200 Subject: [PATCH] feat: dynamic group labels --- index.html | 3 +- src/charts/bubbleChart.ts | 125 ++++++++++++++++++++- src/countries.ts | 222 +++++++++++++++++++++++++++++++++++--- src/main.ts | 7 +- src/positions.ts | 25 +++++ src/styles/index.scss | 44 +++----- src/utils.ts | 7 ++ 7 files changed, 384 insertions(+), 49 deletions(-) create mode 100644 src/positions.ts create mode 100644 src/utils.ts diff --git a/index.html b/index.html index 4e87918..fe881ee 100644 --- a/index.html +++ b/index.html @@ -41,7 +41,8 @@ <label for="group-by-setting">Gruppieren nach:</label> <select id="group-by-setting" name="group-by-setting"> - <option value="team" selected>Team</option> + <option value="default" selected>---</option> + <option value="team">Team</option> <option value="nation">Nation</option> <option value="pos">Position</option> <option value="league">Liga</option> diff --git a/src/charts/bubbleChart.ts b/src/charts/bubbleChart.ts index d024527..f560ab4 100644 --- a/src/charts/bubbleChart.ts +++ b/src/charts/bubbleChart.ts @@ -2,6 +2,7 @@ import * as d3 from "d3"; import {HierarchyNode, NumberValue, ScaleLinear, ScaleOrdinal, ScaleSequential} from "d3"; import {ChartConfig, ChartConfigParam} from "@/charts/chart.ts"; import SearchableChart from "@/charts/SearchableChart.ts"; +import {debounce} from "@/utils.ts"; export type BubbleChartConfig = ChartConfig & { groupAccessor: (d: any) => string, @@ -23,6 +24,8 @@ export default class BubbleChart extends SearchableChart { highlightedNode: HierarchyNode<any> | null = null colorScale: ScaleSequential<any> | ScaleOrdinal<any, any> | null = null sizeScale: ScaleLinear<any, number> | null = null + groupLabels: any | null = null + groupsWithLabels: any[] = [] constructor(data: any[], _config: BubbleChartConfigParam) { super(data, _config as ChartConfigParam) @@ -37,14 +40,14 @@ export default class BubbleChart extends SearchableChart { } private createConfig(_config: BubbleChartConfigParam) { - return { + return { ..._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'), + groupAccessor: _config.groupAccessor || (() => null), sizeAccessor: _config.sizeAccessor || (() => 5), colorAccessor: _config.colorAccessor || (() => null), zoomExtent: _config.zoomExtent || [0.5, 20], @@ -99,6 +102,7 @@ export default class BubbleChart extends SearchableChart { const node = vis.chart .selectAll("g") + .filter('[bubble]') .data(vis.packRoot?.leaves()) .join( (enter: any) => enter.append("g"), @@ -126,6 +130,7 @@ export default class BubbleChart extends SearchableChart { .selectAll("g") .data(vis.packRoot?.leaves()) .join("g") + .attr("bubble", true) .attr("transform", (d: any) => `translate(${d.x},${d.y})`) node @@ -149,6 +154,111 @@ export default class BubbleChart extends SearchableChart { }) } + private renderGroupLabels() { + this.groupLabels = this.chart.selectAll('g') + .filter('[group]') + .data(this.groupsWithLabels) + .join( + (enter: any) => { + const enterGroup = enter.append("g") + .attr('opacity', 0) + enterGroup + .transition() + .delay(1000) + .duration(1000) + .attr('opacity', 1) + return enterGroup + }, + (update: any) => { + update.selectAll('*').remove() + return update + }, + (exit: any) => exit.transition(300) + .attr('opacity', 0) + .remove() + ) + .attr('group', true) + .attr("transform", (d: any) => `translate(${d.x},${d.y})`) + .on('mouseover', (event: Event, d: any) => { + // hide label + d3.select(event.target.closest('g')) + .selectAll("*") + .transition() + .duration(100) + .attr('opacity', 0) + }) + .on('mouseleave', (event: Event) => { + // show label + d3.select(event.target.closest('g')) + .selectAll("*") + .transition() + .duration(100) + .attr('opacity', 1) + .attr('width', (d: any) => d.bbox.width + 10) + }) + + this.groupLabels.transition() + .delay(1000) + .duration(1000) + .attr('opacity', 1) + + const rects = this.groupLabels + .selectAll('rect') + .filter((d: any) => d.data[0]) + .data((d: any) => [d]) + .join( + enter => enter.append("rect"), + update => update, + exit => exit.remove() + ) + .attr('fill', 'white') + .attr('stroke', 'black') + .attr('stroke-width', 1 / this.zoomLevel) + .attr('rx', 5 / this.zoomLevel) + .attr('ry', 5 / this.zoomLevel) + + this.groupLabels + .selectAll('text') + .data((d: any) => [d]) + .join( + enter => enter.append("text"), + update => update, + exit => exit.remove() + ) + .attr('text-anchor', 'middle') + .attr('alignment-baseline', 'middle') + .attr('font-size', 12 / this.zoomLevel) + .attr('fill', 'black') + .text(d => d.data[0]) + .call((selection: any) => selection.each( + function (d: any) { + d.bbox = this.getBBox() + } + )) + rects.attr('x', (d: any) => d.bbox.x - 5) + .attr('y', (d: any) => d.bbox.y - 5) + .attr('width', (d: any) => d.bbox.width + 10) + .attr('height', (d: any) => d.bbox.height + 10) + } + + private updateGroupsWithLabels = debounce(() => { + if (!this.packRoot) { + this.groupsWithLabels = [] + return + } + const groups = this.packRoot.descendants() + .filter(group => + group.children && + group.r >= (50 / this.zoomLevel) && + group.r < (300 / (this.zoomLevel * 2.5)) && + group.children.length > 5 + ) + + this.groupsWithLabels = groups.length > 1 ? groups : [] + this.renderGroupLabels() + }, 300) + + /** * Updates the d3 hierachy pack root with the current data. * Uses the groupAccessor and sizeAccessor to group the data. @@ -167,6 +277,7 @@ export default class BubbleChart extends SearchableChart { .sum((d: any) => this.getSizeForNode(d)) .sort((d: any) => this.getSizeForNode(d)) ) + this.updateGroupsWithLabels() } private getFillForNode(node: HierarchyNode<any>) { @@ -175,7 +286,7 @@ export default class BubbleChart extends SearchableChart { return this.colorScale(this.config.colorAccessor(node.data) as NumberValue) } - private getSizeForNode(dataPoint: HierarchyNode<any>) : number { + private getSizeForNode(dataPoint: HierarchyNode<any>): number { if (!dataPoint || !this.config.sizeAccessor(dataPoint)) return 0.1 if (!this.sizeScale) return 5 return this.sizeScale(this.config.sizeAccessor(dataPoint) as NumberValue) @@ -218,28 +329,31 @@ export default class BubbleChart extends SearchableChart { this.data, this.config.sizeAccessor as (d: any) => number | null ) as [number, number], - [0,10] + [0, 10] ) } get zoomLevel() { - return this.zoom.scale() + return d3.zoomTransform(this.chart.node()).k } zoomIn() { this.chart.transition() .duration(300) .call(this.zoom.scaleBy, 1.3) + this.updateGroupsWithLabels() } zoomOut() { this.chart.transition() .duration(300) .call(this.zoom.scaleBy, 0.75) + this.updateGroupsWithLabels() } zoomTo(zoomLevel: number) { this.chart.call(this.zoom.scaleTo, zoomLevel) + this.updateGroupsWithLabels() } zoomToPoint(dataPoint: any) { @@ -267,5 +381,6 @@ export default class BubbleChart extends SearchableChart { ) ) } + this.updateGroupsWithLabels() } } \ No newline at end of file diff --git a/src/countries.ts b/src/countries.ts index 187ee3c..07ad61e 100644 --- a/src/countries.ts +++ b/src/countries.ts @@ -62,7 +62,7 @@ export const countries = [ "name": "Argentinien", }, { - "code": "ZAF", + "code": "RSA", "name": "Südafrika", }, { @@ -86,7 +86,7 @@ export const countries = [ "name": "Norwegen", }, { - "code": "DNK", + "code": "DEN", "name": "Dänemark", }, { @@ -94,7 +94,7 @@ export const countries = [ "name": "Finnland", }, { - "code": "CHE", + "code": "SUI", "name": "Schweiz", }, { @@ -106,7 +106,7 @@ export const countries = [ "name": "Belgien", }, { - "code": "NLD", + "code": "NED", "name": "Niederlande", }, { @@ -114,7 +114,7 @@ export const countries = [ "name": "Polen", }, { - "code": "GRC", + "code": "GRE", "name": "Griechenland", }, { @@ -130,7 +130,7 @@ export const countries = [ "name": "Slowakei", }, { - "code": "PRT", + "code": "POR", "name": "Portugal", }, { @@ -213,10 +213,6 @@ export const countries = [ "code": "GHA", "name": "Ghana", }, - { - "code": "DZA", - "name": "Algerien", - }, { "code": "MAR", "name": "Marokko", @@ -334,7 +330,7 @@ export const countries = [ "name": "Serbien", }, { - "code": "HRV", + "code": "CRO", "name": "Kroatien", }, { @@ -358,7 +354,7 @@ export const countries = [ "name": "Montenegro", }, { - "code": "KOS", + "code": "KVX", "name": "Kosovo", }, { @@ -397,4 +393,204 @@ export const countries = [ "code": "GNB", "name": "Guinea-Bissau", }, -] + { + "code": "CIV", + "name": "Elfenbeinküste", + }, + { + "code": "GAB", + "name": "Gabun", + }, + { + "code": "CMR", + "name": "Kamerun", + }, + { + "code": "CGO", + "name": "Kongo", + }, + { + "code": "ZIM", + "name": "Simbabwe", + }, + { + "code": "ZAM", + "name": "Sambia", + }, + { + "code": "MOZ", + "name": "Mosambik", + }, + { + "code": "MWI", + "name": "Malawi", + }, + { + "code": "ANG", + "name": "Angola", + }, + { + "code": "NAM", + "name": "Namibia", + }, + { + "code": "BWA", + "name": "Botswana", + }, + { + "code": "SWZ", + "name": "Swasiland", + }, + { + "code": "LSO", + "name": "Lesotho", + }, + { + "code": "LBY", + "name": "Libyen", + }, + { + "code": "SDN", + "name": "Sudan", + }, + { + "code": "SSD", + "name": "Südsudan", + }, + { + "code": "ERI", + "name": "Eritrea", + }, + { + "code": "DJI", + "name": "Dschibuti", + }, + { + "code": "SOM", + "name": "Somalia", + }, + { + "code": "SEN", + "name": "Senegal", + }, + { + "code": "ALG", + "name": "Algerien", + }, + { + "code": "URU", + "name": "Uruguay", + }, + { + "code": "COL", + "name": "Kolumbien", + }, + { + "code": "PER", + "name": "Peru", + }, + { + "code": "VEN", + "name": "Venezuela", + }, + { + "code": "ECU", + "name": "Ecuador", + }, + { + "code": "BOL", + "name": "Bolivien", + }, + { + "code": "PAR", + "name": "Paraguay", + }, + { + "code": "GUY", + "name": "Guyana", + }, + { + "code": "SUR", + "name": "Suriname", + }, + { + "code": "GUF", + "name": "Französisch-Guayana", + }, + { + "code": "PAN", + "name": "Panama", + }, + { + "code": "ROU", + "name": "Rumänien", + }, + { + "code": "LUX", + "name": "Luxemburg", + }, + { + "code": "JAM", + "name": "Jamaika", + }, + { + "code": "SCO", + "name": "Schottland", + }, + { + "code": "MLI", + "name": "Mali", + }, + { + "code": "WAL", + "name": "Wales", + }, + { + "code": "BUL", + "name": "Bulgarien", + }, + { + "code": "CRC", + "name": "Costa Rica", + }, + { + "code": "COD", + "name": "Demokratische Republik Kongo", + }, + { + "code": "NIR", + "name": "Nordirland", + }, + { + "code": "CPV", + "name": "Kap Verde", + }, + { + "code": "CUW", + "name": "Curaçao", + }, + { + "code": "HAI", + "name": "Haiti", + }, + { + "code": "GUI", + "name": "Guinea", + }, + { + "code": "TOG", + "name": "Togo", + }, + { + "code": "DOM", + "name": "Dominikanische Republik", + }, + { + "code": "CHI", + "name": "Chile", + }, + { + "code": "BFA", + "name": "Burkina Faso", + } +] \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 543ed52..02e32ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import BubbleChart, {BubbleChartConfigParam} from "@/charts/bubbleChart.ts"; import Search from "@/search.ts"; import {countries} from "@/countries.ts"; import {numericColumns, Player} from "@/player.ts"; +import {getPositionName, positions} from "@/positions.ts"; // const radarChartWrapper = document.querySelector('#radar-chart-wrapper') as HTMLDivElement; const bubbleChartWrapper = document.querySelector('#bubble-chart-wrapper') as HTMLDivElement; @@ -12,7 +13,7 @@ let bubbleChart: BubbleChart | null = null let sliderBlocked = false const zoomSlider = document.querySelector('#zoom-slider') as HTMLInputElement -zoomSlider.min = zoomExtent[0].toString() +zoomSlider.min = zoomSlider.value = zoomExtent[0].toString() zoomSlider.max = zoomExtent[1].toString() zoomSlider.step = '0.1' zoomSlider.oninput = (event) => { @@ -55,8 +56,12 @@ dsv(';', 'data/output.csv').then(data => { for (const column of Object.keys(numericColumns)) { d[column] = parseFloat(d[column]) } + if (countries.findIndex(c => c.code === d.nation) === -1) { + console.warn('Unknown country code:', d.nation) + } return { ...d, + pos: getPositionName(d.pos), nation: countries.find(c => c.code === d.nation)?.name ?? d.nation, } as Player } diff --git a/src/positions.ts b/src/positions.ts new file mode 100644 index 0000000..cf93df4 --- /dev/null +++ b/src/positions.ts @@ -0,0 +1,25 @@ +export const positions = [ + { + "pos": "FW", + "name": "Stürmer" + }, + { + "pos": "MF", + "name": "Mittelfeldspieler" + }, + { + "pos": "DF", + "name": "Verteidiger" + }, + { + "pos": "GK", + "name": "Torwart" + } +] + +export const getPositionName = (pos: string) => { + const splitPositions = pos.split(',') + return splitPositions + .map(p => positions.find(c => c.pos === p)?.name ?? p) + .join(', ') +} \ No newline at end of file diff --git a/src/styles/index.scss b/src/styles/index.scss index dd493ad..8649011 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -14,36 +14,16 @@ -20px -20px 60px #ffffff; } +@mixin glass { + background: rgba(255, 255, 255, 0.5); + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); + backdrop-filter: blur(10px); +} + body { font-family: Segoe UI, sans-serif; background-color: var(--quaternary); color: var(--text); - - /*&::before { - content: ''; - position: fixed; - top: 10%; - right: 0; - width: 10rem; - height: 10rem; - background-image: radial-gradient(circle, var(--tertiary), var(--secondary)); - filter: blur(125px); - z-index: -1; - animation: shimmer 10s infinite; - } - - &::after { - content: ''; - position: fixed; - bottom: 10%; - left: 0; - width: 10rem; - height: 10rem; - background-image: radial-gradient(circle, var(--tertiary), var(--secondary)); - filter: blur(125px); - z-index: -1; - animation: shimmer 10s infinite; - }*/ } h1 { @@ -75,6 +55,14 @@ h1 { z-index: 10; } +.groupLabel { + font-size: 1rem; + font-weight: bold; + text-align: center; + padding: .25rem; + @include glass; +} + #app { display: flex; gap: 1rem; @@ -106,9 +94,7 @@ h1 { align-items: flex-end; justify-content: flex-end; gap: 1rem; - background: rgba(255, 255, 255, 0.5); - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); - backdrop-filter: blur(10px); + @include glass; > div { align-items: center; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..f55009a --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,7 @@ +export function debounce(func: Function, timeout = 300){ + let timer: number | undefined; + return (...args: any[]) => { + clearTimeout(timer); + timer = setTimeout(() => { func.apply(this, args); }, timeout); + }; +} \ No newline at end of file -- GitLab