diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 06c926b94e..206e3a04d8 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,4 +1,4 @@ -name: Lint and check formatting +name: lint & test on: pull_request @@ -26,6 +26,23 @@ jobs: - name: Validate code snippets run: pnpm validate:snippets + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: the-guild-org/shared-config/setup@main + name: setup env + with: + packageManager: pnpm + workingDirectory: ./ + + - name: Install Dependencies + run: pnpm i + + - name: Run unit tests + run: pnpm test:unit + playwright: runs-on: ubuntu-latest steps: @@ -46,12 +63,9 @@ jobs: - name: Install Playwright Browsers run: ./node_modules/.bin/playwright install --with-deps - - name: Run Playwright tests + - name: Run end-to-end tests run: ./node_modules/.bin/playwright test - - name: Run unit tests - run: pnpm test:unit - - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/.gitignore b/.gitignore index 2c2af0a81e..9c5209d9cc 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,5 @@ out/ tsconfig.tsbuildinfo playwright-report/ + +.pnpm-store/ diff --git a/package.json b/package.json index 7233ec5412..4cb53f9787 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@hasparus/lezer-json-shikified": "1.1.3", "@headlessui/react": "^2.2.4", "@igorkowalczyk/is-browser": "^5.1.0", - "@lezer/highlight": "1.2.1", + "@lezer/highlight": "^1.2.1", "@next/bundle-analyzer": "^15.4.5", "@plaiceholder/next": "^3.0.0", "@sparticuz/chromium": "^138.0.2", diff --git a/playwright.config.ts b/playwright.config.ts index de0ed7f7d4..1d32e2a082 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -8,18 +8,35 @@ export default defineConfig({ outputDir: "./test/out", fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 2 : 1, workers: process.env.CI ? 1 : undefined, - reporter: "html", + reporter: process.env.CI ? [["github"], ["html"]] : "list", use: { baseURL: "http://localhost:3000", - trace: "on-first-retry", + trace: "retain-on-first-failure", + screenshot: "only-on-failure", }, + timeout: 60 * 1000, + projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + channel: "chromium", + ...(process.env.CI + ? { + args: [ + "--enable-gpu", + "--use-gl=angle", + "--use-angle=gl-egl", + "--ignore-gpu-blocklist", + "--enable-unsafe-swiftshader", + ], + } + : {}), + }, }, ], diff --git a/src/app/(development)/layout.tsx b/src/app/(development)/layout.tsx index bcfae89f8e..6b96532225 100644 --- a/src/app/(development)/layout.tsx +++ b/src/app/(development)/layout.tsx @@ -2,7 +2,6 @@ import React from "react" import { notFound } from "next/navigation" import { NewFontsStyleTag } from "../fonts" -// @ts-expect-error: we want to import the same version as Nextra for the main page import { ThemeProvider } from "next-themes" import "../colors.css" diff --git a/src/app/(main)/community/events/benefit-card.tsx b/src/app/(main)/community/events/benefit-card.tsx new file mode 100644 index 0000000000..3d35cdde61 --- /dev/null +++ b/src/app/(main)/community/events/benefit-card.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from "react" + +export function BenefitCard({ + title, + description, + icon, +}: { + title: string + description: string + icon: ReactNode +}) { + return ( +
+ {icon} +
+

{title}

+

{description}

+
+
+ ) +} diff --git a/src/app/(main)/community/events/benefits-section.tsx b/src/app/(main)/community/events/benefits-section.tsx new file mode 100644 index 0000000000..bb0d333bd9 --- /dev/null +++ b/src/app/(main)/community/events/benefits-section.tsx @@ -0,0 +1,45 @@ +import UsersIcon from "@/app/conf/_design-system/pixelarticons/users.svg?svgr" +import CommentIcon from "@/app/conf/_design-system/pixelarticons/comment.svg?svgr" +import SlidersIcon from "@/app/conf/_design-system/pixelarticons/sliders.svg?svgr" +import EyeIcon from "@/app/conf/_design-system/pixelarticons/eye.svg?svgr" + +import { BenefitCard } from "./benefit-card" + +export function BenefitsSection() { + return ( +
+
+

+ Benefits of getting involved +

+

+ Contributing to GraphQL means more than writing code — it’s a chance + to collaborate, share ideas, and shape the future of the ecosystem. +

+
+ +
+ } + title="Valuable networking opportunities" + description="Engage in conversations and hands-on projects to deepen your understanding of GraphQL." + /> + } + title="Collaborate with others" + description="Connect with contributors and teams building GraphQL tools and platforms." + /> + } + title="Help guide the spec" + description="Share ideas, give feedback, or participate in working groups to influence the future of GraphQL." + /> + } + title="Connect in real life" + description="Put a face to the handle — meet contributors in person at events and meetups. Build lasting connections beyond the screen." + /> +
+
+ ) +} diff --git a/src/app/(main)/community/events/bring-graphql-to-your-community.tsx b/src/app/(main)/community/events/bring-graphql-to-your-community.tsx new file mode 100644 index 0000000000..053fc49808 --- /dev/null +++ b/src/app/(main)/community/events/bring-graphql-to-your-community.tsx @@ -0,0 +1,55 @@ +import { Button } from "../../../conf/_design-system/button" +import { StripesDecoration } from "../../../conf/_design-system/stripes-decoration" +import { DISCORD_CHANNEL_LINK } from "./links" + +export function BringGraphQLToYourCommunity() { + return ( +
+
+ +
+

Bring GraphQL to your community

+

+ Learn how to start a local initiative and create your own – host + events, share knowledge, and grow the GraphQL community where you + live. +

+
+
+ + +
+
+
+ ) +} + +function Stripes() { + const mask = "linear-gradient(20deg, transparent 80%, rgb(0 0 0 / 0.6))" + return ( +
+ +
+ ) +} diff --git a/src/components/events/event-card.tsx b/src/app/(main)/community/events/event-card.tsx similarity index 67% rename from src/components/events/event-card.tsx rename to src/app/(main)/community/events/event-card.tsx index 944f746ba1..346bdee5bc 100644 --- a/src/components/events/event-card.tsx +++ b/src/app/(main)/community/events/event-card.tsx @@ -3,7 +3,8 @@ import { clsx } from "clsx" import { CalendarIcon } from "@/app/conf/_design-system/pixelarticons/calendar-icon" import { PinIcon } from "@/app/conf/_design-system/pixelarticons/pin-icon" -import { Tag } from "../../app/conf/_design-system/tag" +import { Tag } from "@/app/conf/_design-system/tag" +import { eventTagColors } from "./event-filter-tag" const dateFormatter = new Intl.DateTimeFormat("en", { day: "numeric", @@ -55,6 +56,7 @@ export interface EventCardProps { name: ReactNode meta?: ReactNode official?: boolean + kind: "meetup" | "conference" | "working-group" } export function EventCard({ @@ -64,15 +66,24 @@ export function EventCard({ name, meta, official, + kind, }: EventCardProps) { const dateLabel = formatDateLabel(date) const parsedDate = normaliseDate(date) - return (
+ {kind} {meta ? ( - {meta} + {meta} ) : ( Official GraphQL Local )} - {official ? ( - - - ★ - - Official - - ) : meta ? null : ( -
- )}
@@ -109,15 +111,15 @@ export function EventCard({
{dateLabel && ( -
- +
+ {parsedDate ? ( ) : ( @@ -126,8 +128,8 @@ export function EventCard({
)} {city && ( -
- +
+ {city}
)} diff --git a/src/app/(main)/community/events/event-filter-tag.tsx b/src/app/(main)/community/events/event-filter-tag.tsx new file mode 100644 index 0000000000..9b1ebc398f --- /dev/null +++ b/src/app/(main)/community/events/event-filter-tag.tsx @@ -0,0 +1,51 @@ +import { Tag } from "@/app/conf/_design-system/tag" +import { CheckboxIcon } from "@/app/conf/_design-system/pixelarticons/checkbox-icon" +import clsx from "clsx" + +export type EventKind = "meetup" | "conference" | "working-group" + +export const eventTagColors = { + conference: "hsl(var(--color-pri-base))", + meetup: "hsl(var(--color-sec-dark))", + "working-group": "#6883FF", +} + +export interface EventFilterTagProps + extends Omit, "onChange"> { + kind: EventKind + checked: boolean + onChange: (event: React.ChangeEvent) => void +} + +export function EventFilterTag({ + kind, + checked, + onChange, + ...rest +}: EventFilterTagProps) { + return ( + + ) +} diff --git a/src/app/(main)/community/events/events-list.tsx b/src/app/(main)/community/events/events-list.tsx new file mode 100644 index 0000000000..d9c49c856f --- /dev/null +++ b/src/app/(main)/community/events/events-list.tsx @@ -0,0 +1,134 @@ +"use client" + +import { useState, type ComponentPropsWithoutRef } from "react" +import { clsx } from "clsx" + +import { EventCard } from "./event-card" +import { EventsScrollview } from "./events-scrollview" +import type { Event, Meetup } from "./events" +import { EventFilterTag, EventKind } from "./event-filter-tag" + +interface FilterChipProps extends ComponentPropsWithoutRef<"button"> { + active?: boolean + count?: number +} + +export function FilterChip({ + active = false, + children, + className, + count, + disabled, + type, + ...props +}: FilterChipProps) { + const showCount = typeof count === "number" + + return ( + + ) +} + +const ALL_SHOWN = { + meetup: true, + conference: true, + "working-group": true, +} satisfies Record + +export function EventsList({ + events, + className, +}: { + events: Array + className?: string +}) { + const [kindFilters, setKindFilters] = useState(ALL_SHOWN) + + if (events.length === 0) return null + + const tags: Set = new Set() + events.forEach(event => { + // todo: add working groups + if ("node" in event) tags.add("meetup") + else tags.add("conference") + }) + + return ( +
+ {tags.size > 1 && events.length > 4 ? ( +
+ Event type +
+ {Array.from(tags).map(tag => ( + { + setKindFilters(prev => ({ + ...prev, + [tag]: event.target.checked, + })) + }} + /> + ))} +
+
+ ) : null} + + {events + .filter(event => { + if ("node" in event) return kindFilters["meetup"] + else return kindFilters["conference"] + }) + .map(event => + "node" in event ? ( + + ) : ( + + ), + )} + +
+ ) +} diff --git a/src/app/(main)/community/events/events-scrollview.tsx b/src/app/(main)/community/events/events-scrollview.tsx new file mode 100644 index 0000000000..d58d57d178 --- /dev/null +++ b/src/app/(main)/community/events/events-scrollview.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from "react" + +export function EventsScrollview({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/events/index.ts b/src/app/(main)/community/events/events.ts similarity index 97% rename from src/components/events/index.ts rename to src/app/(main)/community/events/events.ts index 8befb6af03..1246fad931 100644 --- a/src/components/events/index.ts +++ b/src/app/(main)/community/events/events.ts @@ -1,6 +1,6 @@ export * from "./event-card" -interface Event { +export interface Event { name: string slug: string location: string @@ -158,3 +158,7 @@ export const events: Event[] = [ hostLink: "https://www.truedigitalpark.com/", }, ].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + +import type { meetups } from "@/components/meetups" + +export type Meetup = (typeof meetups)[number] diff --git a/src/app/(main)/community/events/get-your-meetup-noticed-section.tsx b/src/app/(main)/community/events/get-your-meetup-noticed-section.tsx new file mode 100644 index 0000000000..be2b784624 --- /dev/null +++ b/src/app/(main)/community/events/get-your-meetup-noticed-section.tsx @@ -0,0 +1,50 @@ +import Mailbox from "./mailbox.svg?svgr" +import { Button } from "@/app/conf/_design-system/button" +import { DISCORD_CHANNEL_LINK, DISCORD_SERVER_LINK } from "./links" + +export function GetYourMeetupNoticedSection() { + return ( +
+ +
+ ) +} diff --git a/src/app/(main)/community/events/links.tsx b/src/app/(main)/community/events/links.tsx new file mode 100644 index 0000000000..4628dd394a --- /dev/null +++ b/src/app/(main)/community/events/links.tsx @@ -0,0 +1,4 @@ +export const DISCORD_SERVER_LINK = "https://discord.graphql.org" + +export const DISCORD_CHANNEL_LINK = + "https://discord.com/channels/625400653321076807/1020000211927576766/" diff --git a/src/app/(main)/community/events/mailbox.svg b/src/app/(main)/community/events/mailbox.svg new file mode 100644 index 0000000000..8edf239122 --- /dev/null +++ b/src/app/(main)/community/events/mailbox.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/app/(main)/community/events/map-skeleton.tsx b/src/app/(main)/community/events/map-skeleton.tsx new file mode 100644 index 0000000000..7e7d87e2c0 --- /dev/null +++ b/src/app/(main)/community/events/map-skeleton.tsx @@ -0,0 +1,13 @@ +export function MapSkeleton({ className }: { className?: string }) { + return ( +
+ ) +} diff --git a/src/app/(main)/community/events/map/diagnostics.ts b/src/app/(main)/community/events/map/diagnostics.ts new file mode 100644 index 0000000000..a682d25a8b --- /dev/null +++ b/src/app/(main)/community/events/map/diagnostics.ts @@ -0,0 +1,119 @@ +import { lonLatToUV } from "./projection" +import type { MarkerPoint } from "./engine" +import type { WorldDimensions, Vec2Like } from "./viewport-math" + +export type DiagnosticsFrame = { + zoom: number + pan: Vec2Like + target: Vec2Like + dims: WorldDimensions + cellSize: number + squareSize: number + pixelRatio: number + fps: number +} + +export type Diagnostics = { + afterRender(frame: DiagnosticsFrame): void +} + +type DiagnosticsOptions = { + markers: MarkerPoint[] +} + +const LOG_INTERVAL_MS = 2_000 +const MARKER_SCREEN_EPS = 2 + +export function createDiagnostics(options: DiagnosticsOptions): Diagnostics { + const lastMarkerPositions = options.markers.map(marker => ({ + id: marker.id, + uv: lonLatToUV(marker.lon, marker.lat), + })) + let lastLog = 0 + return { + afterRender(frame) { + const now = performance.now() + if (now - lastLog < LOG_INTERVAL_MS) return + lastLog = now + const markerStats = computeMarkerVisibility(lastMarkerPositions, frame) + const isZoomedOut = frame.zoom <= 1.05 + const centered = + Math.abs(frame.target[0] - 0.5) < 0.05 && + Math.abs(frame.target[1] - 0.5) < 0.05 + const bold = "font-weight:600" + if ( + isZoomedOut && + centered && + markerStats.visible < options.markers.length + ) { + console.warn( + `%cMeetupsMap%c ⚠️ markers ${markerStats.visible}/${options.markers.length} (missing ${markerStats.missingIds.length}): ${markerStats.missingIds.join(", ")}`, + bold, + "", + ) + return + } + const tilesX = estimateTileCount( + frame.dims.width, + frame.cellSize, + frame.pixelRatio, + ) + const tilesY = estimateTileCount( + frame.dims.height, + frame.cellSize, + frame.pixelRatio, + ) + console.info( + `%cMeetupsMap%c zoom %c${frame.zoom.toFixed(2)}%c, fps %c${frame.fps.toFixed(1)}%c, markers %c${markerStats.visible}/${options.markers.length}%c · tiles ${tilesX}×${tilesY}`, + bold, + "", + bold, + "", + bold, + "", + bold, + "", + ) + }, + } +} + +export type MarkerUV = { + id: string + uv: [number, number] +} + +export function computeMarkerVisibility( + markers: MarkerUV[], + frame: DiagnosticsFrame, +) { + const zoomedWorldWidth = frame.dims.worldWidth * frame.zoom + const zoomedWorldHeight = frame.dims.worldHeight * frame.zoom + let visible = 0 + const missingIds: string[] = [] + for (const marker of markers) { + const screenX = frame.pan[0] + marker.uv[0] * zoomedWorldWidth + const yNormalized = 1 - marker.uv[1] + const screenY = frame.pan[1] + yNormalized * zoomedWorldHeight + const onScreen = + screenX >= -MARKER_SCREEN_EPS && + screenX <= frame.dims.width + MARKER_SCREEN_EPS && + screenY >= -MARKER_SCREEN_EPS && + screenY <= frame.dims.height + MARKER_SCREEN_EPS + if (onScreen) { + visible += 1 + } else { + missingIds.push(marker.id) + } + } + return { visible, missingIds } +} + +function estimateTileCount( + lengthPx: number, + cellSize: number, + pixelRatio: number, +) { + const deviceCell = Math.max(1, cellSize * pixelRatio) + return Math.max(1, Math.round(lengthPx / deviceCell)) +} diff --git a/src/app/(main)/community/events/map/engine.ts b/src/app/(main)/community/events/map/engine.ts new file mode 100644 index 0000000000..12260c2d33 --- /dev/null +++ b/src/app/(main)/community/events/map/engine.ts @@ -0,0 +1,946 @@ +import { createProgram, initGL, loadLandMaskTexture } from "./gl-utils" +import { lonLatToUV, uvToLonLat } from "./projection" +import { dotsFrag, fullscreenVert, MARKER_CAPACITY } from "./shaders" +import { createDiagnostics, type Diagnostics } from "./diagnostics" +import { + clamp, + clampLatitude, + computeLatitudeBounds, + computePointerVelocity, + computeWorldDimensions, + dragTargetByPixels, + screenToUV, + stepInertia, + updatePanFromTarget, + wrapCentered, + zoomAroundPointer, + type LatitudeBounds, + type WorldDimensions, +} from "./viewport-math" +import type { MapColors } from "./map-colors" + +export type MarkerPoint = { + id: string + lon: number + lat: number +} + +export type MapHandle = { + dispose(): void + setThemeColors(colors: MapColors): void + setActiveMarker(id: string | null): void + resetView(): void +} + +export type BootOptions = { + canvas: HTMLCanvasElement + markers: MarkerPoint[] + maskUrl: string + initialCellSize: number + initialSquareSize: number + aspectRatio: number + theme: MapColors + signal?: AbortSignal + onActiveMarkerChange?: (id: string | null) => void +} + +const MIN_ZOOM = 1 +const MAX_ZOOM = 20 +const MARKER_TYPE_REGULAR = 1 +const MARKER_TYPE_ACTIVE = 2 +const ACTIVE_TRANSITION_MS = 100 +/** + * Per-frame damping factor (scaled by dt / (1/60s)). + * Decrease value to increase damping. + */ +const INERTIA_DAMPING = 0.92 +/** Reference frame time in milliseconds for the damping exponent. */ +const INERTIA_BASE_DT = 1000 / 60 +/** Velocities below this normalized threshold snap directly to zero. */ +const INERTIA_EPS = 1e-5 + +export async function bootMeetupsMap(options: BootOptions): Promise { + const gl = initGL(options.canvas) + const dotsProgram = createProgram(gl, fullscreenVert, dotsFrag) + let landTexture: WebGLTexture | null = null + try { + landTexture = await loadLandMaskTexture(gl, options.maskUrl, options.signal) + console.assert( + gl.getError() === gl.NO_ERROR, + "WebGL init error", + gl.getError(), + ) + return new MapEngine({ + ...options, + gl, + dotsProgram, + landTexture, + }) + } catch (error) { + gl.deleteProgram(dotsProgram) + if (landTexture) gl.deleteTexture(landTexture) + throw error + } +} + +type InternalOptions = BootOptions & { + gl: WebGL2RenderingContext + dotsProgram: WebGLProgram + landTexture: WebGLTexture +} + +class MapEngine implements MapHandle { + private gl: WebGL2RenderingContext + private canvas: HTMLCanvasElement + private dotsProgram: WebGLProgram + private landTexture: WebGLTexture + private cellSize: number + private squareSize: number + private aspectRatio: number + private zoom = 1 + private pan = new Float32Array([0, 0]) + private target = new Float32Array([0.5, 0.5]) + private velocity = new Float32Array([0, 0]) + private pixelRatio = getDevicePixelRatio() + private seaColor: Float32Array + private landColor: Float32Array + private readonly fullscreenVAO: WebGLVertexArrayObject + private readonly markerPoints: MarkerPoint[] + private readonly markerData: Float32Array + private markerCount: number + private markerCapacityWarned = false + private readonly markerColor: Float32Array + private haloColor: Float32Array + private haloMinOpacity: number + private readonly markerIntensity: Float32Array + private readonly markerIntensityTarget: Float32Array + private readonly markerIndexById: Map + private activeMarkerIndex = -1 + private hoveredMarkerIndex = -1 + private markerUniformDirty = true + private readonly resizeObserver: ResizeObserver + private readonly diagnostics: Diagnostics | null + private lastRenderState: { + pan: [number, number] + zoom: number + dims: WorldDimensions + deviceCell: number + } | null = null + private rafHandle = 0 + private fps = 60 + private lastFrameTime = performance.now() + private readonly pointer = { + active: false, + id: 0, + startX: 0, + startY: 0, + targetAtStart: new Float32Array([0, 0]), + lastMoveTime: 0, + } + private hoverPointer: { x: number; y: number; hasValue: boolean } = { + x: 0, + y: 0, + hasValue: false, + } + private readonly pointerTrail = new Float32Array(192) + private trailCount = 0 + private readonly onActiveMarkerChange?: (id: string | null) => void + private destroyed = false + + constructor(options: InternalOptions) { + this.gl = options.gl + this.canvas = options.canvas + this.dotsProgram = options.dotsProgram + this.landTexture = options.landTexture + this.aspectRatio = options.aspectRatio + this.cellSize = options.initialCellSize + this.squareSize = Math.min(options.initialSquareSize, this.cellSize) + + this.seaColor = new Float32Array(options.theme.sea) + this.landColor = new Float32Array(options.theme.land) + this.markerPoints = options.markers + this.markerData = new Float32Array(MARKER_CAPACITY * 4) + this.markerCount = this.packMarkers(this.markerPoints, this.markerData) + this.markerColor = new Float32Array(options.theme.marker) + this.haloColor = new Float32Array(options.theme.halo) + this.haloMinOpacity = options.theme.haloMinOpacity + this.markerIntensity = new Float32Array(MARKER_CAPACITY) + this.markerIntensityTarget = new Float32Array(MARKER_CAPACITY) + this.markerIndexById = new Map() + this.markerPoints.forEach((marker, index) => { + this.markerIndexById.set(marker.id, index) + }) + this.onActiveMarkerChange = options.onActiveMarkerChange + + this.fullscreenVAO = this.gl.createVertexArray() as WebGLVertexArrayObject + this.uploadMarkerUniforms() + this.pointerTrail.fill(-1) + this.trailCount = 0 + + this.resizeObserver = new ResizeObserver(() => this.resizeCanvas()) + this.resizeObserver.observe(this.canvas) + this.resizeCanvas() + this.updatePanFromTarget() + this.attachEvents() + this.attachDevtools() + this.diagnostics = + process.env.NODE_ENV !== "production" + ? createDiagnostics({ markers: this.markerPoints }) + : null + this.loop() + } + dispose() { + if (this.destroyed) return + this.destroyed = true + cancelAnimationFrame(this.rafHandle) + this.resizeObserver.disconnect() + this.detachEvents() + this.detachDevtools() + this.gl.deleteProgram(this.dotsProgram) + this.gl.deleteTexture(this.landTexture) + this.gl.deleteVertexArray(this.fullscreenVAO) + } + + setThemeColors(colors: MapColors) { + this.seaColor.set(colors.sea) + this.landColor.set(colors.land) + this.markerColor.set(colors.marker) + this.haloColor.set(colors.halo) + this.haloMinOpacity = colors.haloMinOpacity + } + + setActiveMarker(id: string | null) { + const nextIndex = + typeof id === "string" ? (this.markerIndexById.get(id) ?? -1) : -1 + if (nextIndex === this.activeMarkerIndex) { + if (nextIndex >= 0) { + this.markerIntensityTarget[nextIndex] = 1 + } + return + } + if (this.activeMarkerIndex >= 0) { + this.markerIntensityTarget[this.activeMarkerIndex] = 0 + } + this.activeMarkerIndex = nextIndex + if (nextIndex >= 0) { + const base = nextIndex * 4 + this.markerData[base + 2] = MARKER_TYPE_ACTIVE + this.markerIntensityTarget[nextIndex] = 1 + } + this.markerUniformDirty = true + } + + private uploadMarkerUniforms() { + this.gl.useProgram(this.dotsProgram) + const location = this.gl.getUniformLocation(this.dotsProgram, "uMarkers") + if (location) { + this.gl.uniform4fv(location, this.markerData) + } + this.markerUniformDirty = false + } + + private getWorldDimensions(): WorldDimensions { + return computeWorldDimensions( + this.canvas.width, + this.canvas.height, + this.aspectRatio, + ) + } + + private clampLatitude(value: number) { + return clampLatitude(value, this.getLatitudeBounds()) + } + + private getLatitudeBounds(): LatitudeBounds { + const { height, worldHeight } = this.getWorldDimensions() + return computeLatitudeBounds(height, worldHeight, this.zoom) + } + + resetView() { + this.zoom = 1 + this.target[0] = 0.5 + this.target[1] = 0.5 + this.velocity[0] = 0 + this.velocity[1] = 0 + this.updatePanFromTarget() + } + + private packMarkers(markers: MarkerPoint[], target: Float32Array) { + const capacity = MARKER_CAPACITY + const count = Math.min(markers.length, capacity) + for (let i = 0; i < count; i++) { + const marker = markers[i] + const uv = lonLatToUV(marker.lon, marker.lat) + const base = i * 4 + target[base + 0] = uv[0] + target[base + 1] = 1 - uv[1] + target[base + 2] = MARKER_TYPE_REGULAR + target[base + 3] = 0 + } + if (markers.length > capacity && !this.markerCapacityWarned) { + console.warn( + `Meetups map: capped marker count at ${capacity} (received ${markers.length}).`, + ) + this.markerCapacityWarned = true + } + return count + } + + private pointerToDevice(clientX: number, clientY: number) { + const rect = this.canvas.getBoundingClientRect() + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + return null + } + const relativeX = clientX - rect.left + const relativeY = clientY - rect.top + const px = relativeX * this.pixelRatio + const py = this.canvas.height - relativeY * this.pixelRatio + return [px, py] as const + } + + private updateHoveredMarkerFromClient(clientX: number, clientY: number) { + if (this.pointer.active) return + if (!this.onActiveMarkerChange) return + const device = this.pointerToDevice(clientX, clientY) + if (!device) { + this.notifyHoverChange(-1) + return + } + const [px, py] = device + const dims = this.getWorldDimensions() + const deviceCell = this.cellSize * this.pixelRatio + if (!(deviceCell > 0)) { + this.notifyHoverChange(-1) + return + } + const cellX = Math.floor(px / deviceCell) + const cellY = Math.floor(py / deviceCell) + const centerX = (cellX + 0.5) * deviceCell + const centerY = (cellY + 0.5) * deviceCell + const zoomedHeight = dims.worldHeight * this.zoom + if (!(zoomedHeight > 0)) { + this.notifyHoverChange(-1) + return + } + const normalizedY = (centerY - this.pan[1]) / zoomedHeight + if (normalizedY < 0 || normalizedY > 1) { + this.notifyHoverChange(-1) + return + } + const periodX = dims.worldWidth * this.zoom + if (!(periodX > 0 && Number.isFinite(periodX))) { + this.notifyHoverChange(-1) + return + } + const halfPeriod = 0.5 * periodX + let foundIndex = -1 + for (let i = 0; i < this.markerCount; i++) { + const base = i * 4 + const markerX = this.markerData[base] + const markerY = this.markerData[base + 1] + const baseX = this.pan[0] + markerX * periodX + let offset = baseX - centerX + halfPeriod + offset = (((offset % periodX) + periodX) % periodX) - halfPeriod + const nearestX = centerX + offset + const screenY = this.pan[1] + markerY * zoomedHeight + const markerCellX = Math.floor(nearestX / deviceCell) + const markerCellY = Math.floor(screenY / deviceCell) + if (markerCellX === cellX && markerCellY === cellY) { + foundIndex = i + break + } + } + this.notifyHoverChange(foundIndex) + } + + private notifyHoverChange(index: number) { + if (index === this.hoveredMarkerIndex) return + this.hoveredMarkerIndex = index + this.updateCursor() + if (!this.onActiveMarkerChange) return + if (this.destroyed) return + const id = + index >= 0 && index < this.markerPoints.length + ? this.markerPoints[index].id + : null + this.onActiveMarkerChange(id) + } + + private updateCursor() { + if (this.pointer.active) return + this.canvas.style.cursor = + this.hoveredMarkerIndex >= 0 ? "pointer" : "default" + } + + private updatePointerTrail(px: number, py: number) { + const now = performance.now() * 0.001 + const maxGap = this.cellSize * this.pixelRatio * 1.5 + + if (this.trailCount === 0) { + const idx = 0 + this.pointerTrail[idx] = px + this.pointerTrail[idx + 1] = py + this.pointerTrail[idx + 2] = now + this.trailCount = 1 + return + } + + const lastIdx = 0 + const lastX = this.pointerTrail[lastIdx] + const lastY = this.pointerTrail[lastIdx + 1] + const lastTime = this.pointerTrail[lastIdx + 2] + const dx = px - lastX + const dy = py - lastY + const dist = Math.sqrt(dx * dx + dy * dy) + + if (dist <= maxGap) { + this.addTrailPoint(px, py, now) + } else { + const steps = Math.ceil(dist / maxGap) + const stepSize = 1.0 / steps + const timeStep = (now - lastTime) / steps + + for (let i = 0; i < steps; i++) { + const t = (i + 1) * stepSize + const interpX = lastX + dx * t + const interpY = lastY + dy * t + const interpTime = lastTime + timeStep * (i + 1) + this.addTrailPoint(interpX, interpY, interpTime) + } + } + } + + private addTrailPoint(px: number, py: number, time: number) { + if (this.trailCount >= 64) { + for (let i = 189; i >= 3; i -= 3) { + this.pointerTrail[i] = this.pointerTrail[i - 3] + this.pointerTrail[i - 1] = this.pointerTrail[i - 4] + this.pointerTrail[i - 2] = this.pointerTrail[i - 5] + } + } else { + for (let i = this.trailCount * 3 - 1; i >= 0; i--) { + this.pointerTrail[i + 3] = this.pointerTrail[i] + } + this.trailCount++ + } + this.pointerTrail[0] = px + this.pointerTrail[1] = py + this.pointerTrail[2] = time + } + + private attachEvents() { + this.canvas.style.cursor = "default" + this.canvas.addEventListener("pointerdown", this.handlePointerDown) + this.canvas.addEventListener("pointermove", this.handlePointerMove) + this.canvas.addEventListener("pointerup", this.handlePointerUp) + this.canvas.addEventListener("pointerleave", this.handlePointerUp) + this.canvas.addEventListener("pointercancel", this.handlePointerUp) + this.canvas.addEventListener("wheel", this.handleWheel, { passive: false }) + window.addEventListener("keydown", this.handleKeyDown) + window.addEventListener("resize", this.resizeCanvas) + } + + private detachEvents() { + this.canvas.removeEventListener("pointerdown", this.handlePointerDown) + this.canvas.removeEventListener("pointermove", this.handlePointerMove) + this.canvas.removeEventListener("pointerup", this.handlePointerUp) + this.canvas.removeEventListener("pointerleave", this.handlePointerUp) + this.canvas.removeEventListener("pointercancel", this.handlePointerUp) + this.canvas.removeEventListener("wheel", this.handleWheel) + window.removeEventListener("keydown", this.handleKeyDown) + window.removeEventListener("resize", this.resizeCanvas) + } + + private attachDevtools() { + if (process.env.NODE_ENV === "production") return + this.canvas.addEventListener("click", this.handleDebugClick!) + } + + private detachDevtools() { + if (process.env.NODE_ENV === "production") return + this.canvas.removeEventListener("click", this.handleDebugClick!) + } + + private handlePointerDown = (event: PointerEvent) => { + if (event.button !== 0) return + this.pointer.active = true + this.pointer.id = event.pointerId + this.pointer.startX = event.clientX + this.pointer.startY = event.clientY + this.pointer.targetAtStart[0] = this.target[0] + this.pointer.targetAtStart[1] = this.target[1] + this.pointer.lastMoveTime = performance.now() + this.velocity[0] = 0 + this.velocity[1] = 0 + this.canvas.setPointerCapture(event.pointerId) + this.canvas.style.cursor = "move" + this.notifyHoverChange(-1) + this.pointerTrail.fill(-1) + this.trailCount = 0 + } + + private handlePointerMove = (event: PointerEvent) => { + this.hoverPointer.x = event.clientX + this.hoverPointer.y = event.clientY + this.hoverPointer.hasValue = true + const devicePos = this.pointerToDevice(event.clientX, event.clientY) + if (devicePos && !this.pointer.active) { + this.updatePointerTrail(devicePos[0], devicePos[1]) + } + if (!this.pointer.active) { + this.updateHoveredMarkerFromClient(event.clientX, event.clientY) + } + if (!this.pointer.active || event.pointerId !== this.pointer.id) return + const scale = this.pixelRatio + const dx = (event.clientX - this.pointer.startX) * scale + const dy = (event.clientY - this.pointer.startY) * scale + const dims = this.getWorldDimensions() + const prevX = this.target[0] + const prevY = this.target[1] + const nextTarget = dragTargetByPixels( + this.pointer.targetAtStart, + dx, + dy, + this.zoom, + dims, + ) + this.target[0] = nextTarget[0] + this.target[1] = this.clampLatitude(nextTarget[1]) + this.updatePanFromTarget() + + const now = performance.now() + const last = this.pointer.lastMoveTime || now + const dt = Math.max(now - last, 1) + this.pointer.lastMoveTime = now + const velocity = computePointerVelocity( + [prevX, prevY], + [this.target[0], this.target[1]], + dt, + ) + this.velocity[0] = velocity[0] + this.velocity[1] = velocity[1] + } + + private handlePointerUp = (event: PointerEvent) => { + if (event.type === "pointerleave" || event.type === "pointercancel") { + this.hoverPointer.hasValue = false + this.notifyHoverChange(-1) + this.pointerTrail.fill(-1) + this.trailCount = 0 + } + if (!this.pointer.active || event.pointerId !== this.pointer.id) return + this.pointer.active = false + this.canvas.releasePointerCapture(event.pointerId) + this.canvas.style.cursor = "default" + this.pointer.lastMoveTime = 0 + if (event.type === "pointerup") { + this.updateHoveredMarkerFromClient(event.clientX, event.clientY) + } else { + this.updateCursor() + } + } + + private handleDebugClick = + process.env.NODE_ENV === "production" + ? undefined + : (event: MouseEvent) => { + const rect = this.canvas.getBoundingClientRect() + const scale = this.pixelRatio + const px = (event.clientX - rect.left) * scale + const py = (event.clientY - rect.top) * scale + const state = + this.lastRenderState ?? this.captureRenderStateSnapshot() + + const [u, v] = screenToUV(px, py, state.pan, state.zoom, state.dims) + const { lon, lat } = uvToLonLat(u, v) + console.debug( + `MeetupsMap click → lat ${lat.toFixed(2)}, lon ${lon.toFixed(2)}`, + ) + } + + private handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "r" || event.key === "R") { + this.resetView() + } + } + + private handleWheel = (event: WheelEvent) => { + if (!event.ctrlKey) { + // we only handle zooming with control or with pinch gestures (which set ctrlKey) + // to avoid interfering with normal scrolling through the page + return + } + + event.preventDefault() + const rect = this.canvas.getBoundingClientRect() + const scale = this.pixelRatio + const wheel = event as WheelEvent & { + pointerType?: string + sourceCapabilities?: { firesTouchEvents?: boolean } + } + const looksLikeTouch = + wheel.pointerType === "touch" || + wheel.sourceCapabilities?.firesTouchEvents + const deviceHeight = this.canvas.height + const hasOffsets = + Number.isFinite(event.offsetX) && Number.isFinite(event.offsetY) + + const toDevice = (relativeX: number, relativeY: number) => { + const px = relativeX * scale + const py = deviceHeight - relativeY * scale + return [px, py] as const + } + + const [pointerPx, pointerPy] = (() => { + if (hasOffsets) { + return toDevice(event.offsetX, event.offsetY) + } + const hasEventCoords = + Number.isFinite(event.clientX) && Number.isFinite(event.clientY) + const withinBounds = + hasEventCoords && + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + const shouldUseEventCoords = withinBounds && !looksLikeTouch + const pointerClientX = shouldUseEventCoords + ? event.clientX + : this.hoverPointer.hasValue + ? this.hoverPointer.x + : rect.left + rect.width * 0.5 + const pointerClientY = shouldUseEventCoords + ? event.clientY + : this.hoverPointer.hasValue + ? this.hoverPointer.y + : rect.top + rect.height * 0.5 + return toDevice(pointerClientX - rect.left, pointerClientY - rect.top) + })() + + const wheelSensitivity = 0.005 + const zoomFactor = Math.exp(-event.deltaY * wheelSensitivity) + const previousZoom = this.zoom + const nextZoom = clamp(previousZoom * zoomFactor, MIN_ZOOM, MAX_ZOOM) + if (nextZoom === previousZoom) return + + const dims = this.getWorldDimensions() + const [nextTargetX, nextTargetY] = zoomAroundPointer({ + pointerPx, + pointerPy, + previousZoom, + nextZoom, + pan: this.pan, + dims, + }) + this.zoom = nextZoom + this.target[0] = nextTargetX + this.target[1] = this.clampLatitude(nextTargetY) + this.updatePanFromTarget() + this.velocity[0] = 0 + this.velocity[1] = 0 + } + + private resizeCanvas = (explicitPixelRatio?: number | UIEvent) => { + // we discard the argument if it's an event + const nextPixelRatio = + typeof explicitPixelRatio === "number" ? explicitPixelRatio : undefined + + const dpr = nextPixelRatio ?? getDevicePixelRatio() + this.pixelRatio = dpr + const rect = this.canvas.getBoundingClientRect() + const width = Math.max(1, Math.round(rect.width * dpr)) + const height = Math.max(1, Math.round(rect.height * dpr)) + if (width === this.canvas.width && height === this.canvas.height) { + return + } + this.canvas.width = width + this.canvas.height = height + this.updatePanFromTarget() + this.gl.viewport(0, 0, width, height) + } + + private loop() { + if (this.destroyed) return + this.rafHandle = requestAnimationFrame(time => { + const dt = time - this.lastFrameTime + this.lastFrameTime = time + const instantaneous = dt > 0 ? 1000 / dt : 0 + this.fps = this.fps * 0.9 + instantaneous * 0.1 + this.applyInertia(dt) + this.updateActiveMarkers(dt) + this.removeCooledTrailPositions() + this.render() + this.loop() + }) + } + + private removeCooledTrailPositions() { + if (this.trailCount === 0) return + const now = performance.now() * 0.001 + const newestTime = this.pointerTrail[2] + const maxAge = 2.0 + const threshold = newestTime - maxAge + + let writeIdx = 0 + for (let i = 0; i < this.trailCount; i++) { + const idx = i * 3 + const time = this.pointerTrail[idx + 2] + if (time >= threshold) { + if (writeIdx !== i) { + this.pointerTrail[writeIdx * 3] = this.pointerTrail[idx] + this.pointerTrail[writeIdx * 3 + 1] = this.pointerTrail[idx + 1] + this.pointerTrail[writeIdx * 3 + 2] = this.pointerTrail[idx + 2] + } + writeIdx++ + } + } + this.trailCount = writeIdx + } + + private render() { + const gl = this.gl + const deviceRatio = getDevicePixelRatio() + if (deviceRatio !== this.pixelRatio) { + this.resizeCanvas(deviceRatio) + } + const dims = this.getWorldDimensions() + const { width, height, worldWidth, worldHeight } = dims + gl.viewport(0, 0, width, height) + gl.clearColor(this.seaColor[0], this.seaColor[1], this.seaColor[2], 1) + gl.clear(gl.COLOR_BUFFER_BIT) + + const panX = wrapCentered(this.pan[0], worldWidth * this.zoom) + const panY = this.pan[1] + const deviceCell = this.cellSize * this.pixelRatio + const deviceSquare = this.squareSize * this.pixelRatio + + gl.useProgram(this.dotsProgram) + gl.bindVertexArray(this.fullscreenVAO) + if (this.markerUniformDirty) { + this.uploadMarkerUniforms() + } + setUniform2f(gl, this.dotsProgram, "uRes", width, height) + setUniform2f(gl, this.dotsProgram, "uWorldSize", worldWidth, worldHeight) + setUniform2f(gl, this.dotsProgram, "uPan", panX, panY) + setUniform1f(gl, this.dotsProgram, "uZoom", this.zoom) + setUniform1f(gl, this.dotsProgram, "uCell", deviceCell) + setUniform1f(gl, this.dotsProgram, "uSquare", deviceSquare) + setUniform3f( + gl, + this.dotsProgram, + "uLandColor", + this.landColor[0], + this.landColor[1], + this.landColor[2], + ) + setUniform3f( + gl, + this.dotsProgram, + "uSeaColor", + this.seaColor[0], + this.seaColor[1], + this.seaColor[2], + ) + setUniform3f( + gl, + this.dotsProgram, + "uMarkerColor", + this.markerColor[0], + this.markerColor[1], + this.markerColor[2], + ) + setUniform3f( + gl, + this.dotsProgram, + "uHaloColor", + this.haloColor[0], + this.haloColor[1], + this.haloColor[2], + ) + setUniform1f(gl, this.dotsProgram, "uHaloMinOpacity", this.haloMinOpacity) + setUniform1i(gl, this.dotsProgram, "uMarkerCount", this.markerCount) + setUniform1f(gl, this.dotsProgram, "uTime", performance.now() * 0.001) + const trailLocation = gl.getUniformLocation( + this.dotsProgram, + "uPointerTrail", + ) + if (trailLocation) { + gl.uniform3fv(trailLocation, this.pointerTrail) + } + setUniform1i(gl, this.dotsProgram, "uTrailCount", this.trailCount) + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, this.landTexture) + setUniform1i(gl, this.dotsProgram, "uLand", 0) + gl.drawArrays(gl.TRIANGLES, 0, 3) + this.lastRenderState = { + pan: [panX, panY], + zoom: this.zoom, + dims, + deviceCell, + } + if (process.env.NODE_ENV !== "production") { + this.diagnostics?.afterRender({ + zoom: this.zoom, + pan: this.pan, + target: this.target, + dims, + cellSize: this.cellSize, + squareSize: this.squareSize, + pixelRatio: this.pixelRatio, + fps: this.fps, + }) + } + } + + private applyInertia(dtMs: number) { + if (this.pointer.active) { + return + } + const result = stepInertia({ + target: [this.target[0], this.target[1]], + velocity: [this.velocity[0], this.velocity[1]], + dtMs, + bounds: this.getLatitudeBounds(), + damping: INERTIA_DAMPING, + baseDt: INERTIA_BASE_DT, + velocityEps: INERTIA_EPS, + }) + if (!result.moved && result.velocity[0] === 0 && result.velocity[1] === 0) { + this.velocity[0] = 0 + this.velocity[1] = 0 + return + } + this.target[0] = result.target[0] + this.target[1] = result.target[1] + this.velocity[0] = result.velocity[0] + this.velocity[1] = result.velocity[1] + if (result.moved) { + this.updatePanFromTarget() + } + } + + private updateActiveMarkers(dtMs: number) { + if (this.markerCount === 0 || dtMs <= 0) return + const step = Math.min(1, dtMs / ACTIVE_TRANSITION_MS) + if (step <= 0) return + let changed = false + for (let i = 0; i < this.markerCount; i++) { + const target = this.markerIntensityTarget[i] + const current = this.markerIntensity[i] + const diff = target - current + if (Math.abs(diff) <= 1e-4) { + if (current !== target) { + this.markerIntensity[i] = target + this.markerData[i * 4 + 3] = target + if ( + target === 0 && + this.markerData[i * 4 + 2] !== MARKER_TYPE_REGULAR + ) { + this.markerData[i * 4 + 2] = MARKER_TYPE_REGULAR + changed = true + } + changed = true + } + continue + } + const delta = Math.min(Math.abs(diff), step) * Math.sign(diff) + const next = current + delta + this.markerIntensity[i] = next + this.markerData[i * 4 + 3] = next + if (next > 0 && this.markerData[i * 4 + 2] !== MARKER_TYPE_ACTIVE) { + this.markerData[i * 4 + 2] = MARKER_TYPE_ACTIVE + changed = true + } else if ( + next === 0 && + this.markerData[i * 4 + 2] !== MARKER_TYPE_REGULAR + ) { + this.markerData[i * 4 + 2] = MARKER_TYPE_REGULAR + changed = true + } + changed = true + } + if (changed) { + this.markerUniformDirty = true + } + } + + private updatePanFromTarget() { + const dims = this.getWorldDimensions() + const [panX, panY] = updatePanFromTarget(this.target, this.zoom, dims) + this.pan[0] = panX + this.pan[1] = panY + } + + private captureRenderStateSnapshot() { + const dims = this.getWorldDimensions() + const panX = wrapCentered(this.pan[0], dims.worldWidth * this.zoom) + const panY = this.pan[1] + const deviceCell = this.cellSize * this.pixelRatio + return { + pan: [panX, panY] as [number, number], + zoom: this.zoom, + dims, + deviceCell, + } + } +} + +function setUniform3f( + gl: WebGL2RenderingContext, + program: WebGLProgram, + name: string, + x: number, + y: number, + z: number, +) { + const location = gl.getUniformLocation(program, name) + if (location) { + gl.uniform3f(location, x, y, z) + } +} + +function getDevicePixelRatio() { + return Math.max(1, window.devicePixelRatio || 1) +} + +function setUniform2f( + gl: WebGL2RenderingContext, + program: WebGLProgram, + name: string, + x: number, + y: number, +) { + const location = gl.getUniformLocation(program, name) + if (location) { + gl.uniform2f(location, x, y) + } +} + +function setUniform1f( + gl: WebGL2RenderingContext, + program: WebGLProgram, + name: string, + value: number, +) { + const location = gl.getUniformLocation(program, name) + if (location) { + gl.uniform1f(location, value) + } +} + +function setUniform1i( + gl: WebGL2RenderingContext, + program: WebGLProgram, + name: string, + value: number, +) { + const location = gl.getUniformLocation(program, name) + if (location) { + gl.uniform1i(location, value) + } +} diff --git a/src/app/(main)/community/events/map/gl-utils.ts b/src/app/(main)/community/events/map/gl-utils.ts new file mode 100644 index 0000000000..9baea9ca54 --- /dev/null +++ b/src/app/(main)/community/events/map/gl-utils.ts @@ -0,0 +1,116 @@ +export function initGL(canvas: HTMLCanvasElement) { + const gl = canvas.getContext("webgl2", { + antialias: false, + desynchronized: true, + powerPreference: "high-performance", + premultipliedAlpha: false, + preserveDrawingBuffer: false, + }) + + if (!gl) { + throw new Error("WebGL2 is not supported in this browser") + } + + gl.disable(gl.DEPTH_TEST) + gl.disable(gl.STENCIL_TEST) + gl.disable(gl.CULL_FACE) + gl.disable(gl.DITHER) + + return gl +} + +export function createProgram( + gl: WebGL2RenderingContext, + vertSrc: string, + fragSrc: string, +) { + const vert = compileShader(gl, gl.VERTEX_SHADER, vertSrc) + const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc) + + const program = gl.createProgram() + if (!program) { + throw new Error("Unable to create WebGL program") + } + + gl.attachShader(program, vert) + gl.attachShader(program, frag) + gl.linkProgram(program) + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const info = gl.getProgramInfoLog(program) || "Unknown program link error" + gl.deleteShader(vert) + gl.deleteShader(frag) + gl.deleteProgram(program) + throw new Error(info) + } + + gl.deleteShader(vert) + gl.deleteShader(frag) + + return program +} + +function compileShader( + gl: WebGL2RenderingContext, + type: GLenum, + source: string, +) { + const shader = gl.createShader(type) + if (!shader) { + throw new Error("Unable to create shader") + } + gl.shaderSource(shader, source) + gl.compileShader(shader) + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(shader) || "Unknown shader compile error" + gl.deleteShader(shader) + throw new Error(info) + } + + return shader +} + +export async function loadLandMaskTexture( + gl: WebGL2RenderingContext, + url: string, + signal?: AbortSignal, +) { + const response = await fetch(url, { signal }) + if (!response.ok) { + throw new Error(`Failed to load land mask: ${response.status}`) + } + const blob = await response.blob() + const bitmap = await createImageBitmap(blob, { + colorSpaceConversion: "none", + premultiplyAlpha: "none", + }) + + const texture = gl.createTexture() + if (!texture) { + throw new Error("Unable to create texture") + } + + gl.bindTexture(gl.TEXTURE_2D, texture) + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + bitmap.width, + bitmap.height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + bitmap, + ) + gl.bindTexture(gl.TEXTURE_2D, null) + bitmap.close() + + return texture +} diff --git a/src/app/(main)/community/events/map/land-mask.png b/src/app/(main)/community/events/map/land-mask.png new file mode 100644 index 0000000000..df68fb06c7 Binary files /dev/null and b/src/app/(main)/community/events/map/land-mask.png differ diff --git a/src/app/(main)/community/events/map/map-colors.tsx b/src/app/(main)/community/events/map/map-colors.tsx new file mode 100644 index 0000000000..950c403f5d --- /dev/null +++ b/src/app/(main)/community/events/map/map-colors.tsx @@ -0,0 +1,30 @@ +export type ColorVec3 = [r: number, g: number, b: number] + +export type MapColors = { + sea: ColorVec3 + land: ColorVec3 + marker: ColorVec3 + halo: ColorVec3 + haloMinOpacity: number +} + +export const MAP_COLORS = { + light: { + sea: [0.9804, 0.9882, 0.9569], // neu-50 + land: [0.8627, 0.8706, 0.8275], // neu-300 + marker: [0.8824, 0.0039, 0.5961], // #E10198 = pri-base + halo: [1.0, 0.6, 0.8784], // hsl(318, 100%, 80%) = pri-light + haloMinOpacity: 0.75, + }, + dark: { + sea: [0.0549, 0.0588, 0.0431], // neu-50 + land: [0.1647, 0.1804, 0.1373], // a shade darker than neu-800 + marker: [1, 0.6, 0.8745], // #FF99DF = pri-light + halo: [1.0, 0.8, 0.9373], // hsl(319, 100%, 90%) = pri-lighter + haloMinOpacity: 0.5, + }, +} satisfies Record + +export function asRgbString(color: ColorVec3): string { + return `rgb(${color.map(c => Math.round(c * 255)).join(", ")})` +} 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..b73623a5fc --- /dev/null +++ b/src/app/(main)/community/events/map/map-tooltip.tsx @@ -0,0 +1,27 @@ +"use client" + +import { meetups } from "@/components/meetups" + +const meetupNameById = new Map(meetups.map(({ node }) => [node.id, node.name])) + +type MapTooltipProps = { + id: string + activeMeetupId: string | null +} + +export function MapTooltip({ id, activeMeetupId }: MapTooltipProps) { + const name = activeMeetupId && meetupNameById.get(activeMeetupId) + return ( + + {name} + + ) +} diff --git a/src/app/(main)/community/events/map/projection.ts b/src/app/(main)/community/events/map/projection.ts new file mode 100644 index 0000000000..ff7032c3ad --- /dev/null +++ b/src/app/(main)/community/events/map/projection.ts @@ -0,0 +1,66 @@ +const mercatorLimit = 85.05112878 +const minDisplayedLatitude = -60 +/** + * We excluded the south pole from the map, so we need to align the map slightly northwards. + */ +const baseLatitudeOffset = 4 +const baseLongitudeOffset = 0.1 + +const maxProjectedV = latToRawV(mercatorLimit) +const minProjectedV = latToRawV(minDisplayedLatitude) + +export type UV = [number, number] + +export function lonLatToUV(lon: number, lat: number): UV { + const adjustedLon = normalizeLongitude(lon + baseLongitudeOffset) + const u = (adjustedLon + 180) / 360 + const adjustedLat = clampProjectedLatitude(lat + baseLatitudeOffset) + const rawV = latToRawV(adjustedLat) + const normalizedV = clamp01( + (rawV - maxProjectedV) / (minProjectedV - maxProjectedV), + ) + return [u, normalizedV] +} + +export function uvToLonLat(u: number, v: number) { + const wrappedU = ((u % 1) + 1) % 1 + const rawV = clamp01(v) * (minProjectedV - maxProjectedV) + maxProjectedV + const mapLon = wrappedU * 360 - 180 + const mapLat = clampProjectedLatitude(rawVToLat(rawV)) + const lon = normalizeLongitude(mapLon - baseLongitudeOffset) + const lat = clampActualLatitude(mapLat - baseLatitudeOffset) + return { lon, lat } +} + +function clampProjectedLatitude(value: number) { + return Math.max(minDisplayedLatitude, Math.min(mercatorLimit, value)) +} + +function clampActualLatitude(value: number) { + return Math.max(-90, Math.min(90, value)) +} + +function normalizeLongitude(value: number) { + let lon = value + while (lon <= -180) lon += 360 + while (lon > 180) lon -= 360 + return lon +} + +function 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) +} + +function rawVToLat(v: number) { + const exponent = (0.5 - v) * 2 * Math.PI + const latRad = 2 * Math.atan(Math.exp(exponent)) - Math.PI / 2 + return (latRad * 180) / Math.PI +} + +function clamp01(value: number) { + if (value <= 0) return 0 + if (value >= 1) return 1 + return value +} diff --git a/src/app/(main)/community/events/map/shaders.ts b/src/app/(main)/community/events/map/shaders.ts new file mode 100644 index 0000000000..a213ba3dfa --- /dev/null +++ b/src/app/(main)/community/events/map/shaders.ts @@ -0,0 +1,173 @@ +export const fullscreenVert = /* GLSL */ `#version 300 es +precision highp float; + +const vec2 POSITIONS[3] = vec2[3]( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0) +); + +void main() { + gl_Position = vec4(POSITIONS[gl_VertexID], 0.0, 1.0); +} +` + +export const MARKER_CAPACITY = 128 + +export const dotsFrag = /* GLSL */ `#version 300 es +precision highp float; + +out vec4 outColor; + +uniform vec2 uRes; +uniform vec2 uWorldSize; +uniform vec2 uPan; +uniform float uZoom; +uniform float uCell; +uniform float uSquare; +uniform sampler2D uLand; +uniform vec3 uLandColor; +uniform vec3 uSeaColor; +uniform vec4 uMarkers[${MARKER_CAPACITY}]; +uniform int uMarkerCount; +uniform vec3 uMarkerColor; +uniform vec3 uHaloColor; +uniform float uHaloMinOpacity; +uniform vec3 uPointerTrail[64]; +uniform int uTrailCount; +uniform float uTime; + +vec2 markerCellCenter(vec4 marker, vec2 referencePx) { + float periodX = uWorldSize.x * uZoom; + float baseX = uPan.x + (marker.x * periodX); + float offset = + mod(baseX - referencePx.x + 0.5 * periodX, periodX) - 0.5 * periodX; + float alignedX = referencePx.x + offset; + float cellX = floor(alignedX / uCell); + float screenY = uPan.y + (marker.y * uWorldSize.y * uZoom); + float cellY = floor(screenY / uCell); + return vec2((cellX + 0.5) * uCell, (cellY + 0.5) * uCell); +} + +vec2 markerStateAtCellCenterPx(vec2 cellCenterPx) { + ivec2 cellIndex = ivec2(floor(cellCenterPx / uCell)); + for (int i = 0; i < ${MARKER_CAPACITY}; i++) { + if (i >= uMarkerCount) { + break; + } + vec4 marker = uMarkers[i]; + vec2 markerCenter = markerCellCenter(marker, cellCenterPx); + ivec2 markerIndex = ivec2(floor(markerCenter / uCell)); + if (markerIndex.x == cellIndex.x && markerIndex.y == cellIndex.y) { + return vec2(marker.z, marker.w); + } + } + return vec2(0.0); +} + +float sampleCoverage(vec2 uv) { + ivec2 texSize = textureSize(uLand, 0); + vec2 texel = 1.0 / vec2(texSize); + float coverage = 0.0; + + for (int y = 0; y < 2; y++) { + for (int x = 0; x < 2; x++) { + vec2 offset = (vec2(float(x), float(y)) - 0.5) * texel; + coverage += texture(uLand, uv + offset).r; + } + } + + return coverage * 0.25; +} + +void main() { + vec2 fragPx = gl_FragCoord.xy; + vec2 cell = floor(fragPx / uCell) * uCell; + vec2 center = cell + vec2(0.5 * uCell); + float baseHalfSquare = 0.5 * uSquare; + vec2 markerState = markerStateAtCellCenterPx(center); + float markerType = markerState.x; + vec2 uv = (center / uWorldSize) / uZoom - (uPan / (uWorldSize * uZoom)); + uv.x = fract(uv.x); + if (uv.y < 0.0 || uv.y > 1.0) { + discard; + } + vec2 landUV = vec2(uv.x, 1.0 - uv.y); + float seaCoverage = sampleCoverage(landUV); + float landCoverage = 1.0 - seaCoverage; + float haloIntensity = 0.0; + for (int i = 0; i < ${MARKER_CAPACITY}; i++) { + if (i >= uMarkerCount) { + break; + } + vec4 marker = uMarkers[i]; + float strength = marker.w; + if (strength > 0.0) { + vec2 markerCenter = markerCellCenter(marker, fragPx); + float dist = length(fragPx - markerCenter); + float innerRadius = baseHalfSquare + 0.4 * uCell; + float outerRadius = innerRadius + 2.0 * uCell; + float radiusDiff = max(outerRadius - innerRadius, 0.0001); + float t = clamp((dist - innerRadius) / radiusDiff, 0.0, 1.0); + float logFalloff = 1.0 - log(1.0 + t * 9.0) / log(10.0); + float halo = uHaloMinOpacity * logFalloff * strength; + haloIntensity = max(haloIntensity, halo); + } + } + if (markerType <= 0.5 && landCoverage < 0.5 && haloIntensity <= 0.0) { + discard; + } + float landMask = step(0.5, landCoverage); + vec3 terrainColor = mix(uSeaColor, uLandColor, landMask); + vec3 color = terrainColor; + if (markerType > 0.5) { + color = uMarkerColor; + } + float squareHalf = baseHalfSquare; + if (markerType <= 0.5) { + float maxDecrease = 0.0; + float oldestTime = 0.0; + float newestTime = uTime; + if (uTrailCount > 0) { + oldestTime = uPointerTrail[uTrailCount - 1].z; + newestTime = uPointerTrail[0].z; + } + float timeRange = max(newestTime - oldestTime, 0.001); + for (int i = 0; i < 64; i++) { + if (i >= uTrailCount) { + break; + } + vec3 trailPoint = uPointerTrail[i]; + if (trailPoint.x <= 0.0 && trailPoint.y <= 0.0) { + continue; + } + vec2 trailPos = trailPoint.xy; + float dist = length(center - trailPos); + float age = (uTime - trailPoint.z) / timeRange; + age = clamp(age, 0.0, 1.0); + float positionInTrail = float(i) / max(float(uTrailCount - 1), 1.0); + float widthFactor = 1.0 - positionInTrail; + float coolingFactor = pow(1.0 - age, 2.5); + float combinedFactor = widthFactor * coolingFactor; + float cellDist = dist / uCell; + float decrease = 3.0 * combinedFactor * exp(-cellDist * 0.8); + maxDecrease = max(maxDecrease, decrease); + } + squareHalf = max(0.0, squareHalf - maxDecrease); + } + vec2 delta = abs(fragPx - center); + bool insideSquare = delta.x <= squareHalf && delta.y <= squareHalf; + bool isSeaCell = markerType <= 0.5 && landCoverage < 0.5; + bool shouldRenderHalo = !insideSquare || isSeaCell; + if (shouldRenderHalo) { + if (haloIntensity <= 0.0) { + discard; + } + float haloAlpha = clamp(haloIntensity, 0.0, 1.0); + color = mix(uSeaColor, uHaloColor, haloAlpha); + outColor = vec4(color, 1.0); + return; + } + outColor = vec4(color, 1.0); +} +` diff --git a/src/app/(main)/community/events/map/viewport-math.test.tsx b/src/app/(main)/community/events/map/viewport-math.test.tsx new file mode 100644 index 0000000000..b586ceb294 --- /dev/null +++ b/src/app/(main)/community/events/map/viewport-math.test.tsx @@ -0,0 +1,188 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { + clampLatitude, + computeLatitudeBounds, + computePointerVelocity, + computeWorldDimensions, + dragTargetByPixels, + screenToWorld, + screenToUV, + stepInertia, + updatePanFromTarget, + wrap01, + wrapCentered, + zoomAroundPointer, +} from "./viewport-math" +import { lonLatToUV, uvToLonLat } from "./projection" + +describe("viewport-math", () => { + const aspectRatio = 1.65 + + it("computes safe world dimensions even for zero-sized canvases", () => { + const dims = computeWorldDimensions(0, 0, aspectRatio) + assert.strictEqual(dims.width, 1) + assert.strictEqual(dims.height, 1) + assert.ok(Number.isFinite(dims.worldHeight)) + assert.ok(Number.isFinite(dims.worldWidth)) + assert.ok(Math.abs(dims.worldWidth - dims.worldHeight * aspectRatio) < 1e-6) + }) + + it("limits latitude travel range based on zoom", () => { + const dims = computeWorldDimensions(1200, 600, aspectRatio) + const tight = computeLatitudeBounds(dims.height, dims.worldHeight, 2) + assert.deepStrictEqual(tight, { min: 0.25, max: 0.75 }) + + const locked = computeLatitudeBounds(dims.height, dims.worldHeight, 0.1) + assert.deepStrictEqual(locked, { min: 0.5, max: 0.5 }) + + assert.strictEqual(clampLatitude(0.9, tight), 0.75) + assert.strictEqual(clampLatitude(0.1, tight), 0.25) + }) + + it("keeps the requested target under the screen center", () => { + const dims = computeWorldDimensions(800, 600, aspectRatio) + const centerPan = updatePanFromTarget([0.5, 0.5], 1, dims) + const [centerWorldX, centerWorldY] = screenToWorld( + dims.width * 0.5, + dims.height * 0.5, + centerPan, + 1, + dims, + ) + const eps = 1e-6 + assert.ok(Math.abs(centerWorldX - 0.5) < eps) + assert.ok(Math.abs(centerWorldY - 0.5) < eps) + + const movedPan = updatePanFromTarget([0.75, 0.5], 1, dims) + const [movedWorldX] = screenToWorld( + dims.width * 0.5, + dims.height * 0.5, + movedPan, + 1, + dims, + ) + assert.ok(Math.abs(movedWorldX - 0.75) < eps) + assert.ok(movedPan[0] < centerPan[0]) + }) + + it("projects screen coordinates back to world coordinates", () => { + const dims = computeWorldDimensions(800, 600, aspectRatio) + const pan = updatePanFromTarget([0.5, 0.5], 1, dims) + const [worldX, worldY] = screenToWorld(400, 300, pan, 1, dims) + assert.ok(Math.abs(worldX - 0.5) < 1e-6) + assert.ok(Math.abs(worldY - 0.5) < 1e-6) + }) + + it("maps screen pixels directly to UV coordinates", () => { + const dims = computeWorldDimensions(800, 600, aspectRatio) + const pan = updatePanFromTarget([0.5, 0.5], 1, dims) + const [u, v] = screenToUV(400, 300, pan, 1, dims) + assert.ok(Math.abs(u - 0.5) < 1e-6) + assert.ok(Math.abs(v - 0.5) < 1e-6) + }) + + it("wraps horizontal UVs after long pans", () => { + const dims = computeWorldDimensions(800, 600, aspectRatio) + const pan = [dims.worldWidth * 0.5, 0] as [number, number] + const [u] = screenToUV(0, 0, pan, 1, dims) + assert.ok(Math.abs(u - 0.5) < 1e-6) + }) + + it("clamps V to the visible range without flipping", () => { + const dims = computeWorldDimensions(800, 600, aspectRatio) + const top = screenToUV(0, -100, [0, 0], 1, dims) + const bottom = screenToUV(0, dims.height + 100, [0, 0], 1, dims) + assert.strictEqual(top[1], 0) + assert.strictEqual(bottom[1], 1) + }) + + it("round-trips lon/lat when using uv helpers", () => { + const dims = computeWorldDimensions(900, 600, aspectRatio) + const zoom = 1.5 + const target: [number, number] = [0.42, 0.58] + const pan = updatePanFromTarget(target, zoom, dims) + const point = { lon: -0.1276, lat: 51.5074 } + const uv = lonLatToUV(point.lon, point.lat) + const zoomedWidth = dims.worldWidth * zoom + const zoomedHeight = dims.worldHeight * zoom + const px = pan[0] + uv[0] * zoomedWidth + const py = pan[1] + uv[1] * zoomedHeight + const [screenU, screenV] = screenToUV(px, py, pan, zoom, dims) + const eps = 1e-6 + assert.ok(Math.abs(screenU - uv[0]) < eps) + assert.ok(Math.abs(screenV - uv[1]) < eps) + const { lon, lat } = uvToLonLat(screenU, screenV) + assert.ok(Math.abs(lon - point.lon) < 1e-3) + assert.ok(Math.abs(lat - point.lat) < 1e-3) + }) + + it("wraps normalized values into the expected ranges", () => { + assert.ok(Math.abs(wrap01(1.2) - 0.2) < 1e-12) + assert.ok(Math.abs(wrap01(-0.2) - 0.8) < 1e-12) + assert.strictEqual(wrapCentered(6, 10), -4) + assert.strictEqual(wrapCentered(7, 10), -3) + }) + + it("translates targets predictably when dragging in pixel space", () => { + const dims = computeWorldDimensions(1024, 512, aspectRatio) + const start: [number, number] = [0.5, 0.5] + const next = dragTargetByPixels(start, 64, -32, 2, dims) + assert.ok(next[0] < start[0]) + assert.ok(next[1] < start[1]) + const velocity = computePointerVelocity(start, next, 16) + assert.ok(velocity[0] < 0) + assert.ok(velocity[1] < 0) + }) + + it("keeps the zoom pointer anchored in world space", () => { + const dims = computeWorldDimensions(800, 600, aspectRatio) + const target: [number, number] = [0.4, 0.6] + const pan = updatePanFromTarget(target, 1, dims) + const pointerPx = dims.width * 0.25 + const pointerPy = dims.height * 0.75 + const before = screenToWorld(pointerPx, pointerPy, pan, 1, dims) + const zoomedTarget = zoomAroundPointer({ + pointerPx, + pointerPy, + previousZoom: 1, + nextZoom: 2, + pan, + dims, + }) + const zoomedPan = updatePanFromTarget(zoomedTarget, 2, dims) + const after = screenToWorld(pointerPx, pointerPy, zoomedPan, 2, dims) + const eps = 1e-6 + assert.ok(Math.abs(after[0] - before[0]) < eps) + assert.ok(Math.abs(after[1] - before[1]) < eps) + }) + + it("advances inertia and damps velocity", () => { + const bounds = { min: 0.25, max: 0.75 } + const step = stepInertia({ + target: [0.5, 0.5], + velocity: [0.001, -0.002], + dtMs: 16, + bounds, + damping: 0.87, + baseDt: 1000 / 60, + velocityEps: 1e-5, + }) + assert.ok(step.moved) + assert.ok(step.target[0] !== 0.5) + assert.ok(step.velocity[0] < 0.001) + + const clipped = stepInertia({ + target: [0.5, bounds.max], + velocity: [0, 0.01], + dtMs: 16, + bounds, + damping: 0.87, + baseDt: 1000 / 60, + velocityEps: 1e-5, + }) + assert.strictEqual(clipped.target[1], bounds.max) + assert.strictEqual(clipped.velocity[1], 0) + }) +}) diff --git a/src/app/(main)/community/events/map/viewport-math.ts b/src/app/(main)/community/events/map/viewport-math.ts new file mode 100644 index 0000000000..16cb1f868b --- /dev/null +++ b/src/app/(main)/community/events/map/viewport-math.ts @@ -0,0 +1,204 @@ +export type Vec2 = readonly [number, number] +export type Vec2Like = Vec2 | Float32Array + +export type WorldDimensions = { + width: number + height: number + worldWidth: number + worldHeight: number +} + +export type LatitudeBounds = { + min: number + max: number +} + +export type ZoomAroundPointerInput = { + pointerPx: number + pointerPy: number + previousZoom: number + nextZoom: number + pan: Vec2Like + dims: WorldDimensions +} + +export type StepInertiaInput = { + target: Vec2 + velocity: Vec2 + dtMs: number + bounds: LatitudeBounds + damping: number + baseDt: number + velocityEps: number +} + +export type StepInertiaResult = { + target: Vec2 + velocity: Vec2 + moved: boolean +} + +export function computeWorldDimensions( + width: number, + height: number, + aspectRatio: number, +): WorldDimensions { + const safeWidth = width || 1 + const safeHeight = height || 1 + const worldHeight = Math.min(safeWidth / aspectRatio, safeHeight) + const worldWidth = worldHeight * aspectRatio + return { width: safeWidth, height: safeHeight, worldWidth, worldHeight } +} + +export function computeLatitudeBounds( + height: number, + worldHeight: number, + zoom: number, +): LatitudeBounds { + const zoomedHeight = worldHeight * zoom + if (!isFinite(zoomedHeight) || zoomedHeight <= 0) { + return { min: 0.5, max: 0.5 } + } + const fraction = height / (2 * zoomedHeight) + if (fraction >= 0.5) { + return { min: 0.5, max: 0.5 } + } + const margin = clamp01(fraction) + const center = 0.5 + const fullTravel = center - margin + if (fullTravel <= 0) { + return { min: center, max: center } + } + const limitedTravel = clamp01(fullTravel) + return { min: center - limitedTravel, max: center + limitedTravel } +} + +export function clampLatitude(value: number, bounds: LatitudeBounds) { + return clamp(value, bounds.min, bounds.max) +} + +export function updatePanFromTarget( + target: Vec2Like, + zoom: number, + dims: WorldDimensions, +): Vec2 { + const panX = dims.width * 0.5 - target[0] * dims.worldWidth * zoom + const panY = dims.height * 0.5 - target[1] * dims.worldHeight * zoom + return [panX, panY] +} + +export function screenToWorld( + px: number, + py: number, + pan: Vec2Like, + zoom: number, + dims: WorldDimensions, +): Vec2 { + const x = dims.worldWidth > 0 ? (px - pan[0]) / (dims.worldWidth * zoom) : 0 + const y = dims.worldHeight > 0 ? (py - pan[1]) / (dims.worldHeight * zoom) : 0 + return [x, y] +} + +export function screenToUV( + px: number, + py: number, + pan: Vec2Like, + zoom: number, + dims: WorldDimensions, +) { + const [worldX, worldY] = screenToWorld(px, py, pan, zoom, dims) + const u = wrap01(worldX) + const v = clamp01(worldY) + return [u, v] as const +} + +export function dragTargetByPixels( + startTarget: Vec2Like, + dx: number, + dy: number, + zoom: number, + dims: WorldDimensions, +): Vec2 { + const invWidth = dims.worldWidth > 0 ? 1 / (dims.worldWidth * zoom) : 0 + const invHeight = dims.worldHeight > 0 ? 1 / (dims.worldHeight * zoom) : 0 + const nextX = wrap01(startTarget[0] - dx * invWidth) + const nextY = startTarget[1] + dy * invHeight + return [nextX, nextY] +} + +export function computePointerVelocity( + prev: Vec2Like, + next: Vec2Like, + dtMs: number, +): Vec2 { + const dt = Math.max(dtMs, 1) + const invDt = 1 / dt + return [(next[0] - prev[0]) * invDt, (next[1] - prev[1]) * invDt] +} + +export function zoomAroundPointer(input: ZoomAroundPointerInput): Vec2 { + const { pointerPx, pointerPy, previousZoom, nextZoom, pan, dims } = input + const safePrevWidth = dims.worldWidth * previousZoom || 1 + const safePrevHeight = dims.worldHeight * previousZoom || 1 + const worldX = (pointerPx - pan[0]) / safePrevWidth + const worldY = (pointerPy - pan[1]) / safePrevHeight + const safeNextWidth = dims.worldWidth * nextZoom || 1 + const safeNextHeight = dims.worldHeight * nextZoom || 1 + const nextTargetX = wrap01( + worldX - (pointerPx - dims.width * 0.5) / safeNextWidth, + ) + const nextTargetY = worldY - (pointerPy - dims.height * 0.5) / safeNextHeight + return [nextTargetX, nextTargetY] +} + +export function stepInertia(input: StepInertiaInput): StepInertiaResult { + const { target, velocity, dtMs, bounds, damping, baseDt, velocityEps } = input + const velX = velocity[0] + const velY = velocity[1] + if (Math.abs(velX) < velocityEps && Math.abs(velY) < velocityEps) { + return { target, velocity: [0, 0], moved: false } + } + const dt = Math.max(dtMs, 0) + const nextX = wrap01(target[0] + velX * dt) + const unclampedY = target[1] + velY * dt + const nextY = clamp(unclampedY, bounds.min, bounds.max) + let nextVelY = velY + if (nextY === bounds.min || nextY === bounds.max) { + nextVelY = 0 + } + const dampingFactor = Math.pow(damping, dt / baseDt) + let nextVelX = velX * dampingFactor + nextVelY *= dampingFactor + if (Math.abs(nextVelX) < velocityEps) nextVelX = 0 + if (Math.abs(nextVelY) < velocityEps) nextVelY = 0 + const moved = dt > 0 && (nextX !== target[0] || nextY !== target[1]) + return { + target: [nextX, nextY], + velocity: [nextVelX, nextVelY], + moved, + } +} + +export function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +export function wrapCentered(value: number, period: number) { + if (!isFinite(period) || period <= 0) return value + let wrapped = value % period + if (wrapped > period * 0.5) wrapped -= period + if (wrapped < -period * 0.5) wrapped += period + return wrapped +} + +export function wrap01(value: number) { + let wrapped = value % 1 + if (wrapped < 0) wrapped += 1 + return wrapped +} + +export function clamp01(value: number) { + if (value <= 0) return 0 + if (value >= 1) return 1 + return value +} diff --git a/src/app/(main)/community/events/meetups-list.tsx b/src/app/(main)/community/events/meetups-list.tsx new file mode 100644 index 0000000000..2b0d231112 --- /dev/null +++ b/src/app/(main)/community/events/meetups-list.tsx @@ -0,0 +1,108 @@ +"use client" + +import { clsx } from "clsx" +import ExternalLinkIcon from "@/app/conf/_design-system/pixelarticons/external-link.svg?svgr" + +import { meetups as meetupNodes } from "@/components/meetups" +import { useEffect } from "react" + +type MeetupListItem = { + id: string + city: string + country: string + name: string + href: string +} + +const DEFAULT_MEETUPS: MeetupListItem[] = meetupNodes + .map(({ node }) => ({ + id: node.id, + city: node.city || node.name, + country: node.country, + name: node.name, + href: node.link, + })) + .filter(item => Boolean(item.href)) + .sort((a, b) => + a.city.localeCompare(b.city, undefined, { + sensitivity: "base", + numeric: true, + }), + ) + +export interface MeetupsListProps { + className?: string + activeMeetupId?: string | null + onActiveMeetupChange?: (meetupId: string | null) => void + meetups?: MeetupListItem[] +} + +/** + * Displays the catalog of GraphQL meetups in a condensed, scrollable list. + */ +export function MeetupsList({ + className, + activeMeetupId, + onActiveMeetupChange, + meetups = DEFAULT_MEETUPS, +}: MeetupsListProps) { + useEffect(() => { + if (activeMeetupId) { + const list = document.querySelector("#meetups-list") + if (!list) return + + const activeAnchor = list.querySelector( + `a[aria-current="true"]`, + ) + if (!activeAnchor) return + + if (list.scrollTop + list.clientHeight < activeAnchor.offsetTop) { + list.scrollTo({ top: activeAnchor.offsetTop, behavior: "smooth" }) + } + } + }, [activeMeetupId]) + + if (meetups.length === 0) return null + + return ( + + ) +} diff --git a/src/app/(main)/community/events/meetups-map.tsx b/src/app/(main)/community/events/meetups-map.tsx new file mode 100644 index 0000000000..8724d83e6b --- /dev/null +++ b/src/app/(main)/community/events/meetups-map.tsx @@ -0,0 +1,202 @@ +"use client" + +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 } from "./map/map-tooltip" +import { MapSkeleton } from "./map-skeleton" +import { MeetupsList } from "./meetups-list" +import { asRgbString, MAP_COLORS, MapColors } from "./map/map-colors" + +const CELL_SIZE = 8 +const SQUARE_SIZE = 6 +const LAND_MASK_URL = new URL("./map/land-mask.png", import.meta.url).toString() +const ASPECT_RATIO = 1.65 + +type ThemeVariant = keyof typeof MAP_COLORS + +const markerPoints: MarkerPoint[] = meetups.map(({ node }) => ({ + id: node.id, + lon: node.longitude, + lat: node.latitude, +})) + +type MapStatus = "loading" | "ready" | "error" + +export function MeetupsMap() { + const canvasRef = useRef(null) + const handleRef = useRef() + const { resolvedTheme } = useTheme() + const [activeMeetupId, setActiveMeetupId] = useState(null) + const themeColors = useMemo( + () => MAP_COLORS[resolvedTheme as ThemeVariant] || MAP_COLORS.light, + [resolvedTheme], + ) + const initialThemeRef = useRef(themeColors) + + const [status, setStatus] = useState("loading") + const [errorMessage, setErrorMessage] = useState(null) + + const handlePointerMove = (event: ReactPointerEvent) => { + const tooltip = document.getElementById("map-tooltip") + if (!tooltip) return + const rect = event.currentTarget.getBoundingClientRect() + const x = event.clientX - rect.left + const y = event.clientY - rect.top + tooltip.style.setProperty("--x", `${x}px`) + tooltip.style.setProperty("--y", `${y}px`) + } + + const handlePointerLeave = () => { + const tooltip = document.getElementById("map-tooltip") + if (!tooltip) return + tooltip.style.removeProperty("--x") + tooltip.style.removeProperty("--y") + } + + const activeMeetup = useMemo( + () => meetups.find(m => m.node.id === activeMeetupId), + [activeMeetupId], + ) + + const handleMapClick = () => { + if (activeMeetup?.node.link) { + window.open(activeMeetup.node.link, "_blank", "noopener,noreferrer") + } + } + + useEffect(() => { + initialThemeRef.current = themeColors + }, [themeColors]) + + useEffect(() => { + if (status !== "ready" || !handleRef.current) return + handleRef.current.setActiveMarker(activeMeetupId) + }, [status, activeMeetupId]) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const abortController = new AbortController() + let engine: MapHandle | null = null + let disposed = false + + const start = async () => { + try { + engine = await bootMeetupsMap({ + canvas, + markers: markerPoints, + maskUrl: LAND_MASK_URL, + initialCellSize: CELL_SIZE, + initialSquareSize: SQUARE_SIZE, + aspectRatio: ASPECT_RATIO, + theme: initialThemeRef.current, + signal: abortController.signal, + onActiveMarkerChange: setActiveMeetupId, + }) + if (disposed) { + engine.dispose() + return + } + handleRef.current = engine + setStatus("ready") + setErrorMessage(null) + } catch (error) { + if (abortController.signal.aborted) return + console.error(error) + setStatus("error") + setErrorMessage( + error instanceof Error + ? error.message + : "Something went wrong while rendering the map.", + ) + } + } + + void start() + + return () => { + disposed = true + abortController.abort() + handleRef.current = undefined + engine?.dispose() + } + }, []) + + useEffect(() => { + if (!handleRef.current) return + handleRef.current.setThemeColors(themeColors) + }, [themeColors]) + + 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={ + { + "--sea-dark": asRgbString(MAP_COLORS.dark.sea), + "--sea-light": asRgbString(MAP_COLORS.light.sea), + "--land-dark": asRgbString(MAP_COLORS.dark.land), + "--land-light": asRgbString(MAP_COLORS.light.land), + } as React.CSSProperties + } + > +
+ + + + + + + + + {status === "error" && ( +
+ Unable to load the map{errorMessage ? ` (${errorMessage})` : ""} +
+ )} +
+ +
+ ) +} + +function InfoTip() { + return ( +
+ Pinch or ctrl+scroll to zoom +
+ ) +} diff --git a/src/app/(main)/community/events/page.tsx b/src/app/(main)/community/events/page.tsx new file mode 100644 index 0000000000..1d2821a835 --- /dev/null +++ b/src/app/(main)/community/events/page.tsx @@ -0,0 +1,130 @@ +import { Breadcrumbs } from "@/_design-system/breadcrumbs" +import { meetups } from "@/components/meetups" +import { Button } from "@/app/conf/_design-system/button" + +import { MeetupsMap } from "./meetups-map" +import { EventsList } from "./events-list" +import { events, type Event, type Meetup } from "./events" +import { BenefitsSection } from "./benefits-section" +import { GetYourMeetupNoticedSection } from "./get-your-meetup-noticed-section" +import { BringGraphQLToYourCommunity } from "./bring-graphql-to-your-community" +import dynamic from "next/dynamic" + +const ISSUE_TEMPLATE_LINK = + "https://github.com/graphql/community-wg/issues/new?assignees=&labels=event&template=event-submission.yml" + +const GalleryStrip = dynamic( + () => + import("@/app/conf/2025/components/gallery-strip").then( + mod => mod.GalleryStrip, + ), + { ssr: false }, +) + +const { pastEvents, upcomingEvents } = events.reduce( + (acc, event) => { + const now = new Date() + const date = new Date(event.date) + if (date < now) { + acc.pastEvents.push(event) + } else { + acc.upcomingEvents.push(event) + } + return acc + }, + { pastEvents: [], upcomingEvents: [] } as { + pastEvents: Event[] + upcomingEvents: Event[] + }, +) + +const pastEventsAndMeetups: Array = [ + ...pastEvents, + ...meetups, +].sort((a, b) => { + const dateA = + "node" in a ? new Date(a.node.next || a.node.prev) : new Date(a.date) + const dateB = + "node" in b ? new Date(b.node.next || b.node.prev) : new Date(b.date) + return dateB.getTime() - dateA.getTime() +}) + +export default function EventsPage() { + return ( +
+

Events & Meetups

+ +
+ +
+ + {upcomingEvents.length > 0 && ( +
+
+
+

Upcoming events

+

+ See what’s coming up across the GraphQL ecosystem. +

+
+ +
+ +
+ )} + + + +
+

Meetups

+

+ Find and join local GraphQL meetups happening around the world. Select + a city to explore upcoming events. +

+ + +
+ +
+

Past events & meetups

+

+ A look back at how the GraphQL community connects and grows together. +

+ +
+ +

Event gallery

+

+ A look back at what’s been happening. +

+ + + + +
+ ) +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index b4996d34c0..bc0d4946a6 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -1,4 +1,3 @@ -// @ts-expect-error: we want to import the same version as Nextra for the main page import { ThemeProvider } from "next-themes" import { Footer } from "../../components/footer" @@ -11,6 +10,9 @@ import { } from "../../components/navbar/top-level-items" import { Sidebar } from "../../components/sidebar" +import "@/globals.css" +import "@/app/colors.css" + export default function MainLayout({ children, }: { diff --git a/src/app/conf/2025/layout.tsx b/src/app/conf/2025/layout.tsx index ed8b28059e..fe500965fd 100644 --- a/src/app/conf/2025/layout.tsx +++ b/src/app/conf/2025/layout.tsx @@ -7,7 +7,6 @@ import "../../colors.css" import { Navbar } from "./components/navbar" import { Footer } from "./components/footer" -// @ts-expect-error: we want to import the same version as Nextra for the main page import { ThemeProvider } from "next-themes" import { GraphQLConfLogoLink } from "./components/graphql-conf-logo-link" import { GALLERY_LINK } from "./links" diff --git a/src/app/conf/_design-system/pixelarticons/checkbox-icon.tsx b/src/app/conf/_design-system/pixelarticons/checkbox-icon.tsx index 7be08f9281..b0fe637f37 100644 --- a/src/app/conf/_design-system/pixelarticons/checkbox-icon.tsx +++ b/src/app/conf/_design-system/pixelarticons/checkbox-icon.tsx @@ -21,7 +21,7 @@ export function CheckboxIcon({ checked, ...rest }: CheckboxIconProps) { ) : ( - + diff --git a/src/app/conf/_design-system/pixelarticons/comment.svg b/src/app/conf/_design-system/pixelarticons/comment.svg new file mode 100644 index 0000000000..23c2710723 --- /dev/null +++ b/src/app/conf/_design-system/pixelarticons/comment.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/app/conf/_design-system/pixelarticons/eye.svg b/src/app/conf/_design-system/pixelarticons/eye.svg new file mode 100644 index 0000000000..e0267279f6 --- /dev/null +++ b/src/app/conf/_design-system/pixelarticons/eye.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/app/conf/_design-system/pixelarticons/sliders.svg b/src/app/conf/_design-system/pixelarticons/sliders.svg new file mode 100644 index 0000000000..6a915e6910 --- /dev/null +++ b/src/app/conf/_design-system/pixelarticons/sliders.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/app/conf/_design-system/pixelarticons/users.svg b/src/app/conf/_design-system/pixelarticons/users.svg new file mode 100644 index 0000000000..606a702fc0 --- /dev/null +++ b/src/app/conf/_design-system/pixelarticons/users.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index e6777b15d4..e848d35314 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -9,9 +9,6 @@ import stripesMask from "@/components/404-page/image.webp" import { Button } from "./conf/_design-system/button" import MainLayout from "./(main)/layout" -import "@/globals.css" -import "@/app/colors.css" - export default function NotFoundPage() { const pathname = usePathname() const mounted = useMounted() @@ -35,7 +32,7 @@ export default function NotFoundPage() {
-
+

Page not found

diff --git a/src/components/navbar/navbar.tsx b/src/components/navbar/navbar.tsx index 38250354e3..d0e9d8fcbd 100644 --- a/src/components/navbar/navbar.tsx +++ b/src/components/navbar/navbar.tsx @@ -85,12 +85,7 @@ export function Navbar({ items }: NavbarProps): ReactElement { ) return ( -
+