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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions apps/blocks/src/components/docs/demos/ui-map.demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
'use client';

import { useEffect, useState } from 'react';

import { Button } from '@constructive-io/ui/button';
import { Map, MapArc, MapControls, MapMarker, MapPopup, MarkerContent, MarkerPopup } from '@constructive-io/ui/map';

import { Demo } from '@/components/docs/showcase-kit';

const NEW_YORK_CENTER: [number, number] = [-73.9857, 40.7484];

const NEW_YORK_DESTINATIONS = [
{ id: 'central-park', label: 'Central Park', position: [-73.9654, 40.7829] },
{ id: 'washington-square', label: 'Washington Square Park', position: [-73.9973, 40.7308] },
{ id: 'columbia', label: 'Columbia University', position: [-73.9626, 40.8075] },
] satisfies Array<{ id: string; label: string; position: [number, number] }>;

function randomNewYorkPosition(): [number, number] {
const progress = Math.random();
return [
-74.013 + progress * 0.073 + (Math.random() - 0.5) * 0.012,
40.704 + progress * 0.108 + (Math.random() - 0.5) * 0.01,
];
}

function useRandomNewYorkPosition() {
const [position, setPosition] = useState<[number, number]>(NEW_YORK_CENTER);
useEffect(() => setPosition(randomNewYorkPosition()), []);
return [position, setPosition] as const;
}

function LocalMapPreview() {
return (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 size-full bg-muted"
preserveAspectRatio="xMidYMid slice"
viewBox="0 0 1200 900"
>
<path
className="fill-primary/15"
d="M840-20C760 180 910 280 815 440C750 570 860 680 730 920H980C1060 690 915 560 970 420C1040 220 930 110 1010-20Z"
/>
<path className="fill-accent" d="M320 230 460 205 505 330 350 365ZM180 615 310 575 360 710 220 745Z" />
<g className="fill-none stroke-border" strokeWidth="7">
<path d="M60 180 790 690M30 300 810 780M130 60 795 540M120 820 770 130M220 0 260 900M410 0 455 900M610 0 650 900M0 210 820 250M0 440 825 470M0 665 785 700" />
</g>
<g className="fill-none stroke-background" strokeLinecap="round" strokeWidth="30">
<path d="M-40 790 815 35M90-30 715 930M-30 510 850 520" />
</g>
<g className="fill-none stroke-muted-foreground" strokeLinecap="round" strokeWidth="16">
<path d="M-40 790 815 35M90-30 715 930M-30 510 850 520" />
</g>
<g className="fill-muted-foreground text-[28px] font-semibold">
<text x="455" y="410">
Manhattan
</text>
<text x="875" y="410" transform="rotate(90 875 410)">
East River
</text>
</g>
</svg>
);
}

export function BasicMapDemo() {
const [position, setPosition] = useRandomNewYorkPosition();

return (
<Demo>
<div className="relative h-72 w-full max-w-4xl overflow-hidden rounded-lg border bg-muted/30">
<LocalMapPreview />
<Map blank center={NEW_YORK_CENTER} zoom={11.3}>
<MapMarker longitude={position[0]} latitude={position[1]}>
<MarkerContent />
</MapMarker>
<MapPopup longitude={position[0]} latitude={position[1]} offset={18}>
<div className="min-w-36">
<p className="text-sm font-medium">Random New York position</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{position[1].toFixed(5)}, {position[0].toFixed(5)}
</p>
</div>
</MapPopup>
<MapControls showCompass showFullscreen />
</Map>
<Button
className="absolute left-3 top-3 z-20 shadow-sm"
onClick={() => setPosition(randomNewYorkPosition())}
size="sm"
variant="secondary"
>
Random New York position
</Button>
</div>
</Demo>
);
}

export function BlankDataMapDemo() {
const [position, setPosition] = useRandomNewYorkPosition();
const connections = NEW_YORK_DESTINATIONS.map(({ id, position: destination }) => ({
id,
from: position,
to: destination,
}));

return (
<Demo>
<div className="relative h-64 w-full max-w-3xl overflow-hidden rounded-lg border bg-muted/30">
<LocalMapPreview />
<Map blank center={NEW_YORK_CENTER} zoom={11.3}>
<MapArc data={connections} />
<MapMarker longitude={position[0]} latitude={position[1]}>
<MarkerContent />
<MarkerPopup>Random New York origin</MarkerPopup>
</MapMarker>
{NEW_YORK_DESTINATIONS.map((destination) => (
<MapMarker key={destination.id} longitude={destination.position[0]} latitude={destination.position[1]}>
<MarkerContent />
<MarkerPopup>{destination.label}</MarkerPopup>
</MapMarker>
))}
<MapControls />
</Map>
<Button
className="absolute left-3 top-3 z-20 shadow-sm"
onClick={() => setPosition(randomNewYorkPosition())}
size="sm"
variant="secondary"
>
Randomize origin
</Button>
</div>
</Demo>
);
}

export function BlockDemo() {
return <BasicMapDemo />;
}
2 changes: 2 additions & 0 deletions apps/blocks/src/components/docs/showcase-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const DEMO_MODULES = {
'dropdown-menu': () => import('./demos/ui-dropdown-menu.demo'),
input: () => import('./demos/ui-input.demo'),
label: () => import('./demos/ui-label.demo'),
map: () => import('./demos/ui-map.demo'),
pagination: () => import('./demos/ui-pagination.demo'),
popover: () => import('./demos/ui-popover.demo'),
progress: () => import('./demos/ui-progress.demo'),
Expand Down Expand Up @@ -76,6 +77,7 @@ export const UI_DEMOS = {
'dropdown-menu': getUiDemo('dropdown-menu'),
input: getUiDemo('input'),
label: getUiDemo('label'),
map: getUiDemo('map'),
pagination: getUiDemo('pagination'),
popover: getUiDemo('popover'),
progress: getUiDemo('progress'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const HOME_SHOWCASE_ORDER = [
'resizable',
'scroll-area',
'table',
'map',
'label',
'textarea',
] as const satisfies readonly BasePrimitiveName[];
Expand All @@ -48,6 +49,7 @@ export const HOME_SHOWCASE_DEMOS = {
'dropdown-menu': getUiDemo('dropdown-menu', 'BasicDropdownMenuDemo'),
input: getUiDemo('input', 'ControlledInputDemo'),
label: getUiDemo('label', 'RequiredLabelDemo'),
map: getUiDemo('map', 'BasicMapDemo'),
pagination: getUiDemo('pagination', 'BasicPaginationDemo'),
popover: getUiDemo('popover', 'BasicPopoverDemo'),
progress: getUiDemo('progress', 'BlockDemo'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createNoopSchemaBuilderAdapter } from '@constructive-io/schema-builder/
import {
Sheets,
SheetsProvider,
type SheetsGeocodeResult,
type SheetsConfig,
} from '@constructive-io/sheets';
import {
Expand All @@ -28,6 +29,13 @@ import { Badge } from '@constructive-io/ui/badge';

import type { SourceBlockDoc } from '@/lib/source-blocks';

const SHEETS_GEOCODE_RESULTS: readonly SheetsGeocodeResult[] = [
{ label: 'Ho Chi Minh City, Vietnam', longitude: 106.7009, latitude: 10.7769 },
{ label: 'Hanoi, Vietnam', longitude: 105.8342, latitude: 21.0278 },
{ label: 'Da Nang, Vietnam', longitude: 108.2022, latitude: 16.0544 },
{ label: 'Singapore', longitude: 103.8198, latitude: 1.3521 },
];

function createSheetsTables(): MockTable[] {
return [
{
Expand All @@ -37,6 +45,12 @@ function createSheetsTables(): MockTable[] {
{ name: 'name', gqlType: 'String', pgType: 'text' },
{ name: 'status', gqlType: 'String', pgType: 'text' },
{ name: 'owner', gqlType: 'String', pgType: 'text' },
{
name: 'location',
gqlType: 'GeoJSON',
pgType: 'geometry',
subtype: 'GeometryPoint',
},
{ name: 'updatedAt', gqlType: 'Datetime', pgType: 'timestamptz' },
],
rows: [
Expand All @@ -45,27 +59,31 @@ function createSheetsTables(): MockTable[] {
name: 'Atlas migration',
status: 'In progress',
owner: 'Ada Lovelace',
location: { type: 'Point', coordinates: [106.7009, 10.7769] },
updatedAt: '2026-07-28T09:18Z',
},
{
id: 'project-beacon',
name: 'Beacon launch',
status: 'Review',
owner: 'Grace Hopper',
location: { type: 'Point', coordinates: [105.8342, 21.0278] },
updatedAt: '2026-07-27T15:42Z',
},
{
id: 'project-compass',
name: 'Compass research',
status: 'Planned',
owner: 'Alan Turing',
location: { type: 'Point', coordinates: [108.2022, 16.0544] },
updatedAt: '2026-07-24T11:05Z',
},
{
id: 'project-delta',
name: 'Delta onboarding',
status: 'In progress',
owner: 'Katherine Johnson',
location: { type: 'Point', coordinates: [103.8198, 1.3521] },
updatedAt: '2026-07-22T08:30Z',
},
],
Expand All @@ -87,6 +105,29 @@ function SheetsShowcase() {
},
execute: mock.execute,
executeUpload: mock.executeUpload,
map: {
styles: {
light: {
version: 8,
sources: {},
layers: [{ id: 'background', type: 'background', paint: { 'background-color': '#f1f5f9' } }],
},
dark: {
version: 8,
sources: {},
layers: [{ id: 'background', type: 'background', paint: { 'background-color': '#111827' } }],
},
},
geocode: (query, { signal }) => {
signal.throwIfAborted();
const normalizedQuery = query.trim().toLocaleLowerCase();
return Promise.resolve(
SHEETS_GEOCODE_RESULTS.filter((result) =>
result.label.toLocaleLowerCase().includes(normalizedQuery),
),
);
},
},
};
}, []);

Expand Down
2 changes: 2 additions & 0 deletions apps/blocks/src/content/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { drawerDocs } from './drawer';
import { dropdownMenuDocs } from './dropdown-menu';
import { inputDocs } from './input';
import { labelDocs } from './label';
import { mapDocs } from './map';
import { paginationDocs } from './pagination';
import { popoverDocs } from './popover';
import { progressDocs } from './progress';
Expand Down Expand Up @@ -46,6 +47,7 @@ export const PRIMITIVE_DOCS = {
'dropdown-menu': dropdownMenuDocs,
input: inputDocs,
label: labelDocs,
map: mapDocs,
pagination: paginationDocs,
popover: popoverDocs,
progress: progressDocs,
Expand Down
100 changes: 100 additions & 0 deletions apps/blocks/src/content/ui/map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { definePrimitiveDocs } from '@/lib/primitive-docs';

const mapLibreApi = {
href: 'https://maplibre.org/maplibre-gl-js/docs/API/',
label: 'MapLibre GL JS API',
} as const;

export const mapDocs = definePrimitiveDocs({
name: 'map',
stateModel: 'host-owned',
whenToUse: [
'Use Map for interactive location context, point selection, routes, geographic data layers, or clusters that need MapLibre rendering and Constructive controls.',
'Use blank mode when the application owns every rendered layer and does not need a street basemap. Use a static image when people do not need to pan, zoom, select, or inspect geographic data.',
'Supply licensed production styles and tile infrastructure from the host. The built-in demotiles.maplibre.org style is an example default for local previews and documentation.',
],
usage: {
demo: 'BasicMapDemo',
description:
'Give Map a bounded height, pass MapLibre camera options directly, and compose markers, popups, controls, routes, arcs, GeoJSON, or clusters as children. The preview randomizes a position within New York while keeping its local basemap tile-free. The style owns its source attribution, so keep the attribution control visible and verify the provider terms before release.',
},
state: {
title: 'Viewport, styles, and runtime ownership',
description:
'Map can own its camera or accept viewport with onViewportChange for controlled use. Explicit light and dark styles override blank mode and the demo default; when only one themed style is supplied, both themes reuse it. The npm MapLibre build manages its worker. A normal policy must allow worker-src blob:, while a stricter policy should use MapLibre’s CSP bundle and configure its separate worker URL before this component mounts.',
},
examples: [
{
title: 'Tile-free data map',
description:
'Set blank when routes, arcs, markers, or host layers provide all visual content and the page should not request a basemap. This example connects a randomized New York origin to fixed landmarks.',
demo: 'BlankDataMapDemo',
},
],
accessibility: [
'Give every custom marker and popup control an accessible name. The built-in control buttons already expose zoom, locate, compass, fullscreen, and close labels.',
'Treat the map as supporting context when the same coordinates or selected value can be read and edited elsewhere. Map fallback content should preserve that non-visual path when WebGL 2 is unavailable.',
'Keep provider attribution visible and readable in both themes. Do not cover it with application controls or remove credits required by the style and tile license.',
'Keyboard and screen-reader users cannot depend on free-form canvas interaction, so expose important features through an adjacent list, form, or table.',
],
api: [
{
name: 'Map',
description:
'MapLibre container with theme-aware styles, controlled camera support, loading state, and initialization fallback.',
props: [
{
name: 'styles',
type: '{ light?: MapStyleOption; dark?: MapStyleOption }',
description:
'Supplies host-owned style URLs or specifications. One supplied theme is reused for both themes.',
},
{
name: 'blank',
type: 'boolean',
default: 'false',
description: 'Uses a transparent style with no tile sources when explicit styles are absent.',
},
{
name: 'viewport / onViewportChange',
type: 'Partial<MapViewport> / (viewport: MapViewport) => void',
description:
'Controls the camera when both props are supplied, or observes movement through the callback alone.',
},
{
name: 'fallback',
type: 'ReactNode',
description: 'Replaces the default unavailable state after a WebGL 2 or constructor failure.',
},
{
name: 'onError',
type: '(error: Error) => void',
description:
'Reports initialization and later MapLibre errors without removing a usable map for later errors.',
},
],
upstream: mapLibreApi,
},
{
name: 'MapMarker / MarkerContent / MarkerPopup / MarkerTooltip / MarkerLabel',
description: 'Composable marker content with drag callbacks, popup content, hover hints, and labels.',
upstream: mapLibreApi,
},
{
name: 'MapPopup / MapControls',
description: 'Free-standing popup and Constructive zoom, compass, location, and fullscreen controls.',
upstream: mapLibreApi,
},
{
name: 'MapRoute / MapArc / MapGeoJSON / MapClusterLayer',
description: 'Declarative line, arc, feature, and clustered-point layers with typed interaction callbacks.',
upstream: mapLibreApi,
},
{
name: 'useMap',
description:
'Returns the MapLibre instance, load gate, and resolved light or dark theme for advanced host layers.',
upstream: mapLibreApi,
},
],
});
Loading
Loading