From 3feb16e739e1fd1e4fbfde0ddb63b615e0662696 Mon Sep 17 00:00:00 2001 From: SlashStars1 <126016850+SlashStars1@users.noreply.github.com> Date: Sun, 1 Dec 2024 17:46:36 -0600 Subject: [PATCH] [feat] Piechart (#87) * Pie chart percentages + text labels * Added pie chart * Apply formatting * Fix TS build errors * Sort categories in descending order --------- Co-authored-by: TetraTsunami <78718829+TetraTsunami@users.noreply.github.com> --- package-lock.json | 9 +- src/components/graphs/DummyGraph.tsx | 2 +- src/components/graphs/PieChart.tsx | 143 +++++++++++++++++++++++++++ src/pages/StatsDashboard.tsx | 5 +- 4 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 src/components/graphs/PieChart.tsx diff --git a/package-lock.json b/package-lock.json index a593105..42ac90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2274,9 +2274,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001597", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz", - "integrity": "sha512-7LjJvmQU6Sj7bL0j5b5WY/3n7utXUJvAe1lxhsHDbLmwX9mdL86Yjtr+5SRCyf8qME4M7pU2hswj0FpyBVCv9w==", + "version": "1.0.30001684", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001684.tgz", + "integrity": "sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==", "dev": true, "funding": [ { @@ -2291,7 +2291,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "2.4.2", diff --git a/src/components/graphs/DummyGraph.tsx b/src/components/graphs/DummyGraph.tsx index 5c6854d..c7cd5be 100644 --- a/src/components/graphs/DummyGraph.tsx +++ b/src/components/graphs/DummyGraph.tsx @@ -64,4 +64,4 @@ export default function DummyGraph({ incidents, bounds }: { incidents: [string, ) -} +} \ No newline at end of file diff --git a/src/components/graphs/PieChart.tsx b/src/components/graphs/PieChart.tsx new file mode 100644 index 0000000..d8cfe43 --- /dev/null +++ b/src/components/graphs/PieChart.tsx @@ -0,0 +1,143 @@ +import { DB, Incident } from '@/types' +import * as d3 from 'd3' +import { useRef, useEffect } from 'react' + +export default function PieChart({ incidents, data }: { incidents: [string, Incident][]; data: DB }) { + const containerRef = useRef(null) + const d3Ref = useRef(null) + + //gets label and color for each incident + const parsedData = incidents.map((incident) => { + return { + label: data.Categories[data.Types[incident[1].typeID].categoryID].name, + color: data.Categories[data.Types[incident[1].typeID].categoryID].color, + } + }) + let categories = Object.values(data.Categories).map((c) => c.name) //we want in this format: ["drugs", "robbery"] + + type Category = { label: string; value: number } + let cleanData: Category[] = [] //should end up having this format for the pie chart: [{label: "drugs", value: 10} , {}] + + let totalValue = 0 + //creates cleanData + categories.forEach((category) => { + let categoryEntries = parsedData.filter((c) => c.label == category) + let value = 0 + + // for each category count how many incidents were in the category (Creating the value number) + categoryEntries.forEach(() => { + value++ + totalValue++ + }) + + cleanData.push({ label: category, value: value }) + }) + cleanData = cleanData.filter((d) => d.value > 0).sort((a, b) => d3.descending(a.value, b.value)) + + useEffect(() => { + function render() { + if (d3Ref.current) { + const svg = d3.select(d3Ref.current) + // Clear previous contents + svg.selectAll('*').remove() + + // Set dimensions + const width = 400 + const height = 200 + + svg.attr('preserveAspectRatio', 'xMinYMin meet').attr('viewBox', `0 0 ${width} ${height}`) + + const g = svg.append('g') + g.append('g').attr('class', 'slices') + g.append('g').attr('class', 'labels') + g.append('g').attr('class', 'lines') + + g.attr('transform', 'translate(' + width / 4 + ',' + height / 2 + ')') + + const radius = Math.min(width, height) / 2 + + const pie = d3 + .pie() + .sort(null) + .value(function (d) { + return d.value + }) + + //helper function for color + //reduce() iterates over the Categories array and builds an object "acc" so that + //acc will be similar to a dictionary in the form: {"drugs": "black", "robbery": "white"} + const labelColorMap = Object.values(data.Categories).reduce( + (acc, category) => { + acc[category.name] = category.color + return acc + }, + {} as Record + ) + + const color = d3 + .scaleOrdinal() + .domain(cleanData.map((d) => d.label)) + .range(cleanData.map((d) => labelColorMap[d.label])) + + const arc = d3 + .arc() + .outerRadius(radius * 0.8) + .innerRadius(radius * 0.4) + + /* ------- PIE SLICES -------*/ + + const pieData = pie(cleanData) + + // Draw slices + const slice = g.select('.slices').selectAll('path.slice').data(pieData) + + slice + .enter() + .append('path') + .attr('class', 'slice') + .style('fill', (d) => color(d.data.label) as string) + .attr('d', arc as any) + + slice.exit().remove() + + /* ------- LEGEND ------- */ + const legend = svg + .append('g') + .attr('class', 'legend') + .attr('transform', `translate(${width - 200}, 20)`) // Position legend to the right of the pie chart + + const legendItems = legend + .selectAll('.legend-item') + .data(cleanData) + .enter() + .append('g') + .attr('transform', (_, i) => `translate(0, ${i * 20})`) // Position each legend item + + // Add colored rectangles + legendItems + .append('rect') + .attr('width', 12) + .attr('height', 12) + .attr('fill', (d) => color(d.label) as string) + + // Add text labels + legendItems + .append('text') + .attr('x', 18) // Position text next to rectangle + .attr('y', 10) // Vertically align text with rectangle + .text((d) => `${d.label} (${Math.floor((d.value / totalValue) * 100)}%)`) + .style('font-size', '12px') + .attr('fill', '#000') + } + } + addEventListener('resize', render) + render() + return () => removeEventListener('resize', render) + }, [incidents]) + + return ( + + + + ) +} diff --git a/src/pages/StatsDashboard.tsx b/src/pages/StatsDashboard.tsx index 1b3c10c..da3d542 100644 --- a/src/pages/StatsDashboard.tsx +++ b/src/pages/StatsDashboard.tsx @@ -5,6 +5,7 @@ import IncidentTable from '@/components/IncidentTable' import StatisticsFilterBar from '@/components/StatisticsFilterBar' import { calculateBounds } from '@/utils' import DummyGraph from '@/components/graphs/DummyGraph' +import PieChart from '@/components/graphs/PieChart' import StatisticsFilterMap from '@/components/StatisticsFilterMap' export type filterDispatchType = { type: 'ADD_FILTER' | 'REMOVE_FILTER' | 'UPDATE_FILTER'; payload: Partial } @@ -71,12 +72,14 @@ const StatsDashboard: React.FC = ({ data }) => { const filteredBounds = calculateBounds(Object.fromEntries(filteredIncidents)) return ( + Estadísticas setIsShowingMap(!isShowingMap)}> {isShowingMap ? 'Ocultar Mapa' : 'Mostrar Mapa'} + {isShowingMap ? ( @@ -84,7 +87,7 @@ const StatsDashboard: React.FC = ({ data }) => { ) : ( <> - +