style: run prettifier plugin on entire frontend
This commit is contained in:
@@ -3,7 +3,7 @@ import axios from "axios";
|
|||||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import StatsStyling from "../styles/stats_styling";
|
import StatsStyling from "../styles/stats_styling";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
||||||
type ProfileResponse = {
|
type ProfileResponse = {
|
||||||
user?: Record<string, unknown>;
|
user?: Record<string, unknown>;
|
||||||
@@ -33,7 +33,10 @@ const AppLayout = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSignedIn, setIsSignedIn] = useState(false);
|
const [isSignedIn, setIsSignedIn] = useState(false);
|
||||||
const [currentUser, setCurrentUser] = useState<Record<string, unknown> | null>(null);
|
const [currentUser, setCurrentUser] = useState<Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
const syncAuthState = useCallback(async () => {
|
const syncAuthState = useCallback(async () => {
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
@@ -48,7 +51,9 @@ const AppLayout = () => {
|
|||||||
axios.defaults.headers.common.Authorization = `Bearer ${token}`;
|
axios.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get<ProfileResponse>(`${API_BASE_URL}/profile`);
|
const response = await axios.get<ProfileResponse>(
|
||||||
|
`${API_BASE_URL}/profile`,
|
||||||
|
);
|
||||||
setIsSignedIn(true);
|
setIsSignedIn(true);
|
||||||
setCurrentUser(response.data.user ?? null);
|
setCurrentUser(response.data.user ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -81,27 +86,35 @@ const AppLayout = () => {
|
|||||||
<div style={{ ...styles.container, ...styles.appHeaderWrap }}>
|
<div style={{ ...styles.container, ...styles.appHeaderWrap }}>
|
||||||
<div style={{ ...styles.card, ...styles.headerBar }}>
|
<div style={{ ...styles.card, ...styles.headerBar }}>
|
||||||
<div style={styles.appHeaderBrandRow}>
|
<div style={styles.appHeaderBrandRow}>
|
||||||
<span style={styles.appTitle}>
|
<span style={styles.appTitle}>CrossPost Analysis Engine</span>
|
||||||
CrossPost Analysis Engine
|
|
||||||
</span>
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
...styles.authStatusBadge,
|
...styles.authStatusBadge,
|
||||||
...(isSignedIn ? styles.authStatusSignedIn : styles.authStatusSignedOut),
|
...(isSignedIn
|
||||||
|
? styles.authStatusSignedIn
|
||||||
|
: styles.authStatusSignedOut),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isSignedIn ? `Signed in: ${getUserLabel(currentUser)}` : "Not signed in"}
|
{isSignedIn
|
||||||
|
? `Signed in: ${getUserLabel(currentUser)}`
|
||||||
|
: "Not signed in"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.controlsWrapped}>
|
<div style={styles.controlsWrapped}>
|
||||||
{isSignedIn && <button
|
{isSignedIn && (
|
||||||
type="button"
|
<button
|
||||||
style={location.pathname === "/datasets" ? styles.buttonPrimary : styles.buttonSecondary}
|
type="button"
|
||||||
onClick={() => navigate("/datasets")}
|
style={
|
||||||
>
|
location.pathname === "/datasets"
|
||||||
My datasets
|
? styles.buttonPrimary
|
||||||
</button>}
|
: styles.buttonSecondary
|
||||||
|
}
|
||||||
|
onClick={() => navigate("/datasets")}
|
||||||
|
>
|
||||||
|
My datasets
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,20 +8,20 @@ const Card = (props: {
|
|||||||
value: string | number;
|
value: string | number;
|
||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
rightSlot?: React.ReactNode;
|
rightSlot?: React.ReactNode;
|
||||||
style?: CSSProperties
|
style?: CSSProperties;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div style={{ ...styles.cardBase, ...props.style }}>
|
<div style={{ ...styles.cardBase, ...props.style }}>
|
||||||
<div style={styles.cardTopRow}>
|
<div style={styles.cardTopRow}>
|
||||||
<div style={styles.cardLabel}>
|
<div style={styles.cardLabel}>{props.label}</div>
|
||||||
{props.label}
|
|
||||||
</div>
|
|
||||||
{props.rightSlot ? <div>{props.rightSlot}</div> : null}
|
{props.rightSlot ? <div>{props.rightSlot}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.cardValue}>{props.value}</div>
|
<div style={styles.cardValue}>{props.value}</div>
|
||||||
{props.sublabel ? <div style={styles.cardSubLabel}>{props.sublabel}</div> : null}
|
{props.sublabel ? (
|
||||||
|
<div style={styles.cardSubLabel}>{props.sublabel}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Card;
|
export default Card;
|
||||||
|
|||||||
@@ -34,10 +34,20 @@ export default function ConfirmationModal({
|
|||||||
<p style={styles.sectionSubtitle}>{message}</p>
|
<p style={styles.sectionSubtitle}>{message}</p>
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||||
<button type="button" onClick={onCancel} style={styles.buttonSecondary} disabled={loading}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
style={styles.buttonSecondary}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onConfirm} style={styles.buttonDanger} disabled={loading}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
style={styles.buttonDanger}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
{loading ? "Deleting..." : confirmLabel}
|
{loading ? "Deleting..." : confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,15 +14,17 @@ const CulturalStats = ({ data }: CulturalStatsProps) => {
|
|||||||
const inGroupWords = identity?.in_group_usage ?? 0;
|
const inGroupWords = identity?.in_group_usage ?? 0;
|
||||||
const outGroupWords = identity?.out_group_usage ?? 0;
|
const outGroupWords = identity?.out_group_usage ?? 0;
|
||||||
const totalGroupWords = inGroupWords + outGroupWords;
|
const totalGroupWords = inGroupWords + outGroupWords;
|
||||||
const inGroupWordRate = typeof identity?.in_group_ratio === "number"
|
const inGroupWordRate =
|
||||||
? identity.in_group_ratio * 100
|
typeof identity?.in_group_ratio === "number"
|
||||||
: null;
|
? identity.in_group_ratio * 100
|
||||||
const outGroupWordRate = typeof identity?.out_group_ratio === "number"
|
: null;
|
||||||
? identity.out_group_ratio * 100
|
const outGroupWordRate =
|
||||||
: null;
|
typeof identity?.out_group_ratio === "number"
|
||||||
|
? identity.out_group_ratio * 100
|
||||||
|
: null;
|
||||||
const rawEntities = data.avg_emotion_per_entity?.entity_emotion_avg ?? {};
|
const rawEntities = data.avg_emotion_per_entity?.entity_emotion_avg ?? {};
|
||||||
const entities = Object.entries(rawEntities)
|
const entities = Object.entries(rawEntities)
|
||||||
.sort((a, b) => (b[1].post_count - a[1].post_count))
|
.sort((a, b) => b[1].post_count - a[1].post_count)
|
||||||
.slice(0, 20);
|
.slice(0, 20);
|
||||||
|
|
||||||
const topEmotion = (emotionAvg: Record<string, number> | undefined) => {
|
const topEmotion = (emotionAvg: Record<string, number> | undefined) => {
|
||||||
@@ -42,7 +44,10 @@ const CulturalStats = ({ data }: CulturalStatsProps) => {
|
|||||||
<div style={{ ...styles.container, ...styles.grid }}>
|
<div style={{ ...styles.container, ...styles.grid }}>
|
||||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
<h2 style={styles.sectionTitle}>Community Framing Overview</h2>
|
<h2 style={styles.sectionTitle}>Community Framing Overview</h2>
|
||||||
<p style={styles.sectionSubtitle}>Simple view of how often people use "us" words vs "them" words, and the tone around that language.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Simple view of how often people use "us" words vs "them" words, and
|
||||||
|
the tone around that language.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
@@ -84,13 +89,17 @@ const CulturalStats = ({ data }: CulturalStatsProps) => {
|
|||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="In-Group Share"
|
label="In-Group Share"
|
||||||
value={inGroupWordRate === null ? "—" : `${inGroupWordRate.toFixed(2)}%`}
|
value={
|
||||||
|
inGroupWordRate === null ? "—" : `${inGroupWordRate.toFixed(2)}%`
|
||||||
|
}
|
||||||
sublabel="Share of all words"
|
sublabel="Share of all words"
|
||||||
style={{ gridColumn: "span 3" }}
|
style={{ gridColumn: "span 3" }}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="Out-Group Share"
|
label="Out-Group Share"
|
||||||
value={outGroupWordRate === null ? "—" : `${outGroupWordRate.toFixed(2)}%`}
|
value={
|
||||||
|
outGroupWordRate === null ? "—" : `${outGroupWordRate.toFixed(2)}%`
|
||||||
|
}
|
||||||
sublabel="Share of all words"
|
sublabel="Share of all words"
|
||||||
style={{ gridColumn: "span 3" }}
|
style={{ gridColumn: "span 3" }}
|
||||||
/>
|
/>
|
||||||
@@ -98,52 +107,87 @@ const CulturalStats = ({ data }: CulturalStatsProps) => {
|
|||||||
<Card
|
<Card
|
||||||
label="Hedging Words"
|
label="Hedging Words"
|
||||||
value={stance?.hedge_total?.toLocaleString() ?? "—"}
|
value={stance?.hedge_total?.toLocaleString() ?? "—"}
|
||||||
sublabel={typeof stance?.hedge_per_1k_tokens === "number" ? `${stance.hedge_per_1k_tokens.toFixed(1)} per 1k words` : "Word frequency"}
|
sublabel={
|
||||||
|
typeof stance?.hedge_per_1k_tokens === "number"
|
||||||
|
? `${stance.hedge_per_1k_tokens.toFixed(1)} per 1k words`
|
||||||
|
: "Word frequency"
|
||||||
|
}
|
||||||
style={{ gridColumn: "span 3" }}
|
style={{ gridColumn: "span 3" }}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="Certainty Words"
|
label="Certainty Words"
|
||||||
value={stance?.certainty_total?.toLocaleString() ?? "—"}
|
value={stance?.certainty_total?.toLocaleString() ?? "—"}
|
||||||
sublabel={typeof stance?.certainty_per_1k_tokens === "number" ? `${stance.certainty_per_1k_tokens.toFixed(1)} per 1k words` : "Word frequency"}
|
sublabel={
|
||||||
|
typeof stance?.certainty_per_1k_tokens === "number"
|
||||||
|
? `${stance.certainty_per_1k_tokens.toFixed(1)} per 1k words`
|
||||||
|
: "Word frequency"
|
||||||
|
}
|
||||||
style={{ gridColumn: "span 3" }}
|
style={{ gridColumn: "span 3" }}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="Need/Should Words"
|
label="Need/Should Words"
|
||||||
value={stance?.deontic_total?.toLocaleString() ?? "—"}
|
value={stance?.deontic_total?.toLocaleString() ?? "—"}
|
||||||
sublabel={typeof stance?.deontic_per_1k_tokens === "number" ? `${stance.deontic_per_1k_tokens.toFixed(1)} per 1k words` : "Word frequency"}
|
sublabel={
|
||||||
|
typeof stance?.deontic_per_1k_tokens === "number"
|
||||||
|
? `${stance.deontic_per_1k_tokens.toFixed(1)} per 1k words`
|
||||||
|
: "Word frequency"
|
||||||
|
}
|
||||||
style={{ gridColumn: "span 3" }}
|
style={{ gridColumn: "span 3" }}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="Permission Words"
|
label="Permission Words"
|
||||||
value={stance?.permission_total?.toLocaleString() ?? "—"}
|
value={stance?.permission_total?.toLocaleString() ?? "—"}
|
||||||
sublabel={typeof stance?.permission_per_1k_tokens === "number" ? `${stance.permission_per_1k_tokens.toFixed(1)} per 1k words` : "Word frequency"}
|
sublabel={
|
||||||
|
typeof stance?.permission_per_1k_tokens === "number"
|
||||||
|
? `${stance.permission_per_1k_tokens.toFixed(1)} per 1k words`
|
||||||
|
: "Word frequency"
|
||||||
|
}
|
||||||
style={{ gridColumn: "span 3" }}
|
style={{ gridColumn: "span 3" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ ...styles.card, gridColumn: "span 6" }}>
|
<div style={{ ...styles.card, gridColumn: "span 6" }}>
|
||||||
<h2 style={styles.sectionTitle}>Mood in "Us" Posts</h2>
|
<h2 style={styles.sectionTitle}>Mood in "Us" Posts</h2>
|
||||||
<p style={styles.sectionSubtitle}>Most likely emotion when in-group wording is stronger.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
<div style={styles.topUserName}>{topEmotion(identity?.in_group_emotion_avg)}</div>
|
Most likely emotion when in-group wording is stronger.
|
||||||
|
</p>
|
||||||
|
<div style={styles.topUserName}>
|
||||||
|
{topEmotion(identity?.in_group_emotion_avg)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ ...styles.card, gridColumn: "span 6" }}>
|
<div style={{ ...styles.card, gridColumn: "span 6" }}>
|
||||||
<h2 style={styles.sectionTitle}>Mood in "Them" Posts</h2>
|
<h2 style={styles.sectionTitle}>Mood in "Them" Posts</h2>
|
||||||
<p style={styles.sectionSubtitle}>Most likely emotion when out-group wording is stronger.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
<div style={styles.topUserName}>{topEmotion(identity?.out_group_emotion_avg)}</div>
|
Most likely emotion when out-group wording is stronger.
|
||||||
|
</p>
|
||||||
|
<div style={styles.topUserName}>
|
||||||
|
{topEmotion(identity?.out_group_emotion_avg)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
<h2 style={styles.sectionTitle}>Entity Mood Snapshot</h2>
|
<h2 style={styles.sectionTitle}>Entity Mood Snapshot</h2>
|
||||||
<p style={styles.sectionSubtitle}>Most mentioned entities and the mood that appears most with each.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Most mentioned entities and the mood that appears most with each.
|
||||||
|
</p>
|
||||||
{!entities.length ? (
|
{!entities.length ? (
|
||||||
<div style={styles.topUserMeta}>No entity-level cultural data available.</div>
|
<div style={styles.topUserMeta}>
|
||||||
|
No entity-level cultural data available.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ ...styles.topUsersList, maxHeight: 420, overflowY: "auto" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.topUsersList,
|
||||||
|
maxHeight: 420,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{entities.map(([entity, aggregate]) => (
|
{entities.map(([entity, aggregate]) => (
|
||||||
<div key={entity} style={styles.topUserItem}>
|
<div key={entity} style={styles.topUserItem}>
|
||||||
<div style={styles.topUserName}>{entity}</div>
|
<div style={styles.topUserName}>{entity}</div>
|
||||||
<div style={styles.topUserMeta}>
|
<div style={styles.topUserMeta}>
|
||||||
{aggregate.post_count.toLocaleString()} posts • Likely mood: {topEmotion(aggregate.emotion_avg)}
|
{aggregate.post_count.toLocaleString()} posts • Likely mood:{" "}
|
||||||
|
{topEmotion(aggregate.emotion_avg)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import type { ContentAnalysisResponse } from "../types/ApiTypes"
|
import type { ContentAnalysisResponse } from "../types/ApiTypes";
|
||||||
import StatsStyling from "../styles/stats_styling";
|
import StatsStyling from "../styles/stats_styling";
|
||||||
|
|
||||||
const styles = StatsStyling;
|
const styles = StatsStyling;
|
||||||
|
|
||||||
type EmotionalStatsProps = {
|
type EmotionalStatsProps = {
|
||||||
contentData: ContentAnalysisResponse;
|
contentData: ContentAnalysisResponse;
|
||||||
}
|
};
|
||||||
|
|
||||||
const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
|
const EmotionalStats = ({ contentData }: EmotionalStatsProps) => {
|
||||||
const rows = contentData.average_emotion_by_topic ?? [];
|
const rows = contentData.average_emotion_by_topic ?? [];
|
||||||
const overallEmotionAverage = contentData.overall_emotion_average ?? [];
|
const overallEmotionAverage = contentData.overall_emotion_average ?? [];
|
||||||
const dominantEmotionDistribution = contentData.dominant_emotion_distribution ?? [];
|
const dominantEmotionDistribution =
|
||||||
|
contentData.dominant_emotion_distribution ?? [];
|
||||||
const emotionBySource = contentData.emotion_by_source ?? [];
|
const emotionBySource = contentData.emotion_by_source ?? [];
|
||||||
const lowSampleThreshold = 20;
|
const lowSampleThreshold = 20;
|
||||||
const stableSampleThreshold = 50;
|
const stableSampleThreshold = 50;
|
||||||
@@ -34,7 +35,7 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
|
|||||||
topic: String(row.topic),
|
topic: String(row.topic),
|
||||||
count: Number(row.n ?? 0),
|
count: Number(row.n ?? 0),
|
||||||
emotion: maxKey.replace("emotion_", "") || "unknown",
|
emotion: maxKey.replace("emotion_", "") || "unknown",
|
||||||
value: maxValue > Number.NEGATIVE_INFINITY ? maxValue : 0
|
value: maxValue > Number.NEGATIVE_INFINITY ? maxValue : 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,8 +49,12 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
|
|||||||
.filter((count) => Number.isFinite(count) && count > 0)
|
.filter((count) => Number.isFinite(count) && count > 0)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
const lowSampleTopics = strongestPerTopic.filter((topic) => topic.count < lowSampleThreshold).length;
|
const lowSampleTopics = strongestPerTopic.filter(
|
||||||
const stableSampleTopics = strongestPerTopic.filter((topic) => topic.count >= stableSampleThreshold).length;
|
(topic) => topic.count < lowSampleThreshold,
|
||||||
|
).length;
|
||||||
|
const stableSampleTopics = strongestPerTopic.filter(
|
||||||
|
(topic) => topic.count >= stableSampleThreshold,
|
||||||
|
).length;
|
||||||
|
|
||||||
const medianSampleSize = sampleSizes.length
|
const medianSampleSize = sampleSizes.length
|
||||||
? sampleSizes[Math.floor(sampleSizes.length / 2)]
|
? sampleSizes[Math.floor(sampleSizes.length / 2)]
|
||||||
@@ -68,15 +73,37 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
|
|||||||
<div style={styles.page}>
|
<div style={styles.page}>
|
||||||
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
|
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
|
||||||
<h2 style={styles.sectionTitle}>Topic Mood Overview</h2>
|
<h2 style={styles.sectionTitle}>Topic Mood Overview</h2>
|
||||||
<p style={styles.sectionSubtitle}>Use the strength score together with post count. Topics with fewer than {lowSampleThreshold} events are often noisy.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Use the strength score together with post count. Topics with fewer
|
||||||
|
than {lowSampleThreshold} events are often noisy.
|
||||||
|
</p>
|
||||||
<div style={styles.emotionalSummaryRow}>
|
<div style={styles.emotionalSummaryRow}>
|
||||||
<span><strong style={{ color: "#24292f" }}>Topics:</strong> {strongestPerTopic.length}</span>
|
<span>
|
||||||
<span><strong style={{ color: "#24292f" }}>Median Posts:</strong> {medianSampleSize}</span>
|
<strong style={{ color: "#24292f" }}>Topics:</strong>{" "}
|
||||||
<span><strong style={{ color: "#24292f" }}>Small Topics (<{lowSampleThreshold}):</strong> {lowSampleTopics}</span>
|
{strongestPerTopic.length}
|
||||||
<span><strong style={{ color: "#24292f" }}>Stable Topics ({stableSampleThreshold}+):</strong> {stableSampleTopics}</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: "#24292f" }}>Median Posts:</strong>{" "}
|
||||||
|
{medianSampleSize}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: "#24292f" }}>
|
||||||
|
Small Topics (<{lowSampleThreshold}):
|
||||||
|
</strong>{" "}
|
||||||
|
{lowSampleTopics}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong style={{ color: "#24292f" }}>
|
||||||
|
Stable Topics ({stableSampleThreshold}+):
|
||||||
|
</strong>{" "}
|
||||||
|
{stableSampleTopics}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ ...styles.sectionSubtitle, marginTop: 10, marginBottom: 0 }}>
|
<p
|
||||||
Strength means how far the top emotion is ahead in that topic. It does not mean model accuracy.
|
style={{ ...styles.sectionSubtitle, marginTop: 10, marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
Strength means how far the top emotion is ahead in that topic. It does
|
||||||
|
not mean model accuracy.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,14 +112,24 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
|
|||||||
<h2 style={styles.sectionTitle}>Mood Averages</h2>
|
<h2 style={styles.sectionTitle}>Mood Averages</h2>
|
||||||
<p style={styles.sectionSubtitle}>Average score for each emotion.</p>
|
<p style={styles.sectionSubtitle}>Average score for each emotion.</p>
|
||||||
{!overallEmotionAverage.length ? (
|
{!overallEmotionAverage.length ? (
|
||||||
<div style={styles.topUserMeta}>No overall emotion averages available.</div>
|
<div style={styles.topUserMeta}>
|
||||||
|
No overall emotion averages available.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ ...styles.topUsersList, maxHeight: 260, overflowY: "auto" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.topUsersList,
|
||||||
|
maxHeight: 260,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{[...overallEmotionAverage]
|
{[...overallEmotionAverage]
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.map((row) => (
|
.map((row) => (
|
||||||
<div key={row.emotion} style={styles.topUserItem}>
|
<div key={row.emotion} style={styles.topUserItem}>
|
||||||
<div style={styles.topUserName}>{formatEmotion(row.emotion)}</div>
|
<div style={styles.topUserName}>
|
||||||
|
{formatEmotion(row.emotion)}
|
||||||
|
</div>
|
||||||
<div style={styles.topUserMeta}>{row.score.toFixed(3)}</div>
|
<div style={styles.topUserMeta}>{row.score.toFixed(3)}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -102,17 +139,32 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
|
|||||||
|
|
||||||
<div style={{ ...styles.card, gridColumn: "span 4" }}>
|
<div style={{ ...styles.card, gridColumn: "span 4" }}>
|
||||||
<h2 style={styles.sectionTitle}>Mood Split</h2>
|
<h2 style={styles.sectionTitle}>Mood Split</h2>
|
||||||
<p style={styles.sectionSubtitle}>How often each emotion is dominant.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
How often each emotion is dominant.
|
||||||
|
</p>
|
||||||
{!dominantEmotionDistribution.length ? (
|
{!dominantEmotionDistribution.length ? (
|
||||||
<div style={styles.topUserMeta}>No dominant-emotion split available.</div>
|
<div style={styles.topUserMeta}>
|
||||||
|
No dominant-emotion split available.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ ...styles.topUsersList, maxHeight: 260, overflowY: "auto" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.topUsersList,
|
||||||
|
maxHeight: 260,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{[...dominantEmotionDistribution]
|
{[...dominantEmotionDistribution]
|
||||||
.sort((a, b) => b.ratio - a.ratio)
|
.sort((a, b) => b.ratio - a.ratio)
|
||||||
.map((row) => (
|
.map((row) => (
|
||||||
<div key={row.emotion} style={styles.topUserItem}>
|
<div key={row.emotion} style={styles.topUserItem}>
|
||||||
<div style={styles.topUserName}>{formatEmotion(row.emotion)}</div>
|
<div style={styles.topUserName}>
|
||||||
<div style={styles.topUserMeta}>{(row.ratio * 100).toFixed(1)}% • {row.count.toLocaleString()} events</div>
|
{formatEmotion(row.emotion)}
|
||||||
|
</div>
|
||||||
|
<div style={styles.topUserMeta}>
|
||||||
|
{(row.ratio * 100).toFixed(1)}% •{" "}
|
||||||
|
{row.count.toLocaleString()} events
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -123,16 +175,26 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
|
|||||||
<h2 style={styles.sectionTitle}>Mood by Source</h2>
|
<h2 style={styles.sectionTitle}>Mood by Source</h2>
|
||||||
<p style={styles.sectionSubtitle}>Leading emotion in each source.</p>
|
<p style={styles.sectionSubtitle}>Leading emotion in each source.</p>
|
||||||
{!emotionBySource.length ? (
|
{!emotionBySource.length ? (
|
||||||
<div style={styles.topUserMeta}>No source emotion profile available.</div>
|
<div style={styles.topUserMeta}>
|
||||||
|
No source emotion profile available.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ ...styles.topUsersList, maxHeight: 260, overflowY: "auto" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.topUsersList,
|
||||||
|
maxHeight: 260,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{[...emotionBySource]
|
{[...emotionBySource]
|
||||||
.sort((a, b) => b.event_count - a.event_count)
|
.sort((a, b) => b.event_count - a.event_count)
|
||||||
.map((row) => (
|
.map((row) => (
|
||||||
<div key={row.source} style={styles.topUserItem}>
|
<div key={row.source} style={styles.topUserItem}>
|
||||||
<div style={styles.topUserName}>{row.source}</div>
|
<div style={styles.topUserName}>{row.source}</div>
|
||||||
<div style={styles.topUserMeta}>
|
<div style={styles.topUserMeta}>
|
||||||
{formatEmotion(row.dominant_emotion)} • {row.dominant_score.toFixed(3)} • {row.event_count.toLocaleString()} events
|
{formatEmotion(row.dominant_emotion)} •{" "}
|
||||||
|
{row.dominant_score.toFixed(3)} •{" "}
|
||||||
|
{row.event_count.toLocaleString()} events
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -142,20 +204,27 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
|
|||||||
|
|
||||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
<h2 style={styles.sectionTitle}>Topic Snapshots</h2>
|
<h2 style={styles.sectionTitle}>Topic Snapshots</h2>
|
||||||
<p style={styles.sectionSubtitle}>Per-topic mood with strength and post count.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Per-topic mood with strength and post count.
|
||||||
|
</p>
|
||||||
<div style={{ ...styles.grid, marginTop: 10 }}>
|
<div style={{ ...styles.grid, marginTop: 10 }}>
|
||||||
{strongestPerTopic.map((topic) => (
|
{strongestPerTopic.map((topic) => (
|
||||||
<div key={topic.topic} style={{ ...styles.cardBase, gridColumn: "span 4" }}>
|
<div
|
||||||
<h3 style={{ ...styles.sectionTitle, marginBottom: 6 }}>{topic.topic}</h3>
|
key={topic.topic}
|
||||||
<div style={styles.emotionalTopicLabel}>
|
style={{ ...styles.cardBase, gridColumn: "span 4" }}
|
||||||
Likely Mood
|
>
|
||||||
</div>
|
<h3 style={{ ...styles.sectionTitle, marginBottom: 6 }}>
|
||||||
|
{topic.topic}
|
||||||
|
</h3>
|
||||||
|
<div style={styles.emotionalTopicLabel}>Likely Mood</div>
|
||||||
<div style={styles.emotionalTopicValue}>
|
<div style={styles.emotionalTopicValue}>
|
||||||
{formatEmotion(topic.emotion)}
|
{formatEmotion(topic.emotion)}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.emotionalMetricRow}>
|
<div style={styles.emotionalMetricRow}>
|
||||||
<span>Strength</span>
|
<span>Strength</span>
|
||||||
<span style={styles.emotionalMetricValue}>{topic.value.toFixed(3)}</span>
|
<span style={styles.emotionalMetricValue}>
|
||||||
|
{topic.value.toFixed(3)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.emotionalMetricRowCompact}>
|
<div style={styles.emotionalMetricRowCompact}>
|
||||||
<span>Posts in Topic</span>
|
<span>Posts in Topic</span>
|
||||||
@@ -168,6 +237,6 @@ const EmotionalStats = ({contentData}: EmotionalStatsProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default EmotionalStats;
|
export default EmotionalStats;
|
||||||
|
|||||||
@@ -24,25 +24,32 @@ type InteractionalStatsProps = {
|
|||||||
const InteractionalStats = ({ data }: InteractionalStatsProps) => {
|
const InteractionalStats = ({ data }: InteractionalStatsProps) => {
|
||||||
const graph = data.interaction_graph ?? {};
|
const graph = data.interaction_graph ?? {};
|
||||||
const userCount = Object.keys(graph).length;
|
const userCount = Object.keys(graph).length;
|
||||||
const edges = Object.values(graph).flatMap((targets) => Object.values(targets));
|
const edges = Object.values(graph).flatMap((targets) =>
|
||||||
|
Object.values(targets),
|
||||||
|
);
|
||||||
const edgeCount = edges.length;
|
const edgeCount = edges.length;
|
||||||
const interactionVolume = edges.reduce((sum, value) => sum + value, 0);
|
const interactionVolume = edges.reduce((sum, value) => sum + value, 0);
|
||||||
const concentration = data.conversation_concentration;
|
const concentration = data.conversation_concentration;
|
||||||
const topTenCommentShare = typeof concentration?.top_10pct_comment_share === "number"
|
const topTenCommentShare =
|
||||||
? concentration?.top_10pct_comment_share
|
typeof concentration?.top_10pct_comment_share === "number"
|
||||||
: null;
|
? concentration?.top_10pct_comment_share
|
||||||
const topTenAuthorCount = typeof concentration?.top_10pct_author_count === "number"
|
: null;
|
||||||
? concentration.top_10pct_author_count
|
const topTenAuthorCount =
|
||||||
: null;
|
typeof concentration?.top_10pct_author_count === "number"
|
||||||
const totalCommentingAuthors = typeof concentration?.total_commenting_authors === "number"
|
? concentration.top_10pct_author_count
|
||||||
? concentration.total_commenting_authors
|
: null;
|
||||||
: null;
|
const totalCommentingAuthors =
|
||||||
const singleCommentAuthorRatio = typeof concentration?.single_comment_author_ratio === "number"
|
typeof concentration?.total_commenting_authors === "number"
|
||||||
? concentration.single_comment_author_ratio
|
? concentration.total_commenting_authors
|
||||||
: null;
|
: null;
|
||||||
const singleCommentAuthors = typeof concentration?.single_comment_authors === "number"
|
const singleCommentAuthorRatio =
|
||||||
? concentration.single_comment_authors
|
typeof concentration?.single_comment_author_ratio === "number"
|
||||||
: null;
|
? concentration.single_comment_author_ratio
|
||||||
|
: null;
|
||||||
|
const singleCommentAuthors =
|
||||||
|
typeof concentration?.single_comment_authors === "number"
|
||||||
|
? concentration.single_comment_authors
|
||||||
|
: null;
|
||||||
|
|
||||||
const topPairs = (data.top_interaction_pairs ?? [])
|
const topPairs = (data.top_interaction_pairs ?? [])
|
||||||
.filter((item): item is [[string, string], number] => {
|
.filter((item): item is [[string, string], number] => {
|
||||||
@@ -53,26 +60,28 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
|
|||||||
const pair = item[0];
|
const pair = item[0];
|
||||||
const count = item[1];
|
const count = item[1];
|
||||||
|
|
||||||
return Array.isArray(pair)
|
return (
|
||||||
&& pair.length === 2
|
Array.isArray(pair) &&
|
||||||
&& typeof pair[0] === "string"
|
pair.length === 2 &&
|
||||||
&& typeof pair[1] === "string"
|
typeof pair[0] === "string" &&
|
||||||
&& typeof count === "number";
|
typeof pair[1] === "string" &&
|
||||||
|
typeof count === "number"
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.slice(0, 20);
|
.slice(0, 20);
|
||||||
|
|
||||||
const topPairChartData = topPairs.slice(0, 8).map(([[source, target], value], index) => ({
|
const topPairChartData = topPairs
|
||||||
pair: `${source} -> ${target}`,
|
.slice(0, 8)
|
||||||
replies: value,
|
.map(([[source, target], value], index) => ({
|
||||||
rank: index + 1,
|
pair: `${source} -> ${target}`,
|
||||||
}));
|
replies: value,
|
||||||
|
rank: index + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
const topTenSharePercent = topTenCommentShare === null
|
const topTenSharePercent =
|
||||||
? null
|
topTenCommentShare === null ? null : topTenCommentShare * 100;
|
||||||
: topTenCommentShare * 100;
|
const nonTopTenSharePercent =
|
||||||
const nonTopTenSharePercent = topTenSharePercent === null
|
topTenSharePercent === null ? null : Math.max(0, 100 - topTenSharePercent);
|
||||||
? null
|
|
||||||
: Math.max(0, 100 - topTenSharePercent);
|
|
||||||
|
|
||||||
let concentrationPieData: { name: string; value: number }[] = [];
|
let concentrationPieData: { name: string; value: number }[] = [];
|
||||||
if (topTenSharePercent !== null && nonTopTenSharePercent !== null) {
|
if (topTenSharePercent !== null && nonTopTenSharePercent !== null) {
|
||||||
@@ -89,12 +98,18 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
|
|||||||
<div style={{ ...styles.container, ...styles.grid }}>
|
<div style={{ ...styles.container, ...styles.grid }}>
|
||||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
<h2 style={styles.sectionTitle}>Conversation Overview</h2>
|
<h2 style={styles.sectionTitle}>Conversation Overview</h2>
|
||||||
<p style={styles.sectionSubtitle}>Who talks to who, and how concentrated the replies are.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Who talks to who, and how concentrated the replies are.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
label="Average Reply Depth"
|
label="Average Reply Depth"
|
||||||
value={typeof data.average_thread_depth === "number" ? data.average_thread_depth.toFixed(2) : "—"}
|
value={
|
||||||
|
typeof data.average_thread_depth === "number"
|
||||||
|
? data.average_thread_depth.toFixed(2)
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
sublabel="How deep reply chains usually go"
|
sublabel="How deep reply chains usually go"
|
||||||
style={{ gridColumn: "span 3" }}
|
style={{ gridColumn: "span 3" }}
|
||||||
/>
|
/>
|
||||||
@@ -118,31 +133,51 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
|
|||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="Concentrated Replies"
|
label="Concentrated Replies"
|
||||||
value={topTenSharePercent === null ? "-" : `${topTenSharePercent.toFixed(1)}%`}
|
value={
|
||||||
sublabel={topTenAuthorCount === null || totalCommentingAuthors === null
|
topTenSharePercent === null
|
||||||
? "Reply share from the top 10% commenters"
|
? "-"
|
||||||
: `${topTenAuthorCount.toLocaleString()} of ${totalCommentingAuthors.toLocaleString()} authors`}
|
: `${topTenSharePercent.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
sublabel={
|
||||||
|
topTenAuthorCount === null || totalCommentingAuthors === null
|
||||||
|
? "Reply share from the top 10% commenters"
|
||||||
|
: `${topTenAuthorCount.toLocaleString()} of ${totalCommentingAuthors.toLocaleString()} authors`
|
||||||
|
}
|
||||||
style={{ gridColumn: "span 6" }}
|
style={{ gridColumn: "span 6" }}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="Single-Comment Authors"
|
label="Single-Comment Authors"
|
||||||
value={singleCommentAuthorRatio === null ? "-" : `${(singleCommentAuthorRatio * 100).toFixed(1)}%`}
|
value={
|
||||||
sublabel={singleCommentAuthors === null
|
singleCommentAuthorRatio === null
|
||||||
? "Authors who commented exactly once"
|
? "-"
|
||||||
: `${singleCommentAuthors.toLocaleString()} authors commented exactly once`}
|
: `${(singleCommentAuthorRatio * 100).toFixed(1)}%`
|
||||||
|
}
|
||||||
|
sublabel={
|
||||||
|
singleCommentAuthors === null
|
||||||
|
? "Authors who commented exactly once"
|
||||||
|
: `${singleCommentAuthors.toLocaleString()} authors commented exactly once`
|
||||||
|
}
|
||||||
style={{ gridColumn: "span 6" }}
|
style={{ gridColumn: "span 6" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
<h2 style={styles.sectionTitle}>Conversation Visuals</h2>
|
<h2 style={styles.sectionTitle}>Conversation Visuals</h2>
|
||||||
<p style={styles.sectionSubtitle}>Main reply links and concentration split.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Main reply links and concentration split.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div style={{ ...styles.grid, marginTop: 12 }}>
|
<div style={{ ...styles.grid, marginTop: 12 }}>
|
||||||
<div style={{ ...styles.cardBase, gridColumn: "span 6" }}>
|
<div style={{ ...styles.cardBase, gridColumn: "span 6" }}>
|
||||||
<h3 style={{ ...styles.sectionTitle, fontSize: "1rem" }}>Top Interaction Pairs</h3>
|
<h3 style={{ ...styles.sectionTitle, fontSize: "1rem" }}>
|
||||||
|
Top Interaction Pairs
|
||||||
|
</h3>
|
||||||
<div style={{ width: "100%", height: 300 }}>
|
<div style={{ width: "100%", height: 300 }}>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={topPairChartData} layout="vertical" margin={{ top: 8, right: 16, left: 16, bottom: 8 }}>
|
<BarChart
|
||||||
|
data={topPairChartData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 8, right: 16, left: 16, bottom: 8 }}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#d9e2ec" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#d9e2ec" />
|
||||||
<XAxis type="number" allowDecimals={false} />
|
<XAxis type="number" allowDecimals={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
@@ -152,14 +187,20 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
|
|||||||
width={36}
|
width={36}
|
||||||
/>
|
/>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Bar dataKey="replies" fill="#2b6777" radius={[0, 6, 6, 0]} />
|
<Bar
|
||||||
|
dataKey="replies"
|
||||||
|
fill="#2b6777"
|
||||||
|
radius={[0, 6, 6, 0]}
|
||||||
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ ...styles.cardBase, gridColumn: "span 6" }}>
|
<div style={{ ...styles.cardBase, gridColumn: "span 6" }}>
|
||||||
<h3 style={{ ...styles.sectionTitle, fontSize: "1rem" }}>Top 10% vs Other Comment Share</h3>
|
<h3 style={{ ...styles.sectionTitle, fontSize: "1rem" }}>
|
||||||
|
Top 10% vs Other Comment Share
|
||||||
|
</h3>
|
||||||
<div style={{ width: "100%", height: 300 }}>
|
<div style={{ width: "100%", height: 300 }}>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
@@ -172,7 +213,10 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
|
|||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
>
|
>
|
||||||
{concentrationPieData.map((entry, index) => (
|
{concentrationPieData.map((entry, index) => (
|
||||||
<Cell key={`${entry.name}-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
<Cell
|
||||||
|
key={`${entry.name}-${index}`}
|
||||||
|
fill={PIE_COLORS[index % PIE_COLORS.length]}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
@@ -186,15 +230,32 @@ const InteractionalStats = ({ data }: InteractionalStatsProps) => {
|
|||||||
|
|
||||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
<h2 style={styles.sectionTitle}>Frequent Reply Paths</h2>
|
<h2 style={styles.sectionTitle}>Frequent Reply Paths</h2>
|
||||||
<p style={styles.sectionSubtitle}>Most common user-to-user reply paths.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Most common user-to-user reply paths.
|
||||||
|
</p>
|
||||||
{!topPairs.length ? (
|
{!topPairs.length ? (
|
||||||
<div style={styles.topUserMeta}>No interaction pair data available.</div>
|
<div style={styles.topUserMeta}>
|
||||||
|
No interaction pair data available.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ ...styles.topUsersList, maxHeight: 420, overflowY: "auto" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.topUsersList,
|
||||||
|
maxHeight: 420,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{topPairs.map(([[source, target], value], index) => (
|
{topPairs.map(([[source, target], value], index) => (
|
||||||
<div key={`${source}->${target}-${index}`} style={styles.topUserItem}>
|
<div
|
||||||
<div style={styles.topUserName}>{source} -> {target}</div>
|
key={`${source}->${target}-${index}`}
|
||||||
<div style={styles.topUserMeta}>{value.toLocaleString()} replies</div>
|
style={styles.topUserItem}
|
||||||
|
>
|
||||||
|
<div style={styles.topUserName}>
|
||||||
|
{source} -> {target}
|
||||||
|
</div>
|
||||||
|
<div style={styles.topUserMeta}>
|
||||||
|
{value.toLocaleString()} replies
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {
|
|||||||
<div style={{ ...styles.container, ...styles.grid }}>
|
<div style={{ ...styles.container, ...styles.grid }}>
|
||||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
<h2 style={styles.sectionTitle}>Language Overview</h2>
|
<h2 style={styles.sectionTitle}>Language Overview</h2>
|
||||||
<p style={styles.sectionSubtitle}>Quick read on how broad and repetitive the wording is.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Quick read on how broad and repetitive the wording is.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
@@ -40,7 +42,9 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {
|
|||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="Vocabulary Variety"
|
label="Vocabulary Variety"
|
||||||
value={typeof lexical?.ttr === "number" ? lexical.ttr.toFixed(4) : "—"}
|
value={
|
||||||
|
typeof lexical?.ttr === "number" ? lexical.ttr.toFixed(4) : "—"
|
||||||
|
}
|
||||||
sublabel="Higher means less repetition"
|
sublabel="Higher means less repetition"
|
||||||
style={{ gridColumn: "span 4" }}
|
style={{ gridColumn: "span 4" }}
|
||||||
/>
|
/>
|
||||||
@@ -48,11 +52,19 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {
|
|||||||
<div style={{ ...styles.card, gridColumn: "span 4" }}>
|
<div style={{ ...styles.card, gridColumn: "span 4" }}>
|
||||||
<h2 style={styles.sectionTitle}>Top Words</h2>
|
<h2 style={styles.sectionTitle}>Top Words</h2>
|
||||||
<p style={styles.sectionSubtitle}>Most used single words.</p>
|
<p style={styles.sectionSubtitle}>Most used single words.</p>
|
||||||
<div style={{ ...styles.topUsersList, maxHeight: 360, overflowY: "auto" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.topUsersList,
|
||||||
|
maxHeight: 360,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{topWords.map((item) => (
|
{topWords.map((item) => (
|
||||||
<div key={item.word} style={styles.topUserItem}>
|
<div key={item.word} style={styles.topUserItem}>
|
||||||
<div style={styles.topUserName}>{item.word}</div>
|
<div style={styles.topUserName}>{item.word}</div>
|
||||||
<div style={styles.topUserMeta}>{item.count.toLocaleString()} uses</div>
|
<div style={styles.topUserMeta}>
|
||||||
|
{item.count.toLocaleString()} uses
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -61,11 +73,19 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {
|
|||||||
<div style={{ ...styles.card, gridColumn: "span 4" }}>
|
<div style={{ ...styles.card, gridColumn: "span 4" }}>
|
||||||
<h2 style={styles.sectionTitle}>Top Bigrams</h2>
|
<h2 style={styles.sectionTitle}>Top Bigrams</h2>
|
||||||
<p style={styles.sectionSubtitle}>Most used 2-word phrases.</p>
|
<p style={styles.sectionSubtitle}>Most used 2-word phrases.</p>
|
||||||
<div style={{ ...styles.topUsersList, maxHeight: 360, overflowY: "auto" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.topUsersList,
|
||||||
|
maxHeight: 360,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{topBigrams.map((item) => (
|
{topBigrams.map((item) => (
|
||||||
<div key={item.ngram} style={styles.topUserItem}>
|
<div key={item.ngram} style={styles.topUserItem}>
|
||||||
<div style={styles.topUserName}>{item.ngram}</div>
|
<div style={styles.topUserName}>{item.ngram}</div>
|
||||||
<div style={styles.topUserMeta}>{item.count.toLocaleString()} uses</div>
|
<div style={styles.topUserMeta}>
|
||||||
|
{item.count.toLocaleString()} uses
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -74,11 +94,19 @@ const LinguisticStats = ({ data }: LinguisticStatsProps) => {
|
|||||||
<div style={{ ...styles.card, gridColumn: "span 4" }}>
|
<div style={{ ...styles.card, gridColumn: "span 4" }}>
|
||||||
<h2 style={styles.sectionTitle}>Top Trigrams</h2>
|
<h2 style={styles.sectionTitle}>Top Trigrams</h2>
|
||||||
<p style={styles.sectionSubtitle}>Most used 3-word phrases.</p>
|
<p style={styles.sectionSubtitle}>Most used 3-word phrases.</p>
|
||||||
<div style={{ ...styles.topUsersList, maxHeight: 360, overflowY: "auto" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.topUsersList,
|
||||||
|
maxHeight: 360,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{topTrigrams.map((item) => (
|
{topTrigrams.map((item) => (
|
||||||
<div key={item.ngram} style={styles.topUserItem}>
|
<div key={item.ngram} style={styles.topUserItem}>
|
||||||
<div style={styles.topUserName}>{item.ngram}</div>
|
<div style={styles.topUserName}>{item.ngram}</div>
|
||||||
<div style={styles.topUserMeta}>{item.count.toLocaleString()} uses</div>
|
<div style={styles.topUserMeta}>
|
||||||
|
{item.count.toLocaleString()} uses
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,32 +6,32 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ResponsiveContainer
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
import ActivityHeatmap from "../stats/ActivityHeatmap";
|
import ActivityHeatmap from "../stats/ActivityHeatmap";
|
||||||
import { ReactWordcloud } from '@cp949/react-wordcloud';
|
import { ReactWordcloud } from "@cp949/react-wordcloud";
|
||||||
import StatsStyling from "../styles/stats_styling";
|
import StatsStyling from "../styles/stats_styling";
|
||||||
import Card from "../components/Card";
|
import Card from "../components/Card";
|
||||||
import UserModal from "../components/UserModal";
|
import UserModal from "../components/UserModal";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type SummaryResponse,
|
type SummaryResponse,
|
||||||
type FrequencyWord,
|
type FrequencyWord,
|
||||||
type UserAnalysisResponse,
|
type UserAnalysisResponse,
|
||||||
type TimeAnalysisResponse,
|
type TimeAnalysisResponse,
|
||||||
type ContentAnalysisResponse,
|
type ContentAnalysisResponse,
|
||||||
type User
|
type User,
|
||||||
} from '../types/ApiTypes'
|
} from "../types/ApiTypes";
|
||||||
|
|
||||||
const styles = StatsStyling;
|
const styles = StatsStyling;
|
||||||
|
|
||||||
type SummaryStatsProps = {
|
type SummaryStatsProps = {
|
||||||
userData: UserAnalysisResponse | null;
|
userData: UserAnalysisResponse | null;
|
||||||
timeData: TimeAnalysisResponse | null;
|
timeData: TimeAnalysisResponse | null;
|
||||||
contentData: ContentAnalysisResponse | null;
|
contentData: ContentAnalysisResponse | null;
|
||||||
summary: SummaryResponse | null;
|
summary: SummaryResponse | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
function formatDateRange(startUnix: number, endUnix: number) {
|
function formatDateRange(startUnix: number, endUnix: number) {
|
||||||
const start = new Date(startUnix * 1000);
|
const start = new Date(startUnix * 1000);
|
||||||
@@ -48,168 +48,180 @@ function formatDateRange(startUnix: number, endUnix: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function convertFrequencyData(data: FrequencyWord[]) {
|
function convertFrequencyData(data: FrequencyWord[]) {
|
||||||
return data.map((d: FrequencyWord) => ({
|
return data.map((d: FrequencyWord) => ({
|
||||||
text: d.word,
|
text: d.word,
|
||||||
value: d.count,
|
value: d.count,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const SummaryStats = ({userData, timeData, contentData, summary}: SummaryStatsProps) => {
|
const SummaryStats = ({
|
||||||
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
userData,
|
||||||
const selectedUserData: User | null = userData?.users.find((u) => u.author === selectedUser) ?? null;
|
timeData,
|
||||||
|
contentData,
|
||||||
|
summary,
|
||||||
|
}: SummaryStatsProps) => {
|
||||||
|
const [selectedUser, setSelectedUser] = useState<string | null>(null);
|
||||||
|
const selectedUserData: User | null =
|
||||||
|
userData?.users.find((u) => u.author === selectedUser) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.page}>
|
<div style={styles.page}>
|
||||||
|
{/* main grid*/}
|
||||||
|
<div style={{ ...styles.container, ...styles.grid }}>
|
||||||
|
<Card
|
||||||
|
label="Total Activity"
|
||||||
|
value={summary?.total_events ?? "—"}
|
||||||
|
sublabel="Posts + comments"
|
||||||
|
style={{
|
||||||
|
gridColumn: "span 4",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
label="Active People"
|
||||||
|
value={summary?.unique_users ?? "—"}
|
||||||
|
sublabel="Distinct users"
|
||||||
|
style={{
|
||||||
|
gridColumn: "span 4",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
label="Posts vs Comments"
|
||||||
|
value={
|
||||||
|
summary ? `${summary.total_posts} / ${summary.total_comments}` : "—"
|
||||||
|
}
|
||||||
|
sublabel={`Comments per post: ${summary?.comments_per_post ?? "—"}`}
|
||||||
|
style={{
|
||||||
|
gridColumn: "span 4",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* main grid*/}
|
<Card
|
||||||
<div style={{ ...styles.container, ...styles.grid}}>
|
label="Time Range"
|
||||||
<Card
|
value={
|
||||||
label="Total Activity"
|
summary?.time_range
|
||||||
value={summary?.total_events ?? "—"}
|
? formatDateRange(
|
||||||
sublabel="Posts + comments"
|
summary.time_range.start,
|
||||||
style={{
|
summary.time_range.end,
|
||||||
gridColumn: "span 4"
|
)
|
||||||
}}
|
: "—"
|
||||||
/>
|
}
|
||||||
<Card
|
sublabel="Based on dataset timestamps"
|
||||||
label="Active People"
|
style={{
|
||||||
value={summary?.unique_users ?? "—"}
|
gridColumn: "span 4",
|
||||||
sublabel="Distinct users"
|
}}
|
||||||
style={{
|
/>
|
||||||
gridColumn: "span 4"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Card
|
|
||||||
label="Posts vs Comments"
|
|
||||||
value={
|
|
||||||
summary
|
|
||||||
? `${summary.total_posts} / ${summary.total_comments}`
|
|
||||||
: "—"
|
|
||||||
}
|
|
||||||
sublabel={`Comments per post: ${summary?.comments_per_post ?? "—"}`}
|
|
||||||
style={{
|
|
||||||
gridColumn: "span 4"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
label="Time Range"
|
label="One-Time Users"
|
||||||
value={
|
value={
|
||||||
summary?.time_range
|
typeof summary?.lurker_ratio === "number"
|
||||||
? formatDateRange(summary.time_range.start, summary.time_range.end)
|
? `${Math.round(summary.lurker_ratio * 100)}%`
|
||||||
: "—"
|
: "—"
|
||||||
}
|
}
|
||||||
sublabel="Based on dataset timestamps"
|
sublabel="Users with only one event"
|
||||||
style={{
|
style={{
|
||||||
gridColumn: "span 4"
|
gridColumn: "span 4",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
label="One-Time Users"
|
label="Sources"
|
||||||
value={
|
value={summary?.sources?.length ?? "—"}
|
||||||
typeof summary?.lurker_ratio === "number"
|
sublabel={
|
||||||
? `${Math.round(summary.lurker_ratio * 100)}%`
|
summary?.sources?.length
|
||||||
: "—"
|
? summary.sources.slice(0, 3).join(", ") +
|
||||||
}
|
(summary.sources.length > 3 ? "…" : "")
|
||||||
sublabel="Users with only one event"
|
: "—"
|
||||||
style={{
|
}
|
||||||
gridColumn: "span 4"
|
style={{
|
||||||
}}
|
gridColumn: "span 4",
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
<Card
|
|
||||||
label="Sources"
|
|
||||||
value={summary?.sources?.length ?? "—"}
|
|
||||||
sublabel={
|
|
||||||
summary?.sources?.length
|
|
||||||
? summary.sources.slice(0, 3).join(", ") +
|
|
||||||
(summary.sources.length > 3 ? "…" : "")
|
|
||||||
: "—"
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
gridColumn: "span 4"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* events per day */}
|
{/* events per day */}
|
||||||
<div style={{ ...styles.card, gridColumn: "span 5" }}>
|
<div style={{ ...styles.card, gridColumn: "span 5" }}>
|
||||||
<h2 style={styles.sectionTitle}>Activity Over Time</h2>
|
<h2 style={styles.sectionTitle}>Activity Over Time</h2>
|
||||||
<p style={styles.sectionSubtitle}>How much posting happened each day.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
How much posting happened each day.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div style={styles.chartWrapper}>
|
<div style={styles.chartWrapper}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={timeData?.events_per_day ?? []}>
|
<LineChart data={timeData?.events_per_day ?? []}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="date" />
|
<XAxis dataKey="date" />
|
||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Line type="monotone" dataKey="count" name="Events" />
|
<Line type="monotone" dataKey="count" name="Events" />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Word Cloud */}
|
{/* Word Cloud */}
|
||||||
<div style={{ ...styles.card, gridColumn: "span 4" }}>
|
<div style={{ ...styles.card, gridColumn: "span 4" }}>
|
||||||
<h2 style={styles.sectionTitle}>Common Words</h2>
|
<h2 style={styles.sectionTitle}>Common Words</h2>
|
||||||
<p style={styles.sectionSubtitle}>Frequently used words across the dataset.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Frequently used words across the dataset.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div style={styles.chartWrapper}>
|
<div style={styles.chartWrapper}>
|
||||||
<ReactWordcloud
|
<ReactWordcloud
|
||||||
words={convertFrequencyData(contentData?.word_frequencies ?? [])}
|
words={convertFrequencyData(contentData?.word_frequencies ?? [])}
|
||||||
options={{
|
options={{
|
||||||
rotations: 2,
|
rotations: 2,
|
||||||
rotationAngles: [0, 90],
|
rotationAngles: [0, 90],
|
||||||
fontSizes: [14, 60],
|
fontSizes: [14, 60],
|
||||||
enableTooltip: true,
|
enableTooltip: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Users */}
|
{/* Top Users */}
|
||||||
<div style={{...styles.card, ...styles.scrollArea, gridColumn: "span 3",
|
<div
|
||||||
}}
|
style={{ ...styles.card, ...styles.scrollArea, gridColumn: "span 3" }}
|
||||||
>
|
>
|
||||||
<h2 style={styles.sectionTitle}>Most Active Users</h2>
|
<h2 style={styles.sectionTitle}>Most Active Users</h2>
|
||||||
<p style={styles.sectionSubtitle}>Who posted the most events.</p>
|
<p style={styles.sectionSubtitle}>Who posted the most events.</p>
|
||||||
|
|
||||||
<div style={styles.topUsersList}>
|
<div style={styles.topUsersList}>
|
||||||
{userData?.top_users.slice(0, 100).map((item) => (
|
{userData?.top_users.slice(0, 100).map((item) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.author}-${item.source}`}
|
key={`${item.author}-${item.source}`}
|
||||||
style={{ ...styles.topUserItem, cursor: "pointer" }}
|
style={{ ...styles.topUserItem, cursor: "pointer" }}
|
||||||
onClick={() => setSelectedUser(item.author)}
|
onClick={() => setSelectedUser(item.author)}
|
||||||
>
|
>
|
||||||
<div style={styles.topUserName}>{item.author}</div>
|
<div style={styles.topUserName}>{item.author}</div>
|
||||||
<div style={styles.topUserMeta}>
|
<div style={styles.topUserMeta}>
|
||||||
{item.source} • {item.count} events
|
{item.source} • {item.count} events
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Heatmap */}
|
{/* Heatmap */}
|
||||||
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
<h2 style={styles.sectionTitle}>Weekly Activity Pattern</h2>
|
<h2 style={styles.sectionTitle}>Weekly Activity Pattern</h2>
|
||||||
<p style={styles.sectionSubtitle}>When activity tends to happen by weekday and hour.</p>
|
<p style={styles.sectionSubtitle}>
|
||||||
|
When activity tends to happen by weekday and hour.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div style={styles.heatmapWrapper}>
|
<div style={styles.heatmapWrapper}>
|
||||||
<ActivityHeatmap data={timeData?.weekday_hour_heatmap ?? []} />
|
<ActivityHeatmap data={timeData?.weekday_hour_heatmap ?? []} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<UserModal
|
<UserModal
|
||||||
open={!!selectedUser}
|
open={!!selectedUser}
|
||||||
onClose={() => setSelectedUser(null)}
|
onClose={() => setSelectedUser(null)}
|
||||||
username={selectedUser ?? ""}
|
username={selectedUser ?? ""}
|
||||||
userData={selectedUserData}
|
userData={selectedUserData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default SummaryStats;
|
export default SummaryStats;
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ type Props = {
|
|||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UserModal({ open, onClose, userData, username }: Props) {
|
export default function UserModal({
|
||||||
const dominantEmotionEntry = Object.entries(userData?.avg_emotions ?? {})
|
open,
|
||||||
.sort((a, b) => b[1] - a[1])[0];
|
onClose,
|
||||||
|
userData,
|
||||||
|
username,
|
||||||
|
}: Props) {
|
||||||
|
const dominantEmotionEntry = Object.entries(
|
||||||
|
userData?.avg_emotions ?? {},
|
||||||
|
).sort((a, b) => b[1] - a[1])[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} style={styles.modalRoot}>
|
<Dialog open={open} onClose={onClose} style={styles.modalRoot}>
|
||||||
@@ -36,7 +42,9 @@ export default function UserModal({ open, onClose, userData, username }: Props)
|
|||||||
<p style={styles.sectionSubtitle}>No data for this user.</p>
|
<p style={styles.sectionSubtitle}>No data for this user.</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={styles.topUsersList}>
|
<div style={styles.topUsersList}>
|
||||||
<div style={{...styles.topUserName, fontSize: 20}}>{userData.author}</div>
|
<div style={{ ...styles.topUserName, fontSize: 20 }}>
|
||||||
|
{userData.author}
|
||||||
|
</div>
|
||||||
<div style={styles.topUserItem}>
|
<div style={styles.topUserItem}>
|
||||||
<div style={styles.topUserName}>Posts</div>
|
<div style={styles.topUserName}>Posts</div>
|
||||||
<div style={styles.topUserMeta}>{userData.post}</div>
|
<div style={styles.topUserMeta}>{userData.post}</div>
|
||||||
@@ -65,7 +73,8 @@ export default function UserModal({ open, onClose, userData, username }: Props)
|
|||||||
<div style={styles.topUserItem}>
|
<div style={styles.topUserItem}>
|
||||||
<div style={styles.topUserName}>Vocab Richness</div>
|
<div style={styles.topUserName}>Vocab Richness</div>
|
||||||
<div style={styles.topUserMeta}>
|
<div style={styles.topUserMeta}>
|
||||||
{userData.vocab.vocab_richness} (avg {userData.vocab.avg_words_per_event} words/event)
|
{userData.vocab.vocab_richness} (avg{" "}
|
||||||
|
{userData.vocab.avg_words_per_event} words/event)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -74,7 +83,8 @@ export default function UserModal({ open, onClose, userData, username }: Props)
|
|||||||
<div style={styles.topUserItem}>
|
<div style={styles.topUserItem}>
|
||||||
<div style={styles.topUserName}>Dominant Avg Emotion</div>
|
<div style={styles.topUserName}>Dominant Avg Emotion</div>
|
||||||
<div style={styles.topUserMeta}>
|
<div style={styles.topUserMeta}>
|
||||||
{dominantEmotionEntry[0].replace("emotion_", "")} ({dominantEmotionEntry[1].toFixed(3)})
|
{dominantEmotionEntry[0].replace("emotion_", "")} (
|
||||||
|
{dominantEmotionEntry[1].toFixed(3)})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import ForceGraph3D from "react-force-graph-3d";
|
import ForceGraph3D from "react-force-graph-3d";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type UserAnalysisResponse,
|
type UserAnalysisResponse,
|
||||||
type InteractionGraph
|
type InteractionGraph,
|
||||||
} from '../types/ApiTypes';
|
} from "../types/ApiTypes";
|
||||||
|
|
||||||
import StatsStyling from "../styles/stats_styling";
|
import StatsStyling from "../styles/stats_styling";
|
||||||
import Card from "./Card";
|
import Card from "./Card";
|
||||||
@@ -12,38 +12,43 @@ import Card from "./Card";
|
|||||||
const styles = StatsStyling;
|
const styles = StatsStyling;
|
||||||
|
|
||||||
type GraphLink = {
|
type GraphLink = {
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ApiToGraphData(apiData: InteractionGraph) {
|
function ApiToGraphData(apiData: InteractionGraph) {
|
||||||
const nodes = Object.keys(apiData).map(username => ({ id: username }));
|
const nodes = Object.keys(apiData).map((username) => ({ id: username }));
|
||||||
const links: GraphLink[] = [];
|
const links: GraphLink[] = [];
|
||||||
|
|
||||||
for (const [source, targets] of Object.entries(apiData)) {
|
for (const [source, targets] of Object.entries(apiData)) {
|
||||||
for (const [target, count] of Object.entries(targets)) {
|
for (const [target, count] of Object.entries(targets)) {
|
||||||
links.push({ source, target, value: count });
|
links.push({ source, target, value: count });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// drop low-value and deleted interactions to reduce clutter
|
|
||||||
const filteredLinks = links.filter(link =>
|
|
||||||
link.value >= 2 &&
|
|
||||||
link.source !== "[deleted]" &&
|
|
||||||
link.target !== "[deleted]"
|
|
||||||
);
|
|
||||||
|
|
||||||
// also filter out nodes that are no longer connected after link filtering
|
// drop low-value and deleted interactions to reduce clutter
|
||||||
const connectedNodeIds = new Set(filteredLinks.flatMap(link => [link.source, link.target]));
|
const filteredLinks = links.filter(
|
||||||
const filteredNodes = nodes.filter(node => connectedNodeIds.has(node.id));
|
(link) =>
|
||||||
|
link.value >= 2 &&
|
||||||
|
link.source !== "[deleted]" &&
|
||||||
|
link.target !== "[deleted]",
|
||||||
|
);
|
||||||
|
|
||||||
return { nodes: filteredNodes, links: filteredLinks};
|
// also filter out nodes that are no longer connected after link filtering
|
||||||
|
const connectedNodeIds = new Set(
|
||||||
|
filteredLinks.flatMap((link) => [link.source, link.target]),
|
||||||
|
);
|
||||||
|
const filteredNodes = nodes.filter((node) => connectedNodeIds.has(node.id));
|
||||||
|
|
||||||
|
return { nodes: filteredNodes, links: filteredLinks };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const UserStats = (props: { data: UserAnalysisResponse }) => {
|
const UserStats = (props: { data: UserAnalysisResponse }) => {
|
||||||
const graphData = useMemo(() => ApiToGraphData(props.data.interaction_graph), [props.data.interaction_graph]);
|
const graphData = useMemo(
|
||||||
|
() => ApiToGraphData(props.data.interaction_graph),
|
||||||
|
[props.data.interaction_graph],
|
||||||
|
);
|
||||||
const graphContainerRef = useRef<HTMLDivElement | null>(null);
|
const graphContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [graphSize, setGraphSize] = useState({ width: 720, height: 540 });
|
const [graphSize, setGraphSize] = useState({ width: 720, height: 540 });
|
||||||
|
|
||||||
@@ -63,86 +68,113 @@ const UserStats = (props: { data: UserAnalysisResponse }) => {
|
|||||||
|
|
||||||
const totalUsers = props.data.users.length;
|
const totalUsers = props.data.users.length;
|
||||||
const connectedUsers = graphData.nodes.length;
|
const connectedUsers = graphData.nodes.length;
|
||||||
const totalInteractions = graphData.links.reduce((sum, link) => sum + link.value, 0);
|
const totalInteractions = graphData.links.reduce(
|
||||||
const avgInteractionsPerConnectedUser = connectedUsers ? totalInteractions / connectedUsers : 0;
|
(sum, link) => sum + link.value,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const avgInteractionsPerConnectedUser = connectedUsers
|
||||||
|
? totalInteractions / connectedUsers
|
||||||
|
: 0;
|
||||||
|
|
||||||
const strongestLink = graphData.links.reduce<GraphLink | null>((best, current) => {
|
const strongestLink = graphData.links.reduce<GraphLink | null>(
|
||||||
if (!best || current.value > best.value) {
|
(best, current) => {
|
||||||
return current;
|
if (!best || current.value > best.value) {
|
||||||
}
|
return current;
|
||||||
return best;
|
}
|
||||||
}, null);
|
return best;
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const highlyInteractiveUser = [...props.data.users].sort((a, b) => b.comment_share - a.comment_share)[0];
|
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]");
|
const mostActiveUser = props.data.top_users.find(
|
||||||
|
(u) => u.author !== "[deleted]",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.page}>
|
<div style={styles.page}>
|
||||||
<div style={{ ...styles.container, ...styles.grid }}>
|
<div style={{ ...styles.container, ...styles.grid }}>
|
||||||
<Card
|
<Card
|
||||||
label="Users"
|
label="Users"
|
||||||
value={totalUsers.toLocaleString()}
|
value={totalUsers.toLocaleString()}
|
||||||
sublabel={`${connectedUsers.toLocaleString()} users in filtered graph`}
|
sublabel={`${connectedUsers.toLocaleString()} users in filtered graph`}
|
||||||
style={{ gridColumn: "span 3" }}
|
style={{ gridColumn: "span 3" }}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="Replies"
|
label="Replies"
|
||||||
value={totalInteractions.toLocaleString()}
|
value={totalInteractions.toLocaleString()}
|
||||||
sublabel="Links with at least 2 replies"
|
sublabel="Links with at least 2 replies"
|
||||||
style={{ gridColumn: "span 3" }}
|
style={{ gridColumn: "span 3" }}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="Replies per Connected User"
|
label="Replies per Connected User"
|
||||||
value={avgInteractionsPerConnectedUser.toFixed(1)}
|
value={avgInteractionsPerConnectedUser.toFixed(1)}
|
||||||
sublabel="Average from visible graph links"
|
sublabel="Average from visible graph links"
|
||||||
style={{ gridColumn: "span 3" }}
|
style={{ gridColumn: "span 3" }}
|
||||||
/>
|
/>
|
||||||
<Card
|
<Card
|
||||||
label="Most Active User"
|
label="Most Active User"
|
||||||
value={mostActiveUser?.author ?? "—"}
|
value={mostActiveUser?.author ?? "—"}
|
||||||
sublabel={mostActiveUser ? `${mostActiveUser.count.toLocaleString()} events` : "No user activity found"}
|
sublabel={
|
||||||
style={{ gridColumn: "span 3" }}
|
mostActiveUser
|
||||||
/>
|
? `${mostActiveUser.count.toLocaleString()} events`
|
||||||
|
: "No user activity found"
|
||||||
|
}
|
||||||
|
style={{ gridColumn: "span 3" }}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
label="Strongest User Link"
|
label="Strongest User Link"
|
||||||
value={strongestLink ? `${strongestLink.source} -> ${strongestLink.target}` : "—"}
|
value={
|
||||||
sublabel={strongestLink ? `${strongestLink.value.toLocaleString()} replies` : "No graph links after filtering"}
|
strongestLink
|
||||||
style={{ gridColumn: "span 6" }}
|
? `${strongestLink.source} -> ${strongestLink.target}`
|
||||||
/>
|
: "—"
|
||||||
<Card
|
}
|
||||||
label="Most Comment-Heavy User"
|
sublabel={
|
||||||
value={highlyInteractiveUser?.author ?? "—"}
|
strongestLink
|
||||||
sublabel={
|
? `${strongestLink.value.toLocaleString()} replies`
|
||||||
highlyInteractiveUser
|
: "No graph links after filtering"
|
||||||
? `${Math.round(highlyInteractiveUser.comment_share * 100)}% comments`
|
}
|
||||||
: "No user distribution available"
|
style={{ gridColumn: "span 6" }}
|
||||||
}
|
/>
|
||||||
style={{ gridColumn: "span 6" }}
|
<Card
|
||||||
/>
|
label="Most Comment-Heavy 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" }}>
|
<div style={{ ...styles.card, gridColumn: "span 12" }}>
|
||||||
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
|
<h2 style={styles.sectionTitle}>User Interaction Graph</h2>
|
||||||
<p style={styles.sectionSubtitle}>
|
<p style={styles.sectionSubtitle}>
|
||||||
Each node is a user, and each link shows replies between them.
|
Each node is a user, and each link shows replies between them.
|
||||||
</p>
|
</p>
|
||||||
<div ref={graphContainerRef} style={{ width: "100%", height: graphSize.height }}>
|
<div
|
||||||
<ForceGraph3D
|
ref={graphContainerRef}
|
||||||
width={graphSize.width}
|
style={{ width: "100%", height: graphSize.height }}
|
||||||
height={graphSize.height}
|
>
|
||||||
graphData={graphData}
|
<ForceGraph3D
|
||||||
nodeAutoColorBy="id"
|
width={graphSize.width}
|
||||||
linkDirectionalParticles={1}
|
height={graphSize.height}
|
||||||
linkDirectionalParticleSpeed={0.004}
|
graphData={graphData}
|
||||||
linkWidth={(link) => Math.sqrt(Number(link.value))}
|
nodeAutoColorBy="id"
|
||||||
nodeLabel={(node) => `${node.id}`}
|
linkDirectionalParticles={1}
|
||||||
/>
|
linkDirectionalParticleSpeed={0.004}
|
||||||
</div>
|
linkWidth={(link) => Math.sqrt(Number(link.value))}
|
||||||
|
nodeLabel={(node) => `${node.id}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default UserStats;
|
export default UserStats;
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ const AutoScrapePage = () => {
|
|||||||
if (axios.isAxiosError(requestError)) {
|
if (axios.isAxiosError(requestError)) {
|
||||||
setReturnMessage(
|
setReturnMessage(
|
||||||
`Failed to load available sources: ${String(
|
`Failed to load available sources: ${String(
|
||||||
requestError.response?.data?.error || requestError.message
|
requestError.response?.data?.error || requestError.message,
|
||||||
)}`
|
)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setReturnMessage("Failed to load available sources.");
|
setReturnMessage("Failed to load available sources.");
|
||||||
@@ -70,15 +70,19 @@ const AutoScrapePage = () => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateSourceConfig = (index: number, field: keyof SourceConfig, value: string) => {
|
const updateSourceConfig = (
|
||||||
|
index: number,
|
||||||
|
field: keyof SourceConfig,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
setSourceConfigs((previous) =>
|
setSourceConfigs((previous) =>
|
||||||
previous.map((config, configIndex) =>
|
previous.map((config, configIndex) =>
|
||||||
configIndex === index
|
configIndex === index
|
||||||
? field === "sourceName"
|
? field === "sourceName"
|
||||||
? { ...config, sourceName: value, search: "", category: "" }
|
? { ...config, sourceName: value, search: "", category: "" }
|
||||||
: { ...config, [field]: value }
|
: { ...config, [field]: value }
|
||||||
: config
|
: config,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,7 +97,9 @@ const AutoScrapePage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeSourceConfig = (index: number) => {
|
const removeSourceConfig = (index: number) => {
|
||||||
setSourceConfigs((previous) => previous.filter((_, configIndex) => configIndex !== index));
|
setSourceConfigs((previous) =>
|
||||||
|
previous.filter((_, configIndex) => configIndex !== index),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoScrape = async () => {
|
const autoScrape = async () => {
|
||||||
@@ -123,7 +129,9 @@ const AutoScrapePage = () => {
|
|||||||
return {
|
return {
|
||||||
name: source.sourceName,
|
name: source.sourceName,
|
||||||
limit: Number(source.limit || 100),
|
limit: Number(source.limit || 100),
|
||||||
search: supportsSearch(sourceOption) ? source.search.trim() || undefined : undefined,
|
search: supportsSearch(sourceOption)
|
||||||
|
? source.search.trim() || undefined
|
||||||
|
: undefined,
|
||||||
category: supportsCategories(sourceOption)
|
category: supportsCategories(sourceOption)
|
||||||
? source.category.trim() || undefined
|
? source.category.trim() || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -131,12 +139,15 @@ const AutoScrapePage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const invalidSource = normalizedSources.find(
|
const invalidSource = normalizedSources.find(
|
||||||
(source) => !source.name || !Number.isFinite(source.limit) || source.limit <= 0
|
(source) =>
|
||||||
|
!source.name || !Number.isFinite(source.limit) || source.limit <= 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (invalidSource) {
|
if (invalidSource) {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
setReturnMessage("Every source needs a name and a limit greater than zero.");
|
setReturnMessage(
|
||||||
|
"Every source needs a name and a limit greater than zero.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +166,13 @@ const AutoScrapePage = () => {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const datasetId = Number(response.data.dataset_id);
|
const datasetId = Number(response.data.dataset_id);
|
||||||
|
|
||||||
setReturnMessage(
|
setReturnMessage(
|
||||||
`Auto scrape queued successfully (dataset #${datasetId}). Redirecting to processing status...`
|
`Auto scrape queued successfully (dataset #${datasetId}). Redirecting to processing status...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -171,7 +182,9 @@ const AutoScrapePage = () => {
|
|||||||
setHasError(true);
|
setHasError(true);
|
||||||
if (axios.isAxiosError(requestError)) {
|
if (axios.isAxiosError(requestError)) {
|
||||||
const message = String(
|
const message = String(
|
||||||
requestError.response?.data?.error || requestError.message || "Auto scrape failed."
|
requestError.response?.data?.error ||
|
||||||
|
requestError.message ||
|
||||||
|
"Auto scrape failed.",
|
||||||
);
|
);
|
||||||
setReturnMessage(`Auto scrape failed: ${message}`);
|
setReturnMessage(`Auto scrape failed: ${message}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -189,15 +202,26 @@ const AutoScrapePage = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 style={styles.sectionHeaderTitle}>Auto Scrape Dataset</h1>
|
<h1 style={styles.sectionHeaderTitle}>Auto Scrape Dataset</h1>
|
||||||
<p style={styles.sectionHeaderSubtitle}>
|
<p style={styles.sectionHeaderSubtitle}>
|
||||||
Select sources and scrape settings, then queue processing automatically.
|
Select sources and scrape settings, then queue processing
|
||||||
|
automatically.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ ...styles.subtleBodyText, marginTop: 6, color: "#9a6700" }}>
|
<p
|
||||||
Warning: Scraping more than 250 posts from any single site can take hours due to rate limits.
|
style={{
|
||||||
|
...styles.subtleBodyText,
|
||||||
|
marginTop: 6,
|
||||||
|
color: "#9a6700",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Warning: Scraping more than 250 posts from any single site can
|
||||||
|
take hours due to rate limits.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={{ ...styles.buttonPrimary, opacity: isSubmitting || isLoadingSources ? 0.75 : 1 }}
|
style={{
|
||||||
|
...styles.buttonPrimary,
|
||||||
|
opacity: isSubmitting || isLoadingSources ? 0.75 : 1,
|
||||||
|
}}
|
||||||
onClick={autoScrape}
|
onClick={autoScrape}
|
||||||
disabled={isSubmitting || isLoadingSources}
|
disabled={isSubmitting || isLoadingSources}
|
||||||
>
|
>
|
||||||
@@ -213,8 +237,12 @@ const AutoScrapePage = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ ...styles.card, gridColumn: "auto" }}>
|
<div style={{ ...styles.card, gridColumn: "auto" }}>
|
||||||
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Dataset Name</h2>
|
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>
|
||||||
<p style={styles.sectionSubtitle}>Use a clear label so you can identify this run later.</p>
|
Dataset Name
|
||||||
|
</h2>
|
||||||
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Use a clear label so you can identify this run later.
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
style={{ ...styles.input, ...styles.inputFullWidth }}
|
style={{ ...styles.input, ...styles.inputFullWidth }}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -225,97 +253,129 @@ const AutoScrapePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ ...styles.card, gridColumn: "auto" }}>
|
<div style={{ ...styles.card, gridColumn: "auto" }}>
|
||||||
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Sources</h2>
|
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>
|
||||||
|
Sources
|
||||||
|
</h2>
|
||||||
<p style={styles.sectionSubtitle}>
|
<p style={styles.sectionSubtitle}>
|
||||||
Configure source, limit, optional search, and optional category.
|
Configure source, limit, optional search, and optional category.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLoadingSources && <p style={styles.subtleBodyText}>Loading sources...</p>}
|
{isLoadingSources && (
|
||||||
|
<p style={styles.subtleBodyText}>Loading sources...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isLoadingSources && sourceOptions.length === 0 && (
|
{!isLoadingSources && sourceOptions.length === 0 && (
|
||||||
<p style={styles.subtleBodyText}>No source connectors are currently available.</p>
|
<p style={styles.subtleBodyText}>
|
||||||
|
No source connectors are currently available.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoadingSources && sourceOptions.length > 0 && (
|
{!isLoadingSources && sourceOptions.length > 0 && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 10 }}
|
||||||
|
>
|
||||||
{sourceConfigs.map((source, index) => {
|
{sourceConfigs.map((source, index) => {
|
||||||
const sourceOption = getSourceOption(source.sourceName);
|
const sourceOption = getSourceOption(source.sourceName);
|
||||||
const searchEnabled = supportsSearch(sourceOption);
|
const searchEnabled = supportsSearch(sourceOption);
|
||||||
const categoriesEnabled = supportsCategories(sourceOption);
|
const categoriesEnabled = supportsCategories(sourceOption);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`source-${index}`}
|
key={`source-${index}`}
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid #d0d7de",
|
border: "1px solid #d0d7de",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
background: "#f6f8fa",
|
background: "#f6f8fa",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<select
|
|
||||||
value={source.sourceName}
|
|
||||||
style={{ ...styles.input, ...styles.inputFullWidth }}
|
|
||||||
onChange={(event) => updateSourceConfig(index, "sourceName", event.target.value)}
|
|
||||||
>
|
>
|
||||||
{sourceOptions.map((option) => (
|
<select
|
||||||
<option key={option.id} value={option.id}>
|
value={source.sourceName}
|
||||||
{option.label}
|
style={{ ...styles.input, ...styles.inputFullWidth }}
|
||||||
</option>
|
onChange={(event) =>
|
||||||
))}
|
updateSourceConfig(
|
||||||
</select>
|
index,
|
||||||
|
"sourceName",
|
||||||
<input
|
event.target.value,
|
||||||
type="number"
|
)
|
||||||
min={1}
|
}
|
||||||
value={source.limit}
|
|
||||||
placeholder="Limit"
|
|
||||||
style={{ ...styles.input, ...styles.inputFullWidth }}
|
|
||||||
onChange={(event) => updateSourceConfig(index, "limit", event.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={source.search}
|
|
||||||
placeholder={
|
|
||||||
searchEnabled
|
|
||||||
? "Search term (optional)"
|
|
||||||
: "Search not supported for this source"
|
|
||||||
}
|
|
||||||
style={{ ...styles.input, ...styles.inputFullWidth }}
|
|
||||||
disabled={!searchEnabled}
|
|
||||||
onChange={(event) => updateSourceConfig(index, "search", event.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={source.category}
|
|
||||||
placeholder={
|
|
||||||
categoriesEnabled
|
|
||||||
? "Category (optional)"
|
|
||||||
: "Categories not supported for this source"
|
|
||||||
}
|
|
||||||
style={{ ...styles.input, ...styles.inputFullWidth }}
|
|
||||||
disabled={!categoriesEnabled}
|
|
||||||
onChange={(event) => updateSourceConfig(index, "category", event.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{sourceConfigs.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={styles.buttonSecondary}
|
|
||||||
onClick={() => removeSourceConfig(index)}
|
|
||||||
>
|
>
|
||||||
Remove source
|
{sourceOptions.map((option) => (
|
||||||
</button>
|
<option key={option.id} value={option.id}>
|
||||||
)}
|
{option.label}
|
||||||
</div>
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={source.limit}
|
||||||
|
placeholder="Limit"
|
||||||
|
style={{ ...styles.input, ...styles.inputFullWidth }}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSourceConfig(index, "limit", event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={source.search}
|
||||||
|
placeholder={
|
||||||
|
searchEnabled
|
||||||
|
? "Search term (optional)"
|
||||||
|
: "Search not supported for this source"
|
||||||
|
}
|
||||||
|
style={{ ...styles.input, ...styles.inputFullWidth }}
|
||||||
|
disabled={!searchEnabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSourceConfig(
|
||||||
|
index,
|
||||||
|
"search",
|
||||||
|
event.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={source.category}
|
||||||
|
placeholder={
|
||||||
|
categoriesEnabled
|
||||||
|
? "Category (optional)"
|
||||||
|
: "Categories not supported for this source"
|
||||||
|
}
|
||||||
|
style={{ ...styles.input, ...styles.inputFullWidth }}
|
||||||
|
disabled={!categoriesEnabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSourceConfig(
|
||||||
|
index,
|
||||||
|
"category",
|
||||||
|
event.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sourceConfigs.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.buttonSecondary}
|
||||||
|
onClick={() => removeSourceConfig(index)}
|
||||||
|
>
|
||||||
|
Remove source
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<button type="button" style={styles.buttonSecondary} onClick={addSourceConfig}>
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.buttonSecondary}
|
||||||
|
onClick={addSourceConfig}
|
||||||
|
>
|
||||||
Add another source
|
Add another source
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ const DatasetEditPage = () => {
|
|||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
setStatusMessage(String(error.response?.data?.error || error.message));
|
setStatusMessage(
|
||||||
|
String(error.response?.data?.error || error.message),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage("Could not get dataset info.");
|
setStatusMessage("Could not get dataset info.");
|
||||||
}
|
}
|
||||||
@@ -61,7 +63,6 @@ const DatasetEditPage = () => {
|
|||||||
});
|
});
|
||||||
}, [parsedDatasetId]);
|
}, [parsedDatasetId]);
|
||||||
|
|
||||||
|
|
||||||
const saveDatasetName = async (event: FormEvent<HTMLFormElement>) => {
|
const saveDatasetName = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -87,14 +88,18 @@ const DatasetEditPage = () => {
|
|||||||
await axios.patch(
|
await axios.patch(
|
||||||
`${API_BASE_URL}/dataset/${parsedDatasetId}`,
|
`${API_BASE_URL}/dataset/${parsedDatasetId}`,
|
||||||
{ name: trimmedName },
|
{ name: trimmedName },
|
||||||
{ headers: { Authorization: `Bearer ${token}` } }
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
);
|
);
|
||||||
|
|
||||||
navigate("/datasets", { replace: true });
|
navigate("/datasets", { replace: true });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
setStatusMessage(String(error.response?.data?.error || error.message || "Save failed."));
|
setStatusMessage(
|
||||||
|
String(
|
||||||
|
error.response?.data?.error || error.message || "Save failed.",
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage("Save failed due to an unexpected error.");
|
setStatusMessage("Save failed due to an unexpected error.");
|
||||||
}
|
}
|
||||||
@@ -117,17 +122,20 @@ const DatasetEditPage = () => {
|
|||||||
setHasError(false);
|
setHasError(false);
|
||||||
setStatusMessage("");
|
setStatusMessage("");
|
||||||
|
|
||||||
await axios.delete(
|
await axios.delete(`${API_BASE_URL}/dataset/${parsedDatasetId}`, {
|
||||||
`${API_BASE_URL}/dataset/${parsedDatasetId}`,
|
headers: { Authorization: `Bearer ${deleteToken}` },
|
||||||
{ headers: { Authorization: `Bearer ${deleteToken}` } }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
navigate("/datasets", { replace: true });
|
navigate("/datasets", { replace: true });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
setStatusMessage(String(error.response?.data?.error || error.message || "Delete failed."));
|
setStatusMessage(
|
||||||
|
String(
|
||||||
|
error.response?.data?.error || error.message || "Delete failed.",
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage("Delete failed due to an unexpected error.");
|
setStatusMessage("Delete failed due to an unexpected error.");
|
||||||
}
|
}
|
||||||
@@ -142,7 +150,9 @@ const DatasetEditPage = () => {
|
|||||||
<div style={{ ...styles.card, ...styles.headerBar }}>
|
<div style={{ ...styles.card, ...styles.headerBar }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={styles.sectionHeaderTitle}>Edit Dataset</h1>
|
<h1 style={styles.sectionHeaderTitle}>Edit Dataset</h1>
|
||||||
<p style={styles.sectionHeaderSubtitle}>Update the dataset name shown in your datasets list.</p>
|
<p style={styles.sectionHeaderSubtitle}>
|
||||||
|
Update the dataset name shown in your datasets list.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -173,8 +183,8 @@ const DatasetEditPage = () => {
|
|||||||
style={styles.buttonDanger}
|
style={styles.buttonDanger}
|
||||||
onClick={() => setIsDeleteModalOpen(true)}
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
disabled={isSaving || isDeleting}
|
disabled={isSaving || isDeleting}
|
||||||
>
|
>
|
||||||
Delete Dataset
|
Delete Dataset
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -187,15 +197,16 @@ const DatasetEditPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
style={{ ...styles.buttonPrimary, opacity: loading || isSaving ? 0.75 : 1 }}
|
style={{
|
||||||
|
...styles.buttonPrimary,
|
||||||
|
opacity: loading || isSaving ? 0.75 : 1,
|
||||||
|
}}
|
||||||
disabled={loading || isSaving || isDeleting}
|
disabled={loading || isSaving || isDeleting}
|
||||||
>
|
>
|
||||||
{isSaving ? "Saving..." : "Save"}
|
{isSaving ? "Saving..." : "Save"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{loading
|
{loading ? "Loading dataset details..." : statusMessage}
|
||||||
? "Loading dataset details..."
|
|
||||||
: statusMessage}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import axios from "axios";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import StatsStyling from "../styles/stats_styling";
|
import StatsStyling from "../styles/stats_styling";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
||||||
type DatasetStatusResponse = {
|
type DatasetStatusResponse = {
|
||||||
status?: "fetching" | "processing" | "complete" | "error";
|
status?: "fetching" | "processing" | "complete" | "error";
|
||||||
@@ -17,7 +17,8 @@ const DatasetStatusPage = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { datasetId } = useParams<{ datasetId: string }>();
|
const { datasetId } = useParams<{ datasetId: string }>();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [status, setStatus] = useState<DatasetStatusResponse["status"]>("processing");
|
const [status, setStatus] =
|
||||||
|
useState<DatasetStatusResponse["status"]>("processing");
|
||||||
const [statusMessage, setStatusMessage] = useState("");
|
const [statusMessage, setStatusMessage] = useState("");
|
||||||
const parsedDatasetId = useMemo(() => Number(datasetId), [datasetId]);
|
const parsedDatasetId = useMemo(() => Number(datasetId), [datasetId]);
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ const DatasetStatusPage = () => {
|
|||||||
const pollStatus = async () => {
|
const pollStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get<DatasetStatusResponse>(
|
const response = await axios.get<DatasetStatusResponse>(
|
||||||
`${API_BASE_URL}/dataset/${parsedDatasetId}/status`
|
`${API_BASE_URL}/dataset/${parsedDatasetId}/status`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextStatus = response.data.status ?? "processing";
|
const nextStatus = response.data.status ?? "processing";
|
||||||
@@ -51,7 +52,9 @@ const DatasetStatusPage = () => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const message = String(error.response?.data?.error || error.message || "Request failed");
|
const message = String(
|
||||||
|
error.response?.data?.error || error.message || "Request failed",
|
||||||
|
);
|
||||||
setStatusMessage(message);
|
setStatusMessage(message);
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage("Unable to fetch dataset status.");
|
setStatusMessage("Unable to fetch dataset status.");
|
||||||
@@ -73,7 +76,8 @@ const DatasetStatusPage = () => {
|
|||||||
};
|
};
|
||||||
}, [navigate, parsedDatasetId, status]);
|
}, [navigate, parsedDatasetId, status]);
|
||||||
|
|
||||||
const isProcessing = loading || status === "fetching" || status === "processing";
|
const isProcessing =
|
||||||
|
loading || status === "fetching" || status === "processing";
|
||||||
const isError = status === "error";
|
const isError = status === "error";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,26 +85,37 @@ const DatasetStatusPage = () => {
|
|||||||
<div style={styles.containerNarrow}>
|
<div style={styles.containerNarrow}>
|
||||||
<div style={{ ...styles.card, marginTop: 28 }}>
|
<div style={{ ...styles.card, marginTop: 28 }}>
|
||||||
<h1 style={styles.sectionHeaderTitle}>
|
<h1 style={styles.sectionHeaderTitle}>
|
||||||
{isProcessing ? "Processing dataset..." : isError ? "Dataset processing failed" : "Dataset ready"}
|
{isProcessing
|
||||||
|
? "Processing dataset..."
|
||||||
|
: isError
|
||||||
|
? "Dataset processing failed"
|
||||||
|
: "Dataset ready"}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p style={{ ...styles.sectionSubtitle, marginTop: 10 }}>
|
<p style={{ ...styles.sectionSubtitle, marginTop: 10 }}>
|
||||||
{isProcessing &&
|
{isProcessing &&
|
||||||
"Your dataset is being analyzed. This page will redirect to stats automatically once complete."}
|
"Your dataset is being analyzed. This page will redirect to stats automatically once complete."}
|
||||||
{isError && "There was an issue while processing your dataset. Please review the error details."}
|
{isError &&
|
||||||
{status === "complete" && "Processing complete. Redirecting to your stats now..."}
|
"There was an issue while processing your dataset. Please review the error details."}
|
||||||
|
{status === "complete" &&
|
||||||
|
"Processing complete. Redirecting to your stats now..."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
...styles.card,
|
...styles.card,
|
||||||
...styles.statusMessageCard,
|
...styles.statusMessageCard,
|
||||||
borderColor: isError ? "rgba(185, 28, 28, 0.28)" : "rgba(0,0,0,0.06)",
|
borderColor: isError
|
||||||
|
? "rgba(185, 28, 28, 0.28)"
|
||||||
|
: "rgba(0,0,0,0.06)",
|
||||||
background: isError ? "#fff5f5" : "#ffffff",
|
background: isError ? "#fff5f5" : "#ffffff",
|
||||||
color: isError ? "#991b1b" : "#374151",
|
color: isError ? "#991b1b" : "#374151",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{statusMessage || (isProcessing ? "Waiting for updates from the worker queue..." : "No details provided.")}
|
{statusMessage ||
|
||||||
|
(isProcessing
|
||||||
|
? "Waiting for updates from the worker queue..."
|
||||||
|
: "No details provided.")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ const DatasetsPage = () => {
|
|||||||
})
|
})
|
||||||
.catch((requestError: unknown) => {
|
.catch((requestError: unknown) => {
|
||||||
if (axios.isAxiosError(requestError)) {
|
if (axios.isAxiosError(requestError)) {
|
||||||
setError(String(requestError.response?.data?.error || requestError.message));
|
setError(
|
||||||
|
String(requestError.response?.data?.error || requestError.message),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Failed to load datasets.");
|
setError("Failed to load datasets.");
|
||||||
}
|
}
|
||||||
@@ -61,13 +63,28 @@ const DatasetsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.loadingSkeleton}>
|
<div style={styles.loadingSkeleton}>
|
||||||
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineLong }} />
|
<div
|
||||||
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineMed }} />
|
style={{
|
||||||
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineShort }} />
|
...styles.loadingSkeletonLine,
|
||||||
|
...styles.loadingSkeletonLineLong,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.loadingSkeletonLine,
|
||||||
|
...styles.loadingSkeletonLineMed,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.loadingSkeletonLine,
|
||||||
|
...styles.loadingSkeletonLineShort,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,7 +98,11 @@ const DatasetsPage = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.controlsWrapped}>
|
<div style={styles.controlsWrapped}>
|
||||||
<button type="button" style={styles.buttonPrimary} onClick={() => navigate("/upload")}>
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.buttonPrimary}
|
||||||
|
onClick={() => navigate("/upload")}
|
||||||
|
>
|
||||||
Upload New Dataset
|
Upload New Dataset
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -116,20 +137,25 @@ const DatasetsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && datasets.length > 0 && (
|
{!error && datasets.length > 0 && (
|
||||||
<div style={{ ...styles.card, marginTop: 14, padding: 0, overflow: "hidden" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.card,
|
||||||
|
marginTop: 14,
|
||||||
|
padding: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ul style={styles.listNoBullets}>
|
<ul style={styles.listNoBullets}>
|
||||||
{datasets.map((dataset) => {
|
{datasets.map((dataset) => {
|
||||||
const isComplete = dataset.status === "complete" || dataset.status === "error";
|
const isComplete =
|
||||||
|
dataset.status === "complete" || dataset.status === "error";
|
||||||
const editPath = `/dataset/${dataset.id}/edit`;
|
const editPath = `/dataset/${dataset.id}/edit`;
|
||||||
const targetPath = isComplete
|
const targetPath = isComplete
|
||||||
? `/dataset/${dataset.id}/stats`
|
? `/dataset/${dataset.id}/stats`
|
||||||
: `/dataset/${dataset.id}/status`;
|
: `/dataset/${dataset.id}/status`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li key={dataset.id} style={styles.datasetListItem}>
|
||||||
key={dataset.id}
|
|
||||||
style={styles.datasetListItem}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={styles.datasetName}>
|
<div style={styles.datasetName}>
|
||||||
{dataset.name || `Dataset #${dataset.id}`}
|
{dataset.name || `Dataset #${dataset.id}`}
|
||||||
@@ -145,19 +171,23 @@ const DatasetsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{ isComplete &&
|
{isComplete && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={{...styles.buttonSecondary, "margin": "5px"}}
|
style={{ ...styles.buttonSecondary, margin: "5px" }}
|
||||||
onClick={() => navigate(editPath)}
|
onClick={() => navigate(editPath)}
|
||||||
>
|
>
|
||||||
Edit Dataset
|
Edit Dataset
|
||||||
</button>
|
</button>
|
||||||
}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={isComplete ? styles.buttonPrimary : styles.buttonSecondary}
|
style={
|
||||||
|
isComplete
|
||||||
|
? styles.buttonPrimary
|
||||||
|
: styles.buttonSecondary
|
||||||
|
}
|
||||||
onClick={() => navigate(targetPath)}
|
onClick={() => navigate(targetPath)}
|
||||||
>
|
>
|
||||||
{isComplete ? "Open stats" : "View status"}
|
{isComplete ? "Open stats" : "View status"}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import axios from "axios";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import StatsStyling from "../styles/stats_styling";
|
import StatsStyling from "../styles/stats_styling";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
||||||
const styles = StatsStyling;
|
const styles = StatsStyling;
|
||||||
|
|
||||||
@@ -44,13 +44,17 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isRegisterMode) {
|
if (isRegisterMode) {
|
||||||
await axios.post(`${API_BASE_URL}/register`, { username, email, password });
|
await axios.post(`${API_BASE_URL}/register`, {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
setInfo("Account created. You can now sign in.");
|
setInfo("Account created. You can now sign in.");
|
||||||
setIsRegisterMode(false);
|
setIsRegisterMode(false);
|
||||||
} else {
|
} else {
|
||||||
const response = await axios.post<{ access_token: string }>(
|
const response = await axios.post<{ access_token: string }>(
|
||||||
`${API_BASE_URL}/login`,
|
`${API_BASE_URL}/login`,
|
||||||
{ username, password }
|
{ username, password },
|
||||||
);
|
);
|
||||||
|
|
||||||
const token = response.data.access_token;
|
const token = response.data.access_token;
|
||||||
@@ -61,7 +65,11 @@ const LoginPage = () => {
|
|||||||
} catch (requestError: unknown) {
|
} catch (requestError: unknown) {
|
||||||
if (axios.isAxiosError(requestError)) {
|
if (axios.isAxiosError(requestError)) {
|
||||||
setError(
|
setError(
|
||||||
String(requestError.response?.data?.error || requestError.message || "Request failed")
|
String(
|
||||||
|
requestError.response?.data?.error ||
|
||||||
|
requestError.message ||
|
||||||
|
"Request failed",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Unexpected error occurred.");
|
setError("Unexpected error occurred.");
|
||||||
@@ -73,90 +81,86 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.containerAuth}>
|
<div style={styles.containerAuth}>
|
||||||
<div style={{ ...styles.card, ...styles.authCard }}>
|
<div style={{ ...styles.card, ...styles.authCard }}>
|
||||||
<div style={styles.headingBlock}>
|
<div style={styles.headingBlock}>
|
||||||
<h1 style={styles.headingXl}>
|
<h1 style={styles.headingXl}>
|
||||||
{isRegisterMode ? "Create your account" : "Welcome back"}
|
{isRegisterMode ? "Create your account" : "Welcome back"}
|
||||||
</h1>
|
</h1>
|
||||||
<p style={styles.mutedText}>
|
<p style={styles.mutedText}>
|
||||||
{isRegisterMode
|
{isRegisterMode
|
||||||
? "Register to start uploading and exploring your dataset insights."
|
? "Register to start uploading and exploring your dataset insights."
|
||||||
: "Sign in to continue to your analytics workspace."}
|
: "Sign in to continue to your analytics workspace."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} style={styles.authForm}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Username"
|
|
||||||
style={{ ...styles.input, ...styles.authControl }}
|
|
||||||
value={username}
|
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isRegisterMode && (
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Email"
|
|
||||||
style={{ ...styles.input, ...styles.authControl }}
|
|
||||||
value={email}
|
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Password"
|
|
||||||
style={{ ...styles.input, ...styles.authControl }}
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
style={{ ...styles.buttonPrimary, ...styles.authControl, marginTop: 2 }}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading
|
|
||||||
? "Please wait..."
|
|
||||||
: isRegisterMode
|
|
||||||
? "Create account"
|
|
||||||
: "Sign in"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p style={styles.authErrorText}>
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{info && (
|
|
||||||
<p style={styles.authInfoText}>
|
|
||||||
{info}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={styles.authSwitchRow}>
|
|
||||||
<span style={styles.authSwitchLabel}>
|
|
||||||
{isRegisterMode ? "Already have an account?" : "New here?"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
style={styles.authSwitchButton}
|
|
||||||
onClick={() => {
|
|
||||||
setError("");
|
|
||||||
setInfo("");
|
|
||||||
setIsRegisterMode((value) => !value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isRegisterMode ? "Switch to sign in" : "Create account"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={styles.authForm}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
style={{ ...styles.input, ...styles.authControl }}
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isRegisterMode && (
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
style={{ ...styles.input, ...styles.authControl }}
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
style={{ ...styles.input, ...styles.authControl }}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
...styles.buttonPrimary,
|
||||||
|
...styles.authControl,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "Please wait..."
|
||||||
|
: isRegisterMode
|
||||||
|
? "Create account"
|
||||||
|
: "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && <p style={styles.authErrorText}>{error}</p>}
|
||||||
|
|
||||||
|
{info && <p style={styles.authInfoText}>{info}</p>}
|
||||||
|
|
||||||
|
<div style={styles.authSwitchRow}>
|
||||||
|
<span style={styles.authSwitchLabel}>
|
||||||
|
{isRegisterMode ? "Already have an account?" : "New here?"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={styles.authSwitchButton}
|
||||||
|
onClick={() => {
|
||||||
|
setError("");
|
||||||
|
setInfo("");
|
||||||
|
setIsRegisterMode((value) => !value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRegisterMode ? "Switch to sign in" : "Create account"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,47 +9,59 @@ import LinguisticStats from "../components/LinguisticStats";
|
|||||||
import InteractionalStats from "../components/InteractionalStats";
|
import InteractionalStats from "../components/InteractionalStats";
|
||||||
import CulturalStats from "../components/CulturalStats";
|
import CulturalStats from "../components/CulturalStats";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type SummaryResponse,
|
type SummaryResponse,
|
||||||
type UserAnalysisResponse,
|
type UserAnalysisResponse,
|
||||||
type TimeAnalysisResponse,
|
type TimeAnalysisResponse,
|
||||||
type ContentAnalysisResponse,
|
type ContentAnalysisResponse,
|
||||||
type UserEndpointResponse,
|
type UserEndpointResponse,
|
||||||
type LinguisticAnalysisResponse,
|
type LinguisticAnalysisResponse,
|
||||||
type EmotionalAnalysisResponse,
|
type EmotionalAnalysisResponse,
|
||||||
type InteractionAnalysisResponse,
|
type InteractionAnalysisResponse,
|
||||||
type CulturalAnalysisResponse
|
type CulturalAnalysisResponse,
|
||||||
} from '../types/ApiTypes'
|
} from "../types/ApiTypes";
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
|
||||||
const styles = StatsStyling;
|
const styles = StatsStyling;
|
||||||
const DELETED_USERS = ["[deleted]"];
|
const DELETED_USERS = ["[deleted]"];
|
||||||
|
|
||||||
const isDeletedUser = (value: string | null | undefined) => (
|
const isDeletedUser = (value: string | null | undefined) =>
|
||||||
DELETED_USERS.includes((value ?? "").trim().toLowerCase())
|
DELETED_USERS.includes((value ?? "").trim().toLowerCase());
|
||||||
);
|
|
||||||
|
|
||||||
const StatPage = () => {
|
const StatPage = () => {
|
||||||
const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>();
|
const { datasetId: routeDatasetId } = useParams<{ datasetId: string }>();
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [activeView, setActiveView] = useState<"summary" | "emotional" | "user" | "linguistic" | "interactional" | "cultural">("summary");
|
const [activeView, setActiveView] = useState<
|
||||||
|
| "summary"
|
||||||
|
| "emotional"
|
||||||
|
| "user"
|
||||||
|
| "linguistic"
|
||||||
|
| "interactional"
|
||||||
|
| "cultural"
|
||||||
|
>("summary");
|
||||||
|
|
||||||
const [userData, setUserData] = useState<UserAnalysisResponse | null>(null);
|
const [userData, setUserData] = useState<UserAnalysisResponse | null>(null);
|
||||||
const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null);
|
const [timeData, setTimeData] = useState<TimeAnalysisResponse | null>(null);
|
||||||
const [contentData, setContentData] = useState<ContentAnalysisResponse | null>(null);
|
const [contentData, setContentData] =
|
||||||
const [linguisticData, setLinguisticData] = useState<LinguisticAnalysisResponse | null>(null);
|
useState<ContentAnalysisResponse | null>(null);
|
||||||
const [interactionData, setInteractionData] = useState<InteractionAnalysisResponse | null>(null);
|
const [linguisticData, setLinguisticData] =
|
||||||
const [culturalData, setCulturalData] = useState<CulturalAnalysisResponse | null>(null);
|
useState<LinguisticAnalysisResponse | null>(null);
|
||||||
|
const [interactionData, setInteractionData] =
|
||||||
|
useState<InteractionAnalysisResponse | null>(null);
|
||||||
|
const [culturalData, setCulturalData] =
|
||||||
|
useState<CulturalAnalysisResponse | null>(null);
|
||||||
const [summary, setSummary] = useState<SummaryResponse | null>(null);
|
const [summary, setSummary] = useState<SummaryResponse | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const beforeDateRef = useRef<HTMLInputElement>(null);
|
const beforeDateRef = useRef<HTMLInputElement>(null);
|
||||||
const afterDateRef = useRef<HTMLInputElement>(null);
|
const afterDateRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const parsedDatasetId = Number(routeDatasetId ?? "");
|
const parsedDatasetId = Number(routeDatasetId ?? "");
|
||||||
const datasetId = Number.isInteger(parsedDatasetId) && parsedDatasetId > 0 ? parsedDatasetId : null;
|
const datasetId =
|
||||||
|
Number.isInteger(parsedDatasetId) && parsedDatasetId > 0
|
||||||
|
? parsedDatasetId
|
||||||
|
: null;
|
||||||
|
|
||||||
const getFilterParams = () => {
|
const getFilterParams = () => {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
@@ -99,112 +111,147 @@ const StatPage = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
axios.get<TimeAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/temporal`, {
|
axios.get<TimeAnalysisResponse>(
|
||||||
params,
|
`${API_BASE_URL}/dataset/${datasetId}/temporal`,
|
||||||
headers: authHeaders,
|
{
|
||||||
}),
|
params,
|
||||||
axios.get<UserEndpointResponse>(`${API_BASE_URL}/dataset/${datasetId}/user`, {
|
headers: authHeaders,
|
||||||
params,
|
},
|
||||||
headers: authHeaders,
|
),
|
||||||
}),
|
axios.get<UserEndpointResponse>(
|
||||||
axios.get<LinguisticAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/linguistic`, {
|
`${API_BASE_URL}/dataset/${datasetId}/user`,
|
||||||
params,
|
{
|
||||||
headers: authHeaders,
|
params,
|
||||||
}),
|
headers: authHeaders,
|
||||||
axios.get<EmotionalAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/emotional`, {
|
},
|
||||||
params,
|
),
|
||||||
headers: authHeaders,
|
axios.get<LinguisticAnalysisResponse>(
|
||||||
}),
|
`${API_BASE_URL}/dataset/${datasetId}/linguistic`,
|
||||||
axios.get<InteractionAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/interactional`, {
|
{
|
||||||
params,
|
params,
|
||||||
headers: authHeaders,
|
headers: authHeaders,
|
||||||
}),
|
},
|
||||||
axios.get<SummaryResponse>(`${API_BASE_URL}/dataset/${datasetId}/summary`, {
|
),
|
||||||
params,
|
axios.get<EmotionalAnalysisResponse>(
|
||||||
headers: authHeaders,
|
`${API_BASE_URL}/dataset/${datasetId}/emotional`,
|
||||||
}),
|
{
|
||||||
axios.get<CulturalAnalysisResponse>(`${API_BASE_URL}/dataset/${datasetId}/cultural`, {
|
params,
|
||||||
params,
|
headers: authHeaders,
|
||||||
headers: authHeaders,
|
},
|
||||||
}),
|
),
|
||||||
])
|
axios.get<InteractionAnalysisResponse>(
|
||||||
.then(([timeRes, userRes, linguisticRes, emotionalRes, interactionRes, summaryRes, culturalRes]) => {
|
`${API_BASE_URL}/dataset/${datasetId}/interactional`,
|
||||||
const usersList = userRes.data.users ?? [];
|
{
|
||||||
const topUsersList = userRes.data.top_users ?? [];
|
params,
|
||||||
const interactionGraphRaw = interactionRes.data?.interaction_graph ?? {};
|
headers: authHeaders,
|
||||||
const topPairsRaw = interactionRes.data?.top_interaction_pairs ?? [];
|
},
|
||||||
|
),
|
||||||
|
axios.get<SummaryResponse>(
|
||||||
|
`${API_BASE_URL}/dataset/${datasetId}/summary`,
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
headers: authHeaders,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
axios.get<CulturalAnalysisResponse>(
|
||||||
|
`${API_BASE_URL}/dataset/${datasetId}/cultural`,
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
headers: authHeaders,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(
|
||||||
|
([
|
||||||
|
timeRes,
|
||||||
|
userRes,
|
||||||
|
linguisticRes,
|
||||||
|
emotionalRes,
|
||||||
|
interactionRes,
|
||||||
|
summaryRes,
|
||||||
|
culturalRes,
|
||||||
|
]) => {
|
||||||
|
const usersList = userRes.data.users ?? [];
|
||||||
|
const topUsersList = userRes.data.top_users ?? [];
|
||||||
|
const interactionGraphRaw =
|
||||||
|
interactionRes.data?.interaction_graph ?? {};
|
||||||
|
const topPairsRaw = interactionRes.data?.top_interaction_pairs ?? [];
|
||||||
|
|
||||||
const filteredUsers: typeof usersList = [];
|
const filteredUsers: typeof usersList = [];
|
||||||
for (const user of usersList) {
|
for (const user of usersList) {
|
||||||
if (isDeletedUser(user.author)) continue;
|
if (isDeletedUser(user.author)) continue;
|
||||||
filteredUsers.push(user);
|
filteredUsers.push(user);
|
||||||
}
|
|
||||||
|
|
||||||
const filteredTopUsers: typeof topUsersList = [];
|
|
||||||
for (const user of topUsersList) {
|
|
||||||
if (isDeletedUser(user.author)) continue;
|
|
||||||
filteredTopUsers.push(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredInteractionGraph: Record<string, Record<string, number>> = {};
|
|
||||||
for (const [source, targets] of Object.entries(interactionGraphRaw)) {
|
|
||||||
if (isDeletedUser(source)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextTargets: Record<string, number> = {};
|
const filteredTopUsers: typeof topUsersList = [];
|
||||||
for (const [target, count] of Object.entries(targets)) {
|
for (const user of topUsersList) {
|
||||||
if (isDeletedUser(target)) {
|
if (isDeletedUser(user.author)) continue;
|
||||||
|
filteredTopUsers.push(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredInteractionGraph: Record<
|
||||||
|
string,
|
||||||
|
Record<string, number>
|
||||||
|
> = {};
|
||||||
|
for (const [source, targets] of Object.entries(interactionGraphRaw)) {
|
||||||
|
if (isDeletedUser(source)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
nextTargets[target] = count;
|
|
||||||
|
const nextTargets: Record<string, number> = {};
|
||||||
|
for (const [target, count] of Object.entries(targets)) {
|
||||||
|
if (isDeletedUser(target)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextTargets[target] = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredInteractionGraph[source] = nextTargets;
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredInteractionGraph[source] = nextTargets;
|
const filteredTopInteractionPairs: typeof topPairsRaw = [];
|
||||||
}
|
for (const pairEntry of topPairsRaw) {
|
||||||
|
const pair = pairEntry[0];
|
||||||
const filteredTopInteractionPairs: typeof topPairsRaw = [];
|
const source = pair[0];
|
||||||
for (const pairEntry of topPairsRaw) {
|
const target = pair[1];
|
||||||
const pair = pairEntry[0];
|
if (isDeletedUser(source) || isDeletedUser(target)) {
|
||||||
const source = pair[0];
|
continue;
|
||||||
const target = pair[1];
|
}
|
||||||
if (isDeletedUser(source) || isDeletedUser(target)) {
|
filteredTopInteractionPairs.push(pairEntry);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
filteredTopInteractionPairs.push(pairEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinedUserData: UserAnalysisResponse = {
|
const combinedUserData: UserAnalysisResponse = {
|
||||||
...userRes.data,
|
...userRes.data,
|
||||||
users: filteredUsers,
|
users: filteredUsers,
|
||||||
top_users: filteredTopUsers,
|
top_users: filteredTopUsers,
|
||||||
interaction_graph: filteredInteractionGraph,
|
interaction_graph: filteredInteractionGraph,
|
||||||
};
|
};
|
||||||
|
|
||||||
const combinedContentData: ContentAnalysisResponse = {
|
const combinedContentData: ContentAnalysisResponse = {
|
||||||
...linguisticRes.data,
|
...linguisticRes.data,
|
||||||
...emotionalRes.data,
|
...emotionalRes.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredInteractionData: InteractionAnalysisResponse = {
|
const filteredInteractionData: InteractionAnalysisResponse = {
|
||||||
...interactionRes.data,
|
...interactionRes.data,
|
||||||
interaction_graph: filteredInteractionGraph,
|
interaction_graph: filteredInteractionGraph,
|
||||||
top_interaction_pairs: filteredTopInteractionPairs,
|
top_interaction_pairs: filteredTopInteractionPairs,
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredSummary: SummaryResponse = {
|
const filteredSummary: SummaryResponse = {
|
||||||
...summaryRes.data,
|
...summaryRes.data,
|
||||||
unique_users: filteredUsers.length,
|
unique_users: filteredUsers.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
setUserData(combinedUserData);
|
setUserData(combinedUserData);
|
||||||
setTimeData(timeRes.data || null);
|
setTimeData(timeRes.data || null);
|
||||||
setContentData(combinedContentData);
|
setContentData(combinedContentData);
|
||||||
setLinguisticData(linguisticRes.data || null);
|
setLinguisticData(linguisticRes.data || null);
|
||||||
setInteractionData(filteredInteractionData || null);
|
setInteractionData(filteredInteractionData || null);
|
||||||
setCulturalData(culturalRes.data || null);
|
setCulturalData(culturalRes.data || null);
|
||||||
setSummary(filteredSummary || null);
|
setSummary(filteredSummary || null);
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.catch((e) => setError("Failed to load statistics: " + String(e)))
|
.catch((e) => setError("Failed to load statistics: " + String(e)))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
@@ -233,7 +280,7 @@ const StatPage = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
getStats();
|
getStats();
|
||||||
}, [datasetId])
|
}, [datasetId]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -243,155 +290,199 @@ const StatPage = () => {
|
|||||||
<div style={styles.loadingSpinner} />
|
<div style={styles.loadingSpinner} />
|
||||||
<div>
|
<div>
|
||||||
<h2 style={styles.loadingTitle}>Loading analytics</h2>
|
<h2 style={styles.loadingTitle}>Loading analytics</h2>
|
||||||
<p style={styles.loadingSubtitle}>Fetching summary, timeline, user, and content insights.</p>
|
<p style={styles.loadingSubtitle}>
|
||||||
|
Fetching summary, timeline, user, and content insights.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.loadingSkeleton}>
|
<div style={styles.loadingSkeleton}>
|
||||||
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineLong }} />
|
<div
|
||||||
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineMed }} />
|
style={{
|
||||||
<div style={{ ...styles.loadingSkeletonLine, ...styles.loadingSkeletonLineShort }} />
|
...styles.loadingSkeletonLine,
|
||||||
|
...styles.loadingSkeletonLineLong,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.loadingSkeletonLine,
|
||||||
|
...styles.loadingSkeletonLineMed,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.loadingSkeletonLine,
|
||||||
|
...styles.loadingSkeletonLineShort,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error) return <p style={{...styles.page}}>{error}</p>;
|
if (error) return <p style={{ ...styles.page }}>{error}</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.page}>
|
<div style={styles.page}>
|
||||||
<div style={{ ...styles.container, ...styles.card, ...styles.headerBar }}>
|
<div style={{ ...styles.container, ...styles.card, ...styles.headerBar }}>
|
||||||
<div style={styles.controls}>
|
<div style={styles.controls}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="query"
|
id="query"
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
placeholder="Search events..."
|
placeholder="Search events..."
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
ref={beforeDateRef}
|
ref={beforeDateRef}
|
||||||
placeholder="Search before date"
|
placeholder="Search before date"
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
ref={afterDateRef}
|
ref={afterDateRef}
|
||||||
placeholder="Search before date"
|
placeholder="Search before date"
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button onClick={onSubmitFilters} style={styles.buttonPrimary}>
|
<button onClick={onSubmitFilters} style={styles.buttonPrimary}>
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={resetFilters} style={styles.buttonSecondary}>
|
<button onClick={resetFilters} style={styles.buttonSecondary}>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={styles.dashboardMeta}>Analytics Dashboard</div>
|
|
||||||
<div style={styles.dashboardMeta}>Dataset #{datasetId ?? "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ ...styles.container, ...styles.tabsRow, justifyContent: "center" }}>
|
<div style={styles.dashboardMeta}>Analytics Dashboard</div>
|
||||||
<button
|
<div style={styles.dashboardMeta}>Dataset #{datasetId ?? "-"}</div>
|
||||||
onClick={() => setActiveView("summary")}
|
</div>
|
||||||
style={activeView === "summary" ? styles.buttonPrimary : styles.buttonSecondary}
|
|
||||||
>
|
|
||||||
Summary
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveView("emotional")}
|
|
||||||
style={activeView === "emotional" ? styles.buttonPrimary : styles.buttonSecondary}
|
|
||||||
>
|
|
||||||
Emotional
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<div
|
||||||
onClick={() => setActiveView("user")}
|
style={{
|
||||||
style={activeView === "user" ? styles.buttonPrimary : styles.buttonSecondary}
|
...styles.container,
|
||||||
|
...styles.tabsRow,
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Users
|
<button
|
||||||
</button>
|
onClick={() => setActiveView("summary")}
|
||||||
<button
|
style={
|
||||||
onClick={() => setActiveView("linguistic")}
|
activeView === "summary"
|
||||||
style={activeView === "linguistic" ? styles.buttonPrimary : styles.buttonSecondary}
|
? styles.buttonPrimary
|
||||||
>
|
: styles.buttonSecondary
|
||||||
Linguistic
|
}
|
||||||
</button>
|
>
|
||||||
<button
|
Summary
|
||||||
onClick={() => setActiveView("interactional")}
|
</button>
|
||||||
style={activeView === "interactional" ? styles.buttonPrimary : styles.buttonSecondary}
|
<button
|
||||||
>
|
onClick={() => setActiveView("emotional")}
|
||||||
Interactional
|
style={
|
||||||
</button>
|
activeView === "emotional"
|
||||||
<button
|
? styles.buttonPrimary
|
||||||
onClick={() => setActiveView("cultural")}
|
: styles.buttonSecondary
|
||||||
style={activeView === "cultural" ? styles.buttonPrimary : styles.buttonSecondary}
|
}
|
||||||
>
|
>
|
||||||
Cultural
|
Emotional
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveView("user")}
|
||||||
|
style={
|
||||||
|
activeView === "user"
|
||||||
|
? styles.buttonPrimary
|
||||||
|
: styles.buttonSecondary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveView("linguistic")}
|
||||||
|
style={
|
||||||
|
activeView === "linguistic"
|
||||||
|
? styles.buttonPrimary
|
||||||
|
: styles.buttonSecondary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Linguistic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveView("interactional")}
|
||||||
|
style={
|
||||||
|
activeView === "interactional"
|
||||||
|
? styles.buttonPrimary
|
||||||
|
: styles.buttonSecondary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Interactional
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveView("cultural")}
|
||||||
|
style={
|
||||||
|
activeView === "cultural"
|
||||||
|
? styles.buttonPrimary
|
||||||
|
: styles.buttonSecondary
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cultural
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeView === "summary" && (
|
||||||
|
<SummaryStats
|
||||||
|
userData={userData}
|
||||||
|
timeData={timeData}
|
||||||
|
contentData={contentData}
|
||||||
|
summary={summary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeView === "emotional" && contentData && (
|
||||||
|
<EmotionalStats contentData={contentData} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeView === "emotional" && !contentData && (
|
||||||
|
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
|
||||||
|
No emotional data available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeView === "user" && userData && <UserStats data={userData} />}
|
||||||
|
|
||||||
|
{activeView === "linguistic" && linguisticData && (
|
||||||
|
<LinguisticStats data={linguisticData} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeView === "linguistic" && !linguisticData && (
|
||||||
|
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
|
||||||
|
No linguistic data available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeView === "interactional" && interactionData && (
|
||||||
|
<InteractionalStats data={interactionData} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeView === "interactional" && !interactionData && (
|
||||||
|
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
|
||||||
|
No interactional data available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeView === "cultural" && culturalData && (
|
||||||
|
<CulturalStats data={culturalData} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeView === "cultural" && !culturalData && (
|
||||||
|
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
|
||||||
|
No cultural data available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
{activeView === "summary" && (
|
};
|
||||||
<SummaryStats
|
|
||||||
userData={userData}
|
|
||||||
timeData={timeData}
|
|
||||||
contentData={contentData}
|
|
||||||
summary={summary}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === "emotional" && contentData && (
|
|
||||||
<EmotionalStats contentData={contentData} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === "emotional" && !contentData && (
|
|
||||||
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
|
|
||||||
No emotional data available.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === "user" && userData && (
|
|
||||||
<UserStats data={userData} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === "linguistic" && linguisticData && (
|
|
||||||
<LinguisticStats data={linguisticData} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === "linguistic" && !linguisticData && (
|
|
||||||
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
|
|
||||||
No linguistic data available.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === "interactional" && interactionData && (
|
|
||||||
<InteractionalStats data={interactionData} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === "interactional" && !interactionData && (
|
|
||||||
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
|
|
||||||
No interactional data available.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === "cultural" && culturalData && (
|
|
||||||
<CulturalStats data={culturalData} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === "cultural" && !culturalData && (
|
|
||||||
<div style={{ ...styles.container, ...styles.card, marginTop: 16 }}>
|
|
||||||
No cultural data available.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StatPage;
|
export default StatPage;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import StatsStyling from "../styles/stats_styling";
|
import StatsStyling from "../styles/stats_styling";
|
||||||
|
|
||||||
const styles = StatsStyling;
|
const styles = StatsStyling;
|
||||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
||||||
const UploadPage = () => {
|
const UploadPage = () => {
|
||||||
const [datasetName, setDatasetName] = useState("");
|
const [datasetName, setDatasetName] = useState("");
|
||||||
@@ -40,16 +40,20 @@ const UploadPage = () => {
|
|||||||
setHasError(false);
|
setHasError(false);
|
||||||
setReturnMessage("");
|
setReturnMessage("");
|
||||||
|
|
||||||
const response = await axios.post(`${API_BASE_URL}/datasets/upload`, formData, {
|
const response = await axios.post(
|
||||||
headers: {
|
`${API_BASE_URL}/datasets/upload`,
|
||||||
"Content-Type": "multipart/form-data",
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
const datasetId = Number(response.data.dataset_id);
|
const datasetId = Number(response.data.dataset_id);
|
||||||
|
|
||||||
setReturnMessage(
|
setReturnMessage(
|
||||||
`Upload queued successfully (dataset #${datasetId}). Redirecting to processing status...`
|
`Upload queued successfully (dataset #${datasetId}). Redirecting to processing status...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -58,7 +62,9 @@ const UploadPage = () => {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const message = String(error.response?.data?.error || error.message || "Upload failed.");
|
const message = String(
|
||||||
|
error.response?.data?.error || error.message || "Upload failed.",
|
||||||
|
);
|
||||||
setReturnMessage(`Upload failed: ${message}`);
|
setReturnMessage(`Upload failed: ${message}`);
|
||||||
} else {
|
} else {
|
||||||
setReturnMessage("Upload failed due to an unexpected error.");
|
setReturnMessage("Upload failed due to an unexpected error.");
|
||||||
@@ -75,12 +81,16 @@ const UploadPage = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 style={styles.sectionHeaderTitle}>Upload Dataset</h1>
|
<h1 style={styles.sectionHeaderTitle}>Upload Dataset</h1>
|
||||||
<p style={styles.sectionHeaderSubtitle}>
|
<p style={styles.sectionHeaderSubtitle}>
|
||||||
Name your dataset, then upload posts and topic map files to generate analytics.
|
Name your dataset, then upload posts and topic map files to
|
||||||
|
generate analytics.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={{ ...styles.buttonPrimary, opacity: isSubmitting ? 0.75 : 1 }}
|
style={{
|
||||||
|
...styles.buttonPrimary,
|
||||||
|
opacity: isSubmitting ? 0.75 : 1,
|
||||||
|
}}
|
||||||
onClick={uploadFiles}
|
onClick={uploadFiles}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
@@ -96,8 +106,12 @@ const UploadPage = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ ...styles.card, gridColumn: "auto" }}>
|
<div style={{ ...styles.card, gridColumn: "auto" }}>
|
||||||
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Dataset Name</h2>
|
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>
|
||||||
<p style={styles.sectionSubtitle}>Use a clear label so you can identify this upload later.</p>
|
Dataset Name
|
||||||
|
</h2>
|
||||||
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Use a clear label so you can identify this upload later.
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
style={{ ...styles.input, ...styles.inputFullWidth }}
|
style={{ ...styles.input, ...styles.inputFullWidth }}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -108,8 +122,12 @@ const UploadPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ ...styles.card, gridColumn: "auto" }}>
|
<div style={{ ...styles.card, gridColumn: "auto" }}>
|
||||||
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Posts File (.jsonl)</h2>
|
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>
|
||||||
<p style={styles.sectionSubtitle}>Upload the raw post records export.</p>
|
Posts File (.jsonl)
|
||||||
|
</h2>
|
||||||
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Upload the raw post records export.
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
style={{ ...styles.input, ...styles.inputFullWidth }}
|
style={{ ...styles.input, ...styles.inputFullWidth }}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -122,16 +140,24 @@ const UploadPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ ...styles.card, gridColumn: "auto" }}>
|
<div style={{ ...styles.card, gridColumn: "auto" }}>
|
||||||
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>Topics File (.json)</h2>
|
<h2 style={{ ...styles.sectionTitle, color: "#24292f" }}>
|
||||||
<p style={styles.sectionSubtitle}>Upload your topic bucket mapping file.</p>
|
Topics File (.json)
|
||||||
|
</h2>
|
||||||
|
<p style={styles.sectionSubtitle}>
|
||||||
|
Upload your topic bucket mapping file.
|
||||||
|
</p>
|
||||||
<input
|
<input
|
||||||
style={{ ...styles.input, ...styles.inputFullWidth }}
|
style={{ ...styles.input, ...styles.inputFullWidth }}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json"
|
accept=".json"
|
||||||
onChange={(event) => setTopicBucketFile(event.target.files?.[0] ?? null)}
|
onChange={(event) =>
|
||||||
|
setTopicBucketFile(event.target.files?.[0] ?? null)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<p style={styles.subtleBodyText}>
|
<p style={styles.subtleBodyText}>
|
||||||
{topicBucketFile ? `Selected: ${topicBucketFile.name}` : "No file selected"}
|
{topicBucketFile
|
||||||
|
? `Selected: ${topicBucketFile.name}`
|
||||||
|
: "No file selected"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +169,8 @@ const UploadPage = () => {
|
|||||||
...(hasError ? styles.alertCardError : styles.alertCardInfo),
|
...(hasError ? styles.alertCardError : styles.alertCardInfo),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{returnMessage || "After upload, your dataset is queued for processing and you'll land on stats."}
|
{returnMessage ||
|
||||||
|
"After upload, your dataset is queued for processing and you'll land on stats."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ const DAYS = [
|
|||||||
"Sunday",
|
"Sunday",
|
||||||
];
|
];
|
||||||
|
|
||||||
const hourLabel = (h: number) =>
|
const hourLabel = (h: number) => `${h.toString().padStart(2, "0")}:00`;
|
||||||
`${h.toString().padStart(2, "0")}:00`;
|
|
||||||
|
|
||||||
const convertWeeklyData = (dataset: ApiRow[]): ChartSeries[] => {
|
const convertWeeklyData = (dataset: ApiRow[]): ChartSeries[] => {
|
||||||
return dataset.map((dayData, index) => ({
|
return dataset.map((dayData, index) => ({
|
||||||
@@ -40,32 +39,29 @@ const convertWeeklyData = (dataset: ApiRow[]): ChartSeries[] => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => {
|
const ActivityHeatmap = ({ data }: ActivityHeatmapProps) => {
|
||||||
const convertedData = convertWeeklyData(data);
|
const convertedData = convertWeeklyData(data);
|
||||||
|
|
||||||
const maxValue = Math.max(
|
const maxValue = Math.max(
|
||||||
...convertedData.flatMap(day =>
|
...convertedData.flatMap((day) => day.data.map((point) => point.y)),
|
||||||
day.data.map(point => point.y)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveHeatMap
|
<ResponsiveHeatMap
|
||||||
data={convertedData}
|
data={convertedData}
|
||||||
valueFormat=">-.2s"
|
valueFormat=">-.2s"
|
||||||
axisTop={{ tickRotation: -90 }}
|
axisTop={{ tickRotation: -90 }}
|
||||||
axisRight={{ legend: 'Weekday', legendOffset: 70 }}
|
axisRight={{ legend: "Weekday", legendOffset: 70 }}
|
||||||
axisLeft={{ legend: 'Weekday', legendOffset: -72 }}
|
axisLeft={{ legend: "Weekday", legendOffset: -72 }}
|
||||||
colors={{
|
colors={{
|
||||||
type: 'diverging',
|
type: "diverging",
|
||||||
scheme: 'red_yellow_blue',
|
scheme: "red_yellow_blue",
|
||||||
divergeAt: 0.3,
|
divergeAt: 0.3,
|
||||||
minValue: 0,
|
minValue: 0,
|
||||||
maxValue: maxValue
|
maxValue: maxValue,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ActivityHeatmap;
|
export default ActivityHeatmap;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type Emotion = {
|
|||||||
emotion_sadness: number;
|
emotion_sadness: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// User
|
// User
|
||||||
type TopUser = {
|
type TopUser = {
|
||||||
author: string;
|
author: string;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -57,7 +57,7 @@ type UserAnalysisResponse = {
|
|||||||
interaction_graph: InteractionGraph;
|
interaction_graph: InteractionGraph;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Time
|
// Time
|
||||||
type EventsPerDay = {
|
type EventsPerDay = {
|
||||||
date: Date;
|
date: Date;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -125,7 +125,7 @@ type EmotionalAnalysisResponse = {
|
|||||||
emotion_by_source?: EmotionBySource[];
|
emotion_by_source?: EmotionBySource[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Interactional
|
// Interactional
|
||||||
type ConversationConcentration = {
|
type ConversationConcentration = {
|
||||||
total_commenting_authors: number;
|
total_commenting_authors: number;
|
||||||
top_10pct_author_count: number;
|
top_10pct_author_count: number;
|
||||||
@@ -180,7 +180,7 @@ type CulturalAnalysisResponse = {
|
|||||||
avg_emotion_per_entity?: AverageEmotionPerEntity;
|
avg_emotion_per_entity?: AverageEmotionPerEntity;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
type SummaryResponse = {
|
type SummaryResponse = {
|
||||||
total_events: number;
|
total_events: number;
|
||||||
total_posts: number;
|
total_posts: number;
|
||||||
@@ -195,7 +195,7 @@ type SummaryResponse = {
|
|||||||
sources: string[];
|
sources: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
type FilterResponse = {
|
type FilterResponse = {
|
||||||
rows: number;
|
rows: number;
|
||||||
data: any;
|
data: any;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const getDocumentTitle = (pathname: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.includes("stats")) {
|
if (pathname.includes("stats")) {
|
||||||
return "Ethnography Analysis"
|
return "Ethnography Analysis";
|
||||||
}
|
}
|
||||||
|
|
||||||
return STATIC_TITLES[pathname] ?? DEFAULT_TITLE;
|
return STATIC_TITLES[pathname] ?? DEFAULT_TITLE;
|
||||||
|
|||||||
Reference in New Issue
Block a user