Files
crosspost/frontend/src/components/CulturalStats.tsx

250 lines
8.6 KiB
TypeScript

import Card from "./Card";
import StatsStyling from "../styles/stats_styling";
import type { CulturalAnalysisResponse } from "../types/ApiTypes";
import {
buildCertaintySpec,
buildDeonticSpec,
buildEntitySpec,
buildHedgeSpec,
buildIdentityBucketSpec,
buildPermissionSpec,
type CorpusExplorerSpec,
} from "../utils/corpusExplorer";
const styles = StatsStyling;
const exploreButtonStyle = { padding: "4px 8px", fontSize: 12 };
type CulturalStatsProps = {
data: CulturalAnalysisResponse;
onExplore: (spec: CorpusExplorerSpec) => void;
};
const renderExploreButton = (onClick: () => void) => (
<button
onClick={onClick}
style={{ ...styles.buttonSecondary, ...exploreButtonStyle }}
>
Explore
</button>
);
const CulturalStats = ({ data, onExplore }: CulturalStatsProps) => {
const identity = data.identity_markers;
const stance = data.stance_markers;
const inGroupWords = identity?.in_group_usage ?? 0;
const outGroupWords = identity?.out_group_usage ?? 0;
const totalGroupWords = inGroupWords + outGroupWords;
const inGroupWordRate =
typeof identity?.in_group_ratio === "number"
? identity.in_group_ratio * 100
: null;
const outGroupWordRate =
typeof identity?.out_group_ratio === "number"
? identity.out_group_ratio * 100
: null;
const rawEntities = data.avg_emotion_per_entity?.entity_emotion_avg ?? {};
const entities = Object.entries(rawEntities)
.sort((a, b) => b[1].post_count - a[1].post_count)
.slice(0, 20);
const topEmotion = (emotionAvg: Record<string, number> | undefined) => {
const entries = Object.entries(emotionAvg ?? {});
if (!entries.length) {
return "-";
}
entries.sort((a, b) => b[1] - a[1]);
const dominant = entries[0] ?? ["emotion_unknown", 0];
const dominantLabel = dominant[0].replace("emotion_", "");
return `${dominantLabel} (${(dominant[1] * 100).toFixed(1)}%)`;
};
return (
<div style={styles.page}>
<div style={{ ...styles.container, ...styles.grid }}>
<div style={{ ...styles.card, gridColumn: "span 12" }}>
<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>
</div>
<Card
label="In-Group Words"
value={inGroupWords.toLocaleString()}
sublabel="Times we/us/our appears"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Out-Group Words"
value={outGroupWords.toLocaleString()}
sublabel="Times they/them/their appears"
style={{ gridColumn: "span 3" }}
/>
<Card
label="In-Group Posts"
value={identity?.in_group_posts?.toLocaleString() ?? "-"}
sublabel='Posts leaning toward "us" language'
rightSlot={renderExploreButton(() =>
onExplore(buildIdentityBucketSpec("in")),
)}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Out-Group Posts"
value={identity?.out_group_posts?.toLocaleString() ?? "-"}
sublabel='Posts leaning toward "them" language'
rightSlot={renderExploreButton(() =>
onExplore(buildIdentityBucketSpec("out")),
)}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Balanced Posts"
value={identity?.tie_posts?.toLocaleString() ?? "-"}
sublabel="Posts with equal us/them signals"
rightSlot={renderExploreButton(() =>
onExplore(buildIdentityBucketSpec("tie")),
)}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Total Group Words"
value={totalGroupWords.toLocaleString()}
sublabel="In-group + out-group words"
style={{ gridColumn: "span 3" }}
/>
<Card
label="In-Group Share"
value={
inGroupWordRate === null ? "-" : `${inGroupWordRate.toFixed(2)}%`
}
sublabel="Share of all words"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Out-Group Share"
value={
outGroupWordRate === null ? "-" : `${outGroupWordRate.toFixed(2)}%`
}
sublabel="Share of all words"
style={{ gridColumn: "span 3" }}
/>
<Card
label="Hedging Words"
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"
}
rightSlot={renderExploreButton(() => onExplore(buildHedgeSpec()))}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Certainty Words"
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"
}
rightSlot={renderExploreButton(() => onExplore(buildCertaintySpec()))}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Need/Should Words"
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"
}
rightSlot={renderExploreButton(() => onExplore(buildDeonticSpec()))}
style={{ gridColumn: "span 3" }}
/>
<Card
label="Permission Words"
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"
}
rightSlot={renderExploreButton(() => onExplore(buildPermissionSpec()))}
style={{ gridColumn: "span 3" }}
/>
<div style={{ ...styles.card, gridColumn: "span 6" }}>
<h2 style={styles.sectionTitle}>Mood in "Us" Posts</h2>
<p style={styles.sectionSubtitle}>
Most likely emotion when in-group wording is stronger.
</p>
<div style={styles.topUserName}>{topEmotion(identity?.in_group_emotion_avg)}</div>
<div style={{ marginTop: 12 }}>
<button
onClick={() => onExplore(buildIdentityBucketSpec("in"))}
style={styles.buttonSecondary}
>
Explore records
</button>
</div>
</div>
<div style={{ ...styles.card, gridColumn: "span 6" }}>
<h2 style={styles.sectionTitle}>Mood in "Them" Posts</h2>
<p style={styles.sectionSubtitle}>
Most likely emotion when out-group wording is stronger.
</p>
<div style={styles.topUserName}>{topEmotion(identity?.out_group_emotion_avg)}</div>
<div style={{ marginTop: 12 }}>
<button
onClick={() => onExplore(buildIdentityBucketSpec("out"))}
style={styles.buttonSecondary}
>
Explore records
</button>
</div>
</div>
<div style={{ ...styles.card, gridColumn: "span 12" }}>
<h2 style={styles.sectionTitle}>Entity Mood Snapshot</h2>
<p style={styles.sectionSubtitle}>
Most mentioned entities and the mood that appears most with each.
</p>
{!entities.length ? (
<div style={styles.topUserMeta}>No entity-level cultural data available.</div>
) : (
<div
style={{
...styles.topUsersList,
maxHeight: 420,
overflowY: "auto",
}}
>
{entities.map(([entity, aggregate]) => (
<div
key={entity}
style={{ ...styles.topUserItem, cursor: "pointer" }}
onClick={() => onExplore(buildEntitySpec(entity))}
>
<div style={styles.topUserName}>{entity}</div>
<div style={styles.topUserMeta}>
{aggregate.post_count.toLocaleString()} posts Likely mood:{" "}
{topEmotion(aggregate.emotion_avg)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default CulturalStats;