Skip to content

Commit 1ac995a

Browse files
committed
feat: allow month and day units
fixes #20
1 parent 0a0497d commit 1ac995a

File tree

6 files changed

+174
-35
lines changed

6 files changed

+174
-35
lines changed

graphs/bar_chart_race.py

Lines changed: 101 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import re
2+
import logging
3+
from datetime import datetime
4+
from datetime import timedelta
5+
from concurrent.futures import ThreadPoolExecutor
6+
17
import pandas as pd
28
from pandas.errors import ParserError
3-
import re
9+
10+
logger = logging.getLogger("django")
411

512

613
class BaseDf:
@@ -13,6 +20,7 @@ def prepare(self) -> "BaseDf":
1320
self.prepare_value_column()
1421
self.prepare_identifier_columns()
1522
self.drop_other_columns()
23+
self.drop_duplicates()
1624
return self
1725

1826
def verify_column_count(self):
@@ -22,10 +30,9 @@ def verify_column_count(self):
2230
def prepare_date_column(self):
2331
original_name = self.df.columns[-1]
2432
try:
25-
# TODO: allow other units of time
2633
self.df[original_name] = pd.to_datetime(
2734
self.df[original_name], format="ISO8601"
28-
).dt.year
35+
)
2936
except (ValueError, ParserError):
3037
raise BaseDfException("last column must be a date column")
3138
self.df.rename(columns={original_name: "date"}, inplace=True)
@@ -58,6 +65,9 @@ def drop_other_columns(self):
5865
drop = [col for col in self.df.columns if col not in keep]
5966
self.df.drop(columns=drop, inplace=True)
6067

68+
def drop_duplicates(self):
69+
self.df.drop_duplicates(["name", "date"], inplace=True)
70+
6171

6272
class BaseDfException(Exception):
6373
def __init__(self, message):
@@ -66,20 +76,40 @@ def __init__(self, message):
6676

6777

6878
class DfProcessor:
69-
def __init__(self, bdf: BaseDf):
70-
self.df = bdf.df
79+
def __init__(self, bdf: BaseDf, time_unit: str = "year"):
80+
self.df = bdf.df.copy()
81+
self.time_unit = time_unit
82+
if self.time_unit == "year":
83+
self.df["date"] = self.df["date"].dt.strftime("%Y-01-01")
84+
elif self.time_unit == "month":
85+
self.df["date"] = self.df["date"].dt.strftime("%Y-%m-01")
86+
else:
87+
self.df["date"] = self.df["date"].dt.strftime("%Y-%m-%d")
7188

7289
def elements(self):
7390
identifiers = [col for col in ["url", "category"] if col in self.df.columns]
7491
if not identifiers:
75-
return {name: {} for name in self.df["name"].unique()}
92+
return [{"name": name for name in self.df["name"].unique()}]
7693
agg = {col: "first" for col in identifiers}
77-
return self.df[["name", *identifiers]].groupby("name").agg(agg).reset_index().to_dict("records")
94+
return (
95+
self.df[["name", *identifiers]]
96+
.groupby("name")
97+
.agg(agg)
98+
.reset_index()
99+
.to_dict("records")
100+
)
101+
102+
def year_count(self):
103+
min_year = int(self.df["date"].min()[:4])
104+
max_year = int(self.df["date"].max()[:4])
105+
return max_year - min_year + 1
78106

79107
def interpolated_df(self):
80108
df = self.df
109+
names = df["name"].unique()
110+
time_units = self.all_time_units()
81111
mux = pd.MultiIndex.from_product(
82-
[df["name"].unique(), range(df["date"].min(), df["date"].max() + 1)],
112+
[names, time_units],
83113
names=["name", "date"],
84114
)
85115
df = (
@@ -96,18 +126,58 @@ def interpolated_df(self):
96126
df["rank"] = df.groupby("date")["value"].rank(method="dense", ascending=False)
97127
return df
98128

129+
def all_time_units(self):
130+
min_year = int(self.df["date"].min()[:4])
131+
max_year = int(self.df["date"].max()[:4])
132+
year_range = range(min_year, max_year + 1)
133+
if self.time_unit == "year":
134+
year_range = range(min_year, max_year + 1)
135+
return [f"{y}-01-01" for y in year_range]
136+
elif self.time_unit == "month":
137+
start_month = int(self.df["date"].min()[5:7])
138+
start = [f"{min_year}-{m:02d}-01" for m in range(start_month, 13)]
139+
year_range = list(year_range)
140+
year_range.pop(0)
141+
if len(year_range) == 0:
142+
return start
143+
year_range.pop(-1)
144+
between = [
145+
f"{y}-{m}"
146+
for m in [
147+
"01-01",
148+
"02-01",
149+
"03-01",
150+
"04-01",
151+
"05-01",
152+
"06-01",
153+
"07-01",
154+
"08-01",
155+
"09-01",
156+
"10-01",
157+
"11-01",
158+
"12-01",
159+
]
160+
for y in year_range
161+
]
162+
end_month = int(self.df["date"].max()[5:7])
163+
end = [f"{max_year}-{m:02d}-01" for m in range(1, end_month + 1)]
164+
return [*start, *between, *end]
165+
else:
166+
start_date = datetime.strptime(self.df["date"].min(), "%Y-%m-%d").date()
167+
end_date = datetime.strptime(self.df["date"].max(), "%Y-%m-%d").date()
168+
days = (end_date - start_date).days + 1
169+
days_index = []
170+
for n in range(days):
171+
date = start_date + timedelta(days=n)
172+
days_index.append(date.strftime("%Y-%m-%d"))
173+
return days_index
174+
99175
def values_by_date(self):
100176
ip = self.interpolated_df()
101177
vl = []
102-
for date in list(sorted(ip["date"].unique())):
103-
date = int(date)
104-
values = (
105-
ip.loc[ip["date"] == date]
106-
.drop(columns="date")
107-
.sort_values("rank")
108-
.to_dict(orient="records")
109-
)
110-
vl.append({"date": f"{date}-01-01", "values": values})
178+
for date, grouped in ip.set_index("date").groupby(level=0):
179+
values = grouped.sort_values("rank").to_dict(orient="records")
180+
vl.append({"date": date, "values": values})
111181
return vl
112182

113183

@@ -119,5 +189,17 @@ def process_bar_chart_race(df):
119189
return {"failed": e.message}
120190
proc = DfProcessor(bdf)
121191
elements = proc.elements()
122-
values_by_date = proc.values_by_date()
123-
return {"elements": elements, "values_by_date": values_by_date}
192+
data = {"elements": elements}
193+
run_daily = proc.year_count() <= 25
194+
proc_monthly = DfProcessor(bdf, time_unit="month")
195+
proc_daily = DfProcessor(bdf, time_unit="day")
196+
with ThreadPoolExecutor() as executor:
197+
t = executor.submit(proc.values_by_date)
198+
t_monthly = executor.submit(proc_monthly.values_by_date)
199+
if run_daily:
200+
t_daily = executor.submit(proc_daily.values_by_date)
201+
data["values_by_date"] = t.result()
202+
data["values_by_date_monthly"] = t_monthly.result()
203+
if run_daily:
204+
data["values_by_date_daily"] = t_daily.result()
205+
return data

graphs/tests.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pandas as pd
2+
from datetime import datetime
23
from numpy import nan
34
from django.test import TestCase
45
from django.utils.timezone import now
@@ -158,3 +159,27 @@ def test_df_processor(self):
158159
2428708.0,
159160
],
160161
)
162+
163+
def test_df_processor_month(self):
164+
df = TestHelper.mock_df_bcr()
165+
bdf = BaseDf(df).prepare()
166+
proc = DfProcessor(bdf, time_unit="month")
167+
ip = proc.interpolated_df()
168+
self.assertEqual(ip["value"].count(), 57)
169+
months_between = 6 + 12
170+
self.assertEqual(len(ip["date"].unique()), 19)
171+
self.assertEqual(len(ip["date"].unique()), months_between + 1)
172+
vls = proc.values_by_date()
173+
self.assertEqual(len(vls), 19)
174+
175+
def test_df_processor_day(self):
176+
df = TestHelper.mock_df_bcr()
177+
bdf = BaseDf(df).prepare()
178+
proc = DfProcessor(bdf, time_unit="day")
179+
ip = proc.interpolated_df()
180+
self.assertEqual(ip["value"].count(), 1650)
181+
days_between = (datetime(2022, 1, 1) - datetime(2020, 7, 1)).days
182+
self.assertEqual(len(ip["date"].unique()), 550)
183+
self.assertEqual(len(ip["date"].unique()), days_between + 1)
184+
vls = proc.values_by_date()
185+
self.assertEqual(len(vls), 550)

src/Components/Infographics/BarChartRace/barChartRace.jsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import api from '../../../api/axios';
1616
* @param {string} props.colorPalette - List of colors for the chart
1717
* @param {Array} props.barRaceData - Data for the bar chart race
1818
*/
19-
const BarChartRace = ({ title, speed, colorPalette, barRaceData, isDownloadingVideo, setIsDownloadingVideo }) => {
19+
const BarChartRace = ({ title, speed, colorPalette, timeUnit, barRaceData, isDownloadingVideo, setIsDownloadingVideo }) => {
2020
const DEFAULT_TRANSITION_DELAY = 250;
2121
const DOWNLOAD_WAIT_MULTIPLIER = 4;
2222
const svgRef = useRef(null); // Reference to the SVG element
@@ -35,7 +35,13 @@ const BarChartRace = ({ title, speed, colorPalette, barRaceData, isDownloadingVi
3535
useEffect(() => {
3636
const fetchDataAsync = () => {
3737
if (barRaceData) {
38-
var keyframes = barRaceData.values_by_date.map(d => [new Date(d.date), d.values]);
38+
var data_to_use = barRaceData.values_by_date;
39+
if (timeUnit === "day") {
40+
data_to_use = barRaceData.values_by_date_daily;
41+
} else if (timeUnit === "month") {
42+
data_to_use = barRaceData.values_by_date_monthly;
43+
};
44+
var keyframes = data_to_use.map(d => [new Date(d.date), d.values]);
3945

4046
const dataset = {
4147
"elements": barRaceData.elements,
@@ -46,7 +52,7 @@ const BarChartRace = ({ title, speed, colorPalette, barRaceData, isDownloadingVi
4652
}
4753
};
4854
fetchDataAsync();
49-
}, [barRaceData]);
55+
}, [barRaceData, timeUnit]);
5056

5157
useEffect(() => {
5258
if (dataset) {
@@ -64,7 +70,7 @@ const BarChartRace = ({ title, speed, colorPalette, barRaceData, isDownloadingVi
6470
};
6571

6672
const width = container.clientWidth;
67-
const keyframes = initializeChart(svgRef, dataset, width, title, colorPaletteArray);
73+
const keyframes = initializeChart(svgRef, dataset, width, title, colorPaletteArray, timeUnit);
6874
keyframesRef.current = keyframes;
6975

7076
// Initialize chart with the first keyframe.
@@ -82,7 +88,7 @@ const BarChartRace = ({ title, speed, colorPalette, barRaceData, isDownloadingVi
8288
}
8389
};
8490

85-
}, [dataset, title, speed, colorPalette]);
91+
}, [dataset, timeUnit, title, colorPalette]);
8692

8793
const animationDelay = () => {
8894
return 1000 / speed;

src/Components/Infographics/BarChartRace/barChartRaceUtils.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ export let color;
1212

1313
// Variables
1414
let updateBars, updateAxis, updateLabels, updateTicker, x;
15+
let dateFormatter;
1516

1617
// Function to initialize the chart
17-
export const initializeChart = (svgRef, dataset, width, title, colorPaletteArray) => {
18+
export const initializeChart = (svgRef, dataset, width, title, colorPaletteArray, timeUnit) => {
1819
const chartMargin = 30; // Adjust this value to increase the space
1920

2021
// Create SVG element
@@ -61,6 +62,18 @@ export const initializeChart = (svgRef, dataset, width, title, colorPaletteArray
6162
color = (x) => scale(x.name);
6263
}
6364

65+
// define date format
66+
let dateFormat = { year: "numeric" };
67+
68+
if (timeUnit === "day") {
69+
dateFormat = { year: "numeric", month: "numeric", day: "numeric" };
70+
} else if (timeUnit === "month") {
71+
dateFormat = { year: "numeric", month: "long" };
72+
};
73+
74+
// undefined uses the browser's default locale
75+
dateFormatter = Intl.DateTimeFormat(undefined, dateFormat);
76+
6477
// Initialize update functions
6578
updateBars = bars(svgRef.current, x, y, prev, next);
6679
updateAxis = axis(svgRef.current, x, y, width);
@@ -86,8 +99,6 @@ export const updateChart = (keyframe, transition) => {
8699

87100
// Ticker function
88101
function ticker(svgRef, width, keyframes) {
89-
const formatDate = d3.utcFormat("%Y");
90-
91102
const now = svgRef
92103
.append("text")
93104
.style("font", `bold ${barSize}px var(--sans-serif)`)
@@ -96,10 +107,10 @@ function ticker(svgRef, width, keyframes) {
96107
.attr("x", width - 6)
97108
.attr("y", margin.top + barSize * (n - 0.45))
98109
.attr("dy", "0.32em")
99-
.text(formatDate(keyframes[0][0]));
110+
.text(dateFormatter.format(keyframes[0][0]));
100111

101112
return ([date], transition) => {
102-
transition.end().then(() => now.text(formatDate(date)));
113+
transition.end().then(() => now.text(dateFormatter.format(date)));
103114
};
104115
}
105116

src/Components/Modal/modal.jsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { Button, Modal, TextInput, Label, Select, HelperText } from "flowbite-re
44
import { useEffect, useState } from "react";
55

66

7-
export function InfoModal({ currState, onCloseModal, handleChartDisplay, handleChartTitle, handleChartSpeed, handleChartColorPalette }) {
7+
export function InfoModal({ barRaceData, currState, onCloseModal, handleChartDisplay, handleChartTitle, handleChartSpeed, handleChartColorPalette, handleChartTimeUnit }) {
88
const [openModal, setOpenModal] = useState(false);
99
const [chartTitle, setChartTitle] = useState("");
1010
const [chartSpeed, setChartSpeed] = useState(5);
1111
const [chartColorPalette, setChartColorPalette] = useState("");
12+
const [chartTimeUnit, setChartTimeUnit] = useState("year");
1213

1314
useEffect(() => {
1415
setOpenModal(currState);
@@ -31,6 +32,12 @@ export function InfoModal({ currState, onCloseModal, handleChartDisplay, handleC
3132
handleChartColorPalette(value);
3233
}
3334

35+
const handleTimeUnitChange = (event) => {
36+
var value = event.target.value;
37+
setChartTimeUnit(value);
38+
handleChartTimeUnit(value);
39+
}
40+
3441
const handleChartType = () => {
3542
handleChartDisplay("Bar chart race");
3643
}
@@ -53,16 +60,18 @@ export function InfoModal({ currState, onCloseModal, handleChartDisplay, handleC
5360
<div className="mb-2 block">
5461
<Label htmlFor="chartUnit">Speed unit</Label>
5562
</div>
56-
<Select id="chartUnit" required>
57-
<option>Years</option>
63+
<Select id="chartUnit" value={chartTimeUnit} onChange={handleTimeUnitChange} required>
64+
<option value="year">Years</option>
65+
{barRaceData?.hasOwnProperty("values_by_date_monthly") && <option value="month">Months</option>}
66+
{barRaceData?.hasOwnProperty("values_by_date_daily") && <option value="day">Days</option>}
5867
</Select>
5968
</div>
6069

6170
<div className="max-w-md">
6271
<div className="mb-2 block">
6372
<Label htmlFor="chartSpeed">Speed in units per second</Label>
6473
</div>
65-
<TextInput id="chartSpeed" type="number" min="1" max="10" placeholder="Speed in units per second" value={chartSpeed} onChange={handleSpeedChange} required />
74+
<TextInput id="chartSpeed" type="number" min="1" max="50" placeholder="Speed in units per second" value={chartSpeed} onChange={handleSpeedChange} required />
6675
</div>
6776

6877
<div className="max-w-md">

0 commit comments

Comments
 (0)