Skip to content

Commit

Permalink
Display listings as pin in map
Browse files Browse the repository at this point in the history
fixes #49
  • Loading branch information
franthormel committed Aug 8, 2024
1 parent 976e52a commit 1e4ffb2
Showing 1 changed file with 128 additions and 106 deletions.
234 changes: 128 additions & 106 deletions app/listings/map.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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<Point>[]
textFeatures: Feature<Point>[]
activeFeatures: Feature<Point>[]
}

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<Point>[] = []
const textFeatures: Feature<Point>[] = []
const activeFeatures: Feature<Point>[] = []

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<Geometry> | undefined;
// This ID is based upon the indices of the listings
let activeIndex: number = -1;

const map = new Map({
target: 'map',
Expand All @@ -111,54 +131,56 @@ 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,
}),
})
],
view: new View({
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 (
Expand Down

0 comments on commit 1e4ffb2

Please sign in to comment.