Skip to content

Commit 6bb26e9

Browse files
committed
feat: Implement some site statistics
1 parent 9cd61c2 commit 6bb26e9

15 files changed

Lines changed: 2209 additions & 9 deletions

File tree

website/app-templates/smarty/js/d3/countries_heatmap.js

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,14 @@ window.initCountriesHeatmap = function(config) {
3434
const {
3535
anchor = "#countries-map-chart",
3636
dataUrl,
37+
data, // Optional: pass data directly instead of fetching
3738
worldUrl,
38-
topojsonUrl
39+
topojsonUrl,
40+
legendTitle = i18nCountries.legendTitle,
41+
onDataLoaded // Optional: callback when data is loaded
3942
} = config;
4043

41-
if (!dataUrl || !worldUrl || !topojsonUrl) {
44+
if ((!dataUrl && !data) || !worldUrl || !topojsonUrl) {
4245
console.error(i18nCountries.missingConfig, config);
4346
return;
4447
}
@@ -278,7 +281,7 @@ window.initCountriesHeatmap = function(config) {
278281
.attr("text-anchor", "middle")
279282
.attr("fill", "#516373")
280283
.attr("font-size", 11)
281-
.text(i18nCountries.legendTitle);
284+
.text(legendTitle);
282285
}
283286

284287
function render() {
@@ -412,9 +415,9 @@ window.initCountriesHeatmap = function(config) {
412415
const code = String(entry.country).toUpperCase();
413416
dataByCode.set(code, {
414417
country: code,
415-
move_count: Number(entry.move_count) || 0,
418+
move_count: Number(entry.move_count || entry.count) || 0,
416419
mover_count: Number(entry.mover_count) || 0,
417-
last_moved_on_datetime: entry.last_moved_on_datetime || null,
420+
last_moved_on_datetime: entry.last_moved_on_datetime || entry.last_activity || null,
418421
});
419422
});
420423

@@ -434,11 +437,24 @@ window.initCountriesHeatmap = function(config) {
434437

435438
showPlaceholder(i18nCountries.placeholderLoading);
436439

440+
// Fetch or use provided data
441+
const dataPromise = data
442+
? Promise.resolve(data)
443+
: d3.json(dataUrl).catch(() => null);
444+
437445
Promise.all([
438-
d3.json(dataUrl).catch(() => null),
446+
dataPromise,
439447
d3.json(worldUrl).catch(() => null),
440448
]).then(async ([statsRaw, worldRaw]) => {
441-
const stats = Array.isArray(statsRaw) ? statsRaw : [];
449+
// Handle new API response structure (data might be in response.data)
450+
const statsResponse = statsRaw && statsRaw.data ? statsRaw.data : statsRaw;
451+
const stats = Array.isArray(statsResponse) ? statsResponse : [];
452+
453+
// Call onDataLoaded callback with the full response
454+
if (onDataLoaded && typeof onDataLoaded === "function") {
455+
onDataLoaded(statsRaw);
456+
}
457+
442458
if (!normaliseData(stats)) {
443459
return;
444460
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Simple Pie Chart for displaying distribution data
3+
*
4+
* //{literal}
5+
* Initialize with:
6+
* initPieChart({
7+
* anchor: "#chart",
8+
* data: [{label: "Drop", count: 100, percentage: 25}, ...],
9+
* colorScheme: d3.schemeCategory10
10+
* });
11+
* //{/literal}
12+
*/
13+
14+
// Translations
15+
const i18nPieChart = {
16+
missingConfig: "{t}Pie chart: missing required configuration{/t}",
17+
missingD3: "{t}Pie chart: d3 library not loaded{/t}",
18+
missingContainer: "{t}Pie chart: container not found{/t}",
19+
placeholderNoData: "{t}No data available{/t}",
20+
};
21+
22+
//{literal}
23+
window.initPieChart = function(config) {
24+
const {
25+
anchor,
26+
data = [],
27+
colorScheme = d3.schemeCategory10
28+
} = config;
29+
30+
if (!anchor) {
31+
console.error(i18nPieChart.missingConfig, config);
32+
return;
33+
}
34+
35+
function init() {
36+
if (typeof d3 === "undefined" || !d3) {
37+
console.error(i18nPieChart.missingD3);
38+
return;
39+
}
40+
41+
const container = d3.select(anchor);
42+
if (container.empty()) {
43+
console.warn(i18nPieChart.missingContainer, anchor);
44+
return;
45+
}
46+
47+
const margin = { top: 20, right: 120, bottom: 20, left: 20 };
48+
49+
container
50+
.attr("preserveAspectRatio", "xMidYMid meet")
51+
.style("overflow", "visible")
52+
.style("width", "100%")
53+
.style("height", "100%")
54+
.style("display", "block");
55+
56+
function computeSize() {
57+
const node = container.node();
58+
const parent = node ? (node.parentElement || node) : null;
59+
const rect = parent ? parent.getBoundingClientRect() : { width: 400, height: 300 };
60+
return {
61+
fullW: Math.max(300, Math.floor(rect.width || 400)),
62+
fullH: Math.max(200, Math.floor(rect.height || 300)),
63+
};
64+
}
65+
66+
function render() {
67+
if (!data || data.length === 0) {
68+
container.selectAll("*").remove();
69+
container.append("text")
70+
.attr("x", "50%")
71+
.attr("y", "50%")
72+
.attr("text-anchor", "middle")
73+
.attr("fill", "#7a869a")
74+
.attr("font-size", 14)
75+
.text(i18nPieChart.placeholderNoData);
76+
return;
77+
}
78+
79+
const { fullW, fullH } = computeSize();
80+
const width = fullW - margin.left - margin.right;
81+
const height = fullH - margin.top - margin.bottom;
82+
const radius = Math.min(width - 120, height) / 2;
83+
84+
container.attr("viewBox", `0 0 ${fullW} ${fullH}`);
85+
container.selectAll("*").remove();
86+
87+
const g = container.append("g")
88+
.attr("transform", `translate(${margin.left + radius},${margin.top + height / 2})`);
89+
90+
const colorScale = d3.scaleOrdinal(colorScheme);
91+
92+
// Sort data by count descending for consistent legend ordering
93+
const sortedData = [...data].sort((a, b) => b.count - a.count);
94+
95+
const pie = d3.pie()
96+
.value(d => d.count)
97+
.sort(null);
98+
99+
const arc = d3.arc()
100+
.innerRadius(0)
101+
.outerRadius(radius);
102+
103+
const arcs = g.selectAll(".arc")
104+
.data(pie(sortedData))
105+
.enter()
106+
.append("g")
107+
.attr("class", "arc");
108+
109+
arcs.append("path")
110+
.attr("d", arc)
111+
.attr("fill", (d, i) => colorScale(i))
112+
.attr("stroke", "white")
113+
.attr("stroke-width", 2)
114+
.style("opacity", 0.8)
115+
.on("mouseover", function() {
116+
d3.select(this).style("opacity", 1);
117+
})
118+
.on("mouseout", function() {
119+
d3.select(this).style("opacity", 0.8);
120+
});
121+
122+
// Add legend
123+
const legend = container.append("g")
124+
.attr("transform", `translate(${margin.left + radius * 2 + 30},${margin.top + 20})`);
125+
126+
const legendItems = legend.selectAll(".legend-item")
127+
.data(sortedData)
128+
.enter()
129+
.append("g")
130+
.attr("class", "legend-item")
131+
.attr("transform", (d, i) => `translate(0,${i * 25})`);
132+
133+
legendItems.append("rect")
134+
.attr("width", 18)
135+
.attr("height", 18)
136+
.attr("fill", (d, i) => colorScale(i))
137+
.style("opacity", 0.8);
138+
139+
legendItems.append("text")
140+
.attr("x", 24)
141+
.attr("y", 9)
142+
.attr("dy", "0.35em")
143+
.style("font-size", "12px")
144+
.text(d => `${d.label}: ${d.count.toLocaleString()} (${d.percentage.toFixed(1)}%)`);
145+
}
146+
147+
render();
148+
149+
// Handle resize
150+
let resizeTimeout;
151+
window.addEventListener("resize", function() {
152+
clearTimeout(resizeTimeout);
153+
resizeTimeout = setTimeout(render, 250);
154+
});
155+
}
156+
157+
if (document.readyState === "loading") {
158+
document.addEventListener("DOMContentLoaded", init);
159+
} else {
160+
init();
161+
}
162+
};
163+
//{/literal}

0 commit comments

Comments
 (0)