Remodel/packages/web/components/ProfileViewer.tsx
Manohar Gupta 5252d471ba
Some checks are pending
CI / Engine — lint / typecheck / test (push) Waiting to run
CI / API — lint / typecheck / test (push) Waiting to run
CI / Web — typecheck / lint / build (push) Waiting to run
Use empty string for API_BASE in production (relative paths)
2026-05-24 01:19:15 +05:30

477 lines
No EOL
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useCallback } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
ReferenceLine,
LineChart,
Line,
CartesianGrid,
Legend,
} from "recharts";
import {
getAvailableProfiles,
getProfileStats,
getSolarProfile,
getWindProfile,
profileCsvUrl,
type ProfileStats,
} from "@/lib/api";
const LOCATIONS = [
{ id: "GJ", name: "Gujarat" },
{ id: "KA", name: "Karnataka" },
{ id: "RJ", name: "Rajasthan" },
];
const SOLAR_COLOR = "#f97316";
const WIND_COLOR = "#3b82f6";
interface StatsCardProps {
label: string;
value: string;
color?: string;
}
function StatsCard({ label, value, color }: StatsCardProps) {
return (
<div className="bg-card border rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">{label}</div>
<div className="text-lg font-semibold tabular-nums" style={{ color: color || "inherit" }}>
{value}
</div>
</div>
);
}
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number; name: string }[]; label?: string }) {
if (!active || !payload?.length) return null;
return (
<div className="bg-background border rounded-lg shadow-lg p-2 text-xs">
<p className="font-medium mb-1">{label}</p>
{payload.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: entry.name === "Solar" ? SOLAR_COLOR : WIND_COLOR }}
/>
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium">{(entry.value * 100).toFixed(2)}%</span>
</div>
))}
</div>
);
}
export function ProfileViewer() {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<"solar" | "wind" | "compare">("solar");
const [uploadedProfiles, setUploadedProfiles] = useState<{
solar?: number[];
wind?: number[];
filename?: string;
}>({});
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<{
name: string;
path: string;
data: string[][];
} | null>(null);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const API_BASE = typeof window !== "undefined"
? ((window as { location?: { hostname?: string } }).location?.hostname === "localhost"
? "http://localhost:8000"
: "")
: "http://localhost:8000";
async function fetchAndDisplayFile(path: string, name: string) {
console.log("fetchAndDisplayFile called:", path, name);
setIsLoadingFile(true);
try {
const url = `${API_BASE}${path}?as_csv=true`;
console.log("Fetching:", url);
const res = await fetch(url);
console.log("Response status:", res.status);
const text = await res.text();
console.log("Response length:", text.length);
const lines = text.trim().split("\n");
console.log("Lines count:", lines.length);
const data = lines.slice(1).map((line) => line.split(","));
console.log("Data rows:", data.length);
setSelectedFile({ name, path, data });
} catch (err) {
console.error("Failed to fetch file:", err);
} finally {
setIsLoadingFile(false);
}
}
const calculateStats = (data: number[]) => {
const sorted = [...data].sort((a, b) => a - b);
const sum = data.reduce((a, b) => a + b, 0);
return {
avg: sum / data.length,
max: Math.max(...data),
min: Math.min(...data),
median: sorted[Math.floor(sorted.length / 2)],
};
};
const handleFileUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsUploading(true);
setUploadError(null);
try {
const text = await file.text();
const lines = text.trim().split("\n");
if (lines.length < 2) {
throw new Error("File must have at least a header row and one data row");
}
const header = lines[0].toLowerCase();
let parsedData: number[] = [];
if (header.includes("%_generation")) {
const cols = header.split(",");
const colIdx = cols.indexOf("%_generation");
if (colIdx === -1) throw new Error("Could not find %_generation column");
parsedData = lines.slice(1).map((line) => {
const vals = line.split(",");
let val = parseFloat(vals[colIdx]);
if (isNaN(val)) return 0;
if (val > 1) val = val / 100;
return val;
});
} else if (header.includes("irradiance") || header.includes("wind_speed") || header.includes("e_grid")) {
parsedData = lines.slice(1).map((line) => {
const vals = line.split(",");
let val = parseFloat(vals[1]);
if (isNaN(val)) return 0;
if (val > 1) val = val / 1000;
if (val > 1) val = val / 100;
return val;
});
} else {
parsedData = lines.slice(1).map((line) => {
const vals = line.split(",");
let val = parseFloat(vals[0]);
if (isNaN(val)) return 0;
return val;
});
}
if (parsedData.length !== 8760) {
throw new Error(`Expected 8760 values, got ${parsedData.length}`);
}
const isSolar = parsedData.reduce((a, b, i) => {
const hourOfDay = i % 24;
if (hourOfDay >= 6 && hourOfDay <= 18) return a + b;
return a;
}, 0) / parsedData.reduce((a, b) => a + b, 0) > 0.7;
setUploadedProfiles({
solar: isSolar ? parsedData : undefined,
wind: !isSolar ? parsedData : undefined,
filename: file.name,
});
} catch (err) {
setUploadError(err instanceof Error ? err.message : "Failed to parse file");
} finally {
setIsUploading(false);
}
}, []);
const getMonthlyAverages = (data: number[]) => {
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const monthNames = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"];
const monthPositions = [4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3];
return monthNames.map((name, i) => {
const month = monthPositions[i];
const start = MONTH_DAYS.slice(0, month - 1).reduce((a, d) => a + d, 0) * 24;
const days = MONTH_DAYS[month - 1];
const monthData = data.slice(start, start + days * 24);
const avg = monthData.reduce((a, b) => a + b, 0) / monthData.length;
return { name, avg: avg * 100 };
});
};
return (
<>
<button
onClick={() => setIsOpen(true)}
className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</svg>
View Files
</button>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
<div className="relative w-full max-w-5xl max-h-[90vh] mx-4 bg-background rounded-xl shadow-2xl overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold">File Reference Library</h2>
<button onClick={() => setIsOpen(false)} className="p-1 hover:bg-accent rounded-md transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="flex gap-1 p-2 border-b bg-muted/30">
<button onClick={() => setActiveTab("solar")} className={`px-4 py-1.5 text-sm rounded-md transition-colors ${activeTab === "solar" ? "bg-background shadow-sm font-medium" : "hover:bg-background/50"}`}>
Solar
</button>
<button onClick={() => setActiveTab("wind")} className={`px-4 py-1.5 text-sm rounded-md transition-colors ${activeTab === "wind" ? "bg-background shadow-sm font-medium" : "hover:bg-background/50"}`}>
💨 Wind
</button>
<button onClick={() => setActiveTab("compare")} className={`px-4 py-1.5 text-sm rounded-md transition-colors ${activeTab === "compare" ? "bg-background shadow-sm font-medium" : "hover:bg-background/50"}`}>
📊 Compare
</button>
</div>
<div className="p-4 border-b bg-muted/10">
<div className="flex items-center gap-4">
<label className="flex-1">
<span className="text-sm text-muted-foreground block mb-1">
Upload custom profile (CSV with 8760 hourly values):
</span>
<div className="flex items-center gap-2">
<input type="file" accept=".csv" onChange={handleFileUpload} disabled={isUploading} className="hidden" id="profile-upload" />
<label htmlFor="profile-upload" className={`px-3 py-1.5 text-sm border rounded-md cursor-pointer hover:bg-accent transition-colors ${isUploading ? "opacity-50" : ""}`}>
{isUploading ? "Parsing..." : "Choose File"}
</label>
{uploadedProfiles.filename && (
<span className="text-sm text-muted-foreground">
{uploadedProfiles.filename} ({uploadedProfiles.solar ? "Solar" : "Wind"})
</span>
)}
</div>
</label>
{uploadError && <span className="text-sm text-destructive">{uploadError}</span>}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{isLoadingFile ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Loading file data...</p>
</div>
</div>
) : selectedFile ? (
<div className="mb-6 border rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-4 bg-blue-50 border-b">
<div className="flex items-center gap-3">
<span className="text-2xl">{selectedFile.name.includes("Solar") ? "☀️" : "💨"}</span>
<div>
<h3 className="text-base font-semibold">{selectedFile.name}</h3>
<p className="text-xs text-muted-foreground">{selectedFile.data.length} rows of data</p>
</div>
</div>
<div className="flex items-center gap-2">
<a href={`${API_BASE}${selectedFile.path}?as_csv=true`} download className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded hover:bg-primary/90">
Download CSV
</a>
<button onClick={() => setSelectedFile(null)} className="px-3 py-1.5 text-sm border rounded hover:bg-accent">
Close Preview
</button>
</div>
</div>
<div className="max-h-[400px] overflow-auto">
<table className="w-full text-xs">
<thead className="bg-muted border-b sticky top-0">
<tr>
<th className="px-4 py-3 text-left font-semibold text-muted-foreground">Hour</th>
<th className="px-4 py-3 text-left font-semibold text-muted-foreground">Date</th>
<th className="px-4 py-3 text-left font-semibold text-muted-foreground">Month</th>
<th className="px-4 py-3 text-left font-semibold text-muted-foreground">Day</th>
<th className="px-4 py-3 text-left font-semibold text-muted-foreground">Hour of Day</th>
<th className="px-4 py-3 text-right font-semibold text-muted-foreground">% Generation</th>
</tr>
</thead>
<tbody>
{selectedFile.data.map((row: string[], i: number) => (
<tr key={i} className="border-b border-border/30 hover:bg-blue-50/50">
<td className="px-4 py-2 font-medium">{row[0]}</td>
<td className="px-4 py-2">{row[1]}</td>
<td className="px-4 py-2">{row[2]}</td>
<td className="px-4 py-2">{row[3]}</td>
<td className="px-4 py-2">{row[4]}</td>
<td className="px-4 py-2 text-right font-semibold text-blue-600">{row[5]}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<>
<div className="mb-6">
<h3 className="text-sm font-semibold mb-4">📂 Click on any file below to view its contents:</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[
{ name: "Solar - Gujarat", path: "/api/profiles/solar/GJ", type: "solar" },
{ name: "Solar - Karnataka", path: "/api/profiles/solar/KA", type: "solar" },
{ name: "Solar - Rajasthan", path: "/api/profiles/solar/RJ", type: "solar" },
{ name: "Wind - Gujarat", path: "/api/profiles/wind/GJ", type: "wind" },
{ name: "Wind - Karnataka", path: "/api/profiles/wind/KA", type: "wind" },
{ name: "Wind - Rajasthan", path: "/api/profiles/wind/RJ", type: "wind" },
].map((profile) => (
<button
key={profile.name}
onClick={() => fetchAndDisplayFile(profile.path, profile.name)}
className="flex items-center gap-3 p-4 border-2 border-dashed border-border rounded-xl hover:border-primary hover:bg-primary/5 transition-all text-left group"
>
<span className="text-2xl group-hover:scale-110 transition-transform">{profile.type === "solar" ? "☀️" : "💨"}</span>
<div className="flex-1">
<div className="text-sm font-semibold group-hover:text-primary transition-colors">{profile.name}</div>
<div className="text-xs text-muted-foreground mt-1">Click to view 8760 hourly values</div>
</div>
<div className="px-2 py-1 bg-primary/10 rounded text-xs font-medium text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
VIEW
</div>
</button>
))}
</div>
</div>
{activeTab === "solar" && (
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium mb-3 text-muted-foreground">Monthly Average Solar Irradiance</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={[
{ name: "Gujarat", GJ: 22 },
{ name: "Karnataka", GJ: 24 },
{ name: "Rajasthan", GJ: 26 },
]}>
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} domain={[0, 100]} unit="%" />
<Tooltip content={<CustomTooltip />} />
<ReferenceLine y={20} stroke="#94a3b8" strokeDasharray="3 3" label="20%" />
<Bar dataKey="GJ" fill={SOLAR_COLOR} name="Solar CUF" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{uploadedProfiles.solar && (
<div>
<h3 className="text-sm font-medium mb-3 text-muted-foreground">Your Uploaded Solar Profile</h3>
<div className="grid grid-cols-4 gap-2">
{(() => {
const stats = calculateStats(uploadedProfiles.solar);
return (
<>
<StatsCard label="Avg CUF" value={`${(stats.avg * 100).toFixed(2)}%`} color={SOLAR_COLOR} />
<StatsCard label="Max" value={`${(stats.max * 100).toFixed(1)}%`} />
<StatsCard label="Min" value={`${(stats.min * 100).toFixed(1)}%`} />
<StatsCard label="Median" value={`${(stats.median * 100).toFixed(1)}%`} />
</>
);
})()}
</div>
</div>
)}
</div>
)}
{activeTab === "wind" && (
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium mb-3 text-muted-foreground">Monthly Average Wind Speed (m/s)</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={[
{ name: "Gujarat", avg: 6.5 },
{ name: "Karnataka", avg: 7.2 },
{ name: "Rajasthan", avg: 5.8 },
]}>
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="avg" fill={WIND_COLOR} name="Avg Speed" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{uploadedProfiles.wind && (
<div>
<h3 className="text-sm font-medium mb-3 text-muted-foreground">Your Uploaded Wind Profile</h3>
<div className="grid grid-cols-4 gap-2">
{(() => {
const stats = calculateStats(uploadedProfiles.wind);
return (
<>
<StatsCard label="Avg Speed" value={`${(stats.avg * 10).toFixed(2)} m/s`} color={WIND_COLOR} />
<StatsCard label="Max" value={`${(stats.max * 10).toFixed(1)} m/s`} />
<StatsCard label="Min" value={`${(stats.min * 10).toFixed(1)} m/s`} />
<StatsCard label="Median" value={`${(stats.median * 10).toFixed(1)} m/s`} />
</>
);
})()}
</div>
</div>
)}
</div>
)}
{activeTab === "compare" && (
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium mb-3 text-muted-foreground">Solar vs Wind CUF (Typical Daily Pattern)</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={[
{ hour: "00:00", Solar: 0, Wind: 0.3 },
{ hour: "06:00", Solar: 0.3, Wind: 0.35 },
{ hour: "12:00", Solar: 0.8, Wind: 0.4 },
{ hour: "18:00", Solar: 0.3, Wind: 0.35 },
{ hour: "23:00", Solar: 0, Wind: 0.3 },
]}>
<CartesianGrid strokeDasharray="3 3" stroke="#94a3b8" />
<XAxis dataKey="hour" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 12 }} domain={[0, 1]} tickFormatter={(v) => `${(v * 100).toFixed(0)}%`} />
<Tooltip content={<CustomTooltip />} />
<Line type="monotone" dataKey="Solar" stroke={SOLAR_COLOR} dot={false} name="Solar" />
<Line type="monotone" dataKey="Wind" stroke={WIND_COLOR} dot={false} name="Wind" />
<Legend />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
)}
</>
);
}