diff --git a/app/public/isopletas.png b/app/public/isopletas.png new file mode 100644 index 0000000..738478b Binary files /dev/null and b/app/public/isopletas.png differ diff --git a/app/src/components/GlobalWindSettingsModal.tsx b/app/src/components/GlobalWindSettingsModal.tsx index b8366b5..fcac353 100644 --- a/app/src/components/GlobalWindSettingsModal.tsx +++ b/app/src/components/GlobalWindSettingsModal.tsx @@ -16,6 +16,8 @@ import { Input } from '@/components/ui/input'; import { searchStations } from '@/lib/stations-lookup'; import { Settings2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { IsopletasModal } from '@/components/IsopletasModal'; +import { InfoV0Modal } from '@/components/InfoV0Modal'; export function GlobalWindSettingsModal() { const { @@ -36,7 +38,15 @@ export function GlobalWindSettingsModal() { } = useWindStore(); const [stationQuery, setStationQuery] = useState(''); - const filteredStations = useMemo(() => searchStations(stationQuery), [stationQuery]); + const [v0Filter, setV0Filter] = useState(null); + + const filteredStations = useMemo(() => { + let stations = searchStations(stationQuery); + if (v0Filter !== null) { + stations = stations.filter((s) => s.v0 === v0Filter); + } + return stations; + }, [stationQuery, v0Filter]); return ( @@ -63,7 +73,10 @@ export function GlobalWindSettingsModal() {
- +
+ + +
{v0} m/s
setV0(vals[0])} className="py-1 cursor-pointer" /> @@ -74,7 +87,7 @@ export function GlobalWindSettingsModal() { setStationQuery(e.target.value)} - className="h-8 text-xs" - /> +
+ setStationQuery(e.target.value)} + className="h-8 text-xs flex-1" + /> + + +
+ +
+ Filtrar V₀: + {[null, 30, 35, 40, 45, 50].map((val) => ( + + ))} +
+
{filteredStations.map((s) => ( diff --git a/app/src/components/InfoV0Modal.tsx b/app/src/components/InfoV0Modal.tsx new file mode 100644 index 0000000..0dd10bb --- /dev/null +++ b/app/src/components/InfoV0Modal.tsx @@ -0,0 +1,93 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Info, AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface InfoV0ModalProps { + variant?: 'icon' | 'button'; + buttonText?: string; + className?: string; +} + +export function InfoV0Modal({ variant = 'icon', buttonText = 'Atenção', className = '' }: InfoV0ModalProps) { + return ( + + + {variant === 'icon' ? ( + + ) : ( + + )} + + + + +
+ + Divergências de Velocidade Básica (V₀) + + + Entenda por que a velocidade do vento na lista de cidades pode diferir do mapa de isopletas. + +
+
+ +
+
+

+ 1 + Soberania do Anexo C (Dados Reais vs. Macro) +

+

+ As 49 estações principais da lista utilizam os dados do Anexo C da NBR 6123, obtidos a partir de medições estatísticas históricas reais em aeroportos. A norma estabelece que, se houver dado tabulado no anexo para a localidade, este valor oficial deve ser adotado prioritariamente sobre a estimativa visual obtida no mapa de isopletas. +

+
+ +
+

+ 2 + Efeito de Borda e Arredondamento de Segurança +

+

+ Cidades que ficam situadas geograficamente entre duas curvas de vento (por exemplo, na transição entre 30 m/s e 35 m/s) devem adotar o valor da curva de velocidade superior mais próxima (35 m/s) para garantir o conservadorismo estrutural e a segurança humana contra rajadas extremas. +

+
+ +
+

+ 3 + Microclimas e Topografia Local +

+

+ O mapa nacional de isopletas é aproximado. Acidentes geográficos locais como vales profundos, serras (como a Serra do Mar) ou proximidade com o oceano provocam acelerações do vento. As estações de monitoramento registram esses efeitos localmente, alterando a velocidade regulamentada em relação à linha média suave do mapa nacional. +

+
+ +
+ Recomendação Técnica: Verifique sempre o histórico de vento local e, em caso de dúvida, utilize a maior velocidade disponível para assegurar a estabilidade e segurança da edificação. +
+
+
+
+ ); +} diff --git a/app/src/components/IsopletasModal.tsx b/app/src/components/IsopletasModal.tsx new file mode 100644 index 0000000..0a10fef --- /dev/null +++ b/app/src/components/IsopletasModal.tsx @@ -0,0 +1,164 @@ +import { useState, useRef, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { InfoV0Modal } from '@/components/InfoV0Modal'; +import { ZoomIn, ZoomOut, RotateCcw, Map } from 'lucide-react'; + +export function IsopletasModal() { + const [open, setOpen] = useState(false); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const dragStart = useRef({ x: 0, y: 0 }); + const containerRef = useRef(null); + const touchStartDist = useRef(null); + + // Reset zoom on open/close + useEffect(() => { + if (!open) { + handleReset(); + } + }, [open]); + + const handleZoomIn = () => setScale((s) => Math.min(s + 0.25, 4)); + const handleZoomOut = () => setScale((s) => Math.max(s - 0.25, 0.5)); + const handleReset = () => { + setScale(1); + setPosition({ x: 0, y: 0 }); + }; + + // Mouse wheel zoom + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const zoomFactor = 0.1; + if (e.deltaY < 0) { + setScale((s) => Math.min(s + zoomFactor, 4)); + } else { + setScale((s) => Math.max(s - zoomFactor, 0.5)); + } + }; + + // Drag pan + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y }; + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging) return; + setPosition({ + x: e.clientX - dragStart.current.x, + y: e.clientY - dragStart.current.y, + }); + }; + + const handleMouseUpOrLeave = () => { + setIsDragging(false); + }; + + // Touch gestures (Pinch-to-zoom & Drag-to-pan) + const handleTouchStart = (e: React.TouchEvent) => { + if (e.touches.length === 1) { + // Single finger drag + setIsDragging(true); + dragStart.current = { x: e.touches[0].clientX - position.x, y: e.touches[0].clientY - position.y }; + } else if (e.touches.length === 2) { + // Two finger pinch zoom + const dist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + touchStartDist.current = dist; + } + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (e.touches.length === 1 && isDragging) { + setPosition({ + x: e.touches[0].clientX - dragStart.current.x, + y: e.touches[0].clientY - dragStart.current.y, + }); + } else if (e.touches.length === 2 && touchStartDist.current !== null) { + const dist = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY + ); + const factor = dist / touchStartDist.current; + setScale((s) => Math.min(Math.max(s * factor, 0.5), 4)); + touchStartDist.current = dist; + } + }; + + const handleTouchEnd = () => { + setIsDragging(false); + touchStartDist.current = null; + }; + + return ( + + + + + + +
+ Mapa de Isopletas (NBR 6123) + + Velocidade básica do vento V₀ (m/s). Use a roda do mouse, botões ou gestos de pinça para dar zoom. + +
+
+ + + + +
+
+ +
+ Mapa de Isopletas NBR 6123 +
+
+
+ ); +}