Merge pull request #48 from AnmolSaini16/feat/controlled-viewport-and-docs-polish

Add controlled viewport API, new examples, and docs UI improvements
This commit is contained in:
Anmoldeep Singh
2026-02-06 14:54:38 +05:30
committed by GitHub
25 changed files with 453 additions and 142 deletions

File diff suppressed because one or more lines are too long

View File

@@ -34,18 +34,14 @@ function CopyButton({ text }: { text: string }) {
export function Hero() {
return (
<div className="relative">
<div className="absolute inset-0 -z-10 overflow-hidden">
<div className="absolute inset-x-0 -inset-y-32 -z-10 overflow-hidden">
<div
className="absolute inset-0 opacity-[0.4] dark:opacity-[0.15]"
className="absolute inset-0 opacity-[0.3] dark:opacity-[0.15]"
style={{
backgroundImage: `radial-gradient(circle, currentColor 1px, transparent 1px)`,
backgroundSize: "24px 24px",
}}
/>
<div className="absolute -top-40 -right-40 size-96 bg-emerald-500/10 rounded-full blur-3xl" />
<div className="absolute -bottom-40 -left-40 size-96 bg-sky-500/10 rounded-full blur-3xl" />
<div className="absolute inset-0 bg-linear-to-b from-background via-transparent to-background" />
</div>
@@ -63,11 +59,11 @@ export function Hero() {
</p>
<div className="mt-8 animate-fade-up delay-300 w-full max-w-xl">
<div className="bg-card border border-border rounded-lg shadow-sm overflow-hidden">
<div className="bg-card border border-border rounded-lg shadow-xs overflow-hidden">
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50">
<span className="size-2.5 rounded-full bg-red-500/40" />
<span className="size-2.5 rounded-full bg-yellow-500/40" />
<span className="size-2.5 rounded-full bg-green-500/40" />
<span className="size-2 rounded-full bg-foreground/20" />
<span className="size-2 rounded-full bg-foreground/20" />
<span className="size-2 rounded-full bg-foreground/20" />
</div>
<div className="flex items-center gap-3 px-4 py-3 font-mono text-sm">
@@ -88,9 +84,7 @@ export function Hero() {
</Link>
</Button>
<Button variant="ghost" size="lg" asChild>
<Link href="/docs/basic-map" className="text-muted-foreground">
View Components
</Link>
<Link href="/docs/basic-map">View Components</Link>
</Button>
</div>
</div>

View File

@@ -4,15 +4,15 @@ import { ExamplesGrid } from "./_components/examples-grid";
export default function Page() {
return (
<div className="flex flex-col pb-28">
<div className="flex flex-col pb-32">
<Header className="border-b border-transparent" />
<main className="flex-1">
<section className="relative w-full py-20">
<section className="relative w-full py-24">
<Hero />
</section>
<section className="container px-6">
<section className="container">
<ExamplesGrid />
</section>
</main>

View File

@@ -33,7 +33,7 @@ export function DocsSidebar() {
<SidebarContent className="pt-8 no-scrollbar overflow-x-hidden">
{docsNavigation.map((group) => (
<SidebarGroup key={group.title} className="px-1">
<SidebarGroupLabel className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
<SidebarGroupLabel className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/80">
{group.title}
</SidebarGroupLabel>
<SidebarGroupContent>

View File

@@ -57,28 +57,33 @@ export function DocsToc({ items, className }: DocsTocProps) {
}
return (
<div className={cn("flex flex-col gap-2.5 text-sm", className)}>
<p className="text-muted-foreground text-xs font-medium mb-1">
On This Page
</p>
{items.map((item) => (
<a
key={item.slug}
href={`#${item.slug}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(item.slug)?.scrollIntoView({
behavior: "smooth",
});
}}
className={cn(
"text-muted-foreground hover:text-foreground text-[0.8rem] no-underline transition-colors",
item.slug === activeHeading && "text-foreground"
)}
>
{item.title}
</a>
))}
<div className={cn("flex flex-col gap-1", className)}>
<p className="text-foreground text-xs font-medium mb-2">On This Page</p>
<div className="relative">
<div className="absolute left-0 top-1 bottom-1 w-px bg-border" />
<div className="flex flex-col gap-1">
{items.map((item) => {
const isActive = item.slug === activeHeading;
return (
<a
key={item.slug}
href={`#${item.slug}`}
className={cn(
"relative pl-3 py-1 text-[13px] no-underline transition-colors",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{isActive && (
<div className="absolute left-0 top-1 bottom-1 w-px bg-foreground rounded-full" />
)}
{item.title}
</a>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -30,7 +30,7 @@ interface DocsHeaderProps {
export function DocsHeader({ title, description }: DocsHeaderProps) {
return (
<div className="space-y-2">
<div className="space-y-3">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
{title}
</h1>
@@ -61,17 +61,17 @@ export function DocsLayout({
}: DocsLayoutProps) {
return (
<div className="flex gap-8">
<div className="flex-1 min-w-0 max-w-[720px] mx-auto py-8 pb-20">
<div className="flex-1 min-w-0 max-w-[720px] mx-auto pt-10 pb-20">
<DocsHeader title={title} description={description} />
<div className="mt-10 space-y-10">{children}</div>
<div className="mt-12 space-y-12">{children}</div>
{(prev || next) && (
<div className="flex items-center justify-between gap-4 mt-12">
<div className="flex items-center justify-between gap-4 mt-16">
{prev ? (
<Link
href={prev.href}
className="group flex flex-col items-start gap-1"
className="group flex flex-col items-start gap-1.5"
>
<span className="text-xs text-muted-foreground">Previous</span>
<span className="text-sm font-medium group-hover:underline underline-offset-4">
@@ -84,7 +84,7 @@ export function DocsLayout({
{next && (
<Link
href={next.href}
className="group flex flex-col items-end gap-1"
className="group flex flex-col items-end gap-1.5"
>
<span className="text-xs text-muted-foreground">Next</span>
<span className="text-sm font-medium group-hover:underline underline-offset-4">
@@ -96,7 +96,7 @@ export function DocsLayout({
)}
</div>
<aside className="hidden xl:block w-44 shrink-0">
<aside className="hidden xl:block w-48 shrink-0">
<nav className="sticky top-28">
{toc.length > 0 && <DocsToc items={toc} />}
</nav>
@@ -114,13 +114,13 @@ interface DocsSectionProps {
export function DocsSection({ title, children }: DocsSectionProps) {
const id = title ? slugify(title) : undefined;
return (
<section className="space-y-4 scroll-mt-20" id={id}>
<section className="space-y-5 scroll-mt-24" id={id}>
{title && (
<h2 className="text-xl font-semibold tracking-tight text-foreground">
{title}
</h2>
)}
<div className="text-foreground/80 text-base sm:text-[15px] leading-7 space-y-3 [&_p]:leading-7 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-1.5 [&_li]:leading-7 [&_strong]:text-foreground [&_strong]:font-medium [&_em]:text-muted-foreground">
<div className="text-foreground/80 text-[15px] leading-7 space-y-4 [&_p]:leading-7 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-2 [&_li]:leading-7 [&_strong]:text-foreground [&_strong]:font-medium [&_em]:text-muted-foreground">
{children}
</div>
</section>
@@ -134,7 +134,7 @@ interface DocsNoteProps {
export function DocsNote({ children }: DocsNoteProps) {
return (
<div className="rounded-lg border bg-muted/40 px-4 py-3 text-[14px] leading-relaxed text-foreground/70 [&_strong]:text-foreground [&_strong]:font-medium">
<div className="rounded-lg border bg-muted/30 px-5 py-4 text-[14px] leading-relaxed text-foreground/70 [&_strong]:text-foreground [&_strong]:font-medium">
{children}
</div>
);

View File

@@ -22,8 +22,8 @@ export default function ClusterExample() {
data="https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson"
clusterRadius={50}
clusterMaxZoom={14}
clusterColors={["#22c55e", "#eab308", "#ef4444"]}
pointColor="#3b82f6"
clusterColors={["#1d8cf8", "#6d5dfc", "#e23670"]}
pointColor="#1d8cf8"
onPointClick={(feature, coordinates) => {
setSelectedPoint({
coordinates,

View File

@@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
import { Map, type MapViewport } from "@/registry/map";
export function ControlledMapExample() {
const [viewport, setViewport] = useState<MapViewport>({
center: [-74.006, 40.7128],
zoom: 8,
bearing: 0,
pitch: 0,
});
return (
<div className="h-[400px] relative w-full">
<Map viewport={viewport} onViewportChange={setViewport} />
<div className="absolute top-2 left-2 z-10 flex flex-wrap gap-x-3 gap-y-1 text-xs font-mono bg-background/80 backdrop-blur px-2 py-1.5 rounded border">
<span>
<span className="text-muted-foreground">lng:</span>{" "}
{viewport.center[0].toFixed(3)}
</span>
<span>
<span className="text-muted-foreground">lat:</span>{" "}
{viewport.center[1].toFixed(3)}
</span>
<span>
<span className="text-muted-foreground">zoom:</span>{" "}
{viewport.zoom.toFixed(1)}
</span>
<span>
<span className="text-muted-foreground">bearing:</span>{" "}
{viewport.bearing.toFixed(1)}°
</span>
<span>
<span className="text-muted-foreground">pitch:</span>{" "}
{viewport.pitch.toFixed(1)}°
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Map, type MapRef } from "@/registry/map";
const styles = {
default: undefined,
openstreetmap: "https://tiles.openfreemap.org/styles/bright",
openstreetmap3d: "https://tiles.openfreemap.org/styles/liberty",
};
type StyleKey = keyof typeof styles;
export function CustomStyleExample() {
const mapRef = useRef<MapRef>(null);
const [style, setStyle] = useState<StyleKey>("default");
const selectedStyle = styles[style];
const is3D = style === "openstreetmap3d";
useEffect(() => {
mapRef.current?.easeTo({ pitch: is3D ? 60 : 0, duration: 500 });
}, [is3D]);
return (
<div className="h-[400px] relative w-full">
<Map
ref={mapRef}
center={[-0.1276, 51.5074]}
zoom={15}
styles={
selectedStyle
? { light: selectedStyle, dark: selectedStyle }
: undefined
}
/>
<div className="absolute top-2 right-2 z-10">
<select
value={style}
onChange={(e) => setStyle(e.target.value as StyleKey)}
className="bg-background text-foreground border rounded-md px-2 py-1 text-sm shadow"
>
<option value="default">Default (Carto)</option>
<option value="openstreetmap">OpenStreetMap</option>
<option value="openstreetmap3d">OpenStreetMap 3D</option>
</select>
</div>
</div>
);
}

View File

@@ -130,23 +130,21 @@ export default function AdvancedPage() {
map&apos;s pitch and bearing, and listen to map events to display
real-time values.
</p>
<ComponentPreview code={advancedSource}>
<AdvancedUsageExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={advancedSource}>
<AdvancedUsageExample />
</ComponentPreview>
<DocsSection title="Example: Custom GeoJSON Layer">
<p>
Add custom GeoJSON data as layers with fill and outline styles. This
example shows NYC parks with hover interactions.
</p>
<ComponentPreview code={customLayerSource}>
<CustomLayerExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={customLayerSource}>
<CustomLayerExample />
</ComponentPreview>
<DocsSection title="Example: Markers via Layers">
<p>
When displaying hundreds or thousands of markers, use GeoJSON layers
@@ -154,12 +152,11 @@ export default function AdvancedPage() {
approach renders markers on the WebGL canvas, providing significantly
better performance.
</p>
<ComponentPreview code={layerMarkersSource}>
<LayerMarkersExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={layerMarkersSource}>
<LayerMarkersExample />
</ComponentPreview>
<DocsSection title="Extend to Build">
<p>You can extend this to build custom features like:</p>
<ul>

View File

@@ -101,6 +101,11 @@ export default function ApiReferencePage() {
description:
"Child components (markers, popups, controls, routes).",
},
{
name: "className",
type: "string",
description: "Additional CSS classes for the map container.",
},
{
name: "theme",
type: '"light" | "dark"',
@@ -119,6 +124,18 @@ export default function ApiReferencePage() {
description:
'Map projection type. Use { type: "globe" } for 3D globe view.',
},
{
name: "viewport",
type: "Partial<MapViewport>",
description:
"Controlled viewport state. When used with onViewportChange, enables controlled mode. Can also be used alone for initial viewport.",
},
{
name: "onViewportChange",
type: "(viewport: MapViewport) => void",
description:
"Callback fired continuously as the viewport changes (during pan, zoom, rotate). Can be used alone to observe changes, or with viewport prop to enable controlled mode.",
},
]}
/>
</DocsSection>
@@ -569,7 +586,7 @@ export default function ApiReferencePage() {
{
name: "clusterColors",
type: "[string, string, string]",
default: '["#51bbd6", "#f1f075", "#f28cb1"]',
default: '["#22c55e", "#eab308", "#ef4444"]',
description:
"Colors for cluster circles: [small, medium, large] based on point count.",
},

View File

@@ -1,6 +1,13 @@
import { DocsLayout, DocsSection, DocsCode } from "../_components/docs";
import {
DocsLayout,
DocsSection,
DocsCode,
DocsLink,
} from "../_components/docs";
import { ComponentPreview } from "../_components/component-preview";
import { BasicMapExample } from "../_components/examples/basic-map-example";
import { ControlledMapExample } from "../_components/examples/controlled-map-example";
import { CustomStyleExample } from "../_components/examples/custom-style-example";
import { getExampleSource } from "@/lib/get-example-source";
import { Metadata } from "next";
@@ -10,6 +17,8 @@ export const metadata: Metadata = {
export default function BasicMapPage() {
const basicMapSource = getExampleSource("basic-map-example.tsx");
const controlledMapSource = getExampleSource("controlled-map-example.tsx");
const customStyleSource = getExampleSource("custom-style-example.tsx");
return (
<DocsLayout
@@ -17,17 +26,47 @@ export default function BasicMapPage() {
description="The simplest way to add an interactive map to your application."
prev={{ title: "API Reference", href: "/docs/api-reference" }}
next={{ title: "Controls", href: "/docs/controls" }}
toc={[
{ title: "Basic Usage", slug: "basic-usage" },
{ title: "Controlled Mode", slug: "controlled-mode" },
{ title: "Custom Styles", slug: "custom-styles" },
]}
>
<DocsSection>
<DocsSection title="Basic Usage">
<p>
The <DocsCode>Map</DocsCode> component handles MapLibre GL setup,
theming, and provides context for child components.
</p>
<ComponentPreview code={basicMapSource}>
<BasicMapExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={basicMapSource}>
<BasicMapExample />
</ComponentPreview>
<DocsSection title="Controlled Mode">
<p>
Use the <DocsCode>viewport</DocsCode> and{" "}
<DocsCode>onViewportChange</DocsCode> props to control the map&apos;s
viewport externally. This is useful when you need to sync the map
state with your application or respond to viewport changes.
</p>
<ComponentPreview code={controlledMapSource}>
<ControlledMapExample />
</ComponentPreview>
</DocsSection>
<DocsSection title="Custom Styles">
<p>
Use the <DocsCode>styles</DocsCode> prop to provide custom map styles.
This example uses free vector tiles from{" "}
<DocsLink href="https://openfreemap.org" external>
OpenFreeMap
</DocsLink>
, an open-source project, the data comes from OpenStreetMap.
</p>
<ComponentPreview code={customStyleSource}>
<CustomStyleExample />
</ComponentPreview>
</DocsSection>
</DocsLayout>
);
}

View File

@@ -32,11 +32,10 @@ export default function ClustersPage() {
Click on clusters to zoom in. Click individual points to see details
in a popup.
</p>
<ComponentPreview code={clusterSource}>
<ClusterExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={clusterSource}>
<ClusterExample />
</ComponentPreview>
</DocsLayout>
);
}

View File

@@ -23,11 +23,10 @@ export default function ControlsPage() {
The <DocsCode>MapControls</DocsCode> component provides a set of
interactive controls that can be positioned on any corner of the map.
</p>
<ComponentPreview code={controlsSource}>
<MapControlsExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={controlsSource}>
<MapControlsExample />
</ComponentPreview>
</DocsLayout>
);
}

View File

@@ -14,9 +14,11 @@ export default function DocsLayout({
className="sticky top-0 z-50 bg-background/95 border-b backdrop-blur supports-backdrop-filter:bg-background/60"
leftContent={<MobileSidebarTrigger />}
/>
<div className="flex flex-1 container">
<DocsSidebar />
<main className="flex-1 w-full">{children}</main>
<div className="container flex-1">
<div className="flex">
<DocsSidebar />
<main className="flex-1 w-full">{children}</main>
</div>
</div>
</div>
</SidebarProvider>

View File

@@ -57,33 +57,30 @@ export default function MarkersPage() {
<p>
Simple markers with tooltips and popups showing location information.
</p>
<ComponentPreview code={markersSource}>
<MarkersExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={markersSource}>
<MarkersExample />
</ComponentPreview>
<DocsSection title="Rich Popups">
<p>
Build complex popups with images, ratings, and action buttons using
shadcn/ui components.
</p>
<ComponentPreview code={popupSource} className="h-[500px]">
<PopupExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={popupSource} className="h-[500px]">
<PopupExample />
</ComponentPreview>
<DocsSection title="Draggable Marker">
<p>
Create draggable markers that users can move around the map. Click the
marker to see its current coordinates in a popup.
</p>
<ComponentPreview code={draggableMarkerSource}>
<DraggableMarkerExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={draggableMarkerSource}>
<DraggableMarkerExample />
</ComponentPreview>
</DocsLayout>
);
}

View File

@@ -24,11 +24,10 @@ export default function PopupsPage() {
on the map. Unlike <DocsCode>MarkerPopup</DocsCode>, standalone popups
are not attached to markers and can be controlled programmatically.
</p>
<ComponentPreview code={popupSource}>
<StandalonePopupExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={popupSource}>
<StandalonePopupExample />
</ComponentPreview>
</DocsLayout>
);
}

View File

@@ -39,12 +39,11 @@ export default function RoutesPage() {
<DocsSection title="Basic Route">
<p>Draw a route with numbered stop markers along the path.</p>
<ComponentPreview code={routeSource}>
<RouteExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={routeSource}>
<RouteExample />
</ComponentPreview>
<DocsSection title="Route Planning">
<p>
Display multiple route options and let users select between them. This
@@ -54,11 +53,10 @@ export default function RoutesPage() {
</DocsLink>
. Click on a route or use the buttons to switch.
</p>
<ComponentPreview code={osrmRouteSource} className="h-[500px]">
<OsrmRouteExample />
</ComponentPreview>
</DocsSection>
<ComponentPreview code={osrmRouteSource} className="h-[500px]">
<OsrmRouteExample />
</ComponentPreview>
</DocsLayout>
);
}

View File

@@ -118,7 +118,7 @@
@layer base {
* {
@apply border-border outline-ring/50 overscroll-none;
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground font-sans;
@@ -136,7 +136,7 @@
}
.container {
@apply max-w-[1440px] mx-auto px-6;
@apply max-w-[1480px] mx-auto px-6;
}
/* animations for the home page */

View File

@@ -2,7 +2,13 @@
import * as React from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import {
ArrowDown,
ArrowUp,
CornerDownLeft,
FileText,
SearchIcon,
} from "lucide-react";
import {
CommandDialog,
@@ -14,6 +20,7 @@ import {
} from "@/components/ui/command";
import { Kbd } from "@/components/ui/kbd";
import { docsNavigation } from "@/lib/docs-navigation";
import { Button } from "./ui/button";
export function CommandSearch() {
const [open, setOpen] = React.useState(false);
@@ -38,24 +45,35 @@ export function CommandSearch() {
return (
<>
<button
<Button
variant="outline"
size="sm"
onClick={() => setOpen(true)}
aria-label="Search documentation"
className="hidden group sm:flex items-center gap-2 h-8 px-3 rounded-full bg-secondary/60 border border-border/40 text-sm text-muted-foreground hover:text-foreground hover:border-border transition-colors"
className="hidden group md:flex items-center w-[180px] dark:border-border/50 border-border/70 rounded-lg text-sm font-normal text-muted-foreground"
>
<Search className="size-3.5" />
<span className="hidden sm:inline">Search...</span>
<Kbd className="hidden sm:inline-flex ml-2">K</Kbd>
</button>
<SearchIcon className="size-3.5 shrink-0" />
<span>Search docs...</span>
<Kbd className="ml-auto">K</Kbd>
</Button>
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Search Documentation"
description="Search for documentation pages and components"
showCloseButton={false}
>
<CommandInput placeholder="Type to search..." />
<CommandInput
placeholder="Search documentation..."
className="border-none text-sm h-10"
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandEmpty className="py-8 text-muted-foreground text-sm">
<div className="flex flex-col items-center gap-1.5">
<FileText className="size-5 opacity-40" />
<span>No results found.</span>
</div>
</CommandEmpty>
{docsNavigation.map((group) => (
<CommandGroup key={group.title} heading={group.title}>
{group.items.map((item) => (
@@ -71,6 +89,29 @@ export function CommandSearch() {
</CommandGroup>
))}
</CommandList>
<div className="border-t p-3 text-xs text-muted-foreground/80 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="flex items-center gap-1.5">
<Kbd>
<ArrowUp className="size-3" />
</Kbd>
<Kbd>
<ArrowDown className="size-3" />
</Kbd>
<span>navigate</span>
</span>
<span className="flex items-center gap-1.5">
<Kbd>
<CornerDownLeft className="size-3" />
</Kbd>
<span>select</span>
</span>
</div>
<span className="flex items-baseline gap-1.5">
<Kbd>esc</Kbd>
<span>close</span>
</span>
</div>
</CommandDialog>
</>
);

View File

@@ -2,24 +2,24 @@ import Link from "next/link";
export function Footer() {
return (
<footer className="w-full py-8 border-t border-border/40 bg-muted/20">
<div className="container flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2 flex-wrap justify-center sm:justify-start">
<span>© {new Date().getFullYear()} mapcn</span>
<span className="text-border"></span>
<footer className="w-full py-6 border-t">
<div className="w-full container flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">mapcn</span>
<span className="text-muted-foreground/80"></span>
<span>
Built by{" "}
<a
href="https://github.com/AnmolSaini16"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-foreground hover:underline underline-offset-4"
className="font-medium text-foreground underline underline-offset-4"
>
Anmol
</a>
</span>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-5">
<Link
href="/docs"
className="hover:text-foreground transition-colors"

View File

@@ -34,7 +34,7 @@ export function Header({ className, leftContent }: HeaderProps) {
</div>
<div className="flex items-center gap-2 h-4.5">
<CommandSearch />
<Separator orientation="vertical" className="hidden sm:block" />
<Separator orientation="vertical" className="hidden md:block" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" asChild>
@@ -44,7 +44,7 @@ export function Header({ className, leftContent }: HeaderProps) {
rel="noopener noreferrer"
>
<Heart className="size-4 text-pink-500" />
<span className="hidden sm:inline">Sponsor</span>
<span className="hidden md:inline font-normal">Sponsor</span>
</a>
</Button>
</TooltipTrigger>

View File

@@ -17,7 +17,7 @@ const buttonVariants = cva(
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/60",
link: "text-primary underline-offset-4 hover:underline",
},
size: {

View File

@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
@@ -90,7 +91,14 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
@@ -99,7 +107,14 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
className
)}
{...props}
/>
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}

View File

@@ -82,6 +82,16 @@ type MapContextValue = {
const MapContext = createContext<MapContextValue | null>(null);
function getViewport(map: MapLibreGL.Map): MapViewport {
const center = map.getCenter();
return {
center: [center.lng, center.lat],
zoom: map.getZoom(),
bearing: map.getBearing(),
pitch: map.getPitch(),
};
}
function useMap() {
const context = useContext(MapContext);
if (!context) {
@@ -99,8 +109,22 @@ type MapStyleOption = string | MapLibreGL.StyleSpecification;
type Theme = "light" | "dark";
/** Map viewport state */
type MapViewport = {
/** Center coordinates [longitude, latitude] */
center: [number, number];
/** Zoom level */
zoom: number;
/** Bearing (rotation) in degrees */
bearing: number;
/** Pitch (tilt) in degrees */
pitch: number;
};
type MapProps = {
children?: ReactNode;
/** Additional CSS classes for the map container */
className?: string;
/**
* Theme for the map. If not provided, automatically detects system preference.
* Pass your theme value here.
@@ -113,6 +137,17 @@ type MapProps = {
};
/** Map projection type. Use `{ type: "globe" }` for 3D globe view. */
projection?: MapLibreGL.ProjectionSpecification;
/**
* Controlled viewport. When provided with onViewportChange,
* the map becomes controlled and viewport is driven by this prop.
*/
viewport?: Partial<MapViewport>;
/**
* Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch).
* Can be used standalone to observe changes, or with `viewport` prop
* to enable controlled mode where the map viewport is driven by your state.
*/
onViewportChange?: (viewport: MapViewport) => void;
} & Omit<MapLibreGL.MapOptions, "container" | "style">;
type MapRef = MapLibreGL.Map;
@@ -128,7 +163,16 @@ const DefaultLoader = () => (
);
const Map = forwardRef<MapRef, MapProps>(function Map(
{ children, theme: themeProp, styles, projection, ...props },
{
children,
className,
theme: themeProp,
styles,
projection,
viewport,
onViewportChange,
...props
},
ref
) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -137,8 +181,14 @@ const Map = forwardRef<MapRef, MapProps>(function Map(
const [isStyleLoaded, setIsStyleLoaded] = useState(false);
const currentStyleRef = useRef<MapStyleOption | null>(null);
const styleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const internalUpdateRef = useRef(false);
const resolvedTheme = useResolvedTheme(themeProp);
const isControlled = viewport !== undefined && onViewportChange !== undefined;
const onViewportChangeRef = useRef(onViewportChange);
onViewportChangeRef.current = onViewportChange;
const mapStyles = useMemo(
() => ({
dark: styles?.dark ?? defaultStyles.dark,
@@ -147,6 +197,7 @@ const Map = forwardRef<MapRef, MapProps>(function Map(
[styles]
);
// Expose the map instance to the parent component
useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);
const clearStyleTimeout = useCallback(() => {
@@ -156,6 +207,7 @@ const Map = forwardRef<MapRef, MapProps>(function Map(
}
}, []);
// Initialize the map
useEffect(() => {
if (!containerRef.current) return;
@@ -171,6 +223,7 @@ const Map = forwardRef<MapRef, MapProps>(function Map(
compact: true,
},
...props,
...viewport,
});
const styleDataHandler = () => {
@@ -187,14 +240,22 @@ const Map = forwardRef<MapRef, MapProps>(function Map(
};
const loadHandler = () => setIsLoaded(true);
// Viewport change handler - skip if triggered by internal update
const handleMove = () => {
if (internalUpdateRef.current) return;
onViewportChangeRef.current?.(getViewport(map));
};
map.on("load", loadHandler);
map.on("styledata", styleDataHandler);
map.on("move", handleMove);
setMapInstance(map);
return () => {
clearStyleTimeout();
map.off("load", loadHandler);
map.off("styledata", styleDataHandler);
map.off("move", handleMove);
map.remove();
setIsLoaded(false);
setIsStyleLoaded(false);
@@ -203,6 +264,35 @@ const Map = forwardRef<MapRef, MapProps>(function Map(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync controlled viewport to map
useEffect(() => {
if (!mapInstance || !isControlled || !viewport) return;
if (mapInstance.isMoving()) return;
const current = getViewport(mapInstance);
const next = {
center: viewport.center ?? current.center,
zoom: viewport.zoom ?? current.zoom,
bearing: viewport.bearing ?? current.bearing,
pitch: viewport.pitch ?? current.pitch,
};
if (
next.center[0] === current.center[0] &&
next.center[1] === current.center[1] &&
next.zoom === current.zoom &&
next.bearing === current.bearing &&
next.pitch === current.pitch
) {
return;
}
internalUpdateRef.current = true;
mapInstance.jumpTo(next);
internalUpdateRef.current = false;
}, [mapInstance, isControlled, viewport]);
// Handle style change
useEffect(() => {
if (!mapInstance || !resolvedTheme) return;
@@ -228,7 +318,10 @@ const Map = forwardRef<MapRef, MapProps>(function Map(
return (
<MapContext.Provider value={contextValue}>
<div ref={containerRef} className="relative w-full h-full">
<div
ref={containerRef}
className={cn("relative w-full h-full", className)}
>
{!isLoaded && <DefaultLoader />}
{/* SSR-safe: children render only when map is loaded on client */}
{mapInstance && children}
@@ -288,6 +381,23 @@ function MapMarker({
}: MapMarkerProps) {
const { map } = useMap();
const callbacksRef = useRef({
onClick,
onMouseEnter,
onMouseLeave,
onDragStart,
onDrag,
onDragEnd,
});
callbacksRef.current = {
onClick,
onMouseEnter,
onMouseLeave,
onDragStart,
onDrag,
onDragEnd,
};
const marker = useMemo(() => {
const markerInstance = new MapLibreGL.Marker({
...markerOptions,
@@ -295,9 +405,11 @@ function MapMarker({
draggable,
}).setLngLat([longitude, latitude]);
const handleClick = (e: MouseEvent) => onClick?.(e);
const handleMouseEnter = (e: MouseEvent) => onMouseEnter?.(e);
const handleMouseLeave = (e: MouseEvent) => onMouseLeave?.(e);
const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);
const handleMouseEnter = (e: MouseEvent) =>
callbacksRef.current.onMouseEnter?.(e);
const handleMouseLeave = (e: MouseEvent) =>
callbacksRef.current.onMouseLeave?.(e);
markerInstance.getElement()?.addEventListener("click", handleClick);
markerInstance
@@ -309,15 +421,15 @@ function MapMarker({
const handleDragStart = () => {
const lngLat = markerInstance.getLngLat();
onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });
callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });
};
const handleDrag = () => {
const lngLat = markerInstance.getLngLat();
onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });
callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });
};
const handleDragEnd = () => {
const lngLat = markerInstance.getLngLat();
onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });
callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });
};
markerInstance.on("dragstart", handleDragStart);
@@ -827,6 +939,8 @@ function MapPopup({
}: MapPopupProps) {
const { map } = useMap();
const popupOptionsRef = useRef(popupOptions);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const container = useMemo(() => document.createElement("div"), []);
const popup = useMemo(() => {
@@ -845,7 +959,8 @@ function MapPopup({
useEffect(() => {
if (!map) return;
const onCloseProp = () => onClose?.();
const onCloseProp = () => onCloseRef.current?.();
popup.on("close", onCloseProp);
popup.setDOMContent(container);
@@ -881,7 +996,6 @@ function MapPopup({
const handleClose = () => {
popup.remove();
onClose?.();
};
return createPortal(
@@ -1058,7 +1172,7 @@ type MapClusterLayerProps<
clusterMaxZoom?: number;
/** Radius of each cluster when clustering points in pixels (default: 50) */
clusterRadius?: number;
/** Colors for cluster circles: [small, medium, large] based on point count (default: ["#51bbd6", "#f1f075", "#f28cb1"]) */
/** Colors for cluster circles: [small, medium, large] based on point count (default: ["#22c55e", "#eab308", "#ef4444"]) */
clusterColors?: [string, string, string];
/** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */
clusterThresholds?: [number, number];
@@ -1083,7 +1197,7 @@ function MapClusterLayer<
data,
clusterMaxZoom = 14,
clusterRadius = 50,
clusterColors = ["#51bbd6", "#f1f075", "#f28cb1"],
clusterColors = ["#22c55e", "#eab308", "#ef4444"],
clusterThresholds = [100, 750],
pointColor = "#3b82f6",
onPointClick,
@@ -1140,6 +1254,9 @@ function MapClusterLayer<
clusterThresholds[1],
40,
],
"circle-stroke-width": 1,
"circle-stroke-color": "#fff",
"circle-opacity": 0.85,
},
});
@@ -1166,7 +1283,9 @@ function MapClusterLayer<
filter: ["!", ["has", "point_count"]],
paint: {
"circle-color": pointColor,
"circle-radius": 6,
"circle-radius": 5,
"circle-stroke-width": 2,
"circle-stroke-color": "#fff",
},
});
@@ -1360,4 +1479,4 @@ export {
MapClusterLayer,
};
export type { MapRef };
export type { MapRef, MapViewport };