import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { Map } from './map';
import { MapLegend } from '../map-legend';
import { MapNotice } from '../map-notice';
import { MapViewControl } from '../map-view-control';
import type { MapScale } from './map.types';
function MapStoryFrame({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
const meta: Meta = {
title: 'UI/Map',
component: Map,
render: args => (
),
parameters: {
layout: 'centered',
},
argTypes: {
zoom: { control: { type: 'range', min: 1, max: 20 } },
height: { control: 'text' },
fitTo: { control: 'inline-radio', options: ['none', 'markers', 'shapes', 'all'] },
fitPadding: { control: { type: 'range', min: 0, max: 120 } },
maxZoomOnFit: { control: { type: 'range', min: 1, max: 20 } },
loading: { control: 'boolean' },
isEmpty: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj;
/* -------------------------------------------------------------------------- */
/* Sample data — municipalities of Rio Grande do Sul with service volumes. */
/* -------------------------------------------------------------------------- */
const ATTENDANCE_SCALE: MapScale = {
value: 0,
domain: [0, 900],
range: [6, 24],
method: 'sqrt',
};
const MUNICIPALITIES = [
{
id: 'porto-alegre',
label: 'Porto Alegre',
position: { lat: -30.0346, lng: -51.2177 },
volume: 874,
},
{
id: 'caxias-do-sul',
label: 'Caxias do Sul',
position: { lat: -29.1685, lng: -51.1796 },
volume: 412,
},
{ id: 'pelotas', label: 'Pelotas', position: { lat: -31.7654, lng: -52.3376 }, volume: 233 },
{ id: 'canoas', label: 'Canoas', position: { lat: -29.9177, lng: -51.1836 }, volume: 188 },
{ id: 'gravatai', label: 'Gravataí', position: { lat: -29.9444, lng: -50.9919 }, volume: 96 },
{
id: 'santa-maria',
label: 'Santa Maria',
position: { lat: -29.6842, lng: -53.8069 },
volume: 41,
},
];
const VIEWS = [
{ id: 'estado', label: 'Estado', center: { lat: -30.0, lng: -52.5 }, zoom: 6 },
{ id: 'metro', label: 'Região metropolitana', center: { lat: -29.98, lng: -51.15 }, zoom: 10 },
];
export const Default: Story = {
args: {
center: { lat: -23.5505, lng: -46.6333 },
zoom: 12,
height: '400px',
},
};
export const WithMarkers: Story = {
args: {
center: { lat: -23.5505, lng: -46.6333 },
zoom: 13,
height: '500px',
markers: [
{
position: { lat: -23.5505, lng: -46.6333 },
title: 'São Paulo Center',
info: 'The heart of the city',
},
{
position: { lat: -23.5605, lng: -46.6433 },
title: 'Avenida Paulista',
info: 'Financial and cultural hub',
colorToken: '--destructive',
},
],
},
};
/**
* Proportional symbology: the disc area encodes the value, and the size key in
* the legend states the scale. Both read from the same `MapScale` object.
*/
export const ProportionalMarkers: Story = {
args: {
zoom: 7,
center: { lat: -30.0, lng: -52.0 },
height: '520px',
fitTo: 'markers',
maxZoomOnFit: 9,
markers: MUNICIPALITIES.map(municipality => ({
id: municipality.id,
position: municipality.position,
title: municipality.label,
info: `${municipality.volume} atendimentos no mês`,
shape: 'circle' as const,
scale: { ...ATTENDANCE_SCALE, value: municipality.volume },
colorToken: '--chart-1' as const,
fillOpacity: 0.45,
})),
},
render: args => (
),
};
/**
* Click or tab to a marker to select it. This is the gesture six of the surveyed
* applications had to rebuild as a parallel list.
*/
export const SelectableFeatures: Story = {
render: function SelectableFeaturesStory(args) {
const [selectedId, setSelectedId] = React.useState(null);
return (
);
},
args: {
zoom: 7,
center: { lat: -30.0, lng: -52.0 },
height: '480px',
fitTo: 'markers',
maxZoomOnFit: 9,
},
};
/** Instant labels on hover and on keyboard focus, without opening an InfoWindow. */
export const MarkerTooltips: Story = {
args: {
zoom: 7,
center: { lat: -30.0, lng: -52.0 },
height: '460px',
fitTo: 'markers',
maxZoomOnFit: 9,
markers: MUNICIPALITIES.map(municipality => ({
id: municipality.id,
position: municipality.position,
title: municipality.label,
colorToken: '--chart-3' as const,
tooltip: (
{municipality.label} — {municipality.volume} atendimentos
),
tooltipDirection: 'top' as const,
})),
},
};
/** Named camera positions plus the reframe action. */
export const NamedViews: Story = {
render: function NamedViewsStory(args) {
const [activeViewId, setActiveViewId] = React.useState('estado');
return (
setActiveViewId(undefined)}
/>
);
},
args: {
height: '480px',
fitTo: 'markers',
maxZoomOnFit: 9,
markers: MUNICIPALITIES.map(municipality => ({
id: municipality.id,
position: municipality.position,
title: municipality.label,
colorToken: '--chart-1' as const,
})),
},
};
/** An empty filter result is a normal outcome, not an error. */
export const EmptyState: Story = {
args: {
height: '400px',
isEmpty: true,
},
};
/** A data error, distinct from a failure to load the Maps script. */
export const ErrorState: Story = {
args: {
height: '400px',
errorState: (
Falha ao carregar os atendimentos
Tente novamente em alguns instantes
),
},
};
/** Applications with per-profile confidentiality need an explicit denial. */
export const DeniedState: Story = {
args: {
height: '400px',
deniedState: (
Acesso restrito
Seu perfil não permite visualizar a localização destes registros
),
},
};
/** Cartographic attribution and a provenance seal, both theme-aware. */
export const NoticesAndAttribution: Story = {
render: args => (
Dados demonstrativos
),
args: {
zoom: 7,
center: { lat: -30.0, lng: -52.0 },
height: '440px',
attribution: '© OpenStreetMap contributors',
markers: MUNICIPALITIES.slice(0, 3).map(municipality => ({
id: municipality.id,
position: municipality.position,
title: municipality.label,
colorToken: '--chart-4' as const,
})),
},
};
/*
* A coarse outline of Rio Grande do Sul. Real boundary files have thousands of
* vertices; a dozen is enough to show that the outline and the points share one
* projection.
*/
const RS_OUTLINE = {
type: 'FeatureCollection' as const,
features: [
{
type: 'Feature' as const,
properties: { nome: 'Rio Grande do Sul' },
geometry: {
type: 'Polygon',
coordinates: [
[
[-53.11, -27.08],
[-51.55, -26.25],
[-49.72, -28.6],
[-49.69, -29.35],
[-50.7, -30.4],
[-52.0, -31.9],
[-52.65, -33.15],
[-53.4, -33.75],
[-53.51, -32.4],
[-55.6, -30.85],
[-57.65, -30.19],
[-56.4, -28.9],
[-54.9, -27.5],
[-53.11, -27.08],
],
],
},
},
],
};
/**
* No API key, no tiles — and the data is still there. This is what a blocked
* corporate network, an exhausted quota or a lapsed billing account now looks
* like, instead of a panel replaced by a setup prompt.
*
* The outline is what makes it answer "where" and not just "how many", so pass
* `baseGeojson` whenever the geography matters.
*/
export const WithoutBasemap: Story = {
args: {
basemap: 'none',
height: '520px',
baseGeojson: RS_OUTLINE,
attribution: '© IBGE — malha municipal',
markers: MUNICIPALITIES.map(municipality => ({
id: municipality.id,
position: municipality.position,
title: municipality.label,
shape: 'circle' as const,
scale: { ...ATTENDANCE_SCALE, value: municipality.volume },
colorToken: '--chart-1' as const,
fillOpacity: 0.55,
tooltip: (
{municipality.label} — {municipality.volume} atendimentos
),
})),
},
render: args => (
),
};
/** Selection and keyboard navigation work with no basemap behind them. */
export const WithoutBasemapSelectable: Story = {
render: function WithoutBasemapSelectableStory(args) {
const [selectedId, setSelectedId] = React.useState(null);
return (
);
},
args: {
basemap: 'none',
height: '480px',
baseGeojson: RS_OUTLINE,
},
};
/**
* Opting out of the fallback (`basemapFallback={false}`) restores the original
* setup prompt for consumers who prefer it.
*/
export const NoKey: Story = {
args: {
apiKey: '',
basemapFallback: false,
},
};