diff --git a/src/app/(main)/community/events/map/map-tooltip.tsx b/src/app/(main)/community/events/map/map-tooltip.tsx new file mode 100644 index 0000000000..0b318bacf8 --- /dev/null +++ b/src/app/(main)/community/events/map/map-tooltip.tsx @@ -0,0 +1,35 @@ +"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 MapTooltipProps = { + id: string + activeMeetupId: string | null + pointer: MeetupMapPointer +} + +export function MapTooltip({ id, activeMeetupId, pointer }: MapTooltipProps) { + 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..9b9607597f 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 { MapTooltip, type MeetupMapPointer } from "./map/map-tooltip" import { MapSkeleton } from "./map-skeleton" import { MeetupsList } from "./meetups-list" import { asRgbString, MAP_COLORS, MapColors } from "./map/map-colors" @@ -38,6 +45,35 @@ 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 @@ -105,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={ { @@ -115,9 +154,14 @@ export function MeetupsMap() { } as React.CSSProperties } > -
+
+ + diff --git a/test/e2e/community-events.spec.ts b/test/e2e/community-events.spec.ts index 1d06ec0c3a..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 @@ -42,3 +40,85 @@ 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("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 }) + await expect + .poll(async () => { + const box = await mapCanvas.boundingBox() + return Boolean(box && box.width > 100 && box.height > 100) + }) + .toBe(true) + 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 + 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(tooltip).toHaveText("London GraphQL", { timeout: 5000 }) + await expect(tooltip).toBeVisible() +})