Commit c29464d5 authored by Almouhannad Hafez's avatar Almouhannad Hafez

Add stacked bar chart

parent 47eab80b
...@@ -7,4 +7,8 @@ body { ...@@ -7,4 +7,8 @@ body {
box-sizing: border-box; box-sizing: border-box;
overflow-x: hidden; overflow-x: hidden;
background: #f7f7f7; background: #f7f7f7;
}
#stacked-bar-chart-legend .legend-item :hover {
opacity: 0.8;
} }
\ No newline at end of file
import * as d3 from 'd3';
import { ChartConfiguration } from '../chart-base/chart-configuration';
import { StackedBarChart } from '../charts/stacked-bar-chart';
export class StackedBarChartHelper {
private stacks: any[] = [
{ stackName: 'Bronze', stackColor: '#A77044' },
{ stackName: 'Silver', stackColor: '#A7A7AD' },
{ stackName: 'Gold', stackColor: '#FEE101' },
];
private container: any;
private containerId: string = 'stacked-bar-chart-container';
private svgId: string = 'stacked-bar-chart';
private config: ChartConfiguration = new ChartConfiguration(`#${this.svgId}`);
private chart: StackedBarChart;
private data: any[] = [];
private currentIndex: number = 0;
private pageSize: number = 10;
private prevBtnId: string = 'stacked-bar-chart-prev-btn'
private prevBtn: any;
private nextBtnId: string = 'stacked-bar-chart-next-btn'
private nextBtn: any;
private rankingTextId: string = 'stacked-bar-chart-ranking-text';
private rankingText: any;
public setData(data: any[]) {
this.data = data;
// Process data (aggregating by country)
const medalCounts = d3.rollup(this.data,
(entries) => ({
Bronze: entries.filter(e => e.Medal === 'Bronze').length,
Silver: entries.filter(e => e.Medal === 'Silver').length,
Gold: entries.filter(e => e.Medal === 'Gold').length,
total: entries.length
}),
d => d.Country
);
this.data = Array.from(medalCounts, ([country, counts]) => ({
id: country, // We'll consider country name as id sice its unique
...counts
}))
.sort((a, b) => b.total - a.total); // Sort descening
}
public appendChart() {
// Add div container
this.container = d3.select('body')
.append('div')
.attr('class', 'container') // bootstrap
.attr('style', 'width: fit-content;')
.attr('id', `${this.containerId}`);
// Add ranking selection
this.container
.append('div')
.attr('class', 'text-center mt-4 mx-auto navigation-controls')
.html(`
<p style='text-align:center; font-weight:700;'>Select ranking:</p>
<button id="${this.prevBtnId}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
</button>
<span id="${this.rankingTextId}"></span>
<button id="${this.nextBtnId}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-right"></i>
</button>
`);
this.prevBtn = d3.select(`#${this.prevBtnId}`);
this.nextBtn = d3.select(`#${this.nextBtnId}`);
this.rankingText = d3.select(`#${this.rankingTextId}`)
this.prevBtn.on('click', () => {
this.currentIndex = Math.max(0, this.currentIndex - this.pageSize);
this.updateChart();
});
this.nextBtn.on('click', () => {
this.currentIndex = Math.min(this.data.length, this.currentIndex + this.pageSize);
this.updateChart();
});
// add svg for chart
this.container
.append('svg')
.attr('id', `${this.svgId}`);
// initilize new chart
this.chart = new StackedBarChart(this.config, this.stacks, 'Countries', 'Total Medals', this.data);
this.updateChart();
}
private updateChart() {
this.prevBtn.attr('disabled', this.currentIndex === 0 ? true : null); // Disable at start
this.nextBtn.attr('disabled', this.currentIndex >= this.data.length - this.pageSize ? true : null);
this.rankingText.text(`${this.currentIndex + 1} - ${Math.min(this.currentIndex + this.pageSize, this.data.length)}`)
const pageData = this.data.slice(this.currentIndex, this.currentIndex + this.pageSize);
this.chart.data = pageData;
this.chart.updateVis(); // Trigger vis update
}
}
\ No newline at end of file
// No dataset-related code here, just pass prepared data correctly using helper
// and you'll get vis with stacks-based filtering and tooltip printing id value
// Modify some hard-coded margins, paddings values to enhance vis appearance
import * as d3 from 'd3';
import { Chart } from '../chart-base/chart';
import { ChartConfiguration } from '../chart-base/chart-configuration';
export class StackedBarChart extends Chart {
public stacks: any[];
private activeKeys: string[];
private xAxisTitle: string;
private yAxisTitle: string;
private legend: d3.Selection<SVGGElement, unknown, HTMLElement, any>;
private tooltip: d3.Selection<HTMLDivElement, unknown, HTMLElement, any>;
constructor(_config: ChartConfiguration, _stacks: any[], xAxisTitle: string, yAxisTitle: string, _data?: any[]) {
super(_config, _data);
this.stacks = _stacks;
this.activeKeys = this.stacks.map(d => d.stackName);
this.xAxisTitle = xAxisTitle;
this.yAxisTitle = yAxisTitle;
this.initVis();
}
private xScale: d3.ScaleBand<string>;
private yScale: d3.ScaleLinear<number, number, never>;
private colorScale: d3.ScaleOrdinal<string, unknown, never>;
private stackGenerator: d3.Stack<any, { [key: string]: number; }, string>;
protected getDefaultMargins() { return { top: 5, right: 100, bottom: 100, left: 100 }; }
protected getDefaultContainerSize() { return { width: 800, height: 400 }; }
protected initVis() {
const vis = this;
// keys are staks in stacked bar chart
const keys = this.stacks.map(d => d.stackName);
// Axes, scales
vis.xScale = d3.scaleBand().padding(0.3);
vis.xScale.range([0, vis.width]);
vis.chart.append('text')
.attr('class', 'axis-title')
.attr('x', vis.width + 40)
.attr('y', vis.height + 6)
.attr('text-anchor', 'middle')
.attr('fill', 'black')
.style('font-weight', 'bold')
.text(this.xAxisTitle);
vis.chart.append('g').attr('id', 'stacked-bar-chart-x-axis');
vis.yScale = d3.scaleLinear();
vis.yScale.range([vis.height, 0]);
vis.chart.append('text')
.attr('class', 'axis-title')
.attr('transform', 'rotate(-90)')
.attr('x', -vis.height / 2)
.attr('y', -50)
.attr('text-anchor', 'middle')
.style('font-weight', 'bold')
.text(this.yAxisTitle);
vis.chart.append('g').attr('id', 'stacked-bar-chart-y-axis');
vis.colorScale = d3.scaleOrdinal()
.domain(keys)
.range(this.stacks.map(d => d.stackColor));
vis.stackGenerator = d3.stack()
.keys(keys)
.order(d3.stackOrderNone) // keep original stack order
.offset(d3.stackOffsetNone);
// legend (passed stacks names)
vis.legend = vis.chart.append('g')
.attr('id', 'stacked-bar-chart-legend')
.attr('transform', `translate(0,${vis.height + 80})`); // Position legend below chart
vis.legend.append('text')
.attr('x', 25)
.attr('y', 15)
.attr('text-anchor', 'middle')
.style('font-weight', 'bold')
.text('Filter by:');
keys.forEach((key, i) => {
const legendItem = vis.legend.append('g')
.attr('class', 'legend-item')
.attr('transform', `translate(${(i + 1) * 80},0)`)
.style('cursor', 'pointer')
.on('click', _ => vis.handleKeyFiltering(key));
legendItem.append('rect')
.attr('width', 20).attr('height', 20)
.attr('fill', String(vis.colorScale(key)));
legendItem.append('text')
.attr('x', 25).attr('y', 15)
.text(key);
});
// Tooltip
this.tooltip = d3.select('body').append('div')
.attr('class', 'tooltip')
.style('opacity', 0)
.style('position', 'absolute')
.style('background', 'white')
.style('border', '1px solid black')
.style('padding', '5px')
.style('pointer-events', 'none');
}
// Selecting key from legend menu handler
private handleKeyFiltering(medalType: string) {
const keys = this.stacks.map(d => d.stackName);
const index = this.activeKeys.indexOf(medalType);
if (index != -1)
this.activeKeys.splice(index, 1);
else {
this.activeKeys.push(medalType);
}
if (this.activeKeys.length === 0) this.activeKeys = keys; // Reset if none selected
this.updateVis();
}
protected renderVis() {
const vis = this;
const keys = this.stacks.map(d => d.stackName);
const orderedKeys = keys.filter(k => vis.activeKeys.includes(k));
vis.stackGenerator.keys(orderedKeys);
const stackedData = vis.stackGenerator(vis.data);
// Enter-Update(Merge)-Exit for layers(each key stack) and rects
const layers = vis.chart.selectAll('.key-layer')
.data(stackedData, (d: any) => d.key);
layers.exit()
.transition().duration(1000)
.style('opacity', 0)
.remove();
const enterLayers = layers.enter()
.append('g')
.attr('class', 'key-layer')
.attr('fill', (d: any) => vis.colorScale(d.key));
const allLayers = enterLayers.merge(layers);
allLayers.each((layerData: any, index: number, groups: any) => {
const layer = d3.select(groups[index]);
const rects = layer.selectAll<SVGRectElement, any>('rect').data(layerData, (d: any) => d.data.id);
rects.exit().
transition().duration(1000)
.attr('y', vis.height).attr('height', 0)
.remove();
const enterRects = rects.enter()
.append('rect')
.attr('x', (d: any) => vis.xScale(d.data.id)!)
.attr('width', vis.xScale.bandwidth())
.attr('y', vis.height)
.attr('height', 0)
.on('mouseover', function (event: any, d: any) {
vis.tooltip.transition().duration(200).style('opacity', .9);
vis.tooltip.html(`${d.data.id}<br>${d[1] - d[0]}`)
.style('left', (event.pageX - 20) + 'px')
.style('top', (event.pageY - 60) + 'px');
})
.on('mousemove', function (event: any) {
vis.tooltip.style('left', (event.pageX - 20) + 'px')
.style('top', (event.pageY - 60) + 'px');
})
.on('mouseout', function () {
vis.tooltip.transition().duration(200).style('opacity', 0);
});
enterRects.merge(rects)
.transition().duration(1000)
.attr('y', (d: any) => vis.yScale(d[1]))
.attr('height', (d: any) => vis.yScale(d[0]) - vis.yScale(d[1]));
});
vis.legend.selectAll('.legend-item')
.style('opacity', (_, index) => this.getLegendItemOpacity(index));
vis.updateAxes();
}
// Selected or not
private getLegendItemOpacity(index: any) {
const vis = this;
const orderedKeys = this.stacks.map(d => d.stackName);
return vis.activeKeys.includes(orderedKeys[index]) ? 1 : 0.3; // Fade inactive items
}
private updateAxes() {
const vis = this;
// Ensure axis changes are correct after changing data
vis.chart.select('#stacked-bar-chart-x-axis')
.attr('transform', `translate(0,${vis.height})`)
.call(d3.axisBottom(vis.xScale))
.selectAll('text')
.attr('transform', 'rotate(-30)')
.style('text-anchor', 'end')
.style('font-size', '13px')
.style('fill', 'black');
vis.chart.select('#stacked-bar-chart-y-axis')
.call(d3.axisLeft(vis.yScale).ticks(5))
.selectAll('text')
.style('font-size', '13px')
.style('fill', 'black');
}
public updateVis() {
const vis = this;
// Update domains and render
const keys = this.stacks.map(d => d.stackName);
const orderedKeys = keys.filter(k => vis.activeKeys.includes(k));
vis.xScale.domain(vis.data.map((d: any) => d.id));
vis.yScale.domain([0, d3.max(vis.data, (d: any) =>
orderedKeys.reduce((sum, key) => sum + d[key], 0)
)!]);
vis.renderVis();
}
}
\ No newline at end of file
import * as d3 from 'd3'; import * as d3 from 'd3';
import '/src/css/style.css'; import '/src/css/style.css';
import 'bootstrap'; import 'bootstrap';
import { StackedBarChartHelper } from './charts-helpers/stacked-bar-chart-helper';
const datasetPath = import.meta.env.VITE_DATASET_PATH; const datasetPath = import.meta.env.VITE_DATASET_PATH;
d3.csv(datasetPath).then(data => { console.log(data[0]); }) let rawData: any[];
\ No newline at end of file const stackerBarChartHelper = new StackedBarChartHelper();
d3.csv(datasetPath).then(data => {
rawData = data.filter(d => d.Medal !== '');
stackerBarChartHelper.setData(rawData);
stackerBarChartHelper.appendChart();
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment