Editable and removable datasets #8

Merged
dylan merged 9 commits from feat/editable-datasets into main 2026-03-05 16:55:48 +00:00
3 changed files with 113 additions and 28 deletions
Showing only changes of commit 6948891677 - Show all commits

View File

@@ -1,3 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import ForceGraph3D from "react-force-graph-3d";
import {
@@ -6,12 +7,19 @@ import {
} from '../types/ApiTypes';
import StatsStyling from "../styles/stats_styling";
import Card from "./Card";
const styles = StatsStyling;
type GraphLink = {
source: string;
target: string;
value: number;
};
function ApiToGraphData(apiData: InteractionGraph) {
const nodes = Object.keys(apiData).map(username => ({ id: username }));
const links = [];
const links: GraphLink[] = [];
for (const [source, targets] of Object.entries(apiData)) {
for (const [target, count] of Object.entries(targets)) {
@@ -35,24 +43,103 @@ function ApiToGraphData(apiData: InteractionGraph) {
const UserStats = (props: { data: UserAnalysisResponse }) => {
const graphData = ApiToGraphData(props.data.interaction_graph);
const graphData = useMemo(() => ApiToGraphData(props.data.interaction_graph), [props.data.interaction_graph]);
const graphContainerRef = useRef<HTMLDivElement | null>(null);
const [graphSize, setGraphSize] = useState({ width: 720, height: 540 });
useEffect(() => {
const updateGraphSize = () => {
const containerWidth = graphContainerRef.current?.clientWidth ?? 720;
const nextWidth = Math.max(320, Math.floor(containerWidth));
const nextHeight = nextWidth < 700 ? 300 : 540;
setGraphSize({ width: nextWidth, height: nextHeight });
};
updateGraphSize();
window.addEventListener("resize", updateGraphSize);
return () => window.removeEventListener("resize", updateGraphSize);
}, []);
const totalUsers = props.data.users.length;
const connectedUsers = graphData.nodes.length;
const totalInteractions = graphData.links.reduce((sum, link) => sum + link.value, 0);
const avgInteractionsPerConnectedUser = connectedUsers ? totalInteractions / connectedUsers : 0;
const strongestLink = graphData.links.reduce<GraphLink | null>((best, current) => {
if (!best || current.value > best.value) {
return current;
}
return best;
}, null);
const highlyInteractiveUser = [...props.data.users].sort((a, b) => b.comment_share - a.comment_share)[0];
const mostActiveUser = props.data.top_users.find(u => u.author !== "[deleted]");
return (
<div style={styles.page}>
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
<p style={styles.sectionSubtitle}>
This graph visualizes interactions between users based on comments and replies.
Nodes represent users, and edges represent interactions (e.g., comments or replies) between them.
</p>
<div>
<ForceGraph3D
<div style={{ ...styles.container, ...styles.grid }}>
<Card
label="Users"
value={totalUsers.toLocaleString()}
sublabel={`${connectedUsers.toLocaleString()} users in filtered graph`}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Interactions"
value={totalInteractions.toLocaleString()}
sublabel="Filtered links (2+ interactions)"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Average Intensity"
value={avgInteractionsPerConnectedUser.toFixed(1)}
sublabel="Interactions per connected user"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Most Active User"
value={mostActiveUser?.author ?? "—"}
sublabel={mostActiveUser ? `${mostActiveUser.count.toLocaleString()} events` : "No user activity found"}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Strongest Connection"
value={strongestLink ? `${strongestLink.source} -> ${strongestLink.target}` : "—"}
sublabel={strongestLink ? `${strongestLink.value.toLocaleString()} interactions` : "No graph edges after filtering"}
style={{ gridColumn: "span 6" }}
/>
<Card
label="Most Reply-Driven User"
value={highlyInteractiveUser?.author ?? "—"}
sublabel={
highlyInteractiveUser
? `${Math.round(highlyInteractiveUser.comment_share * 100)}% comments`
: "No user distribution available"
}
style={{ gridColumn: "span 6" }}
/>
<div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
<p style={styles.sectionSubtitle}>
Nodes represent users and links represent conversation interactions.
</p>
<div ref={graphContainerRef} style={{ width: "100%", height: graphSize.height }}>
<ForceGraph3D
width={graphSize.width}
height={graphSize.height}
graphData={graphData}
nodeAutoColorBy="id"
linkDirectionalParticles={2}
linkDirectionalParticleSpeed={0.005}
linkWidth={(link) => Math.sqrt(link.value)}
linkDirectionalParticles={1}
linkDirectionalParticleSpeed={0.004}
linkWidth={(link) => Math.sqrt(Number(link.value))}
nodeLabel={(node) => `${node.id}`}
/>
/>
</div>
</div>
</div>
</div>
);

View File

@@ -4,7 +4,7 @@ import { useParams } from "react-router-dom";
import StatsStyling from "../styles/stats_styling";
import SummaryStats from "../components/SummaryStats";
import EmotionalStats from "../components/EmotionalStats";
import InteractionStats from "../components/UserStats";
import UserStats from "../components/UserStats";
import {
type SummaryResponse,
@@ -20,7 +20,7 @@ const StatPage = () => {
const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>();
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [activeView, setActiveView] = useState<"summary" | "emotional" | "interaction">("summary");
const [activeView, setActiveView] = useState<"summary" | "emotional" | "user">("summary");
const [userData, setUserData] = useState<UserAnalysisResponse | null>(null);
const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null);
@@ -139,7 +139,7 @@ const StatPage = () => {
if (loading) {
return (
<div style={styles.loadingPage}>
<div style={styles.loadingCard}>
<div style={{ ...styles.loadingCard, transform: "translateY(-100px)" }}>
<div style={styles.loadingHeader}>
<div style={styles.loadingSpinner} />
<div>
@@ -213,10 +213,10 @@ return (
</button>
<button
onClick={() => setActiveView("interaction")}
style={activeView === "interaction" ? styles.buttonPrimary : styles.buttonSecondary}
onClick={() => setActiveView("user")}
style={activeView === "user" ? styles.buttonPrimary : styles.buttonSecondary}
>
Interaction
Users
</button>
</div>
@@ -239,8 +239,8 @@ return (
</div>
)}
{activeView === "interaction" && userData && (
<InteractionStats data={userData} />
{activeView === "user" && userData && (
<UserStats data={userData} />
)}
</div>

View File

@@ -2,17 +2,15 @@ import pandas as pd
from server.queue.celery_app import celery
from server.analysis.enrichment import DatasetEnrichment
from server.db.database import PostgresConnector
from server.core.datasets import DatasetManager
@celery.task(bind=True, max_retries=3)
def process_dataset(self, dataset_id: int, posts: list, topics: dict):
db = PostgresConnector()
dataset_manager = DatasetManager(db)
try:
from server.db.database import PostgresConnector
from server.core.datasets import DatasetManager
db = PostgresConnector()
dataset_manager = DatasetManager(db)
df = pd.DataFrame(posts)
processor = DatasetEnrichment(df, topics)