mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
delete solar analysis page and remove solar analysis from toolbar
This commit is contained in:
parent
b9d5166e82
commit
c3e657847a
2 changed files with 1 additions and 393 deletions
|
|
@ -56,7 +56,7 @@ const navigationMenuTriggerStyle = cva(
|
|||
"data-[active]:bg-accent/50",
|
||||
"data-[state=open]:bg-gray-200",
|
||||
"text-gray-900",
|
||||
].join(" ")
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
export function Toolbar({
|
||||
|
|
@ -88,16 +88,6 @@ export function Toolbar({
|
|||
</NavigationMenuLink>
|
||||
);
|
||||
|
||||
const solarAnalysisButton = (
|
||||
<NavigationMenuLink
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/solar-analysis`}
|
||||
>
|
||||
<SunIcon className="h-4 w-4 mr-2" />
|
||||
Solar
|
||||
</NavigationMenuLink>
|
||||
);
|
||||
|
||||
const recommendationsButton = (
|
||||
<NavigationMenuLink
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
|
|
@ -136,7 +126,6 @@ export function Toolbar({
|
|||
{Object.keys(decentHomes).length > 0 &&
|
||||
decentHomes.uprn &&
|
||||
decentHomesButton}
|
||||
{solarAnalysisButton}
|
||||
{recommendationsButton}
|
||||
{documentsButton}
|
||||
</NavigationMenuList>
|
||||
|
|
|
|||
|
|
@ -1,381 +0,0 @@
|
|||
import {
|
||||
SunIcon,
|
||||
BoltIcon,
|
||||
ClockIcon,
|
||||
Squares2X2Icon,
|
||||
SparklesIcon,
|
||||
MapPinIcon,
|
||||
CalendarIcon,
|
||||
GlobeAltIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
import { Badge } from "@/app/shadcn_components/ui/badge";
|
||||
import { Separator } from "@/app/shadcn_components/ui/separator";
|
||||
import { getPropertyMeta } from "../utils";
|
||||
import { getSolarData, getSolarScenarioData } from "./utils";
|
||||
import PropertyMapWrapper from "./PropertyMapWrapper";
|
||||
import SolarSimulationWrapper from "./SolarSimulationWrapper";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getDirectionLabel(az: number): { label: string; short: string } {
|
||||
// Standard compass: 0/360=N, 90=E, 180=S, 270=W
|
||||
const norm = ((az % 360) + 360) % 360;
|
||||
if (norm >= 337.5 || norm < 22.5) return { short: "N", label: "North" };
|
||||
if (norm < 67.5) return { short: "NE", label: "North-East" };
|
||||
if (norm < 112.5) return { short: "E", label: "East" };
|
||||
if (norm < 157.5) return { short: "SE", label: "South-East" };
|
||||
if (norm < 202.5) return { short: "S", label: "South" };
|
||||
if (norm < 247.5) return { short: "SW", label: "South-West" };
|
||||
if (norm < 292.5) return { short: "W", label: "West" };
|
||||
return { short: "NW", label: "North-West" };
|
||||
}
|
||||
|
||||
// Warm = south-facing (best solar); cool = north-facing (worst)
|
||||
const SEGMENT_THEMES: Record<string, {
|
||||
gradient: string;
|
||||
border: string;
|
||||
badge: string;
|
||||
label: string;
|
||||
dot: string;
|
||||
}> = {
|
||||
S: { gradient: "from-amber-50 to-orange-50/60", border: "border-amber-200/80", badge: "bg-amber-100 text-amber-800 border-amber-300", label: "text-amber-900", dot: "bg-amber-400" },
|
||||
SE: { gradient: "from-orange-50 to-amber-50/60", border: "border-orange-200/80", badge: "bg-orange-100 text-orange-800 border-orange-300", label: "text-orange-900", dot: "bg-orange-400" },
|
||||
SW: { gradient: "from-orange-50 to-amber-50/60", border: "border-orange-200/80", badge: "bg-orange-100 text-orange-800 border-orange-300", label: "text-orange-900", dot: "bg-orange-400" },
|
||||
E: { gradient: "from-sky-50 to-blue-50/40", border: "border-sky-200/80", badge: "bg-sky-100 text-sky-800 border-sky-300", label: "text-sky-900", dot: "bg-sky-400" },
|
||||
W: { gradient: "from-sky-50 to-blue-50/40", border: "border-sky-200/80", badge: "bg-sky-100 text-sky-800 border-sky-300", label: "text-sky-900", dot: "bg-sky-400" },
|
||||
N: { gradient: "from-slate-50 to-gray-50/40", border: "border-slate-200/80", badge: "bg-slate-100 text-slate-700 border-slate-300", label: "text-slate-800", dot: "bg-slate-400" },
|
||||
NE: { gradient: "from-slate-50 to-gray-50/40", border: "border-slate-200/80", badge: "bg-slate-100 text-slate-700 border-slate-300", label: "text-slate-800", dot: "bg-slate-400" },
|
||||
NW: { gradient: "from-slate-50 to-gray-50/40", border: "border-slate-200/80", badge: "bg-slate-100 text-slate-700 border-slate-300", label: "text-slate-800", dot: "bg-slate-400" },
|
||||
};
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function PageSection({ title, description, children }: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-brandblue tracking-tight">{title}</h2>
|
||||
<p className="text-sm text-gray-400 mt-0.5 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InputRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 group">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="shrink-0 w-7 h-7 rounded-md bg-brandblue/5 flex items-center justify-center text-brandblue/60 group-hover:bg-brandblue/10 transition-colors">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-800 tabular-nums">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoofSegmentCard({
|
||||
index,
|
||||
azimuthDegrees,
|
||||
pitchDegrees,
|
||||
areaMeters2,
|
||||
groundAreaMeters2,
|
||||
sunshineQuantiles,
|
||||
planeHeightAtCenterMeters,
|
||||
}: {
|
||||
index: number;
|
||||
azimuthDegrees: number;
|
||||
pitchDegrees: number;
|
||||
areaMeters2: number;
|
||||
groundAreaMeters2: number;
|
||||
sunshineQuantiles: number[];
|
||||
planeHeightAtCenterMeters: number;
|
||||
}) {
|
||||
const dir = getDirectionLabel(azimuthDegrees);
|
||||
const theme = SEGMENT_THEMES[dir.short] ?? SEGMENT_THEMES["N"];
|
||||
const medianSunshine = sunshineQuantiles?.[4] ?? null;
|
||||
const peakSunshine = sunshineQuantiles?.[8] ?? null;
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border bg-gradient-to-br ${theme.gradient} ${theme.border} shadow-sm overflow-hidden`}>
|
||||
{/* Card header strip */}
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-1">
|
||||
Segment {index + 1}
|
||||
</p>
|
||||
<h3 className={`text-lg font-bold leading-tight ${theme.label}`}>
|
||||
{dir.label}
|
||||
</h3>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 text-xs font-bold px-2.5 py-1 rounded-full border ${theme.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${theme.dot}`} />
|
||||
{dir.short}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sunshine bar */}
|
||||
{medianSunshine !== null && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-[10px] text-gray-400 mb-1">
|
||||
<span>Median sunshine</span>
|
||||
<span className="font-semibold text-gray-600">{Math.round(medianSunshine)} hrs/yr</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-white/60 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${theme.dot} opacity-70`}
|
||||
style={{ width: `${Math.min(100, (medianSunshine / 1600) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="opacity-30" />
|
||||
|
||||
{/* Stats grid */}
|
||||
<dl className="grid grid-cols-2 gap-px bg-black/5 text-sm">
|
||||
{[
|
||||
{ label: "Roof area", value: `${areaMeters2.toFixed(1)} m²` },
|
||||
{ label: "Ground area", value: `${groundAreaMeters2.toFixed(1)} m²` },
|
||||
{ label: "Pitch", value: `${pitchDegrees.toFixed(1)}°` },
|
||||
{ label: "Azimuth", value: `${azimuthDegrees.toFixed(1)}°` },
|
||||
{ label: "Height", value: `${planeHeightAtCenterMeters.toFixed(1)} m` },
|
||||
peakSunshine !== null
|
||||
? { label: "Peak (P90)", value: `${Math.round(peakSunshine)} hrs` }
|
||||
: { label: "", value: "" },
|
||||
].map(({ label, value }, i) =>
|
||||
label ? (
|
||||
<div key={i} className="bg-white/40 px-4 py-3 hover:bg-white/60 transition-colors">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-gray-400 font-medium mb-0.5">{label}</dt>
|
||||
<dd className="font-semibold text-gray-700 tabular-nums">{value}</dd>
|
||||
</div>
|
||||
) : <div key={i} className="bg-white/20" />
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default async function SolarAnalysisPage(props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const solarData = await getSolarData(Number(propertyMeta.uprn));
|
||||
|
||||
if (!solarData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mx-auto mb-4">
|
||||
<SunIcon className="w-8 h-8 text-gray-300" />
|
||||
</div>
|
||||
<p className="text-base font-semibold text-gray-600">No Solar Data Available</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Please check back later for updates.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const solarScenarioData = await getSolarScenarioData(String(solarData.id));
|
||||
|
||||
const {
|
||||
panelWidthMeters,
|
||||
panelHeightMeters,
|
||||
panelCapacityWatts,
|
||||
panelLifetimeYears,
|
||||
maxSunshineHoursPerYear,
|
||||
carbonOffsetFactorKgPerMwh,
|
||||
roofSegmentStats,
|
||||
solarPanelConfigs,
|
||||
maxArrayPanelsCount,
|
||||
maxArrayAreaMeters2,
|
||||
} = solarData.googleApiResponse.solarPotential;
|
||||
|
||||
const buildingCenter = solarData.googleApiResponse.center;
|
||||
const { imageryQuality, imageryDate, regionCode } = solarData.googleApiResponse;
|
||||
const imageryDateStr = `${imageryDate.year}-${String(imageryDate.month).padStart(2, "0")}-${String(imageryDate.day).padStart(2, "0")}`;
|
||||
|
||||
const qualityColors: Record<string, string> = {
|
||||
HIGH: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
MEDIUM: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
LOW: "bg-gray-50 text-gray-600 border-gray-200",
|
||||
};
|
||||
const qualityBadge = qualityColors[imageryQuality] ?? qualityColors.LOW;
|
||||
const qualityText = imageryQuality === "HIGH" ? "High quality"
|
||||
: imageryQuality === "MEDIUM" ? "Medium quality"
|
||||
: "Base quality";
|
||||
|
||||
const maxAnnualKwh = Math.round(
|
||||
solarPanelConfigs[solarPanelConfigs.length - 1].yearlyEnergyDcKwh
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-10 space-y-14">
|
||||
|
||||
{/* ── Hero header ─────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2.5 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brandgold/30 to-brandgold/10 border border-brandgold/30 flex items-center justify-center shrink-0">
|
||||
<SunIcon className="w-5 h-5 text-brandgold" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-brandblue tracking-tight">
|
||||
Solar Potential Analysis
|
||||
</h1>
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${qualityBadge}`}>
|
||||
{qualityText}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 max-w-xl leading-relaxed">
|
||||
Ara uses high-resolution aerial imagery and rooftop geometry data to estimate
|
||||
suitable solar PV packages for retrofit plans.{" "}
|
||||
{solarScenarioData.scenrioType === "building"
|
||||
? "Figures are for the building as a whole."
|
||||
: "Figures are for this individual unit."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Key summary pills */}
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{[
|
||||
{ icon: <MapPinIcon className="w-3.5 h-3.5" />, text: regionCode },
|
||||
{ icon: <CalendarIcon className="w-3.5 h-3.5" />, text: `Imagery: ${imageryDateStr}` },
|
||||
{ icon: <BoltIcon className="w-3.5 h-3.5" />, text: `Up to ${maxAnnualKwh.toLocaleString()} kWh/yr` },
|
||||
{ icon: <Squares2X2Icon className="w-3.5 h-3.5" />, text: `Up to ${maxArrayPanelsCount} panels` },
|
||||
].map(({ icon, text }, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white border border-gray-200 text-gray-500 font-medium shadow-sm">
|
||||
<span className="text-brandblue/60">{icon}</span>
|
||||
{text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Map + Inputs ─────────────────────────────────────────────────── */}
|
||||
<PageSection
|
||||
title="Property Overview"
|
||||
description="Location and the key parameters used to model this property's solar potential."
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-5">
|
||||
|
||||
{/* Map */}
|
||||
<div className="lg:col-span-3 rounded-2xl overflow-hidden border border-gray-200 shadow-sm" style={{ height: "360px" }}>
|
||||
<PropertyMapWrapper
|
||||
latitude={buildingCenter.latitude}
|
||||
longitude={buildingCenter.longitude}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inputs card */}
|
||||
<Card className="lg:col-span-2 shadow-sm border-gray-200/80">
|
||||
<CardHeader className="pb-1 pt-5 px-5">
|
||||
<CardTitle className="text-sm font-bold text-brandblue">Analysis Parameters</CardTitle>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Inputs used to model solar output for this property.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-5 pb-5 pt-1">
|
||||
<div className="divide-y divide-gray-100">
|
||||
<InputRow
|
||||
icon={<Squares2X2Icon className="w-3.5 h-3.5" />}
|
||||
label="Panel dimensions"
|
||||
value={`${panelWidthMeters} m × ${panelHeightMeters} m`}
|
||||
/>
|
||||
<InputRow
|
||||
icon={<BoltIcon className="w-3.5 h-3.5" />}
|
||||
label="Panel capacity"
|
||||
value={`${panelCapacityWatts} W`}
|
||||
/>
|
||||
<InputRow
|
||||
icon={<ClockIcon className="w-3.5 h-3.5" />}
|
||||
label="Panel lifetime"
|
||||
value={`${panelLifetimeYears} years`}
|
||||
/>
|
||||
<InputRow
|
||||
icon={<SunIcon className="w-3.5 h-3.5" />}
|
||||
label="Max sunshine"
|
||||
value={`${Math.round(maxSunshineHoursPerYear).toLocaleString()} hrs/yr`}
|
||||
/>
|
||||
<InputRow
|
||||
icon={<SparklesIcon className="w-3.5 h-3.5" />}
|
||||
label="Carbon offset factor"
|
||||
value={`${Math.round(carbonOffsetFactorKgPerMwh)} kg/MWh`}
|
||||
/>
|
||||
<InputRow
|
||||
icon={<GlobeAltIcon className="w-3.5 h-3.5" />}
|
||||
label="Maximum array"
|
||||
value={`${maxArrayPanelsCount} panels · ${maxArrayAreaMeters2.toFixed(0)} m²`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* ── Roof profile ─────────────────────────────────────────────────── */}
|
||||
<PageSection
|
||||
title="Roof Profile"
|
||||
description={`${roofSegmentStats.length} roof face${roofSegmentStats.length !== 1 ? "s" : ""} identified. Each is assessed independently — south-facing segments with low pitch typically yield the highest solar output.`}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{roofSegmentStats.map((seg: any, i: number) => (
|
||||
<RoofSegmentCard
|
||||
key={i}
|
||||
index={i}
|
||||
azimuthDegrees={seg.azimuthDegrees}
|
||||
pitchDegrees={seg.pitchDegrees}
|
||||
areaMeters2={seg.stats.areaMeters2}
|
||||
groundAreaMeters2={seg.stats.groundAreaMeters2}
|
||||
sunshineQuantiles={seg.stats.sunshineQuantiles}
|
||||
planeHeightAtCenterMeters={seg.planeHeightAtCenterMeters}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* ── Solar configurations ─────────────────────────────────────────── */}
|
||||
<PageSection
|
||||
title="Solar Configurations"
|
||||
description="All array sizes modelled for this property. The efficiency curve reveals how output scales with system size — useful for understanding which roof faces are doing the most work."
|
||||
>
|
||||
<Card className="shadow-sm border-gray-200/80">
|
||||
<CardContent className="pt-6 px-6 pb-6">
|
||||
<SolarSimulationWrapper
|
||||
solarPanelConfigs={solarPanelConfigs}
|
||||
panelCapacityWatts={panelCapacityWatts}
|
||||
panelLifetimeYears={panelLifetimeYears}
|
||||
panelWidthMeters={panelWidthMeters}
|
||||
panelHeightMeters={panelHeightMeters}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue