Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/app/(main)/community/events/map/map-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span
id={id}
role="tooltip"
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"
style={{
transform: `translate3d(calc(${pointer.x}px - 50%), calc(${pointer.y}px - 50% - 24px), 0)`,
}}
>
{name}
</span>
)
}
54 changes: 52 additions & 2 deletions src/app/(main)/community/events/meetups-map.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -38,6 +45,35 @@ export function MeetupsMap() {

const [status, setStatus] = useState<MapStatus>("loading")
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [pointer, setPointer] = useState<MeetupMapPointer>({
x: 0,
y: 0,
visible: false,
})

const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
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
Expand Down Expand Up @@ -105,6 +141,9 @@ export function MeetupsMap() {

return (
<div
onMouseOut={() => {
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={
{
Expand All @@ -115,9 +154,14 @@ export function MeetupsMap() {
} as React.CSSProperties
}
>
<div className="group/map relative grow border-neu-200 bg-[--sea] dark:border-neu-50 dark:bg-[--sea] md:border-l">
<div
className="group/map relative grow border-neu-200 bg-[--sea] dark:border-neu-50 dark:bg-[--sea] md:border-l"
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
<canvas
ref={canvasRef}
aria-describedby="map-tooltip"
aria-label="Interactive WebGL map of GraphQL meetups"
className="block h-80 w-full animate-fade-in transition-opacity duration-150 ease-linear md:h-full"
style={{
Expand All @@ -127,6 +171,12 @@ export function MeetupsMap() {
}}
/>

<MapTooltip
id="map-tooltip"
activeMeetupId={activeMeetupId}
pointer={pointer}
/>

<InfoTip />

<MapSkeleton className={status === "loading" ? "" : "!opacity-0"} />
Expand Down
86 changes: 83 additions & 3 deletions test/e2e/community-events.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
})
Loading