From 2bcf4e62785cf7a230c8a220a1a01665d48616b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Nov 2025 19:14:41 +0000 Subject: [PATCH 1/3] feat: Add map popover and hover effects to meetups map Co-authored-by: hasparus --- .../events/map/meetup-map-popover.tsx | 34 ++++++++ .../(main)/community/events/meetups-map.tsx | 44 ++++++++++- test/e2e/community-events.spec.ts | 78 +++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/app/(main)/community/events/map/meetup-map-popover.tsx diff --git a/src/app/(main)/community/events/map/meetup-map-popover.tsx b/src/app/(main)/community/events/map/meetup-map-popover.tsx new file mode 100644 index 0000000000..29b7a66ed4 --- /dev/null +++ b/src/app/(main)/community/events/map/meetup-map-popover.tsx @@ -0,0 +1,34 @@ +"use client" + +import { meetups } from "@/components/meetups" + +export type MeetupMapPointer = { + x: number + y: number + visible: boolean +} + +const meetupNameById = new Map(meetups.map(({ node }) => [node.id, node.name])) + +type MeetupMapPopoverProps = { + activeMeetupId: string | null + pointer: MeetupMapPointer +} + +export function MeetupMapPopover({ activeMeetupId, pointer }: MeetupMapPopoverProps) { + if (!activeMeetupId || !pointer.visible) return null + const name = meetupNameById.get(activeMeetupId) + if (!name) return null + return ( +
+ {name} +
+ ) +} diff --git a/src/app/(main)/community/events/meetups-map.tsx b/src/app/(main)/community/events/meetups-map.tsx index 83ac1aa858..7fa9fabaf4 100644 --- a/src/app/(main)/community/events/meetups-map.tsx +++ b/src/app/(main)/community/events/meetups-map.tsx @@ -1,11 +1,18 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" +import { + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react" import { useTheme } from "next-themes" import { meetups } from "@/components/meetups" import { bootMeetupsMap, type MapHandle, type MarkerPoint } from "./map/engine" +import { MeetupMapPopover, type MeetupMapPointer } from "./map/meetup-map-popover" import { MapSkeleton } from "./map-skeleton" import { MeetupsList } from "./meetups-list" import { asRgbString, MAP_COLORS, MapColors } from "./map/map-colors" @@ -38,6 +45,33 @@ export function MeetupsMap() { const [status, setStatus] = useState("loading") const [errorMessage, setErrorMessage] = useState(null) + const [pointer, setPointer] = useState({ + x: 0, + y: 0, + visible: false, + }) + + const handlePointerMove = (event: ReactPointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect() + const x = event.clientX - rect.left + const y = event.clientY - rect.top + setPointer(previous => { + if ( + previous.visible && + Math.abs(previous.x - x) < 0.5 && + Math.abs(previous.y - y) < 0.5 + ) { + return previous + } + return { x, y, visible: true } + }) + } + + const handlePointerLeave = () => { + setPointer(previous => + previous.visible ? { x: previous.x, y: previous.y, visible: false } : previous, + ) + } useEffect(() => { initialThemeRef.current = themeColors @@ -115,7 +149,11 @@ export function MeetupsMap() { } as React.CSSProperties } > -
+
+ + diff --git a/test/e2e/community-events.spec.ts b/test/e2e/community-events.spec.ts index 1d06ec0c3a..fb7551739c 100644 --- a/test/e2e/community-events.spec.ts +++ b/test/e2e/community-events.spec.ts @@ -42,3 +42,81 @@ test("community events page map loads and Zurich meetup link works", async ({ await newPage.waitForLoadState("domcontentloaded", { timeout: 10000 }) expect(newPage.url()).toContain("meetup.com/graphql-zurich") }) + +test("community events map popover appears on marker hover", async ({ page }) => { + await page.goto("/community/events") + const mapCanvas = page.locator("canvas").first() + await expect(mapCanvas).toBeVisible({ timeout: 10000 }) + await expect + .poll(async () => { + const box = await mapCanvas.boundingBox() + return Boolean(box && box.width > 100 && box.height > 100) + }) + .toBe(true) + const popover = page.locator('[data-testid="meetup-map-popover"]') + await expect(popover).toHaveCount(0) + await mapCanvas.hover() + const { clientX, clientY } = await page.evaluate(() => { + const canvas = document.querySelector("canvas") as HTMLCanvasElement | null + if (!canvas) throw new Error("Canvas not found") + const targetLat = 51.51 + const targetLon = -0.12 + const aspectRatio = 1.65 + const cellSize = 8 + const mercatorLimit = 85.05112878 + const minDisplayedLatitude = -60 + const baseLatitudeOffset = 4 + const baseLongitudeOffset = 0.1 + const clamp01 = (value: number) => { + if (value <= 0) return 0 + if (value >= 1) return 1 + return value + } + const normalizeLongitude = (value: number) => { + let lon = value + while (lon <= -180) lon += 360 + while (lon > 180) lon -= 360 + return lon + } + const latToRawV = (lat: number) => { + const clampedLat = Math.max(-mercatorLimit, Math.min(mercatorLimit, lat)) + const rad = (clampedLat * Math.PI) / 180 + return 0.5 - Math.log(Math.tan(Math.PI * 0.25 + rad * 0.5)) / (2 * Math.PI) + } + const maxProjectedV = latToRawV(mercatorLimit) + const minProjectedV = latToRawV(minDisplayedLatitude) + const lonLatToUV = (lon: number, lat: number) => { + const adjustedLon = normalizeLongitude(lon + baseLongitudeOffset) + const u = (adjustedLon + 180) / 360 + const adjustedLat = Math.max( + minDisplayedLatitude, + Math.min(mercatorLimit, lat + baseLatitudeOffset), + ) + const rawV = latToRawV(adjustedLat) + const normalizedV = clamp01((rawV - maxProjectedV) / (minProjectedV - maxProjectedV)) + return [u, normalizedV] as const + } + const { width, height } = canvas + const pixelRatio = window.devicePixelRatio || 1 + const worldHeight = Math.min(width / aspectRatio, height) + const worldWidth = worldHeight * aspectRatio + const panX = width * 0.5 - worldWidth * 0.5 + const panY = height * 0.5 - worldHeight * 0.5 + const [u, v] = lonLatToUV(targetLon, targetLat) + const markerY = 1 - v + const screenX = panX + u * worldWidth + const screenY = panY + markerY * worldHeight + const deviceCell = cellSize * pixelRatio + const cellX = Math.floor(screenX / deviceCell) + const cellY = Math.floor(screenY / deviceCell) + const centerX = (cellX + 0.5) * deviceCell + const centerY = (cellY + 0.5) * deviceCell + const rect = canvas.getBoundingClientRect() + const clientX = rect.left + centerX / pixelRatio + const clientY = rect.bottom - centerY / pixelRatio + return { clientX, clientY } + }) + await page.mouse.move(clientX, clientY) + await expect(popover).toHaveText("London GraphQL", { timeout: 5000 }) + await expect(popover).toBeVisible() +}) From a3bf861cad5f192262970eb32f17576406e5c138 Mon Sep 17 00:00:00 2001 From: Piotr Monwid-Olechnowicz Date: Wed, 12 Nov 2025 20:47:03 +0100 Subject: [PATCH 2/3] Tweak styles --- .../{meetup-map-popover.tsx => map-popover.tsx} | 14 ++++++-------- src/app/(main)/community/events/meetups-map.tsx | 11 ++++++++--- 2 files changed, 14 insertions(+), 11 deletions(-) rename src/app/(main)/community/events/map/{meetup-map-popover.tsx => map-popover.tsx} (51%) diff --git a/src/app/(main)/community/events/map/meetup-map-popover.tsx b/src/app/(main)/community/events/map/map-popover.tsx similarity index 51% rename from src/app/(main)/community/events/map/meetup-map-popover.tsx rename to src/app/(main)/community/events/map/map-popover.tsx index 29b7a66ed4..19df400a6e 100644 --- a/src/app/(main)/community/events/map/meetup-map-popover.tsx +++ b/src/app/(main)/community/events/map/map-popover.tsx @@ -10,25 +10,23 @@ export type MeetupMapPointer = { const meetupNameById = new Map(meetups.map(({ node }) => [node.id, node.name])) -type MeetupMapPopoverProps = { +type MapPopoverProps = { activeMeetupId: string | null pointer: MeetupMapPointer } -export function MeetupMapPopover({ activeMeetupId, pointer }: MeetupMapPopoverProps) { +export function MapPopover({ activeMeetupId, pointer }: MapPopoverProps) { if (!activeMeetupId || !pointer.visible) return null const name = meetupNameById.get(activeMeetupId) if (!name) return null return ( -
{name} -
+ ) } diff --git a/src/app/(main)/community/events/meetups-map.tsx b/src/app/(main)/community/events/meetups-map.tsx index 7fa9fabaf4..780e9f0a43 100644 --- a/src/app/(main)/community/events/meetups-map.tsx +++ b/src/app/(main)/community/events/meetups-map.tsx @@ -12,7 +12,7 @@ import { useTheme } from "next-themes" import { meetups } from "@/components/meetups" import { bootMeetupsMap, type MapHandle, type MarkerPoint } from "./map/engine" -import { MeetupMapPopover, type MeetupMapPointer } from "./map/meetup-map-popover" +import { MapPopover, type MeetupMapPointer } from "./map/map-popover" import { MapSkeleton } from "./map-skeleton" import { MeetupsList } from "./meetups-list" import { asRgbString, MAP_COLORS, MapColors } from "./map/map-colors" @@ -69,7 +69,9 @@ export function MeetupsMap() { const handlePointerLeave = () => { setPointer(previous => - previous.visible ? { x: previous.x, y: previous.y, visible: false } : previous, + previous.visible + ? { x: previous.x, y: previous.y, visible: false } + : previous, ) } @@ -139,6 +141,9 @@ export function MeetupsMap() { return (
{ + setActiveMeetupId(null) + }} 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]" style={ { @@ -165,7 +170,7 @@ export function MeetupsMap() { }} /> - + From f25244ad18b1b6f1aa1395c9a6c9adf85668af75 Mon Sep 17 00:00:00 2001 From: Piotr Monwid-Olechnowicz Date: Wed, 12 Nov 2025 20:51:25 +0100 Subject: [PATCH 3/3] Use tooltip role in the test --- .../map/{map-popover.tsx => map-tooltip.tsx} | 7 ++++-- .../(main)/community/events/meetups-map.tsx | 9 ++++++-- test/e2e/community-events.spec.ts | 22 ++++++++++--------- 3 files changed, 24 insertions(+), 14 deletions(-) rename src/app/(main)/community/events/map/{map-popover.tsx => map-tooltip.tsx} (84%) diff --git a/src/app/(main)/community/events/map/map-popover.tsx b/src/app/(main)/community/events/map/map-tooltip.tsx similarity index 84% rename from src/app/(main)/community/events/map/map-popover.tsx rename to src/app/(main)/community/events/map/map-tooltip.tsx index 19df400a6e..0b318bacf8 100644 --- a/src/app/(main)/community/events/map/map-popover.tsx +++ b/src/app/(main)/community/events/map/map-tooltip.tsx @@ -10,17 +10,20 @@ export type MeetupMapPointer = { const meetupNameById = new Map(meetups.map(({ node }) => [node.id, node.name])) -type MapPopoverProps = { +type MapTooltipProps = { + id: string activeMeetupId: string | null pointer: MeetupMapPointer } -export function MapPopover({ activeMeetupId, pointer }: MapPopoverProps) { +export function MapTooltip({ id, activeMeetupId, pointer }: MapTooltipProps) { if (!activeMeetupId || !pointer.visible) return null const name = meetupNameById.get(activeMeetupId) if (!name) return null return ( - + diff --git a/test/e2e/community-events.spec.ts b/test/e2e/community-events.spec.ts index fb7551739c..45d53dc9c1 100644 --- a/test/e2e/community-events.spec.ts +++ b/test/e2e/community-events.spec.ts @@ -1,8 +1,6 @@ import { test, expect } from "@playwright/test" -test("community events page map loads and Zurich meetup link works", async ({ - page, -}) => { +test("map loads and Zurich meetup link works", async ({ page }) => { await page.goto("/community/events") // Wait for the map canvas to be visible @@ -43,7 +41,7 @@ test("community events page map loads and Zurich meetup link works", async ({ expect(newPage.url()).toContain("meetup.com/graphql-zurich") }) -test("community events map popover appears on marker hover", async ({ page }) => { +test("map tooltip appears on marker hover", async ({ page }) => { await page.goto("/community/events") const mapCanvas = page.locator("canvas").first() await expect(mapCanvas).toBeVisible({ timeout: 10000 }) @@ -53,8 +51,8 @@ test("community events map popover appears on marker hover", async ({ page }) => return Boolean(box && box.width > 100 && box.height > 100) }) .toBe(true) - const popover = page.locator('[data-testid="meetup-map-popover"]') - await expect(popover).toHaveCount(0) + const tooltip = page.getByRole("tooltip") + await expect(tooltip).toHaveCount(0) await mapCanvas.hover() const { clientX, clientY } = await page.evaluate(() => { const canvas = document.querySelector("canvas") as HTMLCanvasElement | null @@ -81,7 +79,9 @@ test("community events map popover appears on marker hover", async ({ page }) => const latToRawV = (lat: number) => { const clampedLat = Math.max(-mercatorLimit, Math.min(mercatorLimit, lat)) const rad = (clampedLat * Math.PI) / 180 - return 0.5 - Math.log(Math.tan(Math.PI * 0.25 + rad * 0.5)) / (2 * Math.PI) + return ( + 0.5 - Math.log(Math.tan(Math.PI * 0.25 + rad * 0.5)) / (2 * Math.PI) + ) } const maxProjectedV = latToRawV(mercatorLimit) const minProjectedV = latToRawV(minDisplayedLatitude) @@ -93,7 +93,9 @@ test("community events map popover appears on marker hover", async ({ page }) => Math.min(mercatorLimit, lat + baseLatitudeOffset), ) const rawV = latToRawV(adjustedLat) - const normalizedV = clamp01((rawV - maxProjectedV) / (minProjectedV - maxProjectedV)) + const normalizedV = clamp01( + (rawV - maxProjectedV) / (minProjectedV - maxProjectedV), + ) return [u, normalizedV] as const } const { width, height } = canvas @@ -117,6 +119,6 @@ test("community events map popover appears on marker hover", async ({ page }) => return { clientX, clientY } }) await page.mouse.move(clientX, clientY) - await expect(popover).toHaveText("London GraphQL", { timeout: 5000 }) - await expect(popover).toBeVisible() + await expect(tooltip).toHaveText("London GraphQL", { timeout: 5000 }) + await expect(tooltip).toBeVisible() })