feat: integrate word cloud get data endpoint
This commit is contained in:
parent
71f2ed0a6c
commit
e5a3262091
|
|
@ -1,4 +1,3 @@
|
||||||
// app/api/reviews/sentiment-stats/route.ts
|
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { WordCloudProps } from "@/src/types";
|
|
||||||
import WordCloudItem from "./WordCloudItem";
|
|
||||||
import { useWordCloud } from "@/src/hooks/useWordCloud";
|
import { useWordCloud } from "@/src/hooks/useWordCloud";
|
||||||
|
import WordCloudItem from "./WordCloudItem";
|
||||||
|
|
||||||
export function WordCloud({ words }: WordCloudProps) {
|
export function WordCloud() {
|
||||||
const { mounted, maxValue, minValue, shuffledWords } = useWordCloud({
|
const { mounted, maxValue, minValue, shuffledWords } = useWordCloud();
|
||||||
words,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,14 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { WordCloudItemProps, WordItem } from "@/src/types";
|
import { WordCloudItemProps, WordItem } from "@/src/types";
|
||||||
|
import { setWordCloud } from "@/src/utils/datas";
|
||||||
|
|
||||||
const WordCloudItem: React.FC<WordCloudItemProps> = ({
|
const WordCloudItem: React.FC<WordCloudItemProps> = ({
|
||||||
word,
|
word,
|
||||||
index,
|
index,
|
||||||
maxValue,
|
|
||||||
minValue,
|
minValue,
|
||||||
|
maxValue,
|
||||||
}) => {
|
}) => {
|
||||||
const getSize = (value: number) => {
|
const { getSize, getColor } = setWordCloud({ minValue, maxValue });
|
||||||
if (maxValue === minValue) return 1.5;
|
|
||||||
const normalized = (value - minValue) / (maxValue - minValue);
|
|
||||||
return 0.75 + normalized * 1.5;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColor = (sentiment: WordItem["sentiment"]) => {
|
|
||||||
switch (sentiment) {
|
|
||||||
case "positive":
|
|
||||||
return "text-sentiment-positive hover:bg-sentiment-positive-light";
|
|
||||||
case "negative":
|
|
||||||
return "text-sentiment-negative hover:bg-sentiment-negative-light";
|
|
||||||
default:
|
|
||||||
return "text-sentiment-neutral hover:bg-sentiment-neutral-light";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,90 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { WordCloudProps } from "../types";
|
import {
|
||||||
|
KeywordStats,
|
||||||
|
Review,
|
||||||
|
ReviewResponse,
|
||||||
|
Sentiment,
|
||||||
|
WordItem,
|
||||||
|
} from "@/src/types";
|
||||||
|
import { WORD_LIMIT } from "../utils/datas";
|
||||||
|
|
||||||
export const useWordCloud = ({ words }: WordCloudProps) => {
|
export const useWordCloud = () => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [words, setWords] = useState<WordItem[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
|
||||||
|
const fetchWords = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/review");
|
||||||
|
const result: unknown = await res.json();
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof result !== "object" ||
|
||||||
|
result === null ||
|
||||||
|
!("data" in result)
|
||||||
|
) {
|
||||||
|
console.error("Invalid response from /api/review");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewsData = (result as ReviewResponse).data || [];
|
||||||
|
const reviews: Review[] = reviewsData;
|
||||||
|
|
||||||
|
const keywordMap: Record<string, KeywordStats> = reviews.reduce(
|
||||||
|
(acc, review) => {
|
||||||
|
const sentiment: Sentiment = [
|
||||||
|
"positive",
|
||||||
|
"negative",
|
||||||
|
"neutral",
|
||||||
|
].includes(review.sentiment)
|
||||||
|
? review.sentiment
|
||||||
|
: "neutral";
|
||||||
|
|
||||||
|
(review.keywords || []).forEach((keyword) => {
|
||||||
|
const key = keyword.toLowerCase();
|
||||||
|
|
||||||
|
if (!acc[key]) {
|
||||||
|
acc[key] = { count: 0, positive: 0, negative: 0, neutral: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
acc[key].count += 1;
|
||||||
|
acc[key][sentiment] += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, KeywordStats>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const wordItems: WordItem[] = Object.entries(keywordMap)
|
||||||
|
.map(([text, data]) => {
|
||||||
|
let sentiment: Sentiment = "neutral";
|
||||||
|
if (
|
||||||
|
data.positive >= data.negative &&
|
||||||
|
data.positive >= data.neutral
|
||||||
|
) {
|
||||||
|
sentiment = "positive";
|
||||||
|
} else if (
|
||||||
|
data.negative >= data.positive &&
|
||||||
|
data.negative >= data.neutral
|
||||||
|
) {
|
||||||
|
sentiment = "negative";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text, value: data.count, sentiment };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, WORD_LIMIT);
|
||||||
|
|
||||||
|
setWords(wordItems);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch wordcloud data", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchWords();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const maxValue = Math.max(...words.map((w) => w.value), 1);
|
const maxValue = Math.max(...words.map((w) => w.value), 1);
|
||||||
|
|
@ -15,5 +94,10 @@ export const useWordCloud = ({ words }: WordCloudProps) => {
|
||||||
return [...words].sort(() => Math.random() - 0.5);
|
return [...words].sort(() => Math.random() - 0.5);
|
||||||
}, [words]);
|
}, [words]);
|
||||||
|
|
||||||
return { mounted, maxValue, minValue, shuffledWords };
|
return {
|
||||||
|
mounted,
|
||||||
|
maxValue,
|
||||||
|
minValue,
|
||||||
|
shuffledWords,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Sentiment, UserGender } from "@prisma/client";
|
import { UserGender } from "@prisma/client";
|
||||||
import { LucideIcon } from "lucide-react";
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
export interface ModelDB {
|
export interface ModelDB {
|
||||||
|
|
@ -27,16 +27,17 @@ export interface BrandFilterProps {
|
||||||
onSelect: (brand: string | null) => void;
|
onSelect: (brand: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Review {
|
export type Review = {
|
||||||
id: number;
|
id: number;
|
||||||
product: string;
|
createdAt: string;
|
||||||
brand: string;
|
|
||||||
review: string;
|
|
||||||
rating?: number | null;
|
|
||||||
sentiment: Sentiment;
|
sentiment: Sentiment;
|
||||||
date: string;
|
keywords: string[];
|
||||||
confidence: number;
|
content: string;
|
||||||
}
|
product: {
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface ReviewTableProps {
|
export interface ReviewTableProps {
|
||||||
reviews: Review[];
|
reviews: Review[];
|
||||||
|
|
@ -94,11 +95,11 @@ export interface TrendChartTooltipProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WordItem {
|
// export type WordItem = {
|
||||||
text: string;
|
// text: string;
|
||||||
value: number;
|
// value: number;
|
||||||
sentiment: "positive" | "negative" | "neutral";
|
// sentiment: "positive" | "negative" | "neutral";
|
||||||
}
|
// };
|
||||||
|
|
||||||
export interface WordCloudProps {
|
export interface WordCloudProps {
|
||||||
words: WordItem[];
|
words: WordItem[];
|
||||||
|
|
@ -140,3 +141,25 @@ export interface ApiResponse {
|
||||||
message: string;
|
message: string;
|
||||||
data: ReviewItem[];
|
data: ReviewItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Sentiment = "positive" | "negative" | "neutral";
|
||||||
|
|
||||||
|
export type WordItem = {
|
||||||
|
text: string;
|
||||||
|
value: number;
|
||||||
|
sentiment: Sentiment;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KeywordStats = {
|
||||||
|
count: number;
|
||||||
|
} & Record<Sentiment, number>;
|
||||||
|
|
||||||
|
export type ReviewResponse = {
|
||||||
|
message: string;
|
||||||
|
data: Review[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WordCloudConfig = {
|
||||||
|
minValue: number;
|
||||||
|
maxValue: number;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Frown, Meh, Smile } from "lucide-react";
|
import { Frown, Meh, Smile } from "lucide-react";
|
||||||
|
import { WordCloudConfig, WordCloudItemProps, WordItem } from "../types";
|
||||||
|
|
||||||
export const MODEL_OPTIONS = [
|
export const MODEL_OPTIONS = [
|
||||||
{
|
{
|
||||||
|
|
@ -49,3 +50,26 @@ export const getSentimentDisplay = (sentiment: string) => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WORD_LIMIT = 20;
|
||||||
|
|
||||||
|
export const setWordCloud = ({ maxValue, minValue }: WordCloudConfig) => {
|
||||||
|
const getSize = (value: number) => {
|
||||||
|
if (maxValue === minValue) return 1.5;
|
||||||
|
const normalized = (value - minValue) / (maxValue - minValue);
|
||||||
|
return 0.75 + normalized * 1.5;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColor = (sentiment: WordItem["sentiment"]) => {
|
||||||
|
switch (sentiment) {
|
||||||
|
case "positive":
|
||||||
|
return "text-sentiment-positive hover:bg-sentiment-positive-light";
|
||||||
|
case "negative":
|
||||||
|
return "text-sentiment-negative hover:bg-sentiment-negative-light";
|
||||||
|
default:
|
||||||
|
return "text-sentiment-neutral hover:bg-sentiment-neutral-light";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { getSize, getColor };
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue