Spaces:
Sleeping
Sleeping
Commit
·
73ce5b8
1
Parent(s):
40e9eac
Adjusted backend and frontend for new plots
Browse files- backend/main.py +149 -25
- backend/requirements.txt +22 -22
- frontend/src/components/Dashboard.tsx +151 -25
- frontend/src/components/ProjectExplorer.tsx +15 -0
- frontend/src/hooks/types.ts +4 -5
- frontend/src/hooks/useAppState.ts +7 -3
backend/main.py
CHANGED
@@ -52,13 +52,13 @@ class Settings(SettingsBase):
|
|
52 |
Configuration settings loaded from environment or .env file.
|
53 |
"""
|
54 |
# Data sources
|
55 |
-
parquet_path: str = "gs://
|
56 |
-
whoosh_dir: str = "gs://
|
57 |
-
vectorstore_path: str = "gs://
|
58 |
|
59 |
# Model names
|
60 |
embedding_model: str = "sentence-transformers/LaBSE"
|
61 |
-
llm_model: str = "google/
|
62 |
cross_encoder_model: str = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
|
63 |
|
64 |
# RAG parameters
|
@@ -305,6 +305,7 @@ def get_projects(
|
|
305 |
country: str = "",
|
306 |
fundingScheme: str = "",
|
307 |
proj_id: str = "",
|
|
|
308 |
sortOrder: str = "desc",
|
309 |
sortField: str = "startDate",
|
310 |
):
|
@@ -317,6 +318,7 @@ def get_projects(
|
|
317 |
- search: substring search in project title
|
318 |
- status, legalBasis, organization, country, fundingScheme: filters
|
319 |
- proj_id: exact project ID filter
|
|
|
320 |
- sortOrder: 'asc' or 'desc'
|
321 |
- sortField: field name to sort by (fallback to startDate)
|
322 |
|
@@ -344,6 +346,8 @@ def get_projects(
|
|
344 |
sel = sel.filter(pl.col("_fundingScheme_lc").str.contains(fundingScheme.lower()))
|
345 |
if proj_id:
|
346 |
sel = sel.filter(pl.col("id") == int(proj_id))
|
|
|
|
|
347 |
|
348 |
# Base columns to return
|
349 |
base_cols = [
|
@@ -410,6 +414,8 @@ def get_filters(request: Request):
|
|
410 |
df = df.filter(pl.col("list_name").list.contains(org))
|
411 |
if c := params.get("country"):
|
412 |
df = df.filter(pl.col("list_country").list.contains(c))
|
|
|
|
|
413 |
if search := params.get("search"):
|
414 |
df = df.filter(pl.col("_title_lc").str.contains(search.lower()))
|
415 |
|
@@ -420,57 +426,175 @@ def get_filters(request: Request):
|
|
420 |
return {
|
421 |
"statuses": normalize(df["status"].to_list()),
|
422 |
"legalBases": normalize(df["legalBasis"].to_list()),
|
423 |
-
"organizations": normalize(df["list_name"].explode().to_list())
|
424 |
"countries": normalize(df["list_country"].explode().to_list()),
|
425 |
"fundingSchemes": normalize(df["fundingScheme"].explode().to_list()),
|
|
|
426 |
}
|
427 |
|
428 |
@app.get("/api/stats")
|
429 |
def get_stats(request: Request):
|
430 |
"""
|
431 |
-
Compute
|
432 |
-
|
433 |
-
Returns a dict of chart data for projects per year.
|
434 |
"""
|
435 |
-
|
|
|
436 |
params = request.query_params
|
437 |
|
438 |
-
# Apply
|
439 |
if s := params.get("status"):
|
440 |
lf = lf.filter(pl.col("_status_lc") == s.lower())
|
|
|
441 |
if lb := params.get("legalBasis"):
|
442 |
lf = lf.filter(pl.col("_legalBasis_lc") == lb.lower())
|
|
|
443 |
if org := params.get("organization"):
|
444 |
lf = lf.filter(pl.col("list_name").list.contains(org))
|
|
|
445 |
if c := params.get("country"):
|
446 |
lf = lf.filter(pl.col("list_country").list.contains(c))
|
|
|
447 |
if mn := params.get("minFunding"):
|
448 |
lf = lf.filter(pl.col("ecMaxContribution") >= int(mn))
|
|
|
449 |
if mx := params.get("maxFunding"):
|
450 |
lf = lf.filter(pl.col("ecMaxContribution") <= int(mx))
|
|
|
451 |
if y1 := params.get("minYear"):
|
452 |
lf = lf.filter(pl.col("startDate").dt.year() >= int(y1))
|
|
|
453 |
if y2 := params.get("maxYear"):
|
454 |
lf = lf.filter(pl.col("startDate").dt.year() <= int(y2))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
455 |
|
456 |
-
|
457 |
-
|
458 |
-
|
459 |
-
|
460 |
-
|
461 |
-
|
462 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
463 |
)
|
464 |
-
|
|
|
465 |
|
466 |
-
# Return data ready for frontend charts
|
467 |
return {
|
468 |
-
"Projects per Year":
|
469 |
-
"
|
470 |
-
"
|
471 |
-
"
|
472 |
-
"
|
473 |
-
"Projects per
|
474 |
}
|
475 |
|
476 |
@app.get("/api/project/{project_id}/organizations")
|
|
|
52 |
Configuration settings loaded from environment or .env file.
|
53 |
"""
|
54 |
# Data sources
|
55 |
+
parquet_path: str = "gs://mda_kul_project/data/consolidated_clean_pred.parquet"
|
56 |
+
whoosh_dir: str = "gs://mda_kul_project/whoosh_index"
|
57 |
+
vectorstore_path: str = "gs://mda_kul_project/vectorstore_index"
|
58 |
|
59 |
# Model names
|
60 |
embedding_model: str = "sentence-transformers/LaBSE"
|
61 |
+
llm_model: str = "google/mt5-base"
|
62 |
cross_encoder_model: str = "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
|
63 |
|
64 |
# RAG parameters
|
|
|
305 |
country: str = "",
|
306 |
fundingScheme: str = "",
|
307 |
proj_id: str = "",
|
308 |
+
topic: str = "",
|
309 |
sortOrder: str = "desc",
|
310 |
sortField: str = "startDate",
|
311 |
):
|
|
|
318 |
- search: substring search in project title
|
319 |
- status, legalBasis, organization, country, fundingScheme: filters
|
320 |
- proj_id: exact project ID filter
|
321 |
+
- topic: filter by EuroSciVoc topic
|
322 |
- sortOrder: 'asc' or 'desc'
|
323 |
- sortField: field name to sort by (fallback to startDate)
|
324 |
|
|
|
346 |
sel = sel.filter(pl.col("_fundingScheme_lc").str.contains(fundingScheme.lower()))
|
347 |
if proj_id:
|
348 |
sel = sel.filter(pl.col("id") == int(proj_id))
|
349 |
+
if topic:
|
350 |
+
sel = sel.filter(pl.col("list_euroSciVocTitle").list.contains(topic))
|
351 |
|
352 |
# Base columns to return
|
353 |
base_cols = [
|
|
|
414 |
df = df.filter(pl.col("list_name").list.contains(org))
|
415 |
if c := params.get("country"):
|
416 |
df = df.filter(pl.col("list_country").list.contains(c))
|
417 |
+
if t := params.get("topics"):
|
418 |
+
df = df.filter(pl.col("list_euroSciVocTitle").list.contains(t))
|
419 |
if search := params.get("search"):
|
420 |
df = df.filter(pl.col("_title_lc").str.contains(search.lower()))
|
421 |
|
|
|
426 |
return {
|
427 |
"statuses": normalize(df["status"].to_list()),
|
428 |
"legalBases": normalize(df["legalBasis"].to_list()),
|
429 |
+
"organizations": normalize(df["list_name"].explode().to_list()),
|
430 |
"countries": normalize(df["list_country"].explode().to_list()),
|
431 |
"fundingSchemes": normalize(df["fundingScheme"].explode().to_list()),
|
432 |
+
"topics": normalize(df["list_euroSciVocTitle"].explode().to_list()),
|
433 |
}
|
434 |
|
435 |
@app.get("/api/stats")
|
436 |
def get_stats(request: Request):
|
437 |
"""
|
438 |
+
Compute various statistics on projects with optional filters for status, legal basis, funding, etc.
|
439 |
+
Returns a dict of chart data.
|
|
|
440 |
"""
|
441 |
+
df = app.state.df
|
442 |
+
lf = df.lazy()
|
443 |
params = request.query_params
|
444 |
|
445 |
+
# Apply filters
|
446 |
if s := params.get("status"):
|
447 |
lf = lf.filter(pl.col("_status_lc") == s.lower())
|
448 |
+
df = df.filter(pl.col("_status_lc") == s.lower())
|
449 |
if lb := params.get("legalBasis"):
|
450 |
lf = lf.filter(pl.col("_legalBasis_lc") == lb.lower())
|
451 |
+
df = df.filter(pl.col("_legalBasis_lc") == lb.lower())
|
452 |
if org := params.get("organization"):
|
453 |
lf = lf.filter(pl.col("list_name").list.contains(org))
|
454 |
+
df = df.filter(pl.col("list_name").list.contains(org))
|
455 |
if c := params.get("country"):
|
456 |
lf = lf.filter(pl.col("list_country").list.contains(c))
|
457 |
+
df = df.filter(pl.col("list_country").list.contains(c))
|
458 |
if mn := params.get("minFunding"):
|
459 |
lf = lf.filter(pl.col("ecMaxContribution") >= int(mn))
|
460 |
+
df = df.filter(pl.col("ecMaxContribution") >= int(mn))
|
461 |
if mx := params.get("maxFunding"):
|
462 |
lf = lf.filter(pl.col("ecMaxContribution") <= int(mx))
|
463 |
+
df = df.filter(pl.col("ecMaxContribution") <= int(mx))
|
464 |
if y1 := params.get("minYear"):
|
465 |
lf = lf.filter(pl.col("startDate").dt.year() >= int(y1))
|
466 |
+
df = df.filter(pl.col("startDate").dt.year() >= int(y1))
|
467 |
if y2 := params.get("maxYear"):
|
468 |
lf = lf.filter(pl.col("startDate").dt.year() <= int(y2))
|
469 |
+
df = df.filter(pl.col("startDate").dt.year() <= int(y2))
|
470 |
+
|
471 |
+
# 1) Projects per Year (Line)
|
472 |
+
yearly = (
|
473 |
+
lf
|
474 |
+
.select(pl.col("startDate").dt.year().alias("year"))
|
475 |
+
.group_by("year")
|
476 |
+
.agg(pl.count().alias("count"))
|
477 |
+
.sort("year")
|
478 |
+
.collect()
|
479 |
+
)
|
480 |
+
years = yearly["year"].to_list()
|
481 |
+
year_counts = yearly["count"].to_list()
|
482 |
+
|
483 |
+
# 2) Project-Size Distribution by totalCost buckets (Bar)
|
484 |
+
size_buckets = (
|
485 |
+
df
|
486 |
+
.with_columns(
|
487 |
+
pl.when(pl.col("totalCost") < 100_000).then("<100 K")
|
488 |
+
.when(pl.col("totalCost") < 500_000).then("100 K–500 K")
|
489 |
+
.when(pl.col("totalCost") < 1_000_000).then("500 K–1 M")
|
490 |
+
.when(pl.col("totalCost") < 5_000_000).then("1 M–5 M")
|
491 |
+
.when(pl.col("totalCost") < 10_000_000).then("5 M–10 M")
|
492 |
+
.otherwise("≥10 M")
|
493 |
+
.alias("size_range")
|
494 |
+
)
|
495 |
+
.group_by("size_range")
|
496 |
+
.agg(pl.count().alias("count"))
|
497 |
+
# ensure our custom order
|
498 |
+
.with_columns(
|
499 |
+
pl.col("size_range").apply(
|
500 |
+
lambda x: ["<100 K","100 K–500 K","500 K–1 M","1 M–5 M","5 M–10 M","≥10 M"].index(x)
|
501 |
+
).alias("order")
|
502 |
+
)
|
503 |
+
.sort("order")
|
504 |
+
.collect()
|
505 |
+
)
|
506 |
+
size_labels = size_buckets["size_range"].to_list()
|
507 |
+
size_counts = size_buckets["count"].to_list()
|
508 |
+
|
509 |
+
# 3) EU Co-funding Ratio by Scheme (Bar)
|
510 |
+
# a) First, filter out bad records and compute ratio:
|
511 |
+
clean = (
|
512 |
+
df
|
513 |
+
# drop if either value is null or totalCost is zero
|
514 |
+
.filter(
|
515 |
+
pl.col("ecMaxContribution").is_not_null() &
|
516 |
+
pl.col("totalCost").is_not_null() &
|
517 |
+
(pl.col("totalCost") != 0)
|
518 |
+
)
|
519 |
+
# compute ecMaxContributionRatio as Float64
|
520 |
+
.with_columns(
|
521 |
+
(
|
522 |
+
pl.col("ecMaxContribution").cast(pl.Float64)
|
523 |
+
/ pl.col("totalCost").cast(pl.Float64)
|
524 |
+
).alias("ecMaxContributionRatio")
|
525 |
+
)
|
526 |
+
)
|
527 |
+
|
528 |
+
# b) Explode by scheme and aggregate the mean of ratio
|
529 |
+
ratio = (
|
530 |
+
clean
|
531 |
+
.explode("fundingScheme")
|
532 |
+
.group_by("fundingScheme")
|
533 |
+
.agg(
|
534 |
+
pl.col("ecMaxContributionRatio").mean().alias("avg_ratio")
|
535 |
+
)
|
536 |
+
.sort("avg_ratio", descending=True)
|
537 |
+
.head(10)
|
538 |
+
.collect()
|
539 |
+
)
|
540 |
|
541 |
+
scheme_labels = ratio["fundingScheme"].to_list()
|
542 |
+
scheme_values = (ratio["avg_ratio"] * 100).round(1).to_list() # now in percentage
|
543 |
+
|
544 |
+
|
545 |
+
# 4) Top 10 Macro Topics by EC Contribution (Bar)
|
546 |
+
top_topics = (
|
547 |
+
df
|
548 |
+
.explode("list_euroSciVocTitle")
|
549 |
+
.group_by("list_euroSciVocTitle")
|
550 |
+
.agg(pl.col("ecMaxContribution").sum().alias("total_ec"))
|
551 |
+
.sort("total_ec", descending=True)
|
552 |
+
.head(10)
|
553 |
+
.collect()
|
554 |
+
)
|
555 |
+
topic_labels = top_topics["list_euroSciVocTitle"].to_list()
|
556 |
+
topic_values = (top_topics["total_ec"] / 1e6).round(1).to_list()
|
557 |
+
|
558 |
+
# 5) Projects by Funding Range (Pie)
|
559 |
+
fund_range = (
|
560 |
+
df
|
561 |
+
.with_columns(
|
562 |
+
pl.when(pl.col("ecMaxContribution") < 100_000).then("<100 K")
|
563 |
+
.when(pl.col("ecMaxContribution") < 500_000).then("100 K–500 K")
|
564 |
+
.when(pl.col("ecMaxContribution") < 1_000_000).then("500 K–1 M")
|
565 |
+
.when(pl.col("ecMaxContribution") < 5_000_000).then("1 M–5 M")
|
566 |
+
.when(pl.col("ecMaxContribution") < 10_000_000).then("5 M–10 M")
|
567 |
+
.otherwise("≥10 M")
|
568 |
+
.alias("funding_range")
|
569 |
+
)
|
570 |
+
.group_by("funding_range")
|
571 |
+
.agg(pl.count().alias("count"))
|
572 |
+
.sort("funding_range")
|
573 |
+
.collect()
|
574 |
+
)
|
575 |
+
fr_labels = fund_range["funding_range"].to_list()
|
576 |
+
fr_counts = fund_range["count"].to_list()
|
577 |
+
|
578 |
+
# 6) Projects per Country (Doughnut)
|
579 |
+
country = (
|
580 |
+
df
|
581 |
+
.explode("list_country")
|
582 |
+
.group_by("list_country")
|
583 |
+
.agg(pl.count().alias("count"))
|
584 |
+
.sort("count", descending=True)
|
585 |
+
.head(10)
|
586 |
+
.collect()
|
587 |
)
|
588 |
+
country_labels = country["list_country"].to_list()
|
589 |
+
country_counts = country["count"].to_list()
|
590 |
|
|
|
591 |
return {
|
592 |
+
"Projects per Year": {"labels": years, "values": year_counts},
|
593 |
+
"Project-Size Distribution": {"labels": size_labels, "values": size_counts},
|
594 |
+
"Co-funding Ratio by Scheme": {"labels": scheme_labels, "values": scheme_values},
|
595 |
+
"Top 10 Topics (€ M)": {"labels": topic_labels, "values": topic_values},
|
596 |
+
"Funding Range Breakdown": {"labels": fr_labels, "values": fr_counts},
|
597 |
+
"Projects per Country": {"labels": country_labels, "values": country_counts},
|
598 |
}
|
599 |
|
600 |
@app.get("/api/project/{project_id}/organizations")
|
backend/requirements.txt
CHANGED
@@ -15,9 +15,9 @@ google-cloud-storage==2.11.0
|
|
15 |
|
16 |
# RAG & embeddings
|
17 |
pytorch-lightning==2.5.1
|
18 |
-
langchain
|
19 |
-
langchain-huggingface
|
20 |
-
sentence-transformers
|
21 |
langchain-community==0.3.24
|
22 |
|
23 |
# Vector store
|
@@ -27,31 +27,31 @@ faiss-cpu==1.11.0
|
|
27 |
whoosh==2.7.4
|
28 |
|
29 |
# Transformers
|
30 |
-
torch
|
31 |
blobfile>=1.5.0
|
32 |
tiktoken>=0.4.0
|
33 |
sentencepiece==0.2.0
|
34 |
-
transformers
|
35 |
-
torchvision
|
36 |
-
sympy
|
37 |
-
peft
|
38 |
|
39 |
aiofiles==24.1.0
|
40 |
optimum==1.25.3
|
41 |
bitsandbytes==0.45.5
|
42 |
-
hf_xet
|
43 |
-
HuggingFace
|
44 |
-
huggingface_hub
|
45 |
-
huggingface_hub[hf_xet]
|
46 |
|
47 |
# ————————————————————————————————————————————————
|
48 |
-
|
49 |
-
joblib
|
50 |
-
shap
|
51 |
-
matplotlib
|
52 |
-
scipy
|
53 |
-
scikit-learn
|
54 |
-
imbalanced-learn
|
55 |
-
xgboost
|
56 |
-
evidently
|
57 |
-
optuna
|
|
|
15 |
|
16 |
# RAG & embeddings
|
17 |
pytorch-lightning==2.5.1
|
18 |
+
langchain==0.3.25
|
19 |
+
langchain-huggingface==0.2.0
|
20 |
+
sentence-transformers==4.1.0
|
21 |
langchain-community==0.3.24
|
22 |
|
23 |
# Vector store
|
|
|
27 |
whoosh==2.7.4
|
28 |
|
29 |
# Transformers
|
30 |
+
torch==2.7.0
|
31 |
blobfile>=1.5.0
|
32 |
tiktoken>=0.4.0
|
33 |
sentencepiece==0.2.0
|
34 |
+
transformers==4.62.3
|
35 |
+
torchvision==0.22.0
|
36 |
+
sympy==1.14.0
|
37 |
+
peft==0.15.2
|
38 |
|
39 |
aiofiles==24.1.0
|
40 |
optimum==1.25.3
|
41 |
bitsandbytes==0.45.5
|
42 |
+
hf_xet==1.1.2
|
43 |
+
HuggingFace==0.0.1
|
44 |
+
huggingface_hub==0.32.1
|
45 |
+
huggingface_hub[hf_xet]==0.32.1
|
46 |
|
47 |
# ————————————————————————————————————————————————
|
48 |
+
# Predictive modeling
|
49 |
+
joblib==1.5.1
|
50 |
+
shap==0.47.2
|
51 |
+
matplotlib==3.10.3
|
52 |
+
scipy==1.15.3
|
53 |
+
scikit-learn==1.6.1
|
54 |
+
imbalanced-learn==0.13.0
|
55 |
+
xgboost==3.0.2
|
56 |
+
evidently==0.7.6
|
57 |
+
optuna==4.3.0
|
frontend/src/components/Dashboard.tsx
CHANGED
@@ -25,8 +25,10 @@ import {
|
|
25 |
LineElement,
|
26 |
PointElement,
|
27 |
RadialLinearScale,
|
|
|
|
|
28 |
} from "chart.js";
|
29 |
-
import { Bar, Pie, Doughnut, Line
|
30 |
import type { FilterState, AvailableFilters } from "../hooks/types";
|
31 |
|
32 |
// register chart components
|
@@ -43,16 +45,30 @@ ChartJS.register(
|
|
43 |
RadialLinearScale
|
44 |
);
|
45 |
|
46 |
-
|
47 |
-
interface
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
|
49 |
const FILTER_LABELS: Record<keyof FilterState, string> = {
|
50 |
status: "Status",
|
51 |
organization: "Organization",
|
52 |
country: "Country",
|
53 |
legalBasis: "Legal Basis",
|
|
|
54 |
};
|
55 |
|
|
|
|
|
56 |
interface DashboardProps {
|
57 |
stats: Stats;
|
58 |
filters: FilterState;
|
@@ -60,9 +76,6 @@ interface DashboardProps {
|
|
60 |
availableFilters: AvailableFilters;
|
61 |
}
|
62 |
|
63 |
-
const chartTypes = ["bar","pie","doughnut","line","radar","polarArea"] as const;
|
64 |
-
type ChartType = typeof chartTypes[number];
|
65 |
-
|
66 |
const Dashboard: React.FC<DashboardProps> = ({
|
67 |
stats: initialStats,
|
68 |
filters,
|
@@ -104,7 +117,7 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
104 |
setFilters(prev => ({ ...prev, [k1]: String(min), [k2]: String(max) }));
|
105 |
|
106 |
const filterKeys: Array<keyof FilterState> = [
|
107 |
-
'status', 'organization', 'country', 'legalBasis'
|
108 |
];
|
109 |
|
110 |
if (loadingStats && !Object.keys(statsData).length) {
|
@@ -121,7 +134,8 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
121 |
key === 'status' ? 'statuses'
|
122 |
: key === 'organization' ? 'organizations'
|
123 |
: key === 'country' ? 'countries'
|
124 |
-
:
|
|
|
125 |
] || [];
|
126 |
const isOrg = key === 'organization';
|
127 |
return (
|
@@ -201,23 +215,135 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
201 |
</Flex>
|
202 |
)}
|
203 |
<SimpleGrid columns={{ base:1, md:2, lg:3 }} spacing={6}>
|
204 |
-
{
|
205 |
-
const
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
221 |
})}
|
222 |
</SimpleGrid>
|
223 |
</Box>
|
|
|
25 |
LineElement,
|
26 |
PointElement,
|
27 |
RadialLinearScale,
|
28 |
+
type ChartData,
|
29 |
+
type ChartOptions
|
30 |
} from "chart.js";
|
31 |
+
import { Bar, Pie, Doughnut, Line } from "react-chartjs-2";
|
32 |
import type { FilterState, AvailableFilters } from "../hooks/types";
|
33 |
|
34 |
// register chart components
|
|
|
45 |
RadialLinearScale
|
46 |
);
|
47 |
|
48 |
+
type LegendPosition = "top" | "bottom" | "left" | "right" | "chartArea";
|
49 |
+
interface ChartDataShape { labels: string[]; values: number[]; }
|
50 |
+
interface Stats { [key: string]: ChartDataShape; }
|
51 |
+
|
52 |
+
// define the six charts and their component types
|
53 |
+
const chartOrder: { key: string; type: ChartType }[] = [
|
54 |
+
{ key: "Projects per Year", type: "line" },
|
55 |
+
{ key: "Project-Size Distribution", type: "bar" },
|
56 |
+
{ key: "Co-funding Ratio by Scheme", type: "bar" },
|
57 |
+
{ key: "Top 10 Topics (€ M)", type: "bar" },
|
58 |
+
{ key: "Funding Range Breakdown", type: "pie" },
|
59 |
+
{ key: "Projects per Country", type: "doughnut" },
|
60 |
+
];
|
61 |
|
62 |
const FILTER_LABELS: Record<keyof FilterState, string> = {
|
63 |
status: "Status",
|
64 |
organization: "Organization",
|
65 |
country: "Country",
|
66 |
legalBasis: "Legal Basis",
|
67 |
+
topics: "Topics",
|
68 |
};
|
69 |
|
70 |
+
type ChartType = "bar" | "pie" | "doughnut" | "line";
|
71 |
+
|
72 |
interface DashboardProps {
|
73 |
stats: Stats;
|
74 |
filters: FilterState;
|
|
|
76 |
availableFilters: AvailableFilters;
|
77 |
}
|
78 |
|
|
|
|
|
|
|
79 |
const Dashboard: React.FC<DashboardProps> = ({
|
80 |
stats: initialStats,
|
81 |
filters,
|
|
|
117 |
setFilters(prev => ({ ...prev, [k1]: String(min), [k2]: String(max) }));
|
118 |
|
119 |
const filterKeys: Array<keyof FilterState> = [
|
120 |
+
'status', 'organization', 'country', 'legalBasis','topics'
|
121 |
];
|
122 |
|
123 |
if (loadingStats && !Object.keys(statsData).length) {
|
|
|
134 |
key === 'status' ? 'statuses'
|
135 |
: key === 'organization' ? 'organizations'
|
136 |
: key === 'country' ? 'countries'
|
137 |
+
: key === "legalBasis" ? "legalBases"
|
138 |
+
: 'topics'
|
139 |
] || [];
|
140 |
const isOrg = key === 'organization';
|
141 |
return (
|
|
|
215 |
</Flex>
|
216 |
)}
|
217 |
<SimpleGrid columns={{ base:1, md:2, lg:3 }} spacing={6}>
|
218 |
+
{chartOrder.map(({ key, type }) => {
|
219 |
+
const raw = statsData[key]!;
|
220 |
+
|
221 |
+
// ---- properly typed Chart.js data & options ----
|
222 |
+
if (type === "bar") {
|
223 |
+
const data: ChartData<"bar", number[], string> = {
|
224 |
+
labels: raw.labels,
|
225 |
+
datasets: [
|
226 |
+
{
|
227 |
+
label: key,
|
228 |
+
data: raw.values,
|
229 |
+
backgroundColor: "#003399",
|
230 |
+
borderColor: "#FFCC00",
|
231 |
+
borderWidth: 1,
|
232 |
+
},
|
233 |
+
],
|
234 |
+
};
|
235 |
+
const options: ChartOptions<"bar"> = {
|
236 |
+
responsive: true,
|
237 |
+
plugins: {
|
238 |
+
legend: {
|
239 |
+
position: "top" as LegendPosition,
|
240 |
+
},
|
241 |
+
title: {
|
242 |
+
display: true,
|
243 |
+
text: key,
|
244 |
+
},
|
245 |
+
},
|
246 |
+
};
|
247 |
+
return (
|
248 |
+
<Box key={key} bg="white" borderRadius="md" p={4}>
|
249 |
+
<Bar data={data} options={options} />
|
250 |
+
</Box>
|
251 |
+
);
|
252 |
+
}
|
253 |
+
if (type === "line") {
|
254 |
+
const data: ChartData<"line", number[], string> = {
|
255 |
+
labels: raw.labels,
|
256 |
+
datasets: [
|
257 |
+
{
|
258 |
+
label: key,
|
259 |
+
data: raw.values,
|
260 |
+
backgroundColor: "#003399",
|
261 |
+
borderColor: "#FFCC00",
|
262 |
+
borderWidth: 1,
|
263 |
+
},
|
264 |
+
],
|
265 |
+
};
|
266 |
+
const options: ChartOptions<"line"> = {
|
267 |
+
responsive: true,
|
268 |
+
plugins: {
|
269 |
+
legend: {
|
270 |
+
position: "top" as LegendPosition,
|
271 |
+
},
|
272 |
+
title: {
|
273 |
+
display: true,
|
274 |
+
text: key,
|
275 |
+
},
|
276 |
+
},
|
277 |
+
};
|
278 |
+
return (
|
279 |
+
<Box key={key} bg="white" borderRadius="md" p={4}>
|
280 |
+
<Line data={data} options={options} />
|
281 |
+
</Box>
|
282 |
+
);
|
283 |
+
}
|
284 |
+
if (type === "pie") {
|
285 |
+
const data: ChartData<"pie", number[], string> = {
|
286 |
+
labels: raw.labels,
|
287 |
+
datasets: [
|
288 |
+
{
|
289 |
+
label: key,
|
290 |
+
data: raw.values,
|
291 |
+
backgroundColor: "#003399",
|
292 |
+
borderColor: "#FFCC00",
|
293 |
+
borderWidth: 1,
|
294 |
+
},
|
295 |
+
],
|
296 |
+
};
|
297 |
+
const options: ChartOptions<"pie"> = {
|
298 |
+
responsive: true,
|
299 |
+
plugins: {
|
300 |
+
legend: {
|
301 |
+
position: "top" as LegendPosition,
|
302 |
+
},
|
303 |
+
title: {
|
304 |
+
display: true,
|
305 |
+
text: key,
|
306 |
+
},
|
307 |
+
},
|
308 |
+
};
|
309 |
+
return (
|
310 |
+
<Box key={key} bg="white" borderRadius="md" p={4}>
|
311 |
+
<Pie data={data} options={options} />
|
312 |
+
</Box>
|
313 |
+
);
|
314 |
+
}
|
315 |
+
if (type === "doughnut") {
|
316 |
+
const data: ChartData<"doughnut", number[], string> = {
|
317 |
+
labels: raw.labels,
|
318 |
+
datasets: [
|
319 |
+
{
|
320 |
+
label: key,
|
321 |
+
data: raw.values,
|
322 |
+
backgroundColor: "#003399",
|
323 |
+
borderColor: "#FFCC00",
|
324 |
+
borderWidth: 1,
|
325 |
+
},
|
326 |
+
],
|
327 |
+
};
|
328 |
+
const options: ChartOptions<"doughnut"> = {
|
329 |
+
responsive: true,
|
330 |
+
plugins: {
|
331 |
+
legend: {
|
332 |
+
position: "top" as LegendPosition,
|
333 |
+
},
|
334 |
+
title: {
|
335 |
+
display: true,
|
336 |
+
text: key,
|
337 |
+
},
|
338 |
+
},
|
339 |
+
};
|
340 |
+
return (
|
341 |
+
<Box key={key} bg="white" borderRadius="md" p={4}>
|
342 |
+
<Doughnut data={data} options={options} />
|
343 |
+
</Box>
|
344 |
+
);
|
345 |
+
}
|
346 |
+
return null;
|
347 |
})}
|
348 |
</SimpleGrid>
|
349 |
</Box>
|
frontend/src/components/ProjectExplorer.tsx
CHANGED
@@ -27,6 +27,7 @@ interface FilterOptions {
|
|
27 |
countries: string[];
|
28 |
fundingSchemes: string[];
|
29 |
ids: string[];
|
|
|
30 |
}
|
31 |
const MIN_SEARCH_LEN = 3;
|
32 |
|
@@ -51,6 +52,8 @@ const ProjectExplorer: React.FC<ProjectExplorerProps> = ({
|
|
51 |
setFundingSchemeFilter,
|
52 |
idFilter,
|
53 |
setIdFilter,
|
|
|
|
|
54 |
setSortField,
|
55 |
sortField,
|
56 |
setSortOrder,
|
@@ -72,6 +75,7 @@ const ProjectExplorer: React.FC<ProjectExplorerProps> = ({
|
|
72 |
countries: [],
|
73 |
fundingSchemes: [],
|
74 |
ids: [],
|
|
|
75 |
});
|
76 |
const [loadingFilters, setLoadingFilters] = useState(false);
|
77 |
|
@@ -86,6 +90,7 @@ const ProjectExplorer: React.FC<ProjectExplorerProps> = ({
|
|
86 |
if (search.length >= MIN_SEARCH_LEN) params.set("search", search);
|
87 |
if (idFilter.length >= MIN_SEARCH_LEN) params.set("proj_id", idFilter);
|
88 |
if (fundingSchemeFilter) params.set("fundingScheme", fundingSchemeFilter);
|
|
|
89 |
params.set("sortField", sortField);
|
90 |
params.set("sortOrder", sortOrder);
|
91 |
|
@@ -102,6 +107,7 @@ const ProjectExplorer: React.FC<ProjectExplorerProps> = ({
|
|
102 |
search,
|
103 |
idFilter,
|
104 |
fundingSchemeFilter,
|
|
|
105 |
sortField,
|
106 |
sortOrder,
|
107 |
]);
|
@@ -189,6 +195,15 @@ const ProjectExplorer: React.FC<ProjectExplorerProps> = ({
|
|
189 |
>
|
190 |
{filterOpts.fundingSchemes.map((c) => <option key={c} value={c}>{c}</option>)}
|
191 |
</ChakraSelect>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
192 |
</Flex>
|
193 |
|
194 |
<Box
|
|
|
27 |
countries: string[];
|
28 |
fundingSchemes: string[];
|
29 |
ids: string[];
|
30 |
+
topics: string[];
|
31 |
}
|
32 |
const MIN_SEARCH_LEN = 3;
|
33 |
|
|
|
52 |
setFundingSchemeFilter,
|
53 |
idFilter,
|
54 |
setIdFilter,
|
55 |
+
topicsFilter,
|
56 |
+
setTopicsFilter,
|
57 |
setSortField,
|
58 |
sortField,
|
59 |
setSortOrder,
|
|
|
75 |
countries: [],
|
76 |
fundingSchemes: [],
|
77 |
ids: [],
|
78 |
+
topics: [],
|
79 |
});
|
80 |
const [loadingFilters, setLoadingFilters] = useState(false);
|
81 |
|
|
|
90 |
if (search.length >= MIN_SEARCH_LEN) params.set("search", search);
|
91 |
if (idFilter.length >= MIN_SEARCH_LEN) params.set("proj_id", idFilter);
|
92 |
if (fundingSchemeFilter) params.set("fundingScheme", fundingSchemeFilter);
|
93 |
+
if (topicsFilter) params.set("topics", topicsFilter);
|
94 |
params.set("sortField", sortField);
|
95 |
params.set("sortOrder", sortOrder);
|
96 |
|
|
|
107 |
search,
|
108 |
idFilter,
|
109 |
fundingSchemeFilter,
|
110 |
+
topicsFilter,
|
111 |
sortField,
|
112 |
sortOrder,
|
113 |
]);
|
|
|
195 |
>
|
196 |
{filterOpts.fundingSchemes.map((c) => <option key={c} value={c}>{c}</option>)}
|
197 |
</ChakraSelect>
|
198 |
+
<ChakraSelect
|
199 |
+
placeholder={loadingFilters ? "Loading..." : "Funding Scheme"}
|
200 |
+
value={topicsFilter}
|
201 |
+
onChange={(e) => { setTopicsFilter(e.target.value); setPage(0); }}
|
202 |
+
isDisabled={loadingFilters}
|
203 |
+
width="180px"
|
204 |
+
>
|
205 |
+
{filterOpts.topics.map((c) => <option key={c} value={c}>{c}</option>)}
|
206 |
+
</ChakraSelect>
|
207 |
</Flex>
|
208 |
|
209 |
<Box
|
frontend/src/hooks/types.ts
CHANGED
@@ -22,11 +22,6 @@ export interface Project {
|
|
22 |
|
23 |
export interface ProjectDetailsProps {
|
24 |
project: Project;
|
25 |
-
// question: string;
|
26 |
-
// setQuestion: React.Dispatch<React.SetStateAction<string>>;
|
27 |
-
// askChatbot: () => void;
|
28 |
-
// chatHistory: ChatMessage[];
|
29 |
-
// messagesEndRef: React.RefObject<HTMLDivElement>;
|
30 |
}
|
31 |
|
32 |
|
@@ -57,6 +52,7 @@ export interface FilterState {
|
|
57 |
organization: string;
|
58 |
country: string;
|
59 |
legalBasis: string;
|
|
|
60 |
minYear: string;
|
61 |
maxYear: string;
|
62 |
minFunding: string;
|
@@ -77,6 +73,7 @@ export interface AvailableFilters {
|
|
77 |
legalBases: string[];
|
78 |
fundingSchemes: string[];
|
79 |
ids: string[];
|
|
|
80 |
}
|
81 |
|
82 |
export interface ProjectExplorerProps {
|
@@ -95,6 +92,8 @@ export interface ProjectExplorerProps {
|
|
95 |
setFundingSchemeFilter: (value: string) => void;
|
96 |
idFilter: string;
|
97 |
setIdFilter: (value: string) => void;
|
|
|
|
|
98 |
setSortField: (field: string) => void;
|
99 |
sortField: string;
|
100 |
setSortOrder : (order: "asc" | "desc") => void;
|
|
|
22 |
|
23 |
export interface ProjectDetailsProps {
|
24 |
project: Project;
|
|
|
|
|
|
|
|
|
|
|
25 |
}
|
26 |
|
27 |
|
|
|
52 |
organization: string;
|
53 |
country: string;
|
54 |
legalBasis: string;
|
55 |
+
topics: string;
|
56 |
minYear: string;
|
57 |
maxYear: string;
|
58 |
minFunding: string;
|
|
|
73 |
legalBases: string[];
|
74 |
fundingSchemes: string[];
|
75 |
ids: string[];
|
76 |
+
topics: string[];
|
77 |
}
|
78 |
|
79 |
export interface ProjectExplorerProps {
|
|
|
92 |
setFundingSchemeFilter: (value: string) => void;
|
93 |
idFilter: string;
|
94 |
setIdFilter: (value: string) => void;
|
95 |
+
topicsFilter: string;
|
96 |
+
setTopicsFilter: (value: string) => void;
|
97 |
setSortField: (field: string) => void;
|
98 |
sortField: string;
|
99 |
setSortOrder : (order: "asc" | "desc") => void;
|
frontend/src/hooks/useAppState.ts
CHANGED
@@ -24,6 +24,7 @@ export const useAppState = () => {
|
|
24 |
const [countryFilter, setCountryFilter] = useState('');
|
25 |
const [fundingSchemeFilter, setFundingSchemeFilter ] = useState('');
|
26 |
const [idFilter, setIdFilter] = useState('');
|
|
|
27 |
const [sortField, setSortField] = useState('');
|
28 |
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
|
29 |
const [filters, setFilters] = useState<FilterState>({
|
@@ -31,6 +32,7 @@ export const useAppState = () => {
|
|
31 |
organization: "",
|
32 |
country: "",
|
33 |
legalBasis: "",
|
|
|
34 |
minYear: "2000",
|
35 |
maxYear: "2025",
|
36 |
minFunding: "0",
|
@@ -46,13 +48,14 @@ export const useAppState = () => {
|
|
46 |
countries: [],
|
47 |
legalBases: [],
|
48 |
fundingSchemes:[],
|
49 |
-
ids:[]
|
|
|
50 |
});
|
51 |
|
52 |
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
53 |
|
54 |
const fetchProjects = () => {
|
55 |
-
fetch(`/api/projects?page=${page}&search=${encodeURIComponent(search)}&status=${statusFilter}&legalBasis=${legalFilter}&organization=${orgFilter}&country=${countryFilter}&fundingScheme=${fundingSchemeFilter}&proj_id=${idFilter}&sortField=${sortField}&sortOrder=${sortOrder}`)
|
56 |
.then(res => res.json())
|
57 |
.then((data: Project[]) => setProjects(data))
|
58 |
.catch(console.error);
|
@@ -77,7 +80,8 @@ export const useAppState = () => {
|
|
77 |
countries: data.countries,
|
78 |
legalBases: data.legalBases,
|
79 |
fundingSchemes: data.fundingSchemes,
|
80 |
-
ids: []
|
|
|
81 |
});
|
82 |
});
|
83 |
};
|
|
|
24 |
const [countryFilter, setCountryFilter] = useState('');
|
25 |
const [fundingSchemeFilter, setFundingSchemeFilter ] = useState('');
|
26 |
const [idFilter, setIdFilter] = useState('');
|
27 |
+
const [topicsFilter, setTopicsFilter] = useState('');
|
28 |
const [sortField, setSortField] = useState('');
|
29 |
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
|
30 |
const [filters, setFilters] = useState<FilterState>({
|
|
|
32 |
organization: "",
|
33 |
country: "",
|
34 |
legalBasis: "",
|
35 |
+
topics: "",
|
36 |
minYear: "2000",
|
37 |
maxYear: "2025",
|
38 |
minFunding: "0",
|
|
|
48 |
countries: [],
|
49 |
legalBases: [],
|
50 |
fundingSchemes:[],
|
51 |
+
ids:[],
|
52 |
+
topics: []
|
53 |
});
|
54 |
|
55 |
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
56 |
|
57 |
const fetchProjects = () => {
|
58 |
+
fetch(`/api/projects?page=${page}&search=${encodeURIComponent(search)}&status=${statusFilter}&legalBasis=${legalFilter}&organization=${orgFilter}&country=${countryFilter}&fundingScheme=${fundingSchemeFilter}&proj_id=${idFilter}&topic=${topicsFilter}&sortField=${sortField}&sortOrder=${sortOrder}`)
|
59 |
.then(res => res.json())
|
60 |
.then((data: Project[]) => setProjects(data))
|
61 |
.catch(console.error);
|
|
|
80 |
countries: data.countries,
|
81 |
legalBases: data.legalBases,
|
82 |
fundingSchemes: data.fundingSchemes,
|
83 |
+
ids: [],
|
84 |
+
topics: data.topics
|
85 |
});
|
86 |
});
|
87 |
};
|