>
);
}
-
-function Overay({ onClick }: { onClick: () => void }) {
- return (
-
- );
-}
diff --git a/FE/src/components/ModalOveray.tsx b/FE/src/components/ModalOveray.tsx
new file mode 100644
index 00000000..9e72da54
--- /dev/null
+++ b/FE/src/components/ModalOveray.tsx
@@ -0,0 +1,5 @@
+export default function Overay({ onClick }: { onClick: () => void }) {
+ return (
+
+ );
+}
diff --git a/FE/src/components/Search/SearchCard.tsx b/FE/src/components/Search/SearchCard.tsx
new file mode 100644
index 00000000..9b47c1d2
--- /dev/null
+++ b/FE/src/components/Search/SearchCard.tsx
@@ -0,0 +1,48 @@
+import { SearchDataType } from './searchDataType.ts';
+import { useNavigate } from 'react-router-dom';
+import useSearchModalStore from '../../store/useSearchModalStore.ts';
+import useSearchInputStore from '../../store/useSearchInputStore.ts';
+import { SearchCardHighLight } from './SearchCardHighlight.tsx';
+
+type SearchCardProps = {
+ data: SearchDataType;
+};
+
+export default function SearchCard({ data }: SearchCardProps) {
+ const { code, name, market } = data;
+ const { isOpen, toggleSearchModal } = useSearchModalStore();
+ const { searchInput } = useSearchInputStore();
+
+ const navigation = useNavigate();
+
+ const handleClick = () => {
+ navigation(`/stocks/${code}`);
+ if (isOpen) toggleSearchModal();
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/FE/src/components/Search/SearchCardHighlight.tsx b/FE/src/components/Search/SearchCardHighlight.tsx
new file mode 100644
index 00000000..21751aa6
--- /dev/null
+++ b/FE/src/components/Search/SearchCardHighlight.tsx
@@ -0,0 +1,32 @@
+type SearchCardHighLightProps = {
+ text: string;
+ highlight: string;
+};
+
+export const SearchCardHighLight = ({
+ text,
+ highlight,
+}: SearchCardHighLightProps) => {
+ if (!highlight.trim()) {
+ return
{text}
;
+ }
+
+ const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
+
+ return (
+
+ {parts.map((part, index) =>
+ part.toLowerCase() === highlight.toLowerCase() ? (
+
+ {part}
+
+ ) : (
+ {part}
+ ),
+ )}
+
+ );
+};
diff --git a/FE/src/components/Search/SearchHistoryItem.tsx b/FE/src/components/Search/SearchHistoryItem.tsx
new file mode 100644
index 00000000..0c95e777
--- /dev/null
+++ b/FE/src/components/Search/SearchHistoryItem.tsx
@@ -0,0 +1,24 @@
+import { XMarkIcon } from '@heroicons/react/16/solid';
+import useSearchInputStore from '../../store/useSearchInputStore.ts';
+
+type SearchHistoryItemProps = {
+ item: string;
+ onDelete: (item: string) => void;
+};
+
+export function SearchHistoryItem({ item, onDelete }: SearchHistoryItemProps) {
+ const { setSearchInput } = useSearchInputStore();
+ return (
+
+ setSearchInput(item)}>
+ {item}
+
+
+
+ );
+}
diff --git a/FE/src/components/Search/SearchHistoryList.tsx b/FE/src/components/Search/SearchHistoryList.tsx
new file mode 100644
index 00000000..caa1d7df
--- /dev/null
+++ b/FE/src/components/Search/SearchHistoryList.tsx
@@ -0,0 +1,31 @@
+import { SearchHistoryItem } from './SearchHistoryItem.tsx';
+import { HistoryType } from './searchDataType.ts';
+
+type SearchHistoryListProps = {
+ searchHistory: HistoryType[];
+ onDeleteItem: (item: string) => void;
+};
+
+export function SearchHistoryList({
+ searchHistory,
+ onDeleteItem,
+}: SearchHistoryListProps) {
+ if (searchHistory.length === 0) return;
+
+ return (
+
+
+
+ {searchHistory.map((item: HistoryType) => (
+
+ ))}
+
+
+ );
+}
diff --git a/FE/src/components/Search/SearchInput.tsx b/FE/src/components/Search/SearchInput.tsx
new file mode 100644
index 00000000..e1ec83a0
--- /dev/null
+++ b/FE/src/components/Search/SearchInput.tsx
@@ -0,0 +1,35 @@
+import { MagnifyingGlassIcon } from '@heroicons/react/16/solid';
+import { useEffect, useRef } from 'react';
+
+type SearchInputProps = {
+ value: string;
+ onChange: (value: string) => void;
+};
+
+export function SearchInput({ value, onChange }: SearchInputProps) {
+ const inputRef = useRef
(null);
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ return (
+
+
+ onChange(e.target.value)}
+ />
+
+ );
+}
diff --git a/FE/src/components/Search/SearchList.tsx b/FE/src/components/Search/SearchList.tsx
new file mode 100644
index 00000000..89c0adf6
--- /dev/null
+++ b/FE/src/components/Search/SearchList.tsx
@@ -0,0 +1,31 @@
+import SearchCard from './SearchCard.tsx';
+import { SearchDataType } from './searchDataType.ts';
+import Lottie from 'lottie-react';
+import noResultAnimation from 'assets/noResultAnimation.json';
+
+type SearchListProps = {
+ searchData: SearchDataType[];
+};
+
+export default function SearchList({ searchData }: SearchListProps) {
+ if (searchData.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {searchData.map((data, index) => (
+
+ ))}
+
+ );
+}
diff --git a/FE/src/components/Search/index.tsx b/FE/src/components/Search/index.tsx
new file mode 100644
index 00000000..0173c5b1
--- /dev/null
+++ b/FE/src/components/Search/index.tsx
@@ -0,0 +1,86 @@
+import { useEffect } from 'react';
+import useSearchModalStore from 'store/useSearchModalStore';
+import Overlay from 'components/ModalOveray.tsx';
+import { SearchInput } from './SearchInput';
+import { SearchHistoryList } from './SearchHistoryList';
+import SearchList from './SearchList.tsx';
+import useSearchInputStore from 'store/useSearchInputStore.ts';
+import { useDebounce } from 'utils/useDebounce.ts';
+import { useQuery } from '@tanstack/react-query';
+import { getSearchResults } from 'service/getSearchResults.ts';
+import Lottie from 'lottie-react';
+import searchAnimation from 'assets/searchAnimation.json';
+import { useSearchHistory } from './searchHistoryHook.ts';
+
+export default function SearchModal() {
+ const { isOpen, toggleSearchModal } = useSearchModalStore();
+ const { searchInput, setSearchInput } = useSearchInputStore();
+ const { searchHistory, addSearchHistory, deleteSearchHistory } =
+ useSearchHistory();
+ const shouldSearch = searchInput.trim().length >= 2;
+
+ const { debounceValue, isDebouncing } = useDebounce(
+ shouldSearch ? searchInput : '',
+ 500,
+ );
+
+ const { data, isLoading, isFetching } = useQuery({
+ queryKey: ['search', debounceValue],
+ queryFn: () => getSearchResults(debounceValue),
+ enabled: !!debounceValue && !isDebouncing,
+ });
+
+ useEffect(() => {
+ if (data && data.length > 0 && debounceValue && !isLoading) {
+ addSearchHistory(debounceValue);
+ }
+ }, [data, debounceValue]);
+
+ if (!isOpen) return null;
+
+ const isSearching = isLoading || isFetching || isDebouncing;
+ const showSearchResults = searchInput && !isSearching && data;
+
+ return (
+ <>
+ toggleSearchModal()} />
+
+
+
+
+
+
+ {' '}
+ {!searchInput ? (
+
+ ) : (
+
+
+ 검색 결과
+
+
+
+ {isSearching ? (
+
+
+
+ ) : (
+ showSearchResults &&
+ )}
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/FE/src/components/Search/searchDataType.ts b/FE/src/components/Search/searchDataType.ts
new file mode 100644
index 00000000..76486c7f
--- /dev/null
+++ b/FE/src/components/Search/searchDataType.ts
@@ -0,0 +1,11 @@
+export type SearchDataType = {
+ code: string;
+ name: string;
+ market: string;
+};
+
+export type HistoryType = {
+ id: string;
+ text: string;
+ timestamp: string;
+};
diff --git a/FE/src/components/Search/searchHistoryHook.ts b/FE/src/components/Search/searchHistoryHook.ts
new file mode 100644
index 00000000..63d1e52f
--- /dev/null
+++ b/FE/src/components/Search/searchHistoryHook.ts
@@ -0,0 +1,48 @@
+import { useState, useEffect } from 'react';
+import { HistoryType } from './searchDataType';
+
+const STORAGE_KEY = import.meta.env.VITE_STORAGE_KEY;
+const MAX_HISTORY_ITEMS = import.meta.env.VITE_MAX_HISTORY_ITEMS;
+export function useSearchHistory() {
+ const [searchHistory, setSearchHistory] = useState([]);
+
+ useEffect(() => {
+ const storedHistory = localStorage.getItem(STORAGE_KEY);
+ if (storedHistory) {
+ setSearchHistory(JSON.parse(storedHistory));
+ }
+ }, []);
+
+ const addSearchHistory = (keyword: string) => {
+ if (!keyword.trim()) return;
+
+ setSearchHistory((prev) => {
+ const filteredHistory = prev.filter((item) => item.text !== keyword);
+ const newItem: HistoryType = {
+ id: `${keyword}-${Date.now()}`,
+ text: keyword,
+ timestamp: new Date().toISOString(),
+ };
+ const newHistory = [newItem, ...filteredHistory].slice(
+ 0,
+ MAX_HISTORY_ITEMS,
+ );
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory));
+ return newHistory;
+ });
+ };
+
+ const deleteSearchHistory = (text: string) => {
+ setSearchHistory((prev) => {
+ const newHistory = prev.filter((item) => item.text !== text);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory));
+ return newHistory;
+ });
+ };
+
+ return {
+ searchHistory,
+ addSearchHistory,
+ deleteSearchHistory,
+ };
+}
diff --git a/FE/src/components/StockIndex/Card.tsx b/FE/src/components/StockIndex/Card.tsx
new file mode 100644
index 00000000..b351b25d
--- /dev/null
+++ b/FE/src/components/StockIndex/Card.tsx
@@ -0,0 +1,61 @@
+import {
+ ChartData,
+ StockIndexData,
+ StockIndexValue,
+} from 'components/TopFive/type';
+import { useEffect, useRef, useState } from 'react';
+import { socket } from 'utils/socket.ts';
+import { drawChart } from 'utils/chart';
+
+// const X_LENGTH = 79;
+
+type StockIndexChartProps = {
+ name: string;
+ id: 'KOSPI' | 'KOSDAQ' | 'KOSPI200' | 'KSQ150';
+ initialData: StockIndexData;
+};
+
+export function Card({ name, id, initialData }: StockIndexChartProps) {
+ const { chart, value } = initialData;
+ const [prices, setPrices] = useState(chart);
+ const [stockIndexValue, setStockIndexValue] =
+ useState(value);
+ const canvasRef = useRef(null);
+
+ const changeColor =
+ Number(value.diff) > 0 ? 'text-juga-red-60' : 'text-juga-blue-50';
+
+ socket.on(id, (stockIndex) => {
+ setStockIndexValue(stockIndex);
+ });
+
+ socket.on('chart', (chartData) => {
+ setPrices(chartData[id]);
+ });
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const ctx = canvas?.getContext('2d');
+ if (!ctx) return;
+
+ drawChart(ctx, prices, 79);
+ }, [prices]);
+
+ return (
+
+
+
{name}
+
{stockIndexValue.curr_value}
+
+ {stockIndexValue.diff}({stockIndexValue.diff_rate}%)
+
+
+
+
+ );
+}
diff --git a/FE/src/components/StockIndex/Chart.tsx b/FE/src/components/StockIndex/Chart.tsx
deleted file mode 100644
index 2ab43205..00000000
--- a/FE/src/components/StockIndex/Chart.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { useEffect, useRef, useState } from 'react';
-import { drawChart } from 'utils/chart';
-
-const X_LENGTH = 79;
-
-type StockIndexChartProps = {
- name: string;
-};
-
-export function Chart({ name }: StockIndexChartProps) {
- const [prices, setPrices] = useState([50, 54]);
- const canvasRef = useRef(null);
-
- useEffect(() => {
- const interval = setInterval(() => {
- if (prices.length === X_LENGTH) {
- clearInterval(interval);
- return;
- }
- setPrices((prev) => [...prev, Math.floor(Math.random() * 50) + 25]);
- }, 500);
-
- return () => clearInterval(interval);
- }, [prices.length]);
-
- useEffect(() => {
- const canvas = canvasRef.current;
- const ctx = canvas?.getContext('2d');
- if (!ctx) return;
-
- drawChart(ctx, prices);
- }, [prices]);
-
- return (
-
-
-
{name}
-
2562.4
-
-31.55(-1.2%)
-
-
-
- );
-}
diff --git a/FE/src/components/StockIndex/index.tsx b/FE/src/components/StockIndex/index.tsx
index a5fead6b..133d04c7 100644
--- a/FE/src/components/StockIndex/index.tsx
+++ b/FE/src/components/StockIndex/index.tsx
@@ -1,10 +1,25 @@
-import { Chart } from './Chart';
+import { Card } from './Card.tsx';
+import { useQuery } from '@tanstack/react-query';
export default function StockIndex() {
+ const { data, isLoading } = useQuery({
+ queryKey: ['StockIndex'],
+ queryFn: () =>
+ fetch(`${import.meta.env.VITE_API_URL}/stocks/index`).then((res) =>
+ res.json(),
+ ),
+ });
+
+ if (isLoading) return;
+
+ const { KOSPI, KOSDAQ, KOSPI200, KSQ150 } = data;
+
return (
-
-
-
+
+
+
+
+
);
}
diff --git a/FE/src/components/StocksDetail/Chart.tsx b/FE/src/components/StocksDetail/Chart.tsx
new file mode 100644
index 00000000..5179ed1b
--- /dev/null
+++ b/FE/src/components/StocksDetail/Chart.tsx
@@ -0,0 +1,104 @@
+import { useEffect, useRef, useState } from 'react';
+import { TiemCategory } from 'types';
+import { drawBarChart, drawCandleChart, drawLineChart } from 'utils/chart';
+import { useQuery } from '@tanstack/react-query';
+import { getStocksChartDataByCode } from 'service/stocks';
+
+const categories: { label: string; value: TiemCategory }[] = [
+ { label: '일', value: 'D' },
+ { label: '주', value: 'W' },
+ { label: '월', value: 'M' },
+ { label: '년', value: 'Y' },
+];
+
+type StocksDeatailChartProps = {
+ code: string;
+};
+
+export default function Chart({ code }: StocksDeatailChartProps) {
+ const containerRef = useRef
(null);
+ const canvasRef = useRef(null);
+ const [timeCategory, setTimeCategory] = useState('D');
+
+ const { data, isLoading } = useQuery(
+ ['stocksChartData', code, timeCategory],
+ () => getStocksChartDataByCode(code, timeCategory),
+ );
+
+ useEffect(() => {
+ if (isLoading) return;
+ if (!data) return;
+
+ const parent = containerRef.current;
+ const canvas = canvasRef.current;
+
+ if (!canvas || !parent) return;
+
+ const displayWidth = parent.clientWidth;
+ const displayHeight = parent.clientHeight;
+
+ // 해상도 높이기
+ canvas.width = displayWidth * 2;
+ canvas.height = displayHeight * 2;
+
+ canvas.style.width = `${displayWidth}px`;
+ canvas.style.height = `${displayHeight * 0.8}px`;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ ctx.fillStyle = 'white';
+
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ const padding = {
+ top: 20,
+ right: 80,
+ bottom: 10,
+ left: 20,
+ };
+
+ const chartWidth = canvas.width - padding.left - padding.right;
+ const chartHeight = canvas.height - padding.top - padding.bottom;
+ const boundary = chartHeight * 0.8; // chartHeight의 80%
+
+ const reverseData = data.reverse();
+
+ const arr = reverseData.map((e) => +e.stck_oprc);
+
+ drawLineChart(ctx, arr, 0, 0, chartWidth, boundary, padding, 0.1);
+ drawBarChart(
+ ctx,
+ reverseData,
+ 0,
+ boundary,
+ chartWidth,
+ chartHeight,
+ padding,
+ );
+ drawCandleChart(ctx, reverseData, 0, 0, chartWidth, boundary, padding, 0.1);
+ }, [timeCategory, data, isLoading]);
+
+ return (
+
+
+
차트
+
+
+
+
+ );
+}
diff --git a/FE/src/components/StocksDetail/Header.tsx b/FE/src/components/StocksDetail/Header.tsx
new file mode 100644
index 00000000..55b26de7
--- /dev/null
+++ b/FE/src/components/StocksDetail/Header.tsx
@@ -0,0 +1,63 @@
+import { useQuery } from '@tanstack/react-query';
+import { getStocksByCode } from 'service/stocks';
+
+type StocksDeatailHeaderProps = {
+ code: string;
+};
+
+export default function Header({ code }: StocksDeatailHeaderProps) {
+ const { data, isLoading } = useQuery(['stocks', code], () =>
+ getStocksByCode(code),
+ );
+
+ if (isLoading) return;
+ if (!data) return;
+
+ const {
+ hts_kor_isnm,
+ stck_prpr,
+ prdy_vrss,
+ prdy_vrss_sign,
+ prdy_ctrt,
+ hts_avls,
+ per,
+ } = data;
+
+ const stockInfo: { label: string; value: string }[] = [
+ { label: '시총', value: `${Number(hts_avls).toLocaleString()}억원` },
+ { label: 'PER', value: `${per}배` },
+ ];
+
+ const colorStyleBySign =
+ prdy_vrss_sign === '3'
+ ? ''
+ : prdy_vrss_sign < '3'
+ ? 'text-juga-red-60'
+ : 'text-juga-blue-40';
+
+ return (
+
+
+
+
{hts_kor_isnm}
+
{code}
+
+
+
{Number(stck_prpr).toLocaleString()}원
+
어제보다
+
+ +{Number(prdy_vrss).toLocaleString()}원 ({prdy_ctrt}%)
+
+
+
+
+ {stockInfo.map((e, idx) => (
+
+
{e.label}
+
{e.value}
+
+ ))}
+
+
+ );
+}
diff --git a/FE/src/components/StocksDetail/PriceDataType.ts b/FE/src/components/StocksDetail/PriceDataType.ts
new file mode 100644
index 00000000..b9d3302e
--- /dev/null
+++ b/FE/src/components/StocksDetail/PriceDataType.ts
@@ -0,0 +1,18 @@
+export type PriceDataType = {
+ stck_cntg_hour: string;
+ stck_prpr: 'string';
+ prdy_vrss_sign: 'string';
+ cntg_vol: 'string';
+ prdy_ctrt: 'string';
+};
+
+export type DailyPriceDataType = {
+ stck_bsop_date: string;
+ stck_oprc: string;
+ stck_hgpr: string;
+ stck_lwpr: string;
+ stck_clpr: string;
+ acml_vol: string;
+ prdy_vrss_sign: string;
+ prdy_ctrt: string;
+};
diff --git a/FE/src/components/StocksDetail/PriceSection.tsx b/FE/src/components/StocksDetail/PriceSection.tsx
new file mode 100644
index 00000000..ed2edb46
--- /dev/null
+++ b/FE/src/components/StocksDetail/PriceSection.tsx
@@ -0,0 +1,116 @@
+import { useEffect, useRef, useState } from 'react';
+import PriceTableColumn from './PriceTableColumn.tsx';
+import PriceTableLiveCard from './PriceTableLiveCard.tsx';
+import PriceTableDayCard from './PriceTableDayCard.tsx';
+import { useParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { DailyPriceDataType, PriceDataType } from './PriceDataType.ts';
+import { getTradeHistory } from 'service/getTradeHistory.ts';
+
+export default function PriceSection() {
+ const [buttonFlag, setButtonFlag] = useState(true);
+ const indicatorRef = useRef(null);
+ const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
+ const { id } = useParams();
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['detail', id, buttonFlag],
+ queryFn: () => getTradeHistory(id as string, buttonFlag),
+ refetchInterval: 1000,
+ cacheTime: 30000,
+ staleTime: 1000,
+ });
+
+ useEffect(() => {
+ const tmpIndex = buttonFlag ? 0 : 1;
+ const currentButton = buttonRefs.current[tmpIndex];
+ const indicator = indicatorRef.current;
+
+ if (currentButton && indicator) {
+ indicator.style.left = `${currentButton.offsetLeft}px`;
+ indicator.style.width = `${currentButton.offsetWidth}px`;
+ }
+ }, [buttonFlag]);
+
+ return (
+
+
+ 일별 · 실시간 시세
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ Loading... |
+
+ ) : !data ? (
+
+ No data available |
+
+ ) : buttonFlag ? (
+ data.map((eachData: PriceDataType, index: number) => (
+
+ ))
+ ) : (
+ data.map((eachData: DailyPriceDataType, index: number) => (
+
+ ))
+ )}
+
+
+
+
+
+ );
+}
diff --git a/FE/src/components/StocksDetail/PriceTableColumn.tsx b/FE/src/components/StocksDetail/PriceTableColumn.tsx
new file mode 100644
index 00000000..5b8fb733
--- /dev/null
+++ b/FE/src/components/StocksDetail/PriceTableColumn.tsx
@@ -0,0 +1,32 @@
+type Props = {
+ viewMode: boolean;
+};
+export default function PriceTableColumn({ viewMode }: Props) {
+ if (!viewMode) {
+ return (
+
+
+ 일자 |
+ 종가 |
+ 등락률 |
+ 거래량(주) |
+ {/*거래대금 | */}
+ 시가 |
+ 고가 |
+ 저가 |
+
+
+ );
+ }
+ return (
+
+
+ 채결가 |
+ 채결량(주) |
+ 등락률 |
+ {/*거래량(주) | */}
+ 시간 |
+
+
+ );
+}
diff --git a/FE/src/components/StocksDetail/PriceTableDayCard.tsx b/FE/src/components/StocksDetail/PriceTableDayCard.tsx
new file mode 100644
index 00000000..68d5086e
--- /dev/null
+++ b/FE/src/components/StocksDetail/PriceTableDayCard.tsx
@@ -0,0 +1,42 @@
+import { DailyPriceDataType } from './PriceDataType.ts';
+
+type PriceTableDayCardProps = {
+ data: DailyPriceDataType;
+};
+
+export default function PriceTableDayCard({ data }: PriceTableDayCardProps) {
+ const percent = Number(data.prdy_ctrt);
+ const color = percent > 0 ? 'text-juga-red-60' : 'text-juga-blue-50';
+ function formatTime(time: string) {
+ if (!time.length) return '----.--.--';
+ const year = time.slice(0, 4);
+ const mon = time.slice(4, 6);
+ const day = time.slice(6, 8);
+ return `${year}.${mon}.${day}`;
+ }
+ return (
+
+
+ {formatTime(data.stck_bsop_date)}
+ |
+
+ {Number(data.stck_clpr).toLocaleString()}
+ |
+
+ {percent > 0 ? `+${percent}%` : `${percent}%`}
+ |
+
+ {Number(data.acml_vol).toLocaleString()}
+ |
+
+ {Number(data.stck_oprc).toLocaleString()}
+ |
+
+ {Number(data.stck_hgpr).toLocaleString()}
+ |
+
+ {Number(data.stck_lwpr).toLocaleString()}
+ |
+
+ );
+}
diff --git a/FE/src/components/StocksDetail/PriceTableLiveCard.tsx b/FE/src/components/StocksDetail/PriceTableLiveCard.tsx
new file mode 100644
index 00000000..cbce8628
--- /dev/null
+++ b/FE/src/components/StocksDetail/PriceTableLiveCard.tsx
@@ -0,0 +1,30 @@
+import { PriceDataType } from './PriceDataType.ts';
+
+type PriceTableLiveCardProps = {
+ data: PriceDataType;
+};
+export default function PriceTableLiveCard({ data }: PriceTableLiveCardProps) {
+ const percent = Number(data.prdy_ctrt);
+ const color = percent > 0 ? 'text-juga-red-60' : 'text-juga-blue-50';
+ function formatTime(time: string) {
+ const hour = time.slice(0, 2);
+ const min = time.slice(2, 4);
+ const sec = time.slice(4, 6);
+ return `${hour}.${min}.${sec}`;
+ }
+ return (
+
+
+ {Number(data.stck_prpr).toLocaleString()}
+ |
+ {data.cntg_vol} |
+
+ {percent > 0 ? `+${percent}` : `${percent}`}
+ |
+ {/*거래량 갯수 | */}
+
+ {formatTime(data.stck_cntg_hour)}
+ |
+
+ );
+}
diff --git a/FE/src/components/StocksDetail/TradeSection.tsx b/FE/src/components/StocksDetail/TradeSection.tsx
new file mode 100644
index 00000000..64d659fe
--- /dev/null
+++ b/FE/src/components/StocksDetail/TradeSection.tsx
@@ -0,0 +1,88 @@
+import Lottie from 'lottie-react';
+import { useEffect, useRef, useState } from 'react';
+import emptyAnimation from 'assets/emptyAnimation.json';
+
+export default function TradeSection() {
+ const [category, setCategory] = useState<'buy' | 'sell'>('buy');
+ const indicatorRef = useRef(null);
+ const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
+
+ useEffect(() => {
+ const idx = category === 'buy' ? 0 : 1;
+ const currentButton = buttonRefs.current[idx];
+ const indicator = indicatorRef.current;
+
+ if (currentButton && indicator) {
+ indicator.style.left = `${currentButton.offsetLeft}px`;
+ indicator.style.width = `${currentButton.offsetWidth}px`;
+ }
+ }, [category]);
+
+ return (
+
+ 주문하기
+
+
+
+
+
+ {category === 'buy' ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/FE/src/components/StocksDetail/dummy.ts b/FE/src/components/StocksDetail/dummy.ts
new file mode 100644
index 00000000..ec2288da
--- /dev/null
+++ b/FE/src/components/StocksDetail/dummy.ts
@@ -0,0 +1,611 @@
+export type DummyStock = {
+ date: string;
+ open: number;
+ close: number;
+ high: number;
+ low: number;
+ volume: number;
+};
+
+export const dummy: DummyStock[] = [
+ {
+ date: '2024-11-07',
+ open: 58000,
+ close: 57000,
+ high: 58300,
+ low: 57000,
+ volume: 13451844,
+ },
+ {
+ date: '2024-11-06',
+ open: 56900,
+ close: 57500,
+ high: 58100,
+ low: 56800,
+ volume: 16951844,
+ },
+ {
+ date: '2024-11-05',
+ open: 57600,
+ close: 57300,
+ high: 58000,
+ low: 56300,
+ volume: 21901844,
+ },
+ {
+ date: '2024-11-04',
+ open: 57800,
+ close: 57600,
+ high: 58100,
+ low: 57200,
+ volume: 17291844,
+ },
+ {
+ date: '2024-11-03',
+ open: 58600,
+ close: 58700,
+ high: 59400,
+ low: 58400,
+ volume: 15471844,
+ },
+ {
+ date: '2024-10-31',
+ open: 59000,
+ close: 58300,
+ high: 59600,
+ low: 58100,
+ volume: 18831844,
+ },
+ {
+ date: '2024-10-30',
+ open: 58500,
+ close: 59200,
+ high: 61200,
+ low: 58300,
+ volume: 34901844,
+ },
+ {
+ date: '2024-10-29',
+ open: 59100,
+ close: 59100,
+ high: 59800,
+ low: 58600,
+ volume: 19181844,
+ },
+ {
+ date: '2024-10-28',
+ open: 58000,
+ close: 59600,
+ high: 59600,
+ low: 57300,
+ volume: 27871844,
+ },
+ {
+ date: '2024-10-27',
+ open: 55700,
+ close: 58100,
+ high: 58500,
+ low: 55700,
+ volume: 27571844,
+ },
+ {
+ date: '2024-10-24',
+ open: 56000,
+ close: 55900,
+ high: 56900,
+ low: 55800,
+ volume: 25511844,
+ },
+ {
+ date: '2024-10-23',
+ open: 58200,
+ close: 56600,
+ high: 58500,
+ low: 56600,
+ volume: 30701844,
+ },
+ {
+ date: '2024-10-22',
+ open: 57500,
+ close: 59100,
+ high: 60000,
+ low: 57100,
+ volume: 27111844,
+ },
+ {
+ date: '2024-10-21',
+ open: 58800,
+ close: 57700,
+ high: 58900,
+ low: 57700,
+ volume: 26881844,
+ },
+ {
+ date: '2024-10-20',
+ open: 59000,
+ close: 59000,
+ high: 59600,
+ low: 58500,
+ volume: 18331844,
+ },
+ {
+ date: '2024-10-17',
+ open: 59900,
+ close: 59200,
+ high: 60100,
+ low: 59100,
+ volume: 14171844,
+ },
+ {
+ date: '2024-10-16',
+ open: 59400,
+ close: 59700,
+ high: 60100,
+ low: 59100,
+ volume: 23001844,
+ },
+ {
+ date: '2024-10-15',
+ open: 59400,
+ close: 59500,
+ high: 60000,
+ low: 59200,
+ volume: 23021844,
+ },
+ {
+ date: '2024-10-14',
+ open: 61100,
+ close: 61000,
+ high: 61400,
+ low: 60100,
+ volume: 20651844,
+ },
+ {
+ date: '2024-10-13',
+ open: 59500,
+ close: 60800,
+ high: 61200,
+ low: 59400,
+ volume: 20371844,
+ },
+ {
+ date: '2024-10-10',
+ open: 59100,
+ close: 59300,
+ high: 60100,
+ low: 59000,
+ volume: 28901844,
+ },
+ {
+ date: '2024-10-09',
+ open: 60100,
+ close: 58900,
+ high: 60200,
+ low: 58900,
+ volume: 44601844,
+ },
+ {
+ date: '2024-10-08',
+ open: 60000,
+ close: 60100,
+ high: 60500,
+ low: 59800,
+ volume: 18631844,
+ },
+ {
+ date: '2024-10-07',
+ open: 59800,
+ close: 60000,
+ high: 60300,
+ low: 59500,
+ volume: 17651844,
+ },
+ {
+ date: '2024-10-06',
+ open: 59500,
+ close: 59800,
+ high: 60000,
+ low: 59300,
+ volume: 16671844,
+ },
+ {
+ date: '2024-10-03',
+ open: 59000,
+ close: 59200,
+ high: 59500,
+ low: 58800,
+ volume: 16671844,
+ },
+ {
+ date: '2024-10-02',
+ open: 59500,
+ close: 59000,
+ high: 59800,
+ low: 58800,
+ volume: 17651844,
+ },
+ {
+ date: '2024-10-01',
+ open: 60000,
+ close: 59500,
+ high: 60200,
+ low: 59300,
+ volume: 18631844,
+ },
+ {
+ date: '2024-09-30',
+ open: 60500,
+ close: 60000,
+ high: 60800,
+ low: 59800,
+ volume: 19611844,
+ },
+ {
+ date: '2024-09-27',
+ open: 61000,
+ close: 60500,
+ high: 61200,
+ low: 60300,
+ volume: 20591844,
+ },
+ {
+ date: '2024-09-26',
+ open: 61500,
+ close: 61000,
+ high: 61800,
+ low: 60800,
+ volume: 21571844,
+ },
+ {
+ date: '2024-09-25',
+ open: 62000,
+ close: 61500,
+ high: 62300,
+ low: 61300,
+ volume: 22551844,
+ },
+ {
+ date: '2024-09-24',
+ open: 62500,
+ close: 62000,
+ high: 62800,
+ low: 61800,
+ volume: 23531844,
+ },
+ {
+ date: '2024-09-23',
+ open: 63000,
+ close: 62500,
+ high: 63300,
+ low: 62300,
+ volume: 24511844,
+ },
+ {
+ date: '2024-09-20',
+ open: 63500,
+ close: 63000,
+ high: 63800,
+ low: 62800,
+ volume: 25491844,
+ },
+ {
+ date: '2024-09-19',
+ open: 64000,
+ close: 63500,
+ high: 64300,
+ low: 63300,
+ volume: 26471844,
+ },
+ {
+ date: '2024-09-18',
+ open: 64500,
+ close: 64000,
+ high: 64800,
+ low: 63800,
+ volume: 27451844,
+ },
+ {
+ date: '2024-09-17',
+ open: 65000,
+ close: 64500,
+ high: 65300,
+ low: 64300,
+ volume: 28431844,
+ },
+ {
+ date: '2024-09-16',
+ open: 65500,
+ close: 65000,
+ high: 65800,
+ low: 64800,
+ volume: 29411844,
+ },
+ {
+ date: '2024-09-13',
+ open: 66000,
+ close: 65500,
+ high: 66300,
+ low: 65300,
+ volume: 30391844,
+ },
+ {
+ date: '2024-09-12',
+ open: 66500,
+ close: 66000,
+ high: 66800,
+ low: 65800,
+ volume: 31371844,
+ },
+ {
+ date: '2024-09-11',
+ open: 67000,
+ close: 66500,
+ high: 67300,
+ low: 66300,
+ volume: 32351844,
+ },
+ {
+ date: '2024-09-10',
+ open: 67500,
+ close: 67000,
+ high: 67800,
+ low: 66800,
+ volume: 33331844,
+ },
+ {
+ date: '2024-09-09',
+ open: 68000,
+ close: 67500,
+ high: 68300,
+ low: 67300,
+ volume: 34311844,
+ },
+ {
+ date: '2024-09-06',
+ open: 68500,
+ close: 68000,
+ high: 68800,
+ low: 67800,
+ volume: 35291844,
+ },
+ {
+ date: '2024-09-05',
+ open: 69000,
+ close: 68500,
+ high: 69300,
+ low: 68300,
+ volume: 36271844,
+ },
+ {
+ date: '2024-09-04',
+ open: 69500,
+ close: 69000,
+ high: 69800,
+ low: 68800,
+ volume: 37251844,
+ },
+ {
+ date: '2024-09-03',
+ open: 70000,
+ close: 69500,
+ high: 70300,
+ low: 69300,
+ volume: 38231844,
+ },
+ {
+ date: '2024-09-02',
+ open: 70500,
+ close: 70000,
+ high: 70800,
+ low: 69800,
+ volume: 39211844,
+ },
+ {
+ date: '2024-08-30',
+ open: 71000,
+ close: 70500,
+ high: 71300,
+ low: 70300,
+ volume: 40191844,
+ },
+ {
+ date: '2024-08-29',
+ open: 71500,
+ close: 71000,
+ high: 71800,
+ low: 70800,
+ volume: 40191844,
+ },
+ {
+ date: '2024-08-28',
+ open: 72000,
+ close: 71500,
+ high: 72300,
+ low: 71300,
+ volume: 39171844,
+ },
+ {
+ date: '2024-08-27',
+ open: 72500,
+ close: 72000,
+ high: 72800,
+ low: 71800,
+ volume: 38151844,
+ },
+ {
+ date: '2024-08-26',
+ open: 73000,
+ close: 72500,
+ high: 73300,
+ low: 72300,
+ volume: 37131844,
+ },
+ {
+ date: '2024-08-23',
+ open: 73500,
+ close: 73000,
+ high: 73800,
+ low: 72800,
+ volume: 36111844,
+ },
+ {
+ date: '2024-08-22',
+ open: 74000,
+ close: 73500,
+ high: 74300,
+ low: 73300,
+ volume: 35091844,
+ },
+ {
+ date: '2024-08-21',
+ open: 74500,
+ close: 74000,
+ high: 74800,
+ low: 73800,
+ volume: 34071844,
+ },
+ {
+ date: '2024-08-20',
+ open: 75000,
+ close: 74500,
+ high: 75300,
+ low: 74300,
+ volume: 33051844,
+ },
+ {
+ date: '2024-08-19',
+ open: 75500,
+ close: 75000,
+ high: 75800,
+ low: 74800,
+ volume: 32031844,
+ },
+ {
+ date: '2024-08-16',
+ open: 76000,
+ close: 75500,
+ high: 76300,
+ low: 75300,
+ volume: 31011844,
+ },
+ {
+ date: '2024-08-15',
+ open: 76500,
+ close: 76000,
+ high: 76800,
+ low: 75800,
+ volume: 29991844,
+ },
+ {
+ date: '2024-08-14',
+ open: 77000,
+ close: 76500,
+ high: 77300,
+ low: 76300,
+ volume: 28971844,
+ },
+ {
+ date: '2024-08-13',
+ open: 77500,
+ close: 77000,
+ high: 77800,
+ low: 76800,
+ volume: 27951844,
+ },
+ {
+ date: '2024-08-12',
+ open: 78000,
+ close: 77500,
+ high: 78300,
+ low: 77300,
+ volume: 26931844,
+ },
+ {
+ date: '2024-08-09',
+ open: 78500,
+ close: 78000,
+ high: 78800,
+ low: 77800,
+ volume: 25911844,
+ },
+ {
+ date: '2024-08-08',
+ open: 79000,
+ close: 78500,
+ high: 79300,
+ low: 78300,
+ volume: 24891844,
+ },
+ {
+ date: '2024-08-07',
+ open: 79500,
+ close: 79000,
+ high: 79800,
+ low: 78800,
+ volume: 23871844,
+ },
+ {
+ date: '2024-08-06',
+ open: 80000,
+ close: 79500,
+ high: 80300,
+ low: 79300,
+ volume: 22851844,
+ },
+ {
+ date: '2024-08-05',
+ open: 80500,
+ close: 80000,
+ high: 80800,
+ low: 79800,
+ volume: 21831844,
+ },
+ {
+ date: '2024-08-02',
+ open: 81000,
+ close: 80500,
+ high: 81300,
+ low: 80300,
+ volume: 20811844,
+ },
+ {
+ date: '2024-08-01',
+ open: 81500,
+ close: 81000,
+ high: 81800,
+ low: 80800,
+ volume: 19791844,
+ },
+ {
+ date: '2024-07-31',
+ open: 82000,
+ close: 81500,
+ high: 82300,
+ low: 81300,
+ volume: 18771844,
+ },
+ {
+ date: '2024-07-30',
+ open: 82500,
+ close: 82000,
+ high: 82800,
+ low: 81800,
+ volume: 17751844,
+ },
+ {
+ date: '2024-07-29',
+ open: 83000,
+ close: 82500,
+ high: 83300,
+ low: 82300,
+ volume: 16731844,
+ },
+ {
+ date: '2024-07-26',
+ open: 83500,
+ close: 83000,
+ high: 83800,
+ low: 82800,
+ volume: 15711844,
+ },
+].reverse();
diff --git a/FE/src/components/TopFive/Card.tsx b/FE/src/components/TopFive/Card.tsx
index 3ca284e1..3355f272 100644
--- a/FE/src/components/TopFive/Card.tsx
+++ b/FE/src/components/TopFive/Card.tsx
@@ -21,21 +21,21 @@ export default function Card({
changeValue > 0 ? 'text-juga-red-60' : 'text-juga-blue-50';
return (
-
-
{index + 1}
+
+
{index + 1}
- {price?.toLocaleString()}
+ {Number(price).toLocaleString()}
{changeValue > 0
- ? `${changePrice}(${changeValue}%)`
- : `${changePrice}(${Math.abs(changeValue)}%)`}
+ ? `${Number(changePrice).toLocaleString()}(${changeValue}%)`
+ : `${Number(changePrice).toLocaleString()}(${Math.abs(changeValue)}%)`}
diff --git a/FE/src/components/TopFive/List.tsx b/FE/src/components/TopFive/List.tsx
index cc53b801..a5690518 100644
--- a/FE/src/components/TopFive/List.tsx
+++ b/FE/src/components/TopFive/List.tsx
@@ -10,10 +10,8 @@ type ListProps = {
export default function List({ listTitle, data, isLoading }: ListProps) {
return (
-
-
- {listTitle}
-
+
+
{listTitle}
종목
현재가
@@ -26,7 +24,10 @@ export default function List({ listTitle, data, isLoading }: ListProps) {
))
: data.map((stock: StockData, index) => (
-
+
(buttonRefs.current[index] = el)}
onClick={() => handleMarketChange(market)}
- className={`relative px-2 py-2`}
+ className={'relative px-2 py-2'}
>
{market}
diff --git a/FE/src/components/TopFive/TopFive.tsx b/FE/src/components/TopFive/TopFive.tsx
index 90f54a63..06006305 100644
--- a/FE/src/components/TopFive/TopFive.tsx
+++ b/FE/src/components/TopFive/TopFive.tsx
@@ -3,6 +3,7 @@ import Nav from './Nav';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { MarketType } from './type.ts';
+import { getTopFiveStocks } from '../../service/getTopFiveStocks.ts';
const paramsMap = {
전체: 'ALL',
@@ -10,23 +11,21 @@ const paramsMap = {
코스닥: 'KOSDAQ',
코스피200: 'KOSPI200',
};
-
export default function TopFive() {
const [searchParams] = useSearchParams();
const currentMarket = (searchParams.get('top') || '전체') as MarketType;
const { data, isLoading } = useQuery({
queryKey: ['topfive', currentMarket],
- queryFn: () =>
- fetch(
- `http://223.130.151.42:3000/api/stocks/topfive?market=${paramsMap[currentMarket]}`,
- ).then((res) => res.json()),
+ queryFn: () => getTopFiveStocks(paramsMap[currentMarket]),
keepPreviousData: true,
+ cacheTime: 30000,
+ refetchInterval: 1000,
});
return (
-
+
-
-
-
-
-
-
+
+
>
);
}
diff --git a/FE/src/page/StocksDetail.tsx b/FE/src/page/StocksDetail.tsx
new file mode 100644
index 00000000..5ad96d4d
--- /dev/null
+++ b/FE/src/page/StocksDetail.tsx
@@ -0,0 +1,25 @@
+import Chart from 'components/StocksDetail/Chart';
+import Header from 'components/StocksDetail/Header';
+import PriceSection from 'components/StocksDetail/PriceSection';
+import TradeSection from 'components/StocksDetail/TradeSection';
+import { useParams } from 'react-router-dom';
+
+export default function StocksDetail() {
+ const params = useParams();
+ const { id } = params;
+
+ if (!id) return;
+
+ return (
+
+ );
+}
diff --git a/FE/src/service/auth.ts b/FE/src/service/auth.ts
index e4ecb640..fd0c555f 100644
--- a/FE/src/service/auth.ts
+++ b/FE/src/service/auth.ts
@@ -4,7 +4,7 @@ export async function login(
email: string,
password: string,
): Promise {
- return fetch('http://223.130.151.42:3000/auth/login', {
+ return fetch(`${import.meta.env.VITE_API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
diff --git a/FE/src/service/getSearchResults.ts b/FE/src/service/getSearchResults.ts
new file mode 100644
index 00000000..37e13079
--- /dev/null
+++ b/FE/src/service/getSearchResults.ts
@@ -0,0 +1,11 @@
+export const getSearchResults = async (inputWord: string) => {
+ if (!inputWord) return null;
+
+ const response = await fetch(
+ `${import.meta.env.VITE_API_URL}/stocks/list/search?name=${inputWord}`,
+ );
+ if (!response.ok) {
+ throw new Error('Network response was not ok');
+ }
+ return response.json();
+};
diff --git a/FE/src/service/getTopFiveStocks.ts b/FE/src/service/getTopFiveStocks.ts
new file mode 100644
index 00000000..933420b6
--- /dev/null
+++ b/FE/src/service/getTopFiveStocks.ts
@@ -0,0 +1,9 @@
+export const getTopFiveStocks = async (market: string) => {
+ const response = await fetch(
+ `${import.meta.env.VITE_API_URL}/stocks/topfive?market=${market}`,
+ );
+ if (!response.ok) {
+ throw new Error('Network response was not ok');
+ }
+ return response.json();
+};
diff --git a/FE/src/service/getTradeHistory.ts b/FE/src/service/getTradeHistory.ts
new file mode 100644
index 00000000..db5a22cd
--- /dev/null
+++ b/FE/src/service/getTradeHistory.ts
@@ -0,0 +1,9 @@
+export const getTradeHistory = async (id: string, buttonFlag: boolean) => {
+ const response = await fetch(
+ `${import.meta.env.VITE_API_URL}/stocks/trade-history/${id}/${buttonFlag ? 'today' : 'daily'}`,
+ );
+ if (!response.ok) {
+ throw new Error('Network response was not ok');
+ }
+ return response.json();
+};
diff --git a/FE/src/service/stocks.ts b/FE/src/service/stocks.ts
new file mode 100644
index 00000000..9f5d4bb4
--- /dev/null
+++ b/FE/src/service/stocks.ts
@@ -0,0 +1,24 @@
+import { StockChartUnit, StockDetailType, TiemCategory } from 'types';
+
+export async function getStocksByCode(code: string): Promise {
+ return fetch(`${import.meta.env.VITE_API_URL}/stocks/detail/${code}`).then(
+ (res) => res.json(),
+ );
+}
+
+export async function getStocksChartDataByCode(
+ code: string,
+ peroid: TiemCategory = 'D',
+ start: string = '',
+ end: string = '',
+): Promise {
+ return fetch(`${import.meta.env.VITE_API_URL}/stocks/detail/${code}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ fid_input_date_1: start,
+ fid_input_date_2: end,
+ fid_period_div_code: peroid,
+ }),
+ }).then((res) => res.json());
+}
diff --git a/FE/src/store/useSearchInputStore.ts b/FE/src/store/useSearchInputStore.ts
new file mode 100644
index 00000000..b6fe6451
--- /dev/null
+++ b/FE/src/store/useSearchInputStore.ts
@@ -0,0 +1,21 @@
+import { create } from 'zustand';
+
+interface SearchInputStore {
+ searchInput: string;
+ setSearchInput: (input: string) => void;
+ resetSearchInput: () => void;
+}
+
+const useSearchInputStore = create((set) => ({
+ searchInput: '',
+
+ setSearchInput: (input: string) => {
+ set({ searchInput: input });
+ },
+
+ resetSearchInput: () => {
+ set({ searchInput: '' });
+ },
+}));
+
+export default useSearchInputStore;
diff --git a/FE/src/store/useSearchModalStore.ts b/FE/src/store/useSearchModalStore.ts
new file mode 100644
index 00000000..7d191aad
--- /dev/null
+++ b/FE/src/store/useSearchModalStore.ts
@@ -0,0 +1,13 @@
+import { create } from 'zustand';
+
+type ModalStore = {
+ isOpen: boolean;
+ toggleSearchModal: () => void;
+};
+
+const useSearchModalStore = create((set) => ({
+ isOpen: false,
+ toggleSearchModal: () => set((state) => ({ isOpen: !state.isOpen })),
+}));
+
+export default useSearchModalStore;
diff --git a/FE/src/types.ts b/FE/src/types.ts
index 06b1b10b..c6160fb4 100644
--- a/FE/src/types.ts
+++ b/FE/src/types.ts
@@ -7,3 +7,33 @@ export type LoginFailResponse = {
message: string[];
statusCode: number;
};
+
+export type TiemCategory = 'D' | 'W' | 'M' | 'Y';
+
+export type Padding = {
+ top: number;
+ left: number;
+ right: number;
+ bottom: number;
+};
+
+export type StockDetailType = {
+ hts_kor_isnm: string;
+ stck_shrn_iscd: string;
+ stck_prpr: string;
+ prdy_vrss: string;
+ prdy_vrss_sign: string;
+ prdy_ctrt: string;
+ hts_avls: string;
+ per: string;
+};
+
+export type StockChartUnit = {
+ stck_bsop_date: string;
+ stck_clpr: string;
+ stck_oprc: string;
+ stck_hgpr: string;
+ stck_lwpr: string;
+ acml_vol: string;
+ prdy_vrss_sign: string;
+};
diff --git a/FE/src/utils/chart.ts b/FE/src/utils/chart.ts
index 15b515f9..61c2bc57 100644
--- a/FE/src/utils/chart.ts
+++ b/FE/src/utils/chart.ts
@@ -1,7 +1,176 @@
-const X_LENGTH = 79; // 9:00 ~ 15:30 까지 5분 단위의 총 개수
-const MIDDLE = 50; // 상한가, 하한가를 나누는 기준
+import { Padding, StockChartUnit } from 'types';
+
+export function drawLineChart(
+ ctx: CanvasRenderingContext2D,
+ data: number[],
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ padding: Padding,
+ weight: number = 0, // 0~1 y축 범위 가중치
+ lineWidth: number = 1,
+) {
+ if (data.length === 0) return;
+
+ ctx.beginPath();
+
+ const n = data.length;
+ const yMax = Math.round(Math.max(...data.map((d) => d)) * (1 + weight));
+ const yMin = Math.round(Math.min(...data.map((d) => d)) * (1 - weight));
+
+ data.forEach((e, i) => {
+ const cx = x + padding.left + (width * i) / (n - 1);
+ const cy = y + padding.top + height - (height * (e - yMin)) / (yMax - yMin);
+
+ if (i === 0) {
+ ctx.moveTo(cx, cy);
+ } else {
+ ctx.lineTo(cx, cy);
+ }
+ });
+
+ ctx.lineWidth = lineWidth;
+ ctx.stroke();
+}
+
+export function drawBarChart(
+ ctx: CanvasRenderingContext2D,
+ data: StockChartUnit[],
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ padding: Padding,
+ weight: number = 0, // 0~1 y축 범위 가중치
+) {
+ if (data.length === 0) return;
+ const n = data.length;
+
+ ctx.beginPath();
+
+ const yMax = Math.round(
+ Math.max(...data.map((d) => +d.acml_vol)) * 1 + weight,
+ );
+ const yMin = Math.round(
+ Math.min(...data.map((d) => +d.acml_vol)) * 1 - weight,
+ );
+
+ const gap = Math.floor((width / n) * 0.8);
+
+ const blue = '#2175F3';
+ const red = '#FF3700';
+
+ data.forEach((e, i) => {
+ const cx = x + padding.left + (width * i) / (n - 1);
+ const cy =
+ padding.top + ((height - y) * (+e.acml_vol - yMin)) / (yMax - yMin);
+
+ ctx.fillStyle = +e.stck_oprc < +e.stck_clpr ? red : blue;
+ ctx.fillRect(cx, height, gap, -cy);
+ });
+
+ ctx.stroke();
+}
+
+export function drawCandleChart(
+ ctx: CanvasRenderingContext2D,
+ data: StockChartUnit[],
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ padding: Padding,
+ weight: number = 0, // 0~1 y축 범위 가중치
+) {
+ ctx.beginPath();
+
+ const n = data.length;
+
+ const arr = data.map((d) =>
+ Math.max(+d.stck_clpr, +d.stck_oprc, +d.stck_hgpr, +d.stck_lwpr),
+ );
+
+ const yMax = Math.round(Math.max(...arr) * (1 + weight));
+ const yMin = Math.round(Math.min(...arr) * (1 - weight));
+
+ const labels = getYAxisLabels(yMin, yMax);
+ labels.forEach((label) => {
+ const yPos =
+ padding.top + height - ((label - yMin) / (yMax - yMin)) * height;
+
+ // 라벨 텍스트 그리기
+ ctx.font = '20px Arial';
+ ctx.fillStyle = '#000';
+ ctx.textAlign = 'start';
+ ctx.fillText(label.toLocaleString(), padding.left + width + 10, yPos + 5);
+
+ // Y축 눈금선 그리기
+ ctx.strokeStyle = '#ddd';
+ ctx.beginPath();
+ ctx.moveTo(0, yPos);
+ ctx.lineTo(padding.left + width, yPos);
+ ctx.stroke();
+ });
+
+ data.forEach((e, i) => {
+ ctx.beginPath();
+
+ const { stck_oprc, stck_clpr, stck_hgpr, stck_lwpr } = e;
+ const gap = Math.floor((width / n) * 0.8);
+ const cx = x + padding.left + (width * i) / (n - 1);
+
+ const openY =
+ y + padding.top + height - (height * (+stck_oprc - yMin)) / (yMax - yMin);
+ const closeY =
+ y + padding.top + height - (height * (+stck_clpr - yMin)) / (yMax - yMin);
+ const highY =
+ y + padding.top + height - (height * (+stck_hgpr - yMin)) / (yMax - yMin);
+ const lowY =
+ y + padding.top + height - (height * (+stck_lwpr - yMin)) / (yMax - yMin);
+
+ const blue = '#2175F3';
+ const red = '#FF3700';
+
+ if (+stck_oprc > +stck_clpr) {
+ ctx.fillStyle = blue;
+ ctx.strokeStyle = blue;
+ ctx.fillRect(cx, closeY, gap, openY - closeY);
+ } else {
+ ctx.fillStyle = red;
+ ctx.strokeStyle = red;
+ ctx.fillRect(cx, openY, gap, closeY - openY);
+ }
+
+ const middle = cx + Math.floor(gap / 2);
+
+ ctx.moveTo(middle, highY);
+ ctx.lineTo(middle, lowY);
+ ctx.stroke();
+ });
+}
+
+function getYAxisLabels(min: number, max: number) {
+ let a = min.toString().length - 1;
+ let k = 1;
+ while (a--) k *= 10;
+
+ const start = Math.ceil(min / k) * k;
+ const end = Math.floor(max / k) * k;
+ const labels = [];
+ for (let value = start; value <= end; value += k) {
+ labels.push(value);
+ }
+ return labels;
+}
+
+export const drawChart = (
+ ctx: CanvasRenderingContext2D,
+ data: { time: string; value: string; diff: string }[],
+ xLength: number,
+) => {
+ const n = data.length;
-export const drawChart = (ctx: CanvasRenderingContext2D, data: number[]) => {
const canvas = ctx.canvas;
const width = canvas.width;
const height = canvas.height;
@@ -18,18 +187,51 @@ export const drawChart = (ctx: CanvasRenderingContext2D, data: number[]) => {
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
- const yMax = Math.max(...data.map((d) => d)) * 1.1;
- const yMin = Math.min(...data.map((d) => d)) * 0.9;
+ const MIDDLE =
+ n > 0
+ ? Number(
+ (parseFloat(data[0].value) - parseFloat(data[0].diff)).toFixed(2),
+ )
+ : 50;
+
+ const yMax = Math.max(
+ Math.round(Math.max(...data.map((d) => Number(d.value))) * 1.006 * 100),
+ MIDDLE * 100,
+ );
+ const yMin = Math.min(
+ Math.round(Math.min(...data.map((d) => Number(d.value))) * 0.994 * 100),
+ MIDDLE * 100,
+ );
+
+ data.sort((a, b) => {
+ if (a.time < b.time) return -1;
+ if (a.time > b.time) return 1;
+ return 0;
+ });
+
+ const middleY =
+ padding.top +
+ chartHeight -
+ (chartHeight * (MIDDLE * 100 - yMin)) / (yMax - yMin);
+ ctx.beginPath();
+ ctx.setLineDash([10, 10]);
+ ctx.moveTo(padding.left, middleY);
+ ctx.lineTo(width - padding.right, middleY);
+ ctx.strokeStyle = '#6E8091';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ ctx.setLineDash([]);
// 데이터 선 그리기
- if (data.length > 1) {
+ if (n > 1) {
ctx.beginPath();
data.forEach((point, i) => {
- const x = padding.left + (chartWidth * i) / (X_LENGTH - 1);
+ const value = Math.round(Number(point.value) * 100);
+ const x = padding.left + (chartWidth * i) / (xLength - 1);
const y =
padding.top +
chartHeight -
- (chartHeight * (point - yMin)) / (yMax - yMin);
+ (chartHeight * (value - yMin)) / (yMax - yMin);
if (i === 0) {
ctx.moveTo(x, y);
@@ -38,13 +240,13 @@ export const drawChart = (ctx: CanvasRenderingContext2D, data: number[]) => {
}
});
- const currentValue = data[data.length - 1];
+ const currentValue = Number(data[n - 1].value);
if (currentValue >= MIDDLE) {
ctx.strokeStyle = '#FF3700';
} else {
ctx.strokeStyle = '#2175F3';
}
- ctx.lineWidth = 2;
+ ctx.lineWidth = 3;
ctx.stroke();
}
};
diff --git a/FE/src/utils/socket.ts b/FE/src/utils/socket.ts
new file mode 100644
index 00000000..a7a5fd5f
--- /dev/null
+++ b/FE/src/utils/socket.ts
@@ -0,0 +1,3 @@
+import { io } from 'socket.io-client';
+
+export const socket = io(import.meta.env.VITE_SOCKET_URL);
diff --git a/FE/src/utils/useDebounce.ts b/FE/src/utils/useDebounce.ts
new file mode 100644
index 00000000..ec8f6368
--- /dev/null
+++ b/FE/src/utils/useDebounce.ts
@@ -0,0 +1,24 @@
+import { useEffect, useState } from 'react';
+
+export const useDebounce = (value: string, delay: number) => {
+ const [debounceValue, setDebounceValue] = useState(value);
+ const [isDebouncing, setIsDebouncing] = useState(false);
+
+ useEffect(() => {
+ setIsDebouncing(true);
+
+ const handler = setTimeout(() => {
+ setDebounceValue(value);
+ setIsDebouncing(false);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return {
+ debounceValue,
+ isDebouncing,
+ };
+};