Skip to content

Commit 2bcf4e6

Browse files
cursoragenthasparus
andcommitted
feat: Add map popover and hover effects to meetups map
Co-authored-by: hasparus <hasparus@gmail.com>
1 parent f7ebec5 commit 2bcf4e6

File tree

3 files changed

+154
-2
lines changed

3 files changed

+154
-2
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"use client"
2+
3+
import { meetups } from "@/components/meetups"
4+
5+
export type MeetupMapPointer = {
6+
x: number
7+
y: number
8+
visible: boolean
9+
}
10+
11+
const meetupNameById = new Map(meetups.map(({ node }) => [node.id, node.name]))
12+
13+
type MeetupMapPopoverProps = {
14+
activeMeetupId: string | null
15+
pointer: MeetupMapPointer
16+
}
17+
18+
export function MeetupMapPopover({ activeMeetupId, pointer }: MeetupMapPopoverProps) {
19+
if (!activeMeetupId || !pointer.visible) return null
20+
const name = meetupNameById.get(activeMeetupId)
21+
if (!name) return null
22+
return (
23+
<div
24+
data-testid="meetup-map-popover"
25+
className="pointer-events-none absolute z-10 hidden min-w-0 -translate-x-1/2 -translate-y-3 whitespace-nowrap rounded border border-neu-100 bg-neu-0 px-2 py-1 text-xs font-medium text-neu-900 shadow-sm group-hover/map:flex"
26+
style={{
27+
left: pointer.x,
28+
top: pointer.y,
29+
}}
30+
>
31+
{name}
32+
</div>
33+
)
34+
}

src/app/(main)/community/events/meetups-map.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
"use client"
22

3-
import { useEffect, useMemo, useRef, useState } from "react"
3+
import {
4+
useEffect,
5+
useMemo,
6+
useRef,
7+
useState,
8+
type PointerEvent as ReactPointerEvent,
9+
} from "react"
410
import { useTheme } from "next-themes"
511

612
import { meetups } from "@/components/meetups"
713

814
import { bootMeetupsMap, type MapHandle, type MarkerPoint } from "./map/engine"
15+
import { MeetupMapPopover, type MeetupMapPointer } from "./map/meetup-map-popover"
916
import { MapSkeleton } from "./map-skeleton"
1017
import { MeetupsList } from "./meetups-list"
1118
import { asRgbString, MAP_COLORS, MapColors } from "./map/map-colors"
@@ -38,6 +45,33 @@ export function MeetupsMap() {
3845

3946
const [status, setStatus] = useState<MapStatus>("loading")
4047
const [errorMessage, setErrorMessage] = useState<string | null>(null)
48+
const [pointer, setPointer] = useState<MeetupMapPointer>({
49+
x: 0,
50+
y: 0,
51+
visible: false,
52+
})
53+
54+
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
55+
const rect = event.currentTarget.getBoundingClientRect()
56+
const x = event.clientX - rect.left
57+
const y = event.clientY - rect.top
58+
setPointer(previous => {
59+
if (
60+
previous.visible &&
61+
Math.abs(previous.x - x) < 0.5 &&
62+
Math.abs(previous.y - y) < 0.5
63+
) {
64+
return previous
65+
}
66+
return { x, y, visible: true }
67+
})
68+
}
69+
70+
const handlePointerLeave = () => {
71+
setPointer(previous =>
72+
previous.visible ? { x: previous.x, y: previous.y, visible: false } : previous,
73+
)
74+
}
4175

4276
useEffect(() => {
4377
initialThemeRef.current = themeColors
@@ -115,7 +149,11 @@ export function MeetupsMap() {
115149
} as React.CSSProperties
116150
}
117151
>
118-
<div className="group/map relative grow border-neu-200 bg-[--sea] dark:border-neu-50 dark:bg-[--sea] md:border-l">
152+
<div
153+
className="group/map relative grow border-neu-200 bg-[--sea] dark:border-neu-50 dark:bg-[--sea] md:border-l"
154+
onPointerMove={handlePointerMove}
155+
onPointerLeave={handlePointerLeave}
156+
>
119157
<canvas
120158
ref={canvasRef}
121159
aria-label="Interactive WebGL map of GraphQL meetups"
@@ -127,6 +165,8 @@ export function MeetupsMap() {
127165
}}
128166
/>
129167

168+
<MeetupMapPopover activeMeetupId={activeMeetupId} pointer={pointer} />
169+
130170
<InfoTip />
131171

132172
<MapSkeleton className={status === "loading" ? "" : "!opacity-0"} />

test/e2e/community-events.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,81 @@ test("community events page map loads and Zurich meetup link works", async ({
4242
await newPage.waitForLoadState("domcontentloaded", { timeout: 10000 })
4343
expect(newPage.url()).toContain("meetup.com/graphql-zurich")
4444
})
45+
46+
test("community events map popover appears on marker hover", async ({ page }) => {
47+
await page.goto("/community/events")
48+
const mapCanvas = page.locator("canvas").first()
49+
await expect(mapCanvas).toBeVisible({ timeout: 10000 })
50+
await expect
51+
.poll(async () => {
52+
const box = await mapCanvas.boundingBox()
53+
return Boolean(box && box.width > 100 && box.height > 100)
54+
})
55+
.toBe(true)
56+
const popover = page.locator('[data-testid="meetup-map-popover"]')
57+
await expect(popover).toHaveCount(0)
58+
await mapCanvas.hover()
59+
const { clientX, clientY } = await page.evaluate(() => {
60+
const canvas = document.querySelector("canvas") as HTMLCanvasElement | null
61+
if (!canvas) throw new Error("Canvas not found")
62+
const targetLat = 51.51
63+
const targetLon = -0.12
64+
const aspectRatio = 1.65
65+
const cellSize = 8
66+
const mercatorLimit = 85.05112878
67+
const minDisplayedLatitude = -60
68+
const baseLatitudeOffset = 4
69+
const baseLongitudeOffset = 0.1
70+
const clamp01 = (value: number) => {
71+
if (value <= 0) return 0
72+
if (value >= 1) return 1
73+
return value
74+
}
75+
const normalizeLongitude = (value: number) => {
76+
let lon = value
77+
while (lon <= -180) lon += 360
78+
while (lon > 180) lon -= 360
79+
return lon
80+
}
81+
const latToRawV = (lat: number) => {
82+
const clampedLat = Math.max(-mercatorLimit, Math.min(mercatorLimit, lat))
83+
const rad = (clampedLat * Math.PI) / 180
84+
return 0.5 - Math.log(Math.tan(Math.PI * 0.25 + rad * 0.5)) / (2 * Math.PI)
85+
}
86+
const maxProjectedV = latToRawV(mercatorLimit)
87+
const minProjectedV = latToRawV(minDisplayedLatitude)
88+
const lonLatToUV = (lon: number, lat: number) => {
89+
const adjustedLon = normalizeLongitude(lon + baseLongitudeOffset)
90+
const u = (adjustedLon + 180) / 360
91+
const adjustedLat = Math.max(
92+
minDisplayedLatitude,
93+
Math.min(mercatorLimit, lat + baseLatitudeOffset),
94+
)
95+
const rawV = latToRawV(adjustedLat)
96+
const normalizedV = clamp01((rawV - maxProjectedV) / (minProjectedV - maxProjectedV))
97+
return [u, normalizedV] as const
98+
}
99+
const { width, height } = canvas
100+
const pixelRatio = window.devicePixelRatio || 1
101+
const worldHeight = Math.min(width / aspectRatio, height)
102+
const worldWidth = worldHeight * aspectRatio
103+
const panX = width * 0.5 - worldWidth * 0.5
104+
const panY = height * 0.5 - worldHeight * 0.5
105+
const [u, v] = lonLatToUV(targetLon, targetLat)
106+
const markerY = 1 - v
107+
const screenX = panX + u * worldWidth
108+
const screenY = panY + markerY * worldHeight
109+
const deviceCell = cellSize * pixelRatio
110+
const cellX = Math.floor(screenX / deviceCell)
111+
const cellY = Math.floor(screenY / deviceCell)
112+
const centerX = (cellX + 0.5) * deviceCell
113+
const centerY = (cellY + 0.5) * deviceCell
114+
const rect = canvas.getBoundingClientRect()
115+
const clientX = rect.left + centerX / pixelRatio
116+
const clientY = rect.bottom - centerY / pixelRatio
117+
return { clientX, clientY }
118+
})
119+
await page.mouse.move(clientX, clientY)
120+
await expect(popover).toHaveText("London GraphQL", { timeout: 5000 })
121+
await expect(popover).toBeVisible()
122+
})

0 commit comments

Comments
 (0)