Skip to content

Commit a4f4570

Browse files
committed
Merge branch 'cursor/implement-meetup-map-popover-and-test-796e' into new-events-and-meetups
2 parents 5c29a48 + f25244a commit a4f4570

File tree

3 files changed

+170
-5
lines changed

3 files changed

+170
-5
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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 MapTooltipProps = {
14+
id: string
15+
activeMeetupId: string | null
16+
pointer: MeetupMapPointer
17+
}
18+
19+
export function MapTooltip({ id, activeMeetupId, pointer }: MapTooltipProps) {
20+
if (!activeMeetupId || !pointer.visible) return null
21+
const name = meetupNameById.get(activeMeetupId)
22+
if (!name) return null
23+
return (
24+
<span
25+
id={id}
26+
role="tooltip"
27+
className="pointer-events-none absolute left-0 top-0 z-10 hidden min-w-0 whitespace-nowrap border border-neu-200/40 bg-neu-0/40 px-2 py-1 text-xs font-medium text-neu-900 shadow-sm backdrop-blur-sm group-hover/map:flex"
28+
style={{
29+
transform: `translate3d(calc(${pointer.x}px - 50%), calc(${pointer.y}px - 50% - 24px), 0)`,
30+
}}
31+
>
32+
{name}
33+
</span>
34+
)
35+
}

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

Lines changed: 52 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 { MapTooltip, type MeetupMapPointer } from "./map/map-tooltip"
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,35 @@ 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
73+
? { x: previous.x, y: previous.y, visible: false }
74+
: previous,
75+
)
76+
}
4177

4278
useEffect(() => {
4379
initialThemeRef.current = themeColors
@@ -105,6 +141,9 @@ export function MeetupsMap() {
105141

106142
return (
107143
<div
144+
onMouseOut={() => {
145+
setActiveMeetupId(null)
146+
}}
108147
className="my-6 flex flex-row-reverse divide-neu-200 border border-neu-200 bg-[--sea] [--sea:--sea-light] dark:divide-neu-50 dark:border-neu-50 dark:[--sea:--sea-dark] max-md:flex-col max-md:divide-y md:h-[592px]"
109148
style={
110149
{
@@ -115,9 +154,14 @@ export function MeetupsMap() {
115154
} as React.CSSProperties
116155
}
117156
>
118-
<div className="group/map relative grow border-neu-200 bg-[--sea] dark:border-neu-50 dark:bg-[--sea] md:border-l">
157+
<div
158+
className="group/map relative grow border-neu-200 bg-[--sea] dark:border-neu-50 dark:bg-[--sea] md:border-l"
159+
onPointerMove={handlePointerMove}
160+
onPointerLeave={handlePointerLeave}
161+
>
119162
<canvas
120163
ref={canvasRef}
164+
aria-describedby="map-tooltip"
121165
aria-label="Interactive WebGL map of GraphQL meetups"
122166
className="block h-80 w-full animate-fade-in transition-opacity duration-150 ease-linear md:h-full"
123167
style={{
@@ -127,6 +171,12 @@ export function MeetupsMap() {
127171
}}
128172
/>
129173

174+
<MapTooltip
175+
id="map-tooltip"
176+
activeMeetupId={activeMeetupId}
177+
pointer={pointer}
178+
/>
179+
130180
<InfoTip />
131181

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

test/e2e/community-events.spec.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { test, expect } from "@playwright/test"
22

3-
test("community events page map loads and Zurich meetup link works", async ({
4-
page,
5-
}) => {
3+
test("map loads and Zurich meetup link works", async ({ page }) => {
64
await page.goto("/community/events")
75

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

0 commit comments

Comments
 (0)