feat: add wind speed discrepancy info modal and integrate warning triggers across UI tabs and isopletas map
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 638 KiB |
@@ -16,6 +16,8 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { searchStations } from '@/lib/stations-lookup';
|
import { searchStations } from '@/lib/stations-lookup';
|
||||||
import { Settings2 } from 'lucide-react';
|
import { Settings2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { IsopletasModal } from '@/components/IsopletasModal';
|
||||||
|
import { InfoV0Modal } from '@/components/InfoV0Modal';
|
||||||
|
|
||||||
export function GlobalWindSettingsModal() {
|
export function GlobalWindSettingsModal() {
|
||||||
const {
|
const {
|
||||||
@@ -36,7 +38,15 @@ export function GlobalWindSettingsModal() {
|
|||||||
} = useWindStore();
|
} = useWindStore();
|
||||||
|
|
||||||
const [stationQuery, setStationQuery] = useState('');
|
const [stationQuery, setStationQuery] = useState('');
|
||||||
const filteredStations = useMemo(() => searchStations(stationQuery), [stationQuery]);
|
const [v0Filter, setV0Filter] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const filteredStations = useMemo(() => {
|
||||||
|
let stations = searchStations(stationQuery);
|
||||||
|
if (v0Filter !== null) {
|
||||||
|
stations = stations.filter((s) => s.v0 === v0Filter);
|
||||||
|
}
|
||||||
|
return stations;
|
||||||
|
}, [stationQuery, v0Filter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
@@ -63,7 +73,10 @@ export function GlobalWindSettingsModal() {
|
|||||||
<TabsContent value="norma" className="space-y-4 min-w-0">
|
<TabsContent value="norma" className="space-y-4 min-w-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label className="text-xs font-medium text-foreground">Velocidade Básica (V₀)</label>
|
<div className="flex items-center gap-1">
|
||||||
|
<label className="text-xs font-medium text-foreground">Velocidade Básica (V₀)</label>
|
||||||
|
<InfoV0Modal variant="icon" />
|
||||||
|
</div>
|
||||||
<span className="text-xs text-muted-foreground font-mono">{v0} m/s</span>
|
<span className="text-xs text-muted-foreground font-mono">{v0} m/s</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider min={25} max={55} step={1} value={[v0]} onValueChange={(vals) => setV0(vals[0])} className="py-1 cursor-pointer" />
|
<Slider min={25} max={55} step={1} value={[v0]} onValueChange={(vals) => setV0(vals[0])} className="py-1 cursor-pointer" />
|
||||||
@@ -74,7 +87,7 @@ export function GlobalWindSettingsModal() {
|
|||||||
<Select value={s1.toString()} onValueChange={(val) => setS1(Number(val))}>
|
<Select value={s1.toString()} onValueChange={(val) => setS1(Number(val))}>
|
||||||
<SelectTrigger className="w-full text-xs h-8"><SelectValue placeholder="S₁" /></SelectTrigger>
|
<SelectTrigger className="w-full text-xs h-8"><SelectValue placeholder="S₁" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="0.9">0,9 (Vale profundo protegido)</SelectItem>
|
<SelectItem value="0.9">0,9 (Vale profissional protegido)</SelectItem>
|
||||||
<SelectItem value="1">1,0 (Terreno plano)</SelectItem>
|
<SelectItem value="1">1,0 (Terreno plano)</SelectItem>
|
||||||
<SelectItem value="1.1">1,1 (Talude)</SelectItem>
|
<SelectItem value="1.1">1,1 (Talude)</SelectItem>
|
||||||
<SelectItem value="1.2">1,2 (Morro)</SelectItem>
|
<SelectItem value="1.2">1,2 (Morro)</SelectItem>
|
||||||
@@ -128,12 +141,34 @@ export function GlobalWindSettingsModal() {
|
|||||||
|
|
||||||
<TabsContent value="local" className="space-y-3 min-w-0">
|
<TabsContent value="local" className="space-y-3 min-w-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<div className="flex gap-1.5 items-center">
|
||||||
placeholder="Buscar cidade ou estação..."
|
<Input
|
||||||
value={stationQuery}
|
placeholder="Buscar cidade ou estação..."
|
||||||
onChange={(e) => setStationQuery(e.target.value)}
|
value={stationQuery}
|
||||||
className="h-8 text-xs"
|
onChange={(e) => setStationQuery(e.target.value)}
|
||||||
/>
|
className="h-8 text-xs flex-1"
|
||||||
|
/>
|
||||||
|
<InfoV0Modal variant="button" buttonText="Divergências?" />
|
||||||
|
<IsopletasModal />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 py-1 overflow-x-auto no-scrollbar">
|
||||||
|
<span className="text-[10px] text-muted-foreground mr-1 shrink-0">Filtrar V₀:</span>
|
||||||
|
{[null, 30, 35, 40, 45, 50].map((val) => (
|
||||||
|
<button
|
||||||
|
key={val ?? 'todos'}
|
||||||
|
onClick={() => setV0Filter(val)}
|
||||||
|
className={`h-6 px-2.5 text-[10px] rounded-full border transition-all font-mono font-medium shrink-0 ${
|
||||||
|
v0Filter === val
|
||||||
|
? 'bg-primary text-primary-foreground border-primary shadow-sm'
|
||||||
|
: 'bg-background text-muted-foreground hover:text-foreground border-border hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{val ? `${val} m/s` : 'Todos'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[200px] overflow-y-auto rounded-md border divide-y">
|
<div className="max-h-[200px] overflow-y-auto rounded-md border divide-y">
|
||||||
{filteredStations.map((s) => (
|
{filteredStations.map((s) => (
|
||||||
<button
|
<button
|
||||||
@@ -143,7 +178,7 @@ export function GlobalWindSettingsModal() {
|
|||||||
>
|
>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="font-medium text-xs truncate max-w-[150px]">{s.nome}</span>
|
<span className="font-medium text-xs truncate max-w-[150px]">{s.nome}</span>
|
||||||
<Badge variant="outline" className="font-mono text-[10px] py-0 px-1">V₀ = {s.v0} m/s</Badge>
|
<Badge variant="outline" className="font-mono text-[10px] py-0 px-1 font-semibold">V₀ = {s.v0} m/s</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">{s.latitude} · {s.longitude} · {s.altitude} m</div>
|
<div className="text-[10px] text-muted-foreground">{s.latitude} · {s.longitude} · {s.altitude} m</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{variant === 'icon' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-5 w-5 text-muted-foreground hover:text-primary rounded-full p-0 shrink-0 ${className}`}
|
||||||
|
title="Por que existem divergências na velocidade do vento?"
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={`h-7 px-2.5 text-[10px] gap-1 border-amber-500/30 hover:border-amber-500/50 text-amber-600 dark:text-amber-400 bg-amber-500/5 hover:bg-amber-500/10 shrink-0 ${className}`}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
<span>{buttonText}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px] max-w-[95vw] p-4 sm:p-6 bg-background border border-border shadow-2xl">
|
||||||
|
<DialogHeader className="border-b pb-2 flex-row gap-2 items-center">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-500 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-sm font-semibold text-foreground">
|
||||||
|
Divergências de Velocidade Básica (V₀)
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-[10px]">
|
||||||
|
Entenda por que a velocidade do vento na lista de cidades pode diferir do mapa de isopletas.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 text-xs leading-relaxed text-muted-foreground mt-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h4 className="font-semibold text-foreground flex items-center gap-1.5">
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-mono font-bold">1</span>
|
||||||
|
Soberania do Anexo C (Dados Reais vs. Macro)
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
As 49 estações principais da lista utilizam os dados do <strong>Anexo C da NBR 6123</strong>, 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, <strong>este valor oficial deve ser adotado prioritariamente</strong> sobre a estimativa visual obtida no mapa de isopletas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h4 className="font-semibold text-foreground flex items-center gap-1.5">
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-mono font-bold">2</span>
|
||||||
|
Efeito de Borda e Arredondamento de Segurança
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
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 <strong>curva de velocidade superior mais próxima</strong> (35 m/s) para garantir o conservadorismo estrutural e a segurança humana contra rajadas extremas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h4 className="font-semibold text-foreground flex items-center gap-1.5">
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-mono font-bold">3</span>
|
||||||
|
Microclimas e Topografia Local
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-amber-500/20 bg-amber-500/5 p-2.5 text-[11px] text-amber-600 dark:text-amber-400">
|
||||||
|
<strong>Recomendação Técnica:</strong> 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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
const touchStartDist = useRef<number | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-foreground shrink-0 border-primary/20 hover:bg-primary/5"
|
||||||
|
title="Ver mapa de isopletas"
|
||||||
|
>
|
||||||
|
<Map className="h-4 w-4 text-primary" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-w-[95vw] h-[80vh] flex flex-col p-4 bg-background border border-border shadow-2xl">
|
||||||
|
<DialogHeader className="flex-row items-center justify-between pb-2 border-b">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-sm font-semibold">Mapa de Isopletas (NBR 6123)</DialogTitle>
|
||||||
|
<DialogDescription className="text-[10px]">
|
||||||
|
Velocidade básica do vento V₀ (m/s). Use a roda do mouse, botões ou gestos de pinça para dar zoom.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<InfoV0Modal variant="button" buttonText="Atenção" className="mr-1" />
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleZoomIn} title="Aproximar">
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleZoomOut} title="Afastar">
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleReset} title="Resetar Visualização">
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 w-full h-full overflow-hidden relative cursor-grab active:cursor-grabbing bg-slate-950 rounded-md mt-2 flex items-center justify-center touch-none select-none"
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUpOrLeave}
|
||||||
|
onMouseLeave={handleMouseUpOrLeave}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/isopletas.png"
|
||||||
|
alt="Mapa de Isopletas NBR 6123"
|
||||||
|
className="max-w-full max-h-full object-contain transition-transform duration-75 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user