diff --git a/app/listings/map.tsx b/app/listings/map.tsx index f7d5c1ee..7de8c4f1 100644 --- a/app/listings/map.tsx +++ b/app/listings/map.tsx @@ -1,20 +1,21 @@ "use client" -import { Feature, View } from "ol"; -import { Geometry } from "ol/geom"; -import Point from "ol/geom/Point"; -import TileLayer from "ol/layer/Tile"; -import VectorLayer from "ol/layer/Vector"; -import Map from 'ol/Map'; -import { fromLonLat } from "ol/proj"; -import OSM from "ol/source/OSM"; -import VectorSource from "ol/source/Vector"; -import { Fill, Stroke, Style, Text } from "ol/style"; -import CircleStyle from "ol/style/Circle"; -import { useEffect } from "react"; -import { Listing } from "./types"; - -const ICON_DEFAULT_STYLE = new Style({ +import { NumberUtils } from "@/lib/commons/number_utils" +import { CURRENCY_FORMATTER } from "@/lib/formatter/currency" +import { Feature, View } from "ol" +import Point from "ol/geom/Point" +import TileLayer from "ol/layer/Tile" +import VectorLayer from "ol/layer/Vector" +import Map from 'ol/Map' +import { fromLonLat } from "ol/proj" +import OSM from "ol/source/OSM" +import VectorSource from "ol/source/Vector" +import { Fill, Stroke, Style, Text } from "ol/style" +import CircleStyle from "ol/style/Circle" +import { useEffect } from "react" +import { Listing } from "./types" + +const ICON_STYLE = new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ @@ -26,82 +27,101 @@ const ICON_DEFAULT_STYLE = new Style({ }), }), }) -const TEXT_DEFAULT_STYLE = new Style({ - text: new Text({ - backgroundStroke: new Stroke({ - color: '#fbbf24', - width: 6, - lineCap: 'round', - lineJoin: 'round', - }), - backgroundFill: new Fill({ - color: '#fbbf24', - }), - fill: new Fill({ - color: '#1F2937' - }), - font: '1rem sans-serif', - padding: [2, 2, 2, 2], + +const TEXT_TEMPLATE_STYLE = new Text({ + backgroundStroke: new Stroke({ + color: '#fbbf24', + width: 2, + lineCap: 'round', + lineJoin: 'round', }), -}); -const ICON_SELECTED_STYLE = new Style({ - image: new CircleStyle({ - radius: 16, - fill: new Fill({ - color: '#FBBF2430', - }), + backgroundFill: new Fill({ + color: '#fbbf24', }), -}); -const TEXT_SELECTED_STYLE = new Style({ - text: new Text({ - backgroundStroke: new Stroke({ - color: '#fbbf24', - width: 6, - lineCap: 'round', - lineJoin: 'round', - }), - backgroundFill: new Fill({ - color: '#fbbf24', - }), - fill: new Fill({ - color: '#1F2937' - }), - font: 'bold 1rem sans-serif', - padding: [2, 2, 2, 2], + fill: new Fill({ + color: '#1F2937' }), -}); + padding: [2, 2, 2, 2], +}) -export interface ListingsMapInterface { - listings: Listing[] +function createDefaultTextStyle(price: number): Style { + // Regular font + // Different text + const text = TEXT_TEMPLATE_STYLE.clone() + text.setFont('0.8rem sans-serif') + text.setText(CURRENCY_FORMATTER.format(price)); + return new Style({ + text: text + }) } -export function ListingsMap(props: ListingsMapInterface) { - - // Map icon feature - const iconFeature = new Feature(new Point(fromLonLat([0, 0]))) - iconFeature.setStyle(ICON_DEFAULT_STYLE) +function creatActiveTextStyle(price: number): Style { + // Regular font + // Different text + const text = TEXT_TEMPLATE_STYLE.clone() + text.setFont('bold 0.8rem sans-serif') + text.setText(CURRENCY_FORMATTER.format(price)); + return new Style({ + text: text + }) +} - const iconSelectedFeature = new Feature(new Point(fromLonLat([20, 0]))) - iconSelectedFeature.setStyle([ICON_SELECTED_STYLE, ICON_DEFAULT_STYLE]) +export interface ListingsMapInterface { + listings: Listing[] +} - // Map text feature - const textFeature = new Feature(new Point(fromLonLat([0, 20]))) - const textDefaultStyle = TEXT_DEFAULT_STYLE; - textDefaultStyle.getText()?.setText("₱20K"); - textFeature.setStyle(TEXT_DEFAULT_STYLE) +/** + * The feature's IDs (same for both icon & text) are the indices used when they were constructed. + */ +interface MapFeatures { + iconFeatures: Feature[] + textFeatures: Feature[] + activeFeatures: Feature[] +} - const textSelectedFeature = new Feature(new Point(fromLonLat([20, 20]))) - const textSelectedStyle = TEXT_SELECTED_STYLE; - textSelectedStyle.getText()?.setText("₱20K"); - textSelectedFeature.setStyle(textSelectedStyle); +function createMapFeatures(listings: Listing[]): MapFeatures { + const iconFeatures: Feature[] = [] + const textFeatures: Feature[] = [] + const activeFeatures: Feature[] = [] + + for (let i = 0; i < listings.length; i++) { + const listing = listings.at(i)! + const point = new Point(fromLonLat([ + listing.address.longitude, + listing.address.latitude, + ])) + + // Icon feature + const iconFeature = new Feature(point) + iconFeature.setStyle(ICON_STYLE) + iconFeature.setId(i) + // Text feature + const textFeature = new Feature(point) + textFeature.setStyle(createDefaultTextStyle(listing.price.value)) + textFeature.setId(i) + // Active features + const activeFeature = new Feature(point) + activeFeature.setStyle(creatActiveTextStyle(listing.price.value)) + activeFeature.setId(i) + + iconFeatures.push(iconFeature) + textFeatures.push(textFeature) + activeFeatures.push(activeFeature) + } + + return { + iconFeatures: iconFeatures, + textFeatures: textFeatures, + activeFeatures: activeFeatures, + } +} - const features = [ - iconFeature, iconSelectedFeature, - textFeature, textSelectedFeature - ] +export function ListingsMap(props: ListingsMapInterface) { + const mapFeatures = createMapFeatures(props.listings); useEffect(() => { - let selected: Feature | undefined; + // This ID is based upon the indices of the listings + let activeIndex: number = -1; const map = new Map({ target: 'map', @@ -111,7 +131,9 @@ export function ListingsMap(props: ListingsMapInterface) { }), new VectorLayer({ source: new VectorSource({ - features: features, + // TODO: Use icons features if zoom level is big + // TODO: Use text features if zoom level is small + features: mapFeatures.iconFeatures, }), }) ], @@ -119,46 +141,46 @@ export function ListingsMap(props: ListingsMapInterface) { center: [0, 0], zoom: 2, }), - }); + }) - // TODO: improve use select interaction; demo on click (for show listing card) https://openlayers.org/en/latest/examples/select-features.html + // TODO: improve use select interaction demo on click (for show listing card) https://openlayers.org/en/latest/examples/select-features.html // demo on hover (for change map pos style) https://openlayers.org/en/latest/examples/select-hover-features.html map.on('pointermove', (e) => { - if (selected) { - // revert back to original style if not same hovered - // TODO: if zoom level (zoom-in) is text feature, use textDefaultStyle - // TODO: if zoom level (zoom-out) is icon feature, use iconDefaultStyle - if (selected === iconFeature) { - selected.setStyle(ICON_DEFAULT_STYLE) - } else if (selected === textFeature) { - selected.setStyle(TEXT_DEFAULT_STYLE) - } - selected = undefined + // Hover (revert style) + if (activeIndex >= 0) { + const activeFeature = mapFeatures.iconFeatures.at(activeIndex); + // Revert style + // TODO: Use icons features if zoom level is big + // TODO: Use text features if zoom level is small + activeFeature?.setStyle(ICON_STYLE) + // Remove index + activeIndex = -1; + // Reset cursor map.getViewport().style.cursor = 'auto' } + // Hover (change style) map.forEachFeatureAtPixel(e.pixel, (f) => { - // TODO: animate change of style https://openlayers.org/en/latest/examples/feature-animation.html - // Icon hover - if (iconFeature === f) { - selected = f; - selected.setStyle([ICON_SELECTED_STYLE, ICON_DEFAULT_STYLE]) - map.getViewport().style.cursor = 'pointer' - return; - } + const hoveredFeature = f as Feature; + const hoveredIndex = NumberUtils.toNumber(hoveredFeature.getId(), -1) - // Text hover - if (textFeature === f) { - selected = f; - selected.setStyle(textSelectedStyle); + // TODO: animate change of style https://openlayers.org/en/latest/examples/feature-animation.html + // TODO: Display popup/banner of listing card + // 1. https://openlayers.org/en/latest/examples/overlay.html (HTML only) + // 2. https://openlayers.org/en/latest/examples/popup.html (Bootstrap) + if (hoveredIndex !== activeIndex) { + activeIndex = hoveredIndex + const textFeature = mapFeatures.activeFeatures.at(activeIndex); + // Change style + hoveredFeature.setStyle(textFeature?.getStyle()) + // Change pointer map.getViewport().style.cursor = 'pointer' - return; } }) }) return () => map.dispose() - }, []); + }, []) // TODO: Think about the problem when icon or text features are dynamically added/removed. Need to think about the side effect dependencies return (