diff --git a/@types/mapbox.d.ts b/@types/mapbox.d.ts new file mode 100644 index 0000000..7d8cf5d --- /dev/null +++ b/@types/mapbox.d.ts @@ -0,0 +1,40 @@ +declare module 'mapbox-gl' { + declare type MapEventMap = { + load: void; + }; + + declare const mapboxgl = { + accessToken: string, + }; + + declare class Map { + constructor(option: { + container: string; + style: string; + center: [number, number]; // lng, lat + zoom: number; + }): this; + + loaded(): boolean; + setCenter(coord: [number, number]): void; + remove(): void; + on( + type: Type, + handler: (param: MapEventMap[Type]) => void + ): void; + off( + type: Type, + handler: (param: MapEventMap[Type]) => void + ): void; + } + + declare class Marker { + constructor(option?: { element?: HTMLElement }): this; + setLngLat(coord: [number, number]): this; + addTo(map: Map): this; + remove(): this; + } + + export { Map, Marker }; + export default mapboxgl; +} diff --git a/components/Course/Editor/Candidates/CandidatesState.ts b/components/Course/Editor/Candidates/CandidatesState.ts index e74f16f..62f213b 100644 --- a/components/Course/Editor/Candidates/CandidatesState.ts +++ b/components/Course/Editor/Candidates/CandidatesState.ts @@ -5,7 +5,7 @@ import type { CandidateCardDTO } from '~/components/Course/Editor/Editor.d'; export const candidatesVar = makeVar([]); -const GET_CANDIDATE_STICKERS = gql` +export const GET_CANDIDATE_STICKERS = gql` query GetStickers { stickers { _id @@ -20,7 +20,7 @@ const GET_CANDIDATE_STICKERS = gql` } `; -function updateCandidates(newCandidates: GQL.Sticker[]) { +export function updateCandidates(newCandidates: GQL.Sticker[]): void { const prevs: CandidateCardDTO[] = candidatesVar(); const candidates: CandidateCardDTO[] = newCandidates .filter((sticker) => !sticker.is_used) diff --git a/components/Course/List/CourseMap/Map/Map.tsx b/components/Course/List/CourseMap/Map/Map.tsx index 2ec731c..9bfefa6 100644 --- a/components/Course/List/CourseMap/Map/Map.tsx +++ b/components/Course/List/CourseMap/Map/Map.tsx @@ -10,19 +10,39 @@ export default function Map(): JSX.Element { return null; } - const center = getCenter(stickers); + const props = getMapProps(stickers); - return ; + return ; } -function getCenter(stickers: GQL.Sticker[]): MB.Center { - let xSum = 0; - let ySum = 0; +function getMapProps(stickers: GQL.Sticker[]): { + center: MB.Coordinate; + markers: MB.Marker[]; +} { + const center: MB.Coordinate = [0, 0]; + const markers: MB.Marker[] = []; - for (const { spot } of stickers) { - xSum += spot.x; - ySum += spot.y; + for (const { _id, spot } of stickers) { + center[0] += spot.x; + center[1] += spot.y; + markers.push({ + id: _id, + coord: [spot.x, spot.y], + Component: function MarkerComponent() { + return ( +
+ ); + }, + }); } - return [xSum / stickers.length, ySum / stickers.length]; + center[0] = center[0] / stickers.length; + center[1] = center[1] / stickers.length; + + return { + center, + markers, + }; } diff --git a/components/Course/List/CourseMap/Map/MapWithScript.tsx b/components/Course/List/CourseMap/Map/MapWithScript.tsx deleted file mode 100644 index bc5c32b..0000000 --- a/components/Course/List/CourseMap/Map/MapWithScript.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import ScriptLoader from '~/components/_common/ScriptLoader'; -import Map, { Props as MapProps } from './Map'; - -export default function MapWithScript(props: MapProps): JSX.Element { - return ( - - {({ isScriptLoaded }) => isScriptLoaded && } - - ); -} diff --git a/components/Course/List/CourseMap/Map/index.ts b/components/Course/List/CourseMap/Map/index.ts index 779a2ec..84bebe9 100644 --- a/components/Course/List/CourseMap/Map/index.ts +++ b/components/Course/List/CourseMap/Map/index.ts @@ -1 +1 @@ -export { default } from './MapWithScript'; +export { default } from './Map'; diff --git a/components/Course/List/Sidebar/Calendar/CalendarState.ts b/components/Course/List/Sidebar/Calendar/CalendarState.ts index 533d6ad..fa68160 100644 --- a/components/Course/List/Sidebar/Calendar/CalendarState.ts +++ b/components/Course/List/Sidebar/Calendar/CalendarState.ts @@ -8,7 +8,7 @@ import { import { useEffect } from 'react'; import { changeCurrentCourses } from '~/components/Course/List/SideBar/CourseList/CourseListState'; -const GET_COURSES_BY_DATE = gql` +export const GET_COURSES_BY_DATE = gql` query GetCoursesByDate { courses { _id diff --git a/components/_assets/map/CourseMarker.tsx b/components/_assets/map/CourseMarker.tsx new file mode 100644 index 0000000..f4c97f5 --- /dev/null +++ b/components/_assets/map/CourseMarker.tsx @@ -0,0 +1,105 @@ +import React, { ReactNode } from 'react'; +import styled from 'styled-components'; +import { useContext } from 'react'; +import { ThemeContext } from 'styled-components'; +import painter from '~/styles/theme/painter'; + +type Props = { + className?: string; +}; + +const Box = styled.div` + position: relative; + width: 48px; + height: 40px; +`; +const Marker = styled.div` + position: absolute; + bottom: 100%; + left: 0; + width: 48px; + margin-bottom: 8px; +`; +const Point = styled.div` + width: 40px; + height: 40px; + margin: 0 auto; + border-radius: 50%; + background-color: ${painter.basic.white}; + &::after { + display: block; + width: 28px; + height: 28px; + border-radius: 50%; + background-color: ${painter.primary.basic}; + content: ''; + } +`; + +export default function CourseMarker(props: Props): ReactNode { + const theme = useContext(ThemeContext); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/components/_common/MapBox/MapBox.d.ts b/components/_common/MapBox/MapBox.d.ts index 637b5fc..c8643f5 100644 --- a/components/_common/MapBox/MapBox.d.ts +++ b/components/_common/MapBox/MapBox.d.ts @@ -1,3 +1,21 @@ +import React, { ReactNode } from 'react'; +import { Marker as MapBoxMarker } from 'mapbox-gl'; + export namespace MB { - export type Center = [number, number]; + export type Lng = number; // x + export type Lat = number; // y + export type Coordinate = [Lng, Lat]; + + export type MarkerProps = { + id: string; + coord: Coordinate; + }; + export type Marker = MarkerProps & { + Component: React.ComponentType; + }; + export type MarkerRecord = { + data: Marker; + rid: string; + marker: MapBoxMarker; + }; } diff --git a/components/_common/MapBox/MapBox.tsx b/components/_common/MapBox/MapBox.tsx index 5b85ec5..e40c0d7 100644 --- a/components/_common/MapBox/MapBox.tsx +++ b/components/_common/MapBox/MapBox.tsx @@ -1,26 +1,31 @@ import React, { ReactNode } from 'react'; +import ReactDOM from 'react-dom'; +import mapboxgl, { Map, Marker } from 'mapbox-gl'; import { MB } from './MapBox.d'; +import cmp from '~/util/cmp'; +import { Registry } from '~/util'; type Props = { id: string; - center: MB.Center; + center: MB.Coordinate; zoom: number; + markers?: MB.Marker[]; }; type State = { - center: MB.Center; + center: MB.Coordinate; zoom: number; }; -const win = window as any; // TODO : 타입 정의 - export default class MapBox extends React.Component { - private map: any; + private map: Map = null; + private markerRegistry: Registry = + new Registry(); constructor(props: Props) { super(props); - win.mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_PUBLIC_TOKEN; + mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_PUBLIC_TOKEN; this.state = { center: this.props.center, zoom: this.props.zoom, @@ -28,8 +33,64 @@ export default class MapBox extends React.Component { } public componentDidMount(): void { - console.log(this.state.center); - this.map = new win.mapboxgl.Map({ + const handler = () => { + this.handleLoadMap(); + this.map.off('load', handler); + }; + + this.mountMap(); + if (this.map.loaded()) { + this.handleLoadMap(); + } else { + this.map.on('load', handler); + } + } + + public componentWillUnmount(): void { + this.unmountMap(); + this.unmountMarkers(); + } + + public componentDidUpdate(prevProps: Props): void { + if (!cmp(prevProps.center, this.props.center)) { + this.map?.setCenter(this.props.center); + } + if ( + !cmp( + prevProps.markers.map((marker) => marker.id), + this.markerRegistry.map((id) => id) + ) + ) { + this.mountMarkers(); + } + } + + public render(): ReactNode { + const markerContainer = document.getElementById('marker-shadow'); + const markerNodes = (this.props.markers || []).map((markerProps) => { + const props = { ...markerProps }; + const Marker = markerProps.Component; + const rid = this.getMarkerRegistryId(props.id); + + delete props.Component; + + return ( +
+ +
+ ); + }); + + return ( + <> +
+ {ReactDOM.createPortal(markerNodes, markerContainer)} + + ); + } + + private mountMap() { + this.map = new Map({ container: this.props.id, style: 'mapbox://styles/mapbox/streets-v11', // style URL center: this.state.center, @@ -37,13 +98,74 @@ export default class MapBox extends React.Component { }); } - public componentDidUpdate(): void { - // TODO + private unmountMap() { + this.map?.remove(); + this.map = null; } - public render(): ReactNode { - return ( -
+ // TODO: O(2NM) => O(NM) 개선 가능 + private mountMarkers(markers: MB.Marker[] = []) { + if (!this.map) { + return; + } + + const newRids = markers.map((marker) => + this.getMarkerRegistryId(marker.id) ); + + this.removeOldMarkers(newRids); + this.appendNewMarkers(newRids, markers); + } + + private removeOldMarkers(newRids: string[]) { + this.markerRegistry.each((_, record) => { + if (newRids.indexOf(record.rid)) return; + record.marker.remove(); + this.markerRegistry.remove(record.data.id); + }); } + + private appendNewMarkers(newRids: string[], markers: MB.Marker[]) { + for (let i = 0; i < markers.length; ++i) { + const { coord, id } = markers[i]; + const rid = newRids[i]; + + if (this.markerRegistry.is(id)) { + return; + } + + const shadow = document.getElementById(rid); + + if (!shadow) { + return; + } + + const element = + shadow.firstElementChild && + (shadow.firstElementChild.cloneNode() as HTMLElement); + const marker: Marker = new Marker({ element }); + + marker.setLngLat(coord).addTo(this.map); + + const record: MB.MarkerRecord = { + data: markers[i], + rid, + marker, + }; + + this.markerRegistry.set(record.data.id, record); + } + } + + private unmountMarkers() { + this.markerRegistry.clear(); + } + + private getMarkerRegistryId(id: string): string { + return `mr-${id}`; + } + + private readonly handleLoadMap = () => { + this.mountMarkers(this.props.markers); + }; } diff --git a/package.json b/package.json index 81a8434..85ed19e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dotenv-webpack": "^6.0.0", "graphql": "^15.4.0", "lodash": "^4.17.20", + "mapbox-gl": "^2.3.0", "next": "10.0.5", "react": "17.0.1", "react-dom": "17.0.1", diff --git a/pages/_document.tsx b/pages/_document.tsx index 8321d79..c0d5432 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -48,6 +48,7 @@ class MyDocument extends Document {