diff --git a/client/api/v1.d.ts b/client/api/v1.d.ts index 4a003272..937bad42 100644 --- a/client/api/v1.d.ts +++ b/client/api/v1.d.ts @@ -180,6 +180,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/bus/stat/process/all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["process"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/bus-review": { parameters: { query?: never; @@ -368,6 +384,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/bus/statics/now": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getStatics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/bus/route": { parameters: { query?: never; @@ -495,6 +527,9 @@ export interface components { UserUpdateReqDto: { nickname: string; }; + ErrorRespDto: { + message: string; + }; /** @description 유저 정보 응답 DTO */ UserRespDto: { /** Format: int64 */ @@ -504,9 +539,6 @@ export interface components { nickname: string; oauthProvider: string; }; - ErrorRespDto: { - message: string; - }; SubwayReviewReqDto: { /** Format: int64 */ stationId: number; @@ -660,6 +692,38 @@ export interface components { /** Format: date-time */ createdAt: string; }; + BusRemainSeatDto: { + /** @enum {string} */ + plateType: "UNKNOWN" | "SMALL" | "MEDIUM" | "LARGE" | "DOUBLE_DECKER"; + plateNo: string; + /** Format: date-time */ + standardTime: string; + remainSeatList: components["schemas"]["SeatInfo"][]; + }; + BusStaticsDto: { + /** Format: date-time */ + requestTime: string; + /** Format: int64 */ + routeId: number; + routeName: string; + /** Format: int32 */ + stationNum: number; + /** Format: int32 */ + busNum: number; + stationList: components["schemas"]["StationInfo"][]; + data: components["schemas"]["BusRemainSeatDto"][]; + }; + SeatInfo: { + /** Format: date-time */ + arrivedTime: string; + /** Format: int32 */ + remainSeat: number; + }; + StationInfo: { + /** Format: int64 */ + stationId: number; + stationName: string; + }; BusRouteRespDto: { /** Format: int64 */ routeId: number; @@ -712,6 +776,7 @@ export interface components { routeName: string; routeStation: components["schemas"]["BusRouteStationRespDto"]; arrivalInfo?: components["schemas"]["BusArrivalRespDto"]; + statics?: components["schemas"]["BusStaticsDto"]; }; }; responses: never; @@ -1332,6 +1397,26 @@ export interface operations { }; }; }; + process: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string; + }; + }; + }; + }; getBusReviewList: { parameters: { query: { @@ -1706,6 +1791,31 @@ export interface operations { }; }; }; + getStatics: { + parameters: { + query: { + routeStationId: number; + stationNum?: number; + timeRange?: number; + week?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BusStaticsDto"]; + }; + }; + }; + }; getRoutes: { parameters: { query?: { diff --git a/client/app/bookmark/components/BookmarkRoutes.tsx b/client/app/bookmark/components/BookmarkRoutes.tsx index b0840ee2..9feac1f6 100644 --- a/client/app/bookmark/components/BookmarkRoutes.tsx +++ b/client/app/bookmark/components/BookmarkRoutes.tsx @@ -1,6 +1,6 @@ import { components } from "@/api/v1" -import BusLiveInfoCard from "@/app/bookmark/components/BusLiveInfoCard" +import BusLiveInfoCard from "@/components/bus/BusLiveInfoCard" type BookmarkDetailResp = components["schemas"]["BookmarkDetailRespDto"] diff --git a/client/app/bookmark/components/BusLiveInfoCard.tsx b/client/app/bookmark/components/BusLiveInfoCard.tsx deleted file mode 100644 index f3a16860..00000000 --- a/client/app/bookmark/components/BusLiveInfoCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useCallback, useEffect, useState } from "react" -import useClient from "@/api/useClient" -import { components } from "@/api/v1" - -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import ArrivalInfo from "@/app/bookmark/components/ArrivalInfo" - -type RouteStation = components["schemas"]["BusRouteStationRespDto"] -type BusLiveInfoResp = components["schemas"]["BusLiveInfoRespDto"] -interface BookmarkBusCardProps { - routeStation: RouteStation | undefined -} -export default function BusLiveInfoCard({ - routeStation, -}: BookmarkBusCardProps) { - const { busRouteStationId, routeName, stationName } = routeStation || {} - const [liveInfo, setLiveInfo] = useState( - undefined - ) - const client = useClient() - - const fetchLiveInfos = useCallback(async () => { - if (!busRouteStationId) { - return - } - const { data, error } = await client.GET("/api/bus/live/{routeStationId}", { - params: { - path: { - routeStationId: busRouteStationId, - }, - }, - }) - if (error) { - console.error(error) - return - } - if (data) { - setLiveInfo(data) - } - }, [busRouteStationId]) - - useEffect(() => { - fetchLiveInfos() - }, [busRouteStationId]) - - return ( - - {routeStation && ( - <> -
버스 정보
- - {`${routeName}`} - {stationName} - - -
- {liveInfo?.arrivalInfo && ( - <> -

도착 정보

- - - - )} - {!liveInfo?.arrivalInfo && ( -

도착 정보가 없습니다.

- )} -
-
- - )} - {!routeStation && ( - - {`노선 및 정류장을 입력해주세요`} - - )} -
- ) -} diff --git a/client/app/bookmark/components/EditBookmark.tsx b/client/app/bookmark/components/EditBookmark.tsx index 506f3c99..dc1f450a 100644 --- a/client/app/bookmark/components/EditBookmark.tsx +++ b/client/app/bookmark/components/EditBookmark.tsx @@ -4,10 +4,10 @@ import { components } from "@/api/v1" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import SearchBusRoute from "@/components/bus/SearchBusRoute" +import SelectRouteStation from "@/components/bus/SelectRouteStation" import BusEditCard from "@/app/bookmark/components/BusEditCard" import RouteStationCard from "@/app/bookmark/components/RouteStationCard" -import SearchBusRoute from "@/app/bookmark/components/SearchBusRoute" -import SelectRouteStation from "@/app/bookmark/components/SelectRouteStation" type BookmarkResp = components["schemas"]["BookmarkRespDto"] type BookmarkDetailResp = components["schemas"]["BookmarkDetailRespDto"] diff --git a/client/app/page.tsx b/client/app/page.tsx index 6c1f7d3a..5fc9a307 100644 --- a/client/app/page.tsx +++ b/client/app/page.tsx @@ -3,9 +3,9 @@ import { useState } from "react" import { components } from "@/api/v1" -import BusLiveInfoCard from "@/app/bookmark/components/BusLiveInfoCard" -import SearchBusRoute from "@/app/bookmark/components/SearchBusRoute" -import SelectRouteStation from "@/app/bookmark/components/SelectRouteStation" +import BusLiveInfoCard from "@/components/bus/BusLiveInfoCard" +import SearchBusRoute from "@/components/bus/SearchBusRoute" +import SelectRouteStation from "@/components/bus/SelectRouteStation" type BusRoute = components["schemas"]["BusRouteRespDto"] type BusRouteStation = components["schemas"]["BusRouteStationRespDto"] diff --git a/client/app/bookmark/components/ArrivalInfo.tsx b/client/components/bus/ArrivalInfo.tsx similarity index 100% rename from client/app/bookmark/components/ArrivalInfo.tsx rename to client/components/bus/ArrivalInfo.tsx diff --git a/client/components/bus/BusLiveInfoCard.tsx b/client/components/bus/BusLiveInfoCard.tsx new file mode 100644 index 00000000..b2bc9234 --- /dev/null +++ b/client/components/bus/BusLiveInfoCard.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from "react" +import useClient from "@/api/useClient" +import { components } from "@/api/v1" + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import ArrivalInfo from "@/components/bus/ArrivalInfo" +import BusSeatStatics from "@/components/bus/BusSeatStatics" + +type RouteStation = components["schemas"]["BusRouteStationRespDto"] +type BusLiveInfoResp = components["schemas"]["BusLiveInfoRespDto"] +interface BookmarkBusCardProps { + routeStation: RouteStation | undefined +} +export default function BusLiveInfoCard({ + routeStation, +}: BookmarkBusCardProps) { + const { busRouteStationId, routeName, stationName } = routeStation || {} + const [liveInfo, setLiveInfo] = useState( + undefined + ) + const client = useClient() + + const fetchLiveInfos = useCallback(async () => { + if (!busRouteStationId) { + return + } + const { data, error } = await client.GET("/api/bus/live/{routeStationId}", { + params: { + path: { + routeStationId: busRouteStationId, + }, + }, + }) + if (error) { + console.error(error) + return + } + if (data) { + setLiveInfo(data) + } + }, [busRouteStationId]) + + useEffect(() => { + fetchLiveInfos() + }, [busRouteStationId]) + + return ( +
+ + {routeStation && ( +
+
버스 정보
+ + {`${routeName}`} + {stationName} + + +
+ {liveInfo?.arrivalInfo && ( + <> +

도착 정보

+ + + + )} + {!liveInfo?.arrivalInfo && ( +

도착 정보가 없습니다.

+ )} +
+
+
+ 과거 통계 + +

{"최근 2주간의 버스 좌석 현황입니다."}

+

{"(빨강 : 1층, 파랑 : 2층)"}

+
+ {liveInfo?.statics && liveInfo.statics.data.length > 0 && ( + + )} + {!liveInfo?.statics || + (liveInfo.statics.data.length === 0 && ( +

+ {"데이터가 없습니다. 🥲"} +

+ ))} +
+
+ )} + {!routeStation && ( + + {`노선 및 정류장을 입력해주세요`} + + )} +
+
+ ) +} diff --git a/client/components/bus/BusSeatStatics.tsx b/client/components/bus/BusSeatStatics.tsx new file mode 100644 index 00000000..bb8ce4c6 --- /dev/null +++ b/client/components/bus/BusSeatStatics.tsx @@ -0,0 +1,236 @@ +import { components } from "@/api/v1" +import { + LabelList, + Line, + LineChart, + ReferenceLine, + Tooltip, + XAxis, + YAxis, +} from "recharts" + +import { ChartConfig, ChartContainer } from "@/components/ui/chart" + +type Statics = components["schemas"]["BusStaticsDto"] +type PlateType = "UNKNOWN" | "SMALL" | "MEDIUM" | "LARGE" | "DOUBLE_DECKER" + +interface BusSeatStaticsProps { + staticsResp: Statics +} + +let currentLargeIndex = 0 +let currentDoubleDeckerIndex = 0 +const getColor = (plateType: PlateType, index: number) => { + // LARGE는 붉은색 - 노란색 계열 + // DOUBLE_DECKER는 파란색 - 초록색 계열 + // 나머지는 검정색 + // RGB 색상으로 표현 + + const redYellows = [ + "rgb(255, 0, 0)", + "rgb(255, 50, 0)", + "rgb(255, 100, 0)", + "rgb(255, 150, 0)", + "rgb(255, 200, 0)", + "rgb(255, 200, 150)", + ] + + const blueGreens = [ + "rgb(0, 0, 255)", + "rgb(0, 50, 255)", + "rgb(0, 100, 255)", + "rgb(0, 150, 255)", + "rgb(0, 200, 255)", + "rgb(0, 200, 150)", + ] + + const getColorIndex = (plateType: PlateType) => { + if (plateType === "LARGE") { + return currentLargeIndex++ + } + if (plateType === "DOUBLE_DECKER") { + return currentDoubleDeckerIndex++ + } + return 0 + } + + if (plateType === "LARGE") { + return "red" + // return redYellows[getColorIndex(plateType) % redYellows.length] + } + if (plateType === "DOUBLE_DECKER") { + return "blue" + // return blueGreens[getColorIndex(plateType) % blueGreens.length] + } + return "black" +} + +export default function BusSeatStatics({ staticsResp }: BusSeatStaticsProps) { + if (!staticsResp || !staticsResp.data) { + return null + } + + const { requestTime, routeName, busNum, stationNum, stationList, data } = + staticsResp + /** + * format + * { stationName: "station1", week1: 100, week2: 200, ... }, + * { stationName: "station2", week1: 20, week2: 20, ... }, + * ... + */ + + // 00:00 형식으로 변환 + const getTimeFromDateTIme = (dateTime: string) => { + const date = new Date(dateTime) + const hour = `${date.getHours()}` + const minute = `${date.getMinutes()}`.padStart(2, "0") + return `${hour}:${minute}` + } + + let chartConfig: ChartConfig = {} + data.forEach((bus, index) => { + const time = + getTimeFromDateTIme(bus.standardTime) + + (bus?.plateType === "DOUBLE_DECKER" ? " (2층)" : " (1층)") + chartConfig[time] = { + label: time, + color: getColor(bus.plateType, index), + } + }) + + const chartData = stationList.map((station, i) => { + const result: any = { + stationName: station.stationName, + } + let x = 0 + + for (const [key, value] of Object.entries(chartConfig)) { + result[key] = data[x].remainSeatList[i].remainSeat + ++x + } + return result + }) + + // 5 단위로 표시 + let min = data + .map((bus) => bus.remainSeatList.map((seat) => seat.remainSeat)) + .flat() + .reduce((a, b) => Math.min(a, b)) + min = Math.floor((min - 10) / 5) * 5 + let max = data + .map((bus) => bus.remainSeatList.map((seat) => seat.remainSeat)) + .flat() + .reduce((a, b) => Math.max(a, b)) + max = Math.ceil((max + 10) / 5) * 5 + + const dataKeys = Object.keys(chartData[0]).filter( + (key) => key !== "stationName" + ) + + const midIndex = Math.floor(stationList.length / 2) + let minIndex = 0 + let maxIndex = 0 + let minSeat = 100 + let maxSeat = 0 + for (let i = 0; i < data.length; i++) { + if ( + data[i]?.remainSeatList[midIndex] && + data[i]?.remainSeatList[midIndex].remainSeat < minSeat + ) { + minSeat = data[i]?.remainSeatList[midIndex].remainSeat + minIndex = i + } + if ( + data[i]?.remainSeatList[midIndex] && + data[i]?.remainSeatList[midIndex].remainSeat > maxSeat + ) { + maxSeat = data[i]?.remainSeatList[midIndex].remainSeat + maxIndex = i + } + } + + return ( + // <> + + + legand 의 element 가 폭을 넘어가지 않고 개행되도록 함. + {/**/} + {/* }*/} + {/* wrapperStyle={{*/} + {/* width: "100%",*/} + {/* display: "flex",*/} + {/* flexWrap: "wrap",*/} + {/* justifyContent: "center",*/} + {/* }}*/} + {/*/>*/} + { + const mid = Math.floor(stationList.length / 2) + if (index === mid) { + return value.slice(0, 8) + } + return `${index - mid > 0 ? "+" : ""}${index - mid}` + }} + /> + + {stationList?.length && ( + + )} + {dataKeys.map((key, index) => ( + <> + + + {index === minIndex && ( + + )} + {index === maxIndex && index !== minIndex && ( + + )} + + + ))} + + + ) +} diff --git a/client/app/bookmark/components/SearchBusRoute.tsx b/client/components/bus/SearchBusRoute.tsx similarity index 97% rename from client/app/bookmark/components/SearchBusRoute.tsx rename to client/components/bus/SearchBusRoute.tsx index 3f7263bd..6b3c5a9e 100644 --- a/client/app/bookmark/components/SearchBusRoute.tsx +++ b/client/components/bus/SearchBusRoute.tsx @@ -1,7 +1,6 @@ import { useCallback, useState } from "react" import useClient from "@/api/useClient" import { components } from "@/api/v1" -import { className } from "postcss-selector-parser" import { Command, @@ -19,6 +18,7 @@ interface SearchBusRouteProps { } export default function SearchBusRoute({ + className, setSelectedRoute, }: SearchBusRouteProps) { const [onSearchBar, setOnSearchBar] = useState(false) diff --git a/client/app/bookmark/components/SelectRouteStation.tsx b/client/components/bus/SelectRouteStation.tsx similarity index 100% rename from client/app/bookmark/components/SelectRouteStation.tsx rename to client/components/bus/SelectRouteStation.tsx diff --git a/client/components/ui/badge.tsx b/client/components/ui/badge.tsx new file mode 100644 index 00000000..f000e3ef --- /dev/null +++ b/client/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/client/components/ui/chart.tsx b/client/components/ui/chart.tsx new file mode 100644 index 00000000..8620baa3 --- /dev/null +++ b/client/components/ui/chart.tsx @@ -0,0 +1,365 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +