1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-25 06:33:48 +00:00

Feature/statistics view (#8182)

* statistics layout, statistics api

* update layout and charts

* reponsive layout, optimize distributed by time chart

* update emptytip and chart title

* change text

* fix ui

* remove empty files

* remove empty files

* performance optimization

* opt-format-datetime

* Update apis.py

---------

Co-authored-by: zhouwenxuan <aries@Mac.local>
Co-authored-by: Michael An <1822852997@qq.com>
Co-authored-by: r350178982 <32759763+r350178982@users.noreply.github.com>
This commit is contained in:
Aries
2025-09-04 08:43:25 +08:00
committed by GitHub
parent c21ea4111c
commit e63ac801e7
19 changed files with 1822 additions and 1 deletions

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="53px" height="40px" viewBox="0 0 53 40" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>协作者数量</title>
<g id="seafile" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="统计视图" transform="translate(-1456, -706)">
<g id="创建时间" transform="translate(345, 577)">
<g id="协作者数量" transform="translate(1111, 129)">
<rect id="path4" fill="#EDF0FF" x="0" y="4.89230769" width="34.4615385" height="34.4615385" rx="17.2307692"></rect>
<path d="M33.4969591,15.9579086 C35.4668826,14.2447402 36.595837,11.7606028 36.5909414,9.1499476 C36.5909414,6.72396149 35.6309469,4.39597483 33.9249567,2.67998465 C32.2217201,0.965837683 29.9054605,0.00132934377 27.4889935,0 C25.0718332,0.00079707802 22.7547557,0.965346255 21.0510304,2.67998465 C19.3424397,4.39969246 18.3839696,6.72576198 18.3850456,9.1499476 C18.386452,11.7596665 19.5151182,14.2415655 21.4810279,15.9579086 C18.9285513,17.120788 16.7655028,18.9949671 15.2510636,21.3558777 C13.7332088,23.720935 12.9252904,26.4716056 12.9230769,29.2818323 C12.9230769,29.7658295 13.1150758,30.2318269 13.4570739,30.5758249 C13.9769377,31.1022131 14.7641583,31.2606097 15.4471528,30.9762483 C16.1301474,30.691887 16.5723592,30.0216226 16.5650561,29.2818323 C16.5650561,26.369849 17.7150495,23.575865 19.7650377,21.5158768 C21.8094462,19.4594377 24.589244,18.3027482 27.4889935,18.3018952 C30.3849769,18.3018952 33.162961,19.4578886 35.2129493,21.5158768 C37.2639289,23.579961 38.4144073,26.3720284 38.412931,29.2818323 C38.412931,30.2869848 39.227768,31.1018219 40.2329205,31.1018219 C41.238073,31.1018219 42.0529101,30.2869848 42.0529101,29.2818323 C42.0511911,26.4709089 41.2432482,23.7194608 39.7249234,21.3538777 C38.2107288,18.9939991 36.0484363,17.1205678 33.4969591,15.9579086 Z M27.4889935,14.6399162 C26.4089997,14.6399162 25.3510058,14.319918 24.4530109,13.7159215 C23.0943784,12.796111 22.2157334,11.3192208 22.0556919,9.68633367 C21.8956505,8.05344653 22.4708256,6.43406547 23.6250156,5.26796983 C25.1849947,3.70112469 27.5365869,3.23112223 29.5789815,4.07797665 C30.5769758,4.49397426 31.4289709,5.19797023 32.0289675,6.09996507 C33.4767135,8.27903679 33.1925611,11.1757198 31.3489714,13.0319254 C30.3274205,14.0600206 28.938319,14.6386929 27.4889935,14.6399162 L27.4889935,14.6399162 Z M45.220892,15.2259128 C47.6047862,12.5280618 48.1898846,8.68502911 46.7168834,5.39996908 C45.9989126,3.79308935 44.832179,2.42773331 43.3569026,1.46799159 C41.8856836,0.510562868 40.1682528,0.000613754306 38.412931,0 C37.9293602,0 37.4657811,0.192970931 37.1249383,0.535996931 C36.6083615,1.06067186 36.4551998,1.84320694 36.7358521,2.52391958 C37.0165044,3.20463222 37.6766775,3.65183593 38.412931,3.65997904 C39.8609227,3.65997904 41.2509147,4.23997572 42.2729088,5.26796983 C43.298903,6.29796393 43.8728997,7.69395594 43.8728997,9.1499476 C43.8667727,11.1019729 42.8282882,12.9050558 41.1429153,13.8899205 C40.5920195,14.2086993 40.2471357,14.7915909 40.2329205,15.4279117 C40.218051,16.0585239 40.5276006,16.6527081 41.0529158,17.0019026 L41.7629118,17.4778999 L41.9989104,17.6058992 C44.1960103,18.6545639 46.0474398,20.3096087 47.3348799,22.3758719 C48.6255814,24.4466232 49.3005167,26.8418455 49.2808687,29.2818323 C49.2830333,30.2877146 50.095003,31.1041457 51.1008583,31.1118218 C51.584429,31.1118218 52.0480081,30.9188509 52.3888509,30.5758249 C52.7309364,30.232063 52.9230769,29.7668011 52.9228479,29.2818323 C52.9376203,26.4746314 52.237652,23.70986 50.8888595,21.2478783 C49.5434655,18.7909637 47.5923906,16.7187211 45.220892,15.2279128 L45.220892,15.2259128 Z" id="combie" fill="#6395FA" fill-rule="nonzero"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="70px" height="46px" viewBox="0 0 70 46" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>文件数量</title>
<g id="seafile" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="统计视图" transform="translate(-1144, -702)">
<g id="文件数量" transform="translate(1144, 702)">
<rect id="矩形" x="0" y="0" width="70" height="46"></rect>
<rect id="path9" fill="#FCEFC9" x="0" y="8.87044271" width="36.4583333" height="36.4583333" rx="18.2291667"></rect>
<g id="wenjian" transform="translate(16.2435, 0)" fill="#FBBC30" fill-rule="nonzero">
<path d="M17.5671967,8.24617523 C17.7556807,8.24617523 17.9348218,8.32782455 18.0583113,8.4704062 L22.5079962,13.6009083 C22.6104019,13.719101 22.6668265,13.8702366 22.6668265,14.0266222 L22.6668265,33.5940242 C22.6668265,33.9523063 22.3751637,34.2439691 22.0168816,34.2439691 L3.24972423,34.2439691 C2.89144213,34.2439691 2.59977938,33.9523063 2.59977938,33.5940242 L2.59977938,8.89612007 C2.59977938,8.53783798 2.89144213,8.24617523 3.24972423,8.24617523 L17.5671967,8.24617523 M17.5671967,5.64639585 L3.24972423,5.64639585 C1.45506402,5.64639585 0,7.10145987 0,8.89612007 L0,33.5940242 C0,35.3886844 1.45506402,36.8437484 3.24972423,36.8437484 L22.0168816,36.8437484 C23.8115418,36.8437484 25.2666059,35.3886844 25.2666059,33.5940242 L25.2666059,14.0266222 C25.2666059,13.2442511 24.9842861,12.488284 24.4720483,11.8972404 L20.0223634,6.76673827 C19.40501,6.05513054 18.5092746,5.64639585 17.5671967,5.64639585 Z" id="image"></path>
<path d="M29.2999199,32.4159992 L27.4345781,32.4159992 C26.7167953,32.4159992 26.1346885,31.8338923 26.1346885,31.1161095 C26.1346885,30.3983266 26.7167953,29.8162198 27.4345781,29.8162198 L29.2999199,29.8162198 C30.3239892,29.8162198 31.156731,29.006226 31.156731,28.0101856 L31.156731,9.30924127 C31.156731,8.87824659 30.9979008,8.46106324 30.7094877,8.13365353 L26.3869483,3.23022588 C26.0335408,2.82929116 25.5196781,2.59977938 24.9773804,2.59977938 L11.0689669,2.59977938 C10.3511841,2.59977938 9.76907724,2.01767253 9.76907724,1.29988969 C9.76907724,0.582106852 10.3511841,0 11.0689669,0 L24.9769742,0 C26.2654899,0 27.4902297,0.550828257 28.3367828,1.51112177 L32.6597285,6.41454941 C33.3669497,7.21682508 33.7565104,8.24495658 33.7565104,9.30924127 L33.7565104,28.0101856 C33.7565104,30.4397606 31.7571176,32.4159992 29.2999199,32.4159992 Z" id="path6"></path>
<path d="M24.5354179,16.3298642 L18.4458409,16.3298642 C16.7413606,16.3298642 15.354947,15.051504 15.354947,13.4802623 L15.354947,7.08846097 L17.9547264,7.08846097 L17.9547264,13.4802623 C17.9547264,13.5566308 18.1403669,13.7300849 18.4458409,13.7300849 L24.5354179,13.7300849 L24.5354179,16.3298642 Z M32.4972423,10.5616037 L26.4076653,10.5616037 C24.7031849,10.5616037 23.3167713,9.28324347 23.3167713,7.71200181 L23.3167713,1.32020047 L25.9165507,1.32020047 L25.9165507,7.71200181 C25.9165507,7.78837033 26.1021912,7.96182436 26.4076653,7.96182436 L32.4972423,7.96182436 L32.4972423,10.5616037 Z M17.7568994,29.1865857 L6.69768163,29.1865857 C5.97989879,29.1865857 5.39779194,29.7686926 5.39779194,30.4864754 C5.39779194,31.2042583 5.97989879,31.7863651 6.69768163,31.7863651 L17.7568994,31.7863651 C18.4746822,31.7863651 19.0567891,31.2042583 19.0567891,30.4864754 C19.0567891,29.7686926 18.4746822,29.1865857 17.7568994,29.1865857 Z M17.7568994,23.2659944 L6.69768163,23.2659944 C5.97989879,23.2659944 5.39779194,23.8481012 5.39779194,24.5658841 C5.39779194,25.2836669 5.97989879,25.8657738 6.69768163,25.8657738 L17.7568994,25.8657738 C18.4746822,25.8657738 19.0567891,25.2836669 19.0567891,24.5658841 C19.0567891,23.8481012 18.4746822,23.2659944 17.7568994,23.2659944 Z M17.7568994,17.3454031 L6.69768163,17.3454031 C5.97989879,17.3454031 5.39779194,17.9275099 5.39779194,18.6452928 C5.39779194,19.3630756 5.97989879,19.9451824 6.69768163,19.9451824 L17.7568994,19.9451824 C18.4746822,19.9451824 19.0567891,19.3630756 19.0567891,18.6452928 C19.0567891,17.9275099 18.4746822,17.3454031 17.7568994,17.3454031 Z" id="combie"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
<style type="text/css">
.st0{fill:#949494;}
</style>
<title>app-statistics</title>
<g id="app-statistics">
<g id="统计" transform="translate(1.000000, 1.000000)">
<path fill="currentColor" id="形状结合" class="st0" d="M12.3,12.9c0.4,0,0.8,0.4,0.8,0.9v15.3c0,0.5-0.3,0.9-0.8,0.9H9.8C9.3,30,9,29.6,9,29.1
V13.8c0-0.5,0.3-0.9,0.8-0.9H12.3z M29.3,10c0.4,0,0.8,0.4,0.8,0.9v18.2c0,0.5-0.3,0.9-0.8,0.9h-2.5c-0.4,0-0.8-0.4-0.8-0.9V10.9
c0-0.5,0.3-0.9,0.8-0.9H29.3z M20.3,17.6c0.4,0,0.8,0.4,0.8,0.9v10.6c0,0.5-0.3,0.9-0.8,0.9h-2.5c-0.4,0-0.8-0.4-0.8-0.9V18.5
c0-0.5,0.3-0.9,0.8-0.9H20.3z M3.3,19.5c0.4,0,0.8,0.4,0.8,0.9v8.7C4,29.6,3.7,30,3.3,30H0.8C0.3,30,0,29.6,0,29.1v-8.7
c0-0.5,0.3-0.9,0.8-0.9H3.3z M30,0l-0.7,6.6l-1.7-1.8l-7.3,9.6c-0.2,0.3-0.5,0.4-0.9,0.5c-0.3,0-0.7-0.1-0.9-0.4l0,0l-7.1-7
l-9.3,9c-0.2,0.2-0.5,0.3-0.8,0.3c-0.3,0-0.7-0.1-0.9-0.4c-0.5-0.5-0.4-1.4,0.1-1.9l0,0l10.2-9.8c0.5-0.4,1.2-0.4,1.7,0l0,0
l6.9,6.8l6.7-8.7l-2-2.2L30,0z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -10,6 +10,7 @@ export const KEY_ADD_VIEW_MAP = {
ADD_GALLERY: 'ADD_GALLERY',
ADD_KANBAN: 'ADD_KANBAN',
ADD_MAP: 'ADD_MAP',
ADD_STATISTICS: 'ADD_STATISTICS',
};
const ADD_VIEW_OPTIONS = [
@@ -25,6 +26,10 @@ const ADD_VIEW_OPTIONS = [
key: KEY_ADD_VIEW_MAP.ADD_KANBAN,
type: VIEW_TYPE.KANBAN,
},
{
key: KEY_ADD_VIEW_MAP.ADD_STATISTICS,
type: VIEW_TYPE.STATISTICS,
}
];
const translateLabel = (type) => {
@@ -37,6 +42,8 @@ const translateLabel = (type) => {
return gettext('Kanban');
case VIEW_TYPE.MAP:
return gettext('Map');
case VIEW_TYPE.STATISTICS:
return gettext('Statistics');
default:
return type;
}

View File

@@ -36,6 +36,10 @@ const ViewsMoreOperations = ({ menuProps }) => {
addView(VIEW_TYPE.MAP);
return;
}
case KEY_ADD_VIEW_MAP.ADD_STATISTICS: {
addView(VIEW_TYPE.STATISTICS);
return;
}
default: {
return;
}

View File

@@ -408,6 +408,12 @@ class MetadataManagerAPI {
return this.req.post(url, params);
};
// statistics
getStatistics = (repoID) => {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/statistics/';
return this.req.get(url);
};
}
const metadataAPI = new MetadataManagerAPI();

View File

@@ -7,6 +7,7 @@ import { SORT_COLUMN_OPTIONS, GALLERY_SORT_COLUMN_OPTIONS, GALLERY_FIRST_SORT_CO
export * from './gallery';
export * from './kanban';
export * from './map';
export * from './statistics';
export * from './table';
export const METADATA_VIEWS_KEY = 'sf-metadata-views';
@@ -25,6 +26,7 @@ export const VIEW_TYPE = {
FACE_RECOGNITION: 'face_recognition',
KANBAN: 'kanban',
MAP: 'map',
STATISTICS: 'statistics',
};
export const FACE_RECOGNITION_VIEW_ID = '_face_recognition';
@@ -35,6 +37,7 @@ export const VIEW_TYPE_ICON = {
[VIEW_TYPE.FACE_RECOGNITION]: 'face-recognition-view',
[VIEW_TYPE.KANBAN]: 'kanban',
[VIEW_TYPE.MAP]: 'map',
[VIEW_TYPE.STATISTICS]: 'statistics',
'image': 'image'
};
@@ -92,6 +95,13 @@ export const VIEW_TYPE_DEFAULT_BASIC_FILTER = {
filter_term: []
},
],
[VIEW_TYPE.STATISTICS]: [
{
column_key: PRIVATE_COLUMN_KEY.IS_DIR,
filter_predicate: FILTER_PREDICATE_TYPE.IS,
filter_term: 'file'
},
],
};
export const VIEW_TYPE_DEFAULT_SORTS = {
@@ -100,6 +110,7 @@ export const VIEW_TYPE_DEFAULT_SORTS = {
[VIEW_TYPE.FACE_RECOGNITION]: [{ column_key: PRIVATE_COLUMN_KEY.FILE_CTIME, sort_type: SORT_TYPE.DOWN }],
[VIEW_TYPE.KANBAN]: [],
[VIEW_TYPE.MAP]: [{ column_key: PRIVATE_COLUMN_KEY.FILE_CTIME, sort_type: SORT_TYPE.DOWN }],
[VIEW_TYPE.STATISTICS]: [],
};
export const VIEW_SORT_COLUMN_RULES = {

View File

@@ -0,0 +1,36 @@
import { gettext } from '../../../utils/constants';
import { PREDEFINED_FILE_TYPE_OPTION_KEY } from '../column/predefined';
export const FILE_TYPE_NAMES = {
[PREDEFINED_FILE_TYPE_OPTION_KEY.PICTURE]: gettext('Pictures'),
[PREDEFINED_FILE_TYPE_OPTION_KEY.DOCUMENT]: gettext('Documents'),
[PREDEFINED_FILE_TYPE_OPTION_KEY.VIDEO]: gettext('Videos'),
[PREDEFINED_FILE_TYPE_OPTION_KEY.AUDIO]: gettext('Audios'),
[PREDEFINED_FILE_TYPE_OPTION_KEY.CODE]: gettext('Codes'),
[PREDEFINED_FILE_TYPE_OPTION_KEY.COMPRESSED]: gettext('Compresseds'),
[PREDEFINED_FILE_TYPE_OPTION_KEY.DIAGRAM]: gettext('Diagrams'),
other: gettext('Others'),
};
export const FILE_TYPE_COLORS = {
[PREDEFINED_FILE_TYPE_OPTION_KEY.PICTURE]: '#9867ba',
[PREDEFINED_FILE_TYPE_OPTION_KEY.DOCUMENT]: '#4ecdc4',
[PREDEFINED_FILE_TYPE_OPTION_KEY.VIDEO]: '#93c4fd',
[PREDEFINED_FILE_TYPE_OPTION_KEY.AUDIO]: '#79ddcb',
[PREDEFINED_FILE_TYPE_OPTION_KEY.CODE]: '#f6c038',
[PREDEFINED_FILE_TYPE_OPTION_KEY.COMPRESSED]: '#9397fd',
[PREDEFINED_FILE_TYPE_OPTION_KEY.DIAGRAM]: '#74b9ff',
other: '#636e72',
};
export const TIME_GROUPING_OPTIONS = [
{ value: 'created', label: gettext('Created time') },
{ value: 'modified', label: gettext('Modified time') },
];
export const CREATOR_SORT_OPTIONS = [
{ value: 'count-asc', text: gettext('Ascending by count') },
{ value: 'count-desc', text: gettext('Descending by count') },
{ value: 'name-asc', text: gettext('Ascending by name') },
{ value: 'name-desc', text: gettext('Descending by name') }
];

View File

@@ -0,0 +1,454 @@
import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
export const BarChart = ({ data, unit }) => {
const svgRef = useRef();
const containerRef = useRef();
const isDark = document.body.getAttribute('data-bs-theme') === 'dark';
useEffect(() => {
if (!data || data.length === 0) return;
const container = containerRef.current;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const containerWidth = container.offsetWidth;
const barWidth = 24;
const minBarSpacing = 24;
const totalBarWidth = barWidth + minBarSpacing;
const requiredWidth = data.length * totalBarWidth;
const marginReserve = 40;
const shouldScroll = requiredWidth > containerWidth - marginReserve;
const chartWidth = shouldScroll ? requiredWidth : Math.max(containerWidth, requiredWidth);
const margin = {
top: 15,
right: 30,
bottom: 60,
left: 20
};
const width = chartWidth - margin.left - margin.right;
const height = 250 - margin.top - margin.bottom;
const g = svg
.attr('width', chartWidth)
.attr('height', 250)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const xScale = d3.scaleBand()
.domain(data.map(d => d.name))
.range([0, width])
.paddingInner(minBarSpacing / totalBarWidth)
.paddingOuter(0.1);
const actualBarWidth = barWidth;
const maxValue = d3.max(data, d => d.value);
const yScale = d3.scaleLinear().domain([0, maxValue]).range([height, 0]).nice();
const yAxisTickFormat = maxValue > 1000 ? d3.format('.1s') : d3.format('d');
const yAxis = g.append('g')
.call(d3.axisLeft(yScale)
.tickSize(-width)
.tickFormat(yAxisTickFormat)
.ticks(4)
);
yAxis.selectAll('text')
.style('font-size', '12px')
.style('fill', 'var(--bs-body-secondary-color)')
.style('color', 'var(--bs-body-secondary-color)');
yAxis.selectAll('.tick line')
.style('stroke', isDark ? 'var(--bs-border-color)' : '#f5f5f5')
.style('stroke-width', 1)
.style('opacity', 0.7);
yAxis.select('.domain').remove();
g.selectAll('.bar')
.data(data)
.enter()
.append('path')
.attr('class', 'bar')
.attr('fill', '#ff9800')
.attr('d', d => {
const x = xScale(d.name) + (xScale.bandwidth() - actualBarWidth) / 2;
const width = actualBarWidth;
const height = 0;
const y = height;
const radius = Math.min(4, actualBarWidth / 6);
return `M${x},${y}
L${x},${y - height + radius}
Q${x},${y - height} ${x + radius},${y - height}
L${x + width - radius},${y - height}
Q${x + width},${y - height} ${x + width},${y - height + radius}
L${x + width},${y}
Z`;
})
.transition()
.duration(400)
.delay((d, i) => i * 100)
.attr('d', d => {
const x = xScale(d.name) + (xScale.bandwidth() - actualBarWidth) / 2;
const width = actualBarWidth;
const barHeight = height - yScale(d.value);
const y = height;
const radius = Math.min(4, barHeight / 2, actualBarWidth / 6);
if (barHeight <= 0) return '';
return `M${x},${y}
L${x},${y - barHeight + radius}
Q${x},${y - barHeight} ${x + radius},${y - barHeight}
L${x + width - radius},${y - barHeight}
Q${x + width},${y - barHeight} ${x + width},${y - barHeight + radius}
L${x + width},${y}
Z`;
});
const xAxis = g.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(xScale).tickSize(0));
xAxis.selectAll('text').remove();
xAxis.selectAll('.custom-tick-text')
.data(data)
.enter()
.append('g')
.attr('class', 'custom-tick-text')
.attr('transform', d => `translate(${xScale(d.name) + xScale.bandwidth() / 2}, 0)`)
.each(function (d) {
const tickGroup = d3.select(this);
const dateText = d.name;
const monthYearMatch = dateText.match(/^([A-Za-z]{3,})\s+(\d{4})$/);
if (monthYearMatch) {
const month = monthYearMatch[1];
const year = monthYearMatch[2];
tickGroup.append('text')
.attr('x', 0)
.attr('y', 12)
.attr('text-anchor', 'middle')
.style('font-size', '11px')
.style('fill', 'var(--bs-body-secondary-color)')
.style('color', 'var(--bs-body-secondary-color)')
.text(month);
tickGroup.append('text')
.attr('x', 0)
.attr('y', 26)
.attr('text-anchor', 'middle')
.style('font-size', '10px')
.style('fill', 'var(--bs-body-secondary-color)')
.style('color', 'var(--bs-body-secondary-color)')
.text(year);
} else {
tickGroup.append('text')
.attr('x', 0)
.attr('y', 16)
.attr('text-anchor', 'middle')
.style('font-size', '11px')
.style('fill', 'var(--bs-body-secondary-color)')
.style('color', 'var(--bs-body-secondary-color)')
.text(dateText.length > 8 ? dateText.substring(0, 6) + '...' : dateText);
}
});
xAxis.select('.domain').remove();
g.selectAll('.value-label')
.data(data)
.enter()
.append('text')
.attr('class', 'value-label')
.attr('x', d => xScale(d.name) + xScale.bandwidth() / 2)
.attr('y', d => yScale(d.value) - 4)
.attr('text-anchor', 'middle')
.style('font-size', '11px')
.style('fill', 'var(--bs-body-secondary-color)')
.style('color', 'var(--bs-body-secondary-color)')
.style('opacity', 0)
.text(d => yAxisTickFormat(d.value))
.transition()
.duration(400)
.delay((d, i) => i * 100 + 400)
.style('opacity', 1);
g.selectAll('.bar')
.on('mouseover', function (event, d) {
d3.select(this).transition().duration(200).attr('fill', '#e65100');
})
.on('mouseout', function (event, d) {
d3.select(this).transition().duration(200).attr('fill', '#ff9800');
});
}, [data, unit, isDark]);
const barWidth = 24;
const minBarSpacing = 24;
const totalBarWidth = barWidth + minBarSpacing;
const requiredWidth = (data?.length || 0) * totalBarWidth;
const needsScrolling = requiredWidth > 400;
return (
<div ref={containerRef} className="bar-chart-responsive" style={{
overflowX: needsScrolling ? 'auto' : 'visible',
overflowY: 'hidden',
}}>
<svg ref={svgRef} style={{
width: needsScrolling ? `${requiredWidth + 40}px` : '100%',
height: '250px',
minWidth: needsScrolling ? `${requiredWidth + 40}px` : '100%'
}}>
</svg>
</div>
);
};
export const HorizontalBarChart = ({ data }) => {
const svgRef = useRef();
const containerRef = useRef();
const isDark = document.body.getAttribute('data-bs-theme') === 'dark';
useEffect(() => {
if (!data || data.length === 0) return;
const container = containerRef.current;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const containerWidth = container.offsetWidth;
const isMobile = containerWidth < 480;
const margin = {
top: 10,
right: isMobile ? 60 : 80,
bottom: 30,
left: isMobile ? 100 : 120
};
const width = Math.max(350, containerWidth - margin.left - margin.right);
const minBarSpacing = 26;
const barHeight = 18;
const totalItemHeight = barHeight + minBarSpacing;
const requiredContentHeight = data.length * totalItemHeight;
const containerHeight = container.offsetHeight || 340;
const availableHeight = containerHeight - margin.top - margin.bottom;
const shouldScroll = requiredContentHeight > availableHeight;
const svgHeight = shouldScroll ? requiredContentHeight + margin.top + margin.bottom : containerHeight;
const actualContentHeight = shouldScroll ? requiredContentHeight : availableHeight;
const centerOffset = shouldScroll ? 0 : Math.max(0, (availableHeight - requiredContentHeight) / 2);
const g = svg
.attr('width', '100%')
.attr('height', svgHeight)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const maxValue = d3.max(data, d => d.value);
const getNiceNumber = (value, round = true) => {
const exponent = Math.floor(Math.log10(value));
const fraction = value / Math.pow(10, exponent);
let niceFraction;
if (round) {
if (fraction < 1.5) niceFraction = 1;
else if (fraction < 3) niceFraction = 2;
else if (fraction < 7) niceFraction = 5;
else niceFraction = 10;
} else {
if (fraction <= 1) niceFraction = 1;
else if (fraction <= 2) niceFraction = 2;
else if (fraction <= 5) niceFraction = 5;
else niceFraction = 10;
}
return niceFraction * Math.pow(10, exponent);
};
const range = getNiceNumber(maxValue, false);
const stepSize = getNiceNumber(range / 4, true);
const niceMax = Math.ceil(maxValue / stepSize) * stepSize;
const adjustedMax = Math.max(niceMax, stepSize * 4);
const finalStepSize = adjustedMax / 4;
const xScale = d3.scaleLinear().domain([0, adjustedMax]).range([0, width - 60]);
const yScale = d3.scaleBand()
.domain(data.map(d => d.name))
.range([centerOffset, centerOffset + (shouldScroll ? requiredContentHeight : Math.min(requiredContentHeight, availableHeight))])
.paddingInner(minBarSpacing / totalItemHeight)
.paddingOuter(0.1);
const xAxis = g.append('g')
.attr('transform', `translate(0,${actualContentHeight})`)
.call(d3.axisBottom(xScale)
.tickValues([0, finalStepSize, finalStepSize * 2, finalStepSize * 3, adjustedMax])
.tickFormat(d3.format('d'))
.tickSize(-actualContentHeight)
);
xAxis.selectAll('text')
.style('font-size', isMobile ? '10px' : '11px')
.style('color', '#666')
.attr('dy', '20px');
xAxis.selectAll('.tick line')
.style('stroke', isDark ? 'var(--bs-border-color)' : '#f5f5f5')
.style('stroke-width', 1);
xAxis.select('.domain').remove();
const barY = d => yScale(d.name) + (yScale.bandwidth() - barHeight) / 2;
g.selectAll('.bar')
.data(data)
.enter()
.append('path')
.attr('class', 'bar')
.attr('fill', '#6395fa')
.attr('d', d => {
const width = 0;
const height = barHeight;
const y = barY(d);
return `M0,${y} L${width},${y} L${width},${y + height} L0,${y + height} Z`;
})
.transition()
.duration(400)
.delay((d, i) => i * 100)
.attr('d', d => {
const width = xScale(d.value);
const height = barHeight;
const y = barY(d);
const radius = Math.min(3, width / 2);
if (width <= 0) return '';
return `M0,${y}
L${width - radius},${y}
Q${width},${y} ${width},${y + radius}
L${width},${y + height - radius}
Q${width},${y + height} ${width - radius},${y + height}
L0,${y + height}
Z`;
});
const yAxisGroup = g.append('g').attr('class', 'y-axis');
data.forEach((d, i) => {
const yPos = yScale(d.name) + yScale.bandwidth() / 2;
const itemGroup = yAxisGroup.append('g').attr('transform', `translate(0, ${yPos})`);
const avatarCenterX = -margin.left + 4 + 14;
itemGroup.append('circle')
.attr('cx', avatarCenterX)
.attr('cy', 0)
.attr('r', 14)
.attr('fill', '#f0f0f0')
.attr('stroke', '#ddd')
.attr('stroke-width', 1);
itemGroup.append('clipPath')
.attr('id', `avatar-clip-${i}`)
.append('circle')
.attr('cx', avatarCenterX)
.attr('cy', 0)
.attr('r', 14);
itemGroup.append('image')
.attr('x', avatarCenterX - 14)
.attr('y', -14)
.attr('width', 28)
.attr('height', 28)
.attr('href', d.avatarUrl)
.attr('clip-path', `url(#avatar-clip-${i})`)
.style('opacity', 0)
.transition()
.duration(400)
.delay(i * 100)
.style('opacity', 1);
const nameX = avatarCenterX + 14 + 12;
const maxNameWidth = 70;
const nameText = itemGroup.append('text')
.attr('x', nameX)
.attr('y', 0)
.attr('dy', '0.35em')
.attr('text-anchor', 'start')
.style('font-size', '13px')
.style('fill', isDark ? 'var(--bs-body-secondary-color)' : '#333')
.style('color', isDark ? 'var(--bs-body-secondary-color)' : '#333');
itemGroup.select('image').append('title').text(d.displayName);
itemGroup.select('text').append('title').text(d.displayName);
nameText.text(d.displayName);
let textWidth = nameText.node().getBBox().width;
if (textWidth > maxNameWidth) {
let truncatedName = d.displayName;
while (truncatedName.length > 3) {
truncatedName = truncatedName.slice(0, -1);
nameText.text(truncatedName + '...');
textWidth = nameText.node().getBBox().width;
if (textWidth <= maxNameWidth) break;
}
}
});
g.selectAll('.value-label')
.data(data)
.enter()
.append('text')
.attr('class', 'value-label')
.attr('x', d => xScale(d.value) + 4)
.attr('y', d => barY(d) + barHeight / 2)
.attr('dy', '0.35em')
.style('font-size', '12px')
.style('fill', '#666')
.style('color', '#666')
.style('opacity', 0)
.text(d => d.value)
.transition()
.duration(400)
.delay((d, i) => i * 100 + 400)
.style('opacity', 1);
}, [data, isDark]);
const minBarSpacing = 26;
const barHeight = 18;
const totalItemHeight = barHeight + minBarSpacing;
const requiredHeight = (data?.length || 0) * totalItemHeight;
const needsScrolling = requiredHeight > 300;
return (
<div ref={containerRef} style={{
width: '100%',
height: '340px',
overflowY: needsScrolling ? 'auto' : 'visible',
overflowX: 'hidden'
}}>
<svg ref={svgRef} style={{
width: '100%',
height: needsScrolling ? 'auto' : '100%',
minHeight: '100%'
}}>
</svg>
</div>
);
};

View File

@@ -0,0 +1,192 @@
import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
const PieChart = ({ data }) => {
const svgRef = useRef();
const containerRef = useRef();
const isDark = document.body.getAttribute('data-bs-theme') === 'dark';
useEffect(() => {
if (!data || data.length === 0) return;
const container = containerRef.current;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const tooltip = d3.select(container)
.append('div')
.attr('class', 'pie-chart-tooltip')
.style('position', 'absolute')
.style('background', 'rgba(0, 0, 0, 0.8)')
.style('color', 'white')
.style('padding', '8px 12px')
.style('border-radius', '4px')
.style('font-size', '12px')
.style('pointer-events', 'none')
.style('opacity', 0)
.style('z-index', '1000');
const containerWidth = container.offsetWidth;
const isMobile = containerWidth < 480;
const radius = 150;
const pieSize = (radius + 20) * 2;
svg.attr('width', '100%').attr('height', pieSize);
const g = svg.append('g')
.attr('transform', `translate(${pieSize / 2}, ${pieSize / 2})`);
const pie = d3.pie().value(d => d.value).sort(null);
const arc = d3.arc().innerRadius(0).outerRadius(radius);
const arcs = g.selectAll('.arc')
.data(pie(data))
.enter()
.append('g')
.attr('class', 'arc');
arcs.append('path')
.attr('d', arc)
.attr('fill', d => d.data.color || '#95a5a6')
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.style('opacity', 0.9)
.on('mouseover', function (event, d) {
const total = d3.sum(data, d => d.value);
const percentage = Math.round((d.data.value / total) * 100);
d3.select(this)
.style('opacity', 1)
.transition()
.duration(200)
.attr('transform', function (d) {
const centroid = arc.centroid(d);
return `translate(${centroid[0] * 0.05}, ${centroid[1] * 0.05})`;
});
const containerRect = container.getBoundingClientRect();
tooltip
.style('opacity', 1)
.html(`<strong>${d.data.label}</strong><br/>Count: ${d.data.value}<br/>Percentage: ${percentage}%`)
.style('left', (event.clientX - containerRect.left + 50) + 'px')
.style('top', (event.clientY - containerRect.top + 100) + 'px');
})
.on('mousemove', function (event, d) {
const containerRect = container.getBoundingClientRect();
tooltip
.style('left', (event.clientX - containerRect.left + 50) + 'px')
.style('top', (event.clientY - containerRect.top + 100) + 'px');
})
.on('mouseout', function (event, d) {
d3.select(this)
.style('opacity', 0.9)
.transition()
.duration(200)
.attr('transform', 'translate(0,0)');
tooltip.style('opacity', 0);
});
const total = d3.sum(data, d => d.value);
const minSlicePercentage = 0.04;
arcs.filter(d => (d.data.value / total) >= minSlicePercentage)
.each(function (d) {
const textGroup = d3.select(this);
const sliceCenterAngle = (d.startAngle + d.endAngle) / 2;
const percentage = Math.round((d.data.value / total) * 100);
const labelText = `${d.data.value}(${percentage}%)`;
const isRightSide = sliceCenterAngle < Math.PI;
const baselineRotation = (sliceCenterAngle * 180 / Math.PI) - 90;
const textRotation = isRightSide ? baselineRotation : baselineRotation + 180;
const measurementText = textGroup.append('text')
.style('font-size', '13px')
.text(labelText)
.style('opacity', 0);
const labelWidth = measurementText.node().getBBox().width;
measurementText.remove();
const edgeMargin = 10;
const maxLabelDistance = radius - edgeMargin;
const safeLabelDistance = Math.min(maxLabelDistance, radius - labelWidth - edgeMargin);
const labelX = Math.cos(sliceCenterAngle - Math.PI / 2) * safeLabelDistance;
const labelY = Math.sin(sliceCenterAngle - Math.PI / 2) * safeLabelDistance;
const textAnchor = isRightSide ? 'start' : 'end';
textGroup.append('text')
.attr('transform', `translate(${labelX}, ${labelY}) rotate(${textRotation})`)
.attr('text-anchor', textAnchor)
.attr('dominant-baseline', 'central')
.style('font-size', '13px')
.style('font-weight', '400')
.style('fill', 'none')
.style('stroke', '#fff')
.style('stroke-width', '2px')
.style('stroke-linejoin', 'round')
.text(labelText);
// Then draw the main text (colored fill, no stroke)
textGroup.append('text')
.attr('transform', `translate(${labelX}, ${labelY}) rotate(${textRotation})`)
.attr('text-anchor', textAnchor)
.attr('dominant-baseline', 'central')
.style('font-size', '13px')
.style('font-weight', '400')
.style('fill', '#666')
.style('color', '#666')
.text(labelText);
});
const legendContainer = svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${pieSize + 15}, ${pieSize / 2 - (data.length * 11)})`);
const legend = legendContainer.selectAll('.legend-item')
.data(data)
.enter()
.append('g')
.attr('class', 'legend-item')
.attr('transform', (d, i) => `translate(0, ${i * 22})`);
legend.append('rect')
.attr('width', 20)
.attr('height', 6)
.attr('rx', 3)
.attr('fill', d => d.color || '#95a5a6');
legend.append('text')
.attr('x', 26)
.attr('y', 3)
.attr('dy', '0.35em')
.style('font-size', '13px')
.style('color', isDark ? 'var(--bs-body-secondary-color)' : '#333')
.style('fill', isDark ? 'var(--bs-body-secondary-color)' : '#333')
.text(d => {
const maxLength = isMobile ? 10 : 15;
const label = d.label.length > maxLength ? d.label.substring(0, maxLength) + '...' : d.label;
return label;
});
}, [data, isDark]);
useEffect(() => {
const currentContainer = containerRef.current;
return () => {
d3.select(currentContainer).selectAll('.pie-chart-tooltip').remove();
};
}, []);
return (
<div ref={containerRef} style={{ width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', paddingLeft: '30px' }}>
<svg ref={svgRef}></svg>
</div>
);
};
export default PieChart;

View File

@@ -0,0 +1,25 @@
import { gettext } from '../../../../utils/constants';
import Icon from '../../../../components/icon';
const SummaryCards = ({ totalFiles, totalCollaborators }) => {
return (
<div className="card-container">
<div className="summary-card">
<Icon className="summary-icon" symbol="file-count" />
<div className="summary-content">
<div className="summary-number">{totalFiles.toLocaleString()}</div>
<div className="summary-label">{gettext('File count')}</div>
</div>
</div>
<div className="summary-card">
<Icon className="summary-icon" symbol="collaborator-count" />
<div className="summary-content">
<div className="summary-number">{totalCollaborators.toLocaleString()}</div>
<div className="summary-label">{gettext('Collaborator count')}</div>
</div>
</div>
</div>
);
};
export default SummaryCards;

View File

@@ -0,0 +1,3 @@
export { default as PieChart } from './PieChart';
export { BarChart, HorizontalBarChart } from './BarChart';
export { default as SummaryCards } from './SummaryCards';

View File

@@ -0,0 +1,413 @@
.statistics-view {
height: 100%;
overflow: auto;
padding: 20px;
box-sizing: border-box;
}
.statistics-container {
width: 100%;
min-width: 1200px;
min-height: 100%;
height: max-content;
gap: 20px;
display: grid;
grid-template-columns: 550px 1fr auto;
grid-template-rows: 420px 340px;
grid-template-areas:
"file-type creator creator"
"time time summary";
}
.chart-container {
background: var(--bs-body-bg);
border-radius: 12px;
padding: 20px;
box-shadow: 0 -2px 4px #f0f0f0;
border: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
overflow: hidden;
transition: box-shadow 0.2s ease;
min-height: 300px;
}
[data-bs-theme=dark] .chart-container {
border-color: var(--bs-border-color);
box-shadow: none;
}
.chart-container:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.chart-container:focus-within {
outline: 2px solid #3498db;
outline-offset: 2px;
}
.chart-container.file-type-chart-container {
grid-area: file-type;
}
.chart-container.creator-chart-container {
grid-area: creator;
}
.chart-container.time-chart-container {
grid-area: time;
gap: 15px;
}
.chart-container.summary-chart-container {
grid-area: summary;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 24px;
width: fit-content;
}
.chart-header {
width: 100%;
height: 36px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.chart-header h4 {
color: var(--bs-body-color);
font-size: 14px;
margin: 0;
border-bottom: none;
padding-bottom: 0;
}
.chart-header .dropdown {
color: var(--bs-icon-color);
}
.chart-header .dropdown:hover {
background-color: var(--bs-hover-bg);
border-radius: 3px;
}
.pie-chart-container,
.horizontal-bar-chart-container,
.bar-chart-container {
flex: 1;
display: flex;
min-height: 0;
}
.pie-chart-container {
justify-content: center;
align-items: center;
min-height: 320px;
}
.bar-chart-container {
height: 240px;
overflow-x: auto;
}
.horizontal-bar-chart-container {
width: 100%;
height: 340px;
overflow-x: auto;
justify-content: center;
align-items: center;
}
.bar-chart-responsive {
width: 100%;
height: 240px;
display: flex;
flex-direction: column;
min-width: 400px;
}
.bar-chart-responsive svg {
flex: 1;
height: 240px;
}
.card-container {
display: flex;
gap: 20px;
}
.summary-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 20px;
background: #fef7ea;
border-radius: 10px;
width: 240px;
height: 240px;
flex-shrink: 0;
}
.summary-card:nth-child(2) {
background: #f8f9fd;
}
.summary-icon {
width: 50px;
height: 50px;
font-size: 32px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.summary-content {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
justify-content: space-between;
}
.summary-number {
font-size: 42px;
font-weight: 700;
color: #212529;
line-height: 1;
margin: 0;
}
.summary-label {
font-size: 16px;
color: #666;
font-weight: 500;
letter-spacing: 0.5px;
margin: 0;
}
.chart-container.time-chart-container .sf-metadata-radio-group {
height: 28px;
border: none;
padding: 2px;
background-color: #f3f3f3;
}
.chart-container.time-chart-container .sf-metadata-radio-group::before {
content: none;
}
[data-bs-theme=dark] .chart-container.time-chart-container .sf-metadata-radio-group {
background-color: var(--bs-nav-active-bg);
}
.chart-container.time-chart-container .sf-metadata-radio-group .sf-metadata-radio-group-option.active {
background-color: var(--bs-body-bg);
}
.chart-container.time-chart-container .sf-metadata-time-grouping-setter .sf-metadata-radio-group-option {
height: 24px;
font-size: 13px;
padding: 0 6px;
background-color: transparent;
border-radius: 3px;
}
.sf-metadata-time-grouping-setter[data-active="created"]::before {
transform: translateX(0);
}
.sf-metadata-time-grouping-setter[data-active="modified"]::before {
transform: translateX(98px);
}
.statistics-loading {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
min-height: 400px;
}
.statistics-error {
text-align: center;
padding: 48px 24px;
color: #666;
font-size: 16px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid var(--bs-border-color);
}
.no-data-message {
text-align: center;
padding: 40px 20px;
color: #666;
font-size: 14px;
font-style: italic;
background: #f8f9fa;
border-radius: 8px;
}
.arc path,
.bar {
transition: all 0.2s ease;
}
.arc path:hover {
opacity: 0.8;
stroke-width: 3px;
}
.bar:hover {
opacity: 0.8;
}
.chart-wrapper {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.arc path,
.bar {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.statistics-view .domain {
stroke: #999;
}
.statistics-view .tick line {
stroke: #999;
}
.statistics-view .tick text {
fill: #666;
font-size: 12px;
}
@media (max-width: 1023.98px) {
.statistics-view {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
min-height: 100%;
padding: 20px;
}
.statistics-container {
grid-template-columns: 1fr;
grid-template-rows: 420px 420px minmax(420px, auto) 420px;
grid-template-areas:
"file-type"
"creator"
"time"
"summary";
max-width: 550px;
min-width: auto;
margin: 0 auto;
height: auto;
}
.chart-container.summary-chart-container {
justify-content: space-between;
align-items: center;
padding: 40px 24px;
}
.summary-card {
width: 240px;
height: 240px;
padding: 24px;
}
.summary-number {
font-size: 24px;
}
.summary-icon {
font-size: 28px;
width: 40px;
height: 40px;
}
}
@media (max-width: 768px) {
.statistics-view {
padding: 0;
}
.statistics-container {
padding: 16px;
gap: 16px;
max-width: 100%;
grid-template-rows: repeat(4, 380px);
}
.chart-container {
padding: 20px;
}
.chart-container h4 {
font-size: 14px;
margin-bottom: 16px;
padding-bottom: 8px;
}
.chart-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.chart-container.summary-chart-container {
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 24px 20px;
}
.summary-card {
width: 100%;
max-width: 300px;
height: 100px;
padding: 20px;
align-self: center;
}
.summary-number {
font-size: 20px;
}
.summary-icon {
font-size: 24px;
width: 36px;
height: 36px;
}
}

View File

@@ -0,0 +1,143 @@
import React, { useState, useMemo } from 'react';
import { gettext } from '../../../utils/constants';
import Loading from '../../../components/loading';
import SortMenu from '../../../components/sort-menu';
import RadioGroup from '../../components/radio-group';
import EmptyTip from '../../../components/empty-tip';
import { useMetadataView } from '../../hooks/metadata-view';
import { useCollaborators } from '../../hooks/collaborators';
import { PieChart, BarChart, HorizontalBarChart, SummaryCards } from './charts';
import { useStatisticsData } from './useStatisticsData';
import { processFileTypeData, processTimeData, processCreatorData } from './utils';
import { TIME_GROUPING_OPTIONS, CREATOR_SORT_OPTIONS } from '../../constants/view/statistics';
import './index.css';
const Statistics = () => {
const { repoID } = useMetadataView();
const { collaborators, getCollaborator: getCollaboratorFromHook } = useCollaborators();
const { isLoading, statisticsData } = useStatisticsData(repoID);
const [timeGrouping, setTimeGrouping] = useState('created');
const [creatorSortBy, setCreatorSortBy] = useState('count');
const [creatorSortOrder, setCreatorSortOrder] = useState('desc');
const pieChartData = useMemo(() =>
processFileTypeData(statisticsData?.fileTypeStats),
[statisticsData]
);
const timeChartData = useMemo(() =>
processTimeData(statisticsData?.timeStats, timeGrouping),
[statisticsData, timeGrouping]
);
const creatorChartData = useMemo(() =>
processCreatorData(
statisticsData?.creatorStats,
collaborators,
getCollaboratorFromHook,
creatorSortBy,
creatorSortOrder
),
[statisticsData, collaborators, getCollaboratorFromHook, creatorSortBy, creatorSortOrder]
);
const handleTimeGroupingChange = (newGrouping) => {
setTimeGrouping(newGrouping);
};
const handleCreatorSortChange = (item) => {
const [sortBy, sortOrder] = item.value.split('-');
setCreatorSortBy(sortBy);
setCreatorSortOrder(sortOrder);
};
if (isLoading) {
return (
<div className="statistics-view">
<div className="statistics-loading">
<Loading />
</div>
</div>
);
}
if (!statisticsData) {
return (
<EmptyTip text={gettext('No data available for statistics')} />
);
}
return (
<div className="statistics-view">
<div className="statistics-container">
<div className="chart-container file-type-chart-container">
<div className="chart-header">
<h4>{gettext('Files by type')}</h4>
</div>
<div className="pie-chart-container">
<PieChart data={pieChartData} />
</div>
</div>
<div className="chart-container creator-chart-container">
<div className="chart-header">
<h4>{gettext('Files by creator')}</h4>
<SortMenu
sortBy={creatorSortBy}
sortOrder={creatorSortOrder}
sortOptions={CREATOR_SORT_OPTIONS}
onSelectSortOption={handleCreatorSortChange}
/>
</div>
<div className="horizontal-bar-chart-container">
{creatorChartData.length > 0 ? (
<HorizontalBarChart data={creatorChartData} />
) : (
<div className="no-data-message">
{gettext('No creator data available')}
</div>
)}
</div>
</div>
<div className="chart-container time-chart-container">
<div className="chart-header">
<h4>{gettext('Distributed by time')}</h4>
<RadioGroup
className="sf-metadata-time-grouping-setter"
value={timeGrouping}
options={TIME_GROUPING_OPTIONS}
onChange={handleTimeGroupingChange}
/>
</div>
<div className="bar-chart-container">
{timeChartData.length > 0 ? (
<BarChart
data={timeChartData}
unit={statisticsData?.timeStats?.[timeGrouping]?.unit}
/>
) : (
<div className="no-data-message">
{gettext('No time-based data available')}
</div>
)}
</div>
</div>
<div className="chart-container summary-chart-container">
<div className="chart-header">
<h4>{gettext('Library')}</h4>
</div>
<SummaryCards
totalFiles={statisticsData.totalFiles}
totalCollaborators={statisticsData.totalCollaborators}
/>
</div>
</div>
</div>
);
};
export default Statistics;

View File

@@ -0,0 +1,69 @@
import { useState, useEffect, useCallback } from 'react';
import { Utils } from '../../../utils/utils';
import toaster from '../../../components/toast';
import metadataAPI from '../../api';
export const useStatisticsData = (repoID) => {
const [isLoading, setIsLoading] = useState(true);
const [statisticsData, setStatisticsData] = useState(null);
const fetchStatisticsData = useCallback(async () => {
if (!repoID) {
setIsLoading(false);
return;
}
try {
setIsLoading(true);
const response = await metadataAPI.getStatistics(repoID);
if (response.data && response.data.summary_stats.total_files !== 0) {
const transformedData = {
fileTypeStats: response.data.file_type_stats.map(item => ({
type: item.type,
count: item.count
})),
timeStats: {
created: {
unit: response.data.time_stats.created.unit || 'year',
data: (response.data.time_stats.created.data || []).map(item => ({
period: item.period || item.year || item.month || item.day,
label: item.label || item.year?.toString() || item.period,
count: item.count
}))
},
modified: {
unit: response.data.time_stats.modified.unit || 'year',
data: (response.data.time_stats.modified.data || []).map(item => ({
period: item.period || item.year || item.month || item.day,
label: item.label || item.year?.toString() || item.period,
count: item.count
}))
}
},
creatorStats: response.data.creator_stats.map(item => ({
creator: item.creator,
count: item.count
})),
totalFiles: response.data.summary_stats.total_files,
totalCollaborators: response.data.summary_stats.total_collaborators
};
setStatisticsData(transformedData);
} else {
setStatisticsData(null);
}
} catch (error) {
toaster.danger(Utils.getErrorMsg(error));
setStatisticsData(null);
} finally {
setIsLoading(false);
}
}, [repoID]);
useEffect(() => {
fetchStatisticsData();
}, [fetchStatisticsData]);
return { isLoading, statisticsData, refetch: fetchStatisticsData };
};

View File

@@ -0,0 +1,54 @@
import { mediaUrl } from '../../../utils/constants';
import { getCollaborator } from '../../utils/cell/column/collaborator';
import { FILE_TYPE_NAMES, FILE_TYPE_COLORS } from '../../constants/view/statistics';
export const processFileTypeData = (fileTypeStats) => {
if (!fileTypeStats) return [];
return fileTypeStats.map(item => ({
label: FILE_TYPE_NAMES[item.type] || item.type,
value: item.count,
color: FILE_TYPE_COLORS[item.type] || '#636e72',
type: item.type
}));
};
export const processTimeData = (timeStats, timeGrouping) => {
if (!timeStats?.[timeGrouping]?.data) return [];
return timeStats[timeGrouping].data.map(item => ({
name: item.label,
value: item.count,
period: item.period
}));
};
export const processCreatorData = (creatorStats, collaborators, getCollaboratorFromHook, sortBy, sortOrder) => {
if (!creatorStats) return [];
const processed = creatorStats.map(item => {
const collaborator = getCollaboratorFromHook
? getCollaboratorFromHook(item.creator)
: getCollaborator(collaborators, item.creator);
return {
name: item.creator,
displayName: collaborator?.name || item.creator,
avatarUrl: collaborator?.avatar_url || `${mediaUrl}/avatars/default.png`,
value: item.count,
collaborator: collaborator
};
});
return processed.sort((a, b) => {
let compareValue = 0;
if (sortBy === 'count') {
compareValue = a.value - b.value;
} else if (sortBy === 'name') {
compareValue = a.displayName.localeCompare(b.displayName);
}
return sortOrder === 'asc' ? compareValue : -compareValue;
});
};

View File

@@ -6,6 +6,7 @@ import Gallery from './gallery';
import FaceRecognition from './face-recognition';
import Kanban from './kanban';
import Map from './map';
import Statistics from './statistics';
import { useMetadataView } from '../hooks/metadata-view';
import { VIEW_TYPE } from '../constants';
import { gettext } from '../../utils/constants';
@@ -32,6 +33,9 @@ const View = () => {
case VIEW_TYPE.MAP: {
return <Map />;
}
case VIEW_TYPE.STATISTICS: {
return <Statistics />;
}
default:
return null;
}