diff --git a/apps/nowcasting-app/components/Toggle.tsx b/apps/nowcasting-app/components/Toggle.tsx new file mode 100644 index 00000000..aecfd9e9 --- /dev/null +++ b/apps/nowcasting-app/components/Toggle.tsx @@ -0,0 +1,14 @@ +import { FC } from "react"; + +const Toggle: FC<{ + onClick: () => void; + visible: boolean; +}> = ({ onClick, visible }) => { + return ( + + ); +}; + +export default Toggle; diff --git a/apps/nowcasting-app/components/charts/ChartLegend.tsx b/apps/nowcasting-app/components/charts/ChartLegend.tsx index 263bb5d6..aad4cad8 100644 --- a/apps/nowcasting-app/components/charts/ChartLegend.tsx +++ b/apps/nowcasting-app/components/charts/ChartLegend.tsx @@ -1,115 +1,85 @@ import Tooltip from "../tooltip"; import { ChartInfo } from "../../ChartInfo"; import { InfoIcon, LegendLineGraphIcon } from "../icons/icons"; -import { FC } from "react"; +import { FC, useEffect } from "react"; import useGlobalState from "../helpers/globalState"; -import { getRounded4HoursAgoString } from "../helpers/utils"; - -const LegendItem: FC<{ - iconClasses: string; - label: string; - dashed?: boolean; - dataKey: string; -}> = ({ iconClasses, label, dashed, dataKey }) => { - const [visibleLines, setVisibleLines] = useGlobalState("visibleLines"); - const isVisible = visibleLines.includes(dataKey); - - const toggleLineVisibility = () => { - if (isVisible) { - setVisibleLines(visibleLines.filter((line) => line !== dataKey)); - } else { - setVisibleLines([...visibleLines, dataKey]); - } - }; - - return ( -
- - -
- ); -}; +import LegendItem from "./LegendItem"; +import { N_HOUR_FORECAST_OPTIONS } from "../../constant"; type ChartLegendProps = { className?: string; }; -export const ChartLegend: React.FC = ({ className }) => { - const [show4hView] = useGlobalState("show4hView"); +export const ChartLegend: FC = ({ className }) => { + const [showNHourView] = useGlobalState("showNHourView"); + const [nHourForecast, setNHourForecast] = useGlobalState("nHourForecast"); - const fourHoursAgo = getRounded4HoursAgoString(); - const legendItemContainerClasses = `flex flex-initial flex-col lg:flex-row 3xl:flex-col justify-between${ + const legendItemContainerClasses = `flex flex-initial flex-col @sm:gap-1 @6xl:gap-6 @6xl:flex-row ${ className ? ` ${className}` : "" }`; return ( -
-
-
- - -
-
- - -
- {show4hView && ( +
+
+
- )} -
-
- - -
- } - position="top" - className={"text-right"} - fullWidth - > - - +
+ + {/**/} + {showNHourView && ( + + )} +
+
+
+
+
+ +
{" "} + hour
+ forecast +
+
); diff --git a/apps/nowcasting-app/components/charts/DataLoadingChartStatus.tsx b/apps/nowcasting-app/components/charts/DataLoadingChartStatus.tsx index 2a3d1a38..4cce6719 100644 --- a/apps/nowcasting-app/components/charts/DataLoadingChartStatus.tsx +++ b/apps/nowcasting-app/components/charts/DataLoadingChartStatus.tsx @@ -31,7 +31,7 @@ const DataLoadingChartStatus = < const isLoadingData = !loadingState.initialLoadComplete || (loadingState.showMessage && !!loadingState.message.length); - const [show4hView] = useGlobalState("show4hView"); + const [showNHourView] = useGlobalState("showNHourView"); if (!loadingState || !loadingState.endpointStates || !loadingState.endpointStates.type) return null; @@ -42,7 +42,7 @@ const DataLoadingChartStatus = < isLoadingData={isLoadingData} message={loadingState.message} endpointStates={loadingState.endpointStates} - show4hView={show4hView} + showNHourView={showNHourView} /> ); } else if (isEndpointStateType(loadingState.endpointStates, "sites")) { @@ -51,7 +51,7 @@ const DataLoadingChartStatus = < message={loadingState.message} endpointStates={loadingState.endpointStates} isLoadingData={isLoadingData} - show4hView={show4hView} + showNHourView={showNHourView} /> ); } @@ -63,18 +63,18 @@ type EndpointStatusListProps = { isLoadingData: boolean; message: string; endpointStates: K; - show4hView: boolean | undefined; + showNHourView: boolean | undefined; }; const EndpointStatusList = ({ isLoadingData, message, endpointStates, - show4hView = false + showNHourView = false }: EndpointStatusListProps) => { const endpointsArray = Array.from(Object.entries(endpointStates)); return (
{ if (!endpointStates) return null; - if (key === "national4Hour" && !show4hView) return null; + if (key === "nationalNHour" && !showNHourView) return null; // Filter out "type" key with string state value if (typeof val === "string") return null; const state = endpointStates[key as keyof typeof endpointStates]; diff --git a/apps/nowcasting-app/components/charts/LegendItem.tsx b/apps/nowcasting-app/components/charts/LegendItem.tsx new file mode 100644 index 00000000..a62f5383 --- /dev/null +++ b/apps/nowcasting-app/components/charts/LegendItem.tsx @@ -0,0 +1,44 @@ +import { FC } from "react"; +import useGlobalState from "../helpers/globalState"; +import { LegendLineGraphIcon } from "../icons/icons"; +import Toggle from "../Toggle"; + +const LegendItem: FC<{ + iconClasses: string; + label: string; + dashStyle?: "both" | "dashed" | "solid"; + dataKey: string; +}> = ({ iconClasses, label, dashStyle, dataKey }) => { + const [visibleLines, setVisibleLines] = useGlobalState("visibleLines"); + const isVisible = visibleLines.includes(dataKey); + + const toggleLineVisibility = () => { + if (isVisible) { + setVisibleLines(visibleLines.filter((line) => line !== dataKey)); + } else { + setVisibleLines([...visibleLines, dataKey]); + } + }; + + return ( +
+ + + +
+ ); +}; + +export default LegendItem; diff --git a/apps/nowcasting-app/components/charts/delta-view/delta-buckets-ui.tsx b/apps/nowcasting-app/components/charts/delta-view/delta-buckets-ui.tsx index a847f04b..da45eec6 100644 --- a/apps/nowcasting-app/components/charts/delta-view/delta-buckets-ui.tsx +++ b/apps/nowcasting-app/components/charts/delta-view/delta-buckets-ui.tsx @@ -38,13 +38,13 @@ const BucketItem: React.FC = ({ flex-col items-center rounded`} > @@ -86,7 +86,7 @@ const DeltaBuckets: React.FC<{ return ( <> -
+
{buckets.map((bucket) => { return ; })} diff --git a/apps/nowcasting-app/components/charts/delta-view/delta-view-chart.tsx b/apps/nowcasting-app/components/charts/delta-view/delta-view-chart.tsx index b8caaf10..268b7779 100644 --- a/apps/nowcasting-app/components/charts/delta-view/delta-view-chart.tsx +++ b/apps/nowcasting-app/components/charts/delta-view/delta-view-chart.tsx @@ -2,13 +2,9 @@ import { Dispatch, FC, SetStateAction, useEffect, useMemo } from "react"; import RemixLine from "../remix-line"; import { DELTA_BUCKET, MAX_NATIONAL_GENERATION_MW } from "../../../constant"; import ForecastHeader from "../forecast-header"; -import useGlobalState, { get30MinNow, getNext30MinSlot } from "../../helpers/globalState"; +import useGlobalState, { get30MinSlot } from "../../helpers/globalState"; import useFormatChartData from "../use-format-chart-data"; -import { - convertISODateStringToLondonTime, - formatISODateString, - getRounded4HoursAgoString -} from "../../helpers/utils"; +import { convertToLocaleDateString, formatISODateString } from "../../helpers/utils"; import GspPvRemixChart from "../gsp-pv-remix-chart"; import { useStopAndResetTime } from "../../hooks/use-and-update-selected-time"; import Spinner from "../../icons/spinner"; @@ -22,40 +18,6 @@ import useTimeNow from "../../hooks/use-time-now"; import { ChartLegend } from "../ChartLegend"; import DataLoadingChartStatus from "../DataLoadingChartStatus"; -const LegendItem: FC<{ - iconClasses: string; - label: string; - dashed?: boolean; - dataKey: string; - show4hrView: boolean | undefined; -}> = ({ iconClasses, label, dashed, dataKey, show4hrView }) => { - const [visibleLines, setVisibleLines] = useGlobalState("visibleLines"); - const isVisible = visibleLines.includes(dataKey); - - const toggleLineVisibility = () => { - if (isVisible) { - setVisibleLines(visibleLines.filter((line) => line !== dataKey)); - } else { - setVisibleLines([...visibleLines, dataKey]); - } - }; - - return ( -
- - -
- ); -}; - const GspDeltaColumn: FC<{ gspDeltas: Map | undefined; setClickedGspId: Dispatch>; @@ -77,7 +39,7 @@ const GspDeltaColumn: FC<{ let hasRows = false; return ( <> -
+
{deltaArray.sort(sortFunc).map((gspDelta) => { let bucketColor = ""; let dataKey = ""; @@ -136,80 +98,70 @@ const GspDeltaColumn: FC<{ break; } - const isSelected = selectedBuckets.includes(gspDelta.deltaBucketKey); - if (isSelected && !hasRows) { + const bucketIsSelected = selectedBuckets.includes(gspDelta.deltaBucketKey); + if (bucketIsSelected && !hasRows) { hasRows = true; } + const isSelectedGsp = Number(clickedGspId) === Number(gspDelta.gspId); + // this is normalized putting the delta value over the installed capacity of a gsp const deltaNormalizedPercentage = Math.abs( Number(gspDelta.deltaNormalized) * 100 ).toFixed(0); - const tickerColor = `${clickedGspId === gspDelta.gspId ? `h-2.5` : `h-2`} ${ + const tickerColor = `${isSelectedGsp ? `h-2.5` : `h-2`} ${ gspDelta.delta > 0 ? `bg-ocf-delta-900` : `bg-ocf-delta-100` }`; - const selectedClasses = `${bucketColor} transition duration-200 ease-out hover:ease-in items-start`; + const deltaRowClasses = `bg-ocf-delta-950`; - const selectedDeltaClass = `bg-ocf-gray-800 ${bucketColor} items-end`; + const selectedDeltaRowClasses = `bg-ocf-gray-800 items-end`; - if (!isSelected) { + if (!bucketIsSelected) { return null; } return (
setClickedGspId(gspDelta.gspId)} >
0 ? `border-l-8` : `border-r-8`}`} + className={`items-start xl:items-center text-xs grid grid-cols-12 flex-1 py-1.5 justify-between px-2 + transition duration-200 ease-out hover:ease-in ${bucketColor} ${ + gspDelta.delta > 0 ? `border-l-8` : `border-r-8` + }`} key={`gspCol${gspDelta.gspId}`} > -
+
{gspDelta.gspRegion} {/* normalized percentage: delta value/gsp installed mw capacity */} -
- -

{"Normalized Delta"}

-
- } - > -
-

- {negative ? "-" : "+"} - {deltaNormalizedPercentage}% -

-
- -
-
- {/* normalized percentage: delta value/gsp installed mw capacity */} -
- -

{"Normalized Delta"}

-
- } - > -
-

- {negative ? "-" : "+"} - {deltaNormalizedPercentage}% -

+
+ +

{"Normalized Delta"}

- -
+ } + > + + {negative ? "-" : "+"} + {deltaNormalizedPercentage}% + + +
- {/* delta value in mw */} + {/* delta value in mw */} +
@@ -217,36 +169,42 @@ const GspDeltaColumn: FC<{
} > -
-
-

- {!negative && "+"} - {Number(gspDelta.delta).toFixed(0)}{" "} - MW -

-
+
+

+ {!negative && "+"} + + {Number(gspDelta.delta).toFixed(0)} + {" "} + MW +

+
- {/* currentYield/forecasted yield */} -
- -

{"Actual PV / Forecast"}

-
- } - > -
-
- {Number(gspDelta.currentYield).toFixed(0)}/ - {Number(gspDelta.forecast).toFixed(0)}{" "} - MW -
+ {/* currentYield/forecasted yield */} +
+ +

{"Actual PV / Forecast"}

- -
+ } + > +
+
+ + {Number(gspDelta.currentYield).toFixed(0)} + {" "} + /{" "} + + {Number(gspDelta.forecast).toFixed(0)} + {" "} + MW +
+
+
+ {/*
*/}
@@ -273,7 +227,7 @@ const GspDeltaColumn: FC<{ })} {!hasRows && ( -
+
@@ -296,7 +250,7 @@ type DeltaChartProps = { }; const DeltaChart: FC = ({ className, combinedData, combinedErrors }) => { // const [view] = useGlobalState("view"); - const [show4hView] = useGlobalState("show4hView"); + const [show4hView] = useGlobalState("showNHourView"); const [clickedGspId, setClickedGspId] = useGlobalState("clickedGspId"); const [visibleLines] = useGlobalState("visibleLines"); const [globalZoomArea] = useGlobalState("globalZoomArea"); @@ -307,10 +261,10 @@ const DeltaChart: FC = ({ className, combinedData, combinedErro const [loadingState] = useGlobalState("loadingState"); const { stopTime, resetTime } = useStopAndResetTime(); const selectedTime = formatISODateString(selectedISOTime || new Date().toISOString()); - const selectedTimeHalfHourSlot = getNext30MinSlot(new Date(selectedTime)); - const halfHourAgoDate = new Date(timeNow).setMinutes(new Date(timeNow).getMinutes() - 30); - const halfHourAgo = `${formatISODateString(new Date(halfHourAgoDate).toISOString())}:00Z`; - const hasGspPvInitialForSelectedTime = !!combinedData.pvRealDayInData?.find( + const selectedTimeHalfHourSlot = get30MinSlot(new Date(convertToLocaleDateString(selectedTime))); + // const halfHourAgoDate = new Date(timeNow).setMinutes(new Date(timeNow).getMinutes() - 30); + // const halfHourAgo = `${formatISODateString(new Date(halfHourAgoDate).toISOString())}:00Z`; + const hasGspPvInitialForSelectedTime = combinedData.pvRealDayInData?.find( (d) => d.datetimeUtc.slice(0, 16) === `${formatISODateString(selectedTimeHalfHourSlot.toISOString())}` @@ -320,7 +274,7 @@ const DeltaChart: FC = ({ className, combinedData, combinedErro nationalForecastData, pvRealDayInData, pvRealDayAfterData, - national4HourData, + nationalNHourData, allGspForecastData, allGspRealData, gspDeltas @@ -329,7 +283,7 @@ const DeltaChart: FC = ({ className, combinedData, combinedErro nationalForecastError, pvRealDayInError, pvRealDayAfterError, - national4HourError, + nationalNHourError, allGspForecastError } = combinedErrors; @@ -345,14 +299,14 @@ const DeltaChart: FC = ({ className, combinedData, combinedErro const chartData = useFormatChartData({ forecastData: nationalForecastData, - fourHourData: national4HourData, + fourHourData: nationalNHourData, pvRealDayInData, pvRealDayAfterData, timeTrigger: selectedTime, delta: true }); - // While 4-hour is not available, we default to the latest interval with an Initial Estimate + // While N-hour is not available, we default to the latest interval with an Initial Estimate // useEffect(() => { // if (selectedISOTime === get30MinNow() && view === VIEWS.DELTA) { // setSelectedISOTime(get30MinNow(-60)); @@ -363,7 +317,7 @@ const DeltaChart: FC = ({ className, combinedData, combinedErro nationalForecastError || pvRealDayInError || pvRealDayAfterError || - national4HourError || + nationalNHourError || allGspForecastError ) return
Failed to load data.
; @@ -379,33 +333,32 @@ const DeltaChart: FC = ({ className, combinedData, combinedErro stopTime(); setSelectedISOTime(time + ":00.000Z"); }; - const fourHoursAgo = getRounded4HoursAgoString(); - const legendItemContainerClasses = "flex flex-initial flex-col xl:flex-col justify-between"; return ( -
-
- - -
- loadingState={loadingState} /> - +
+
+ + > +
+ loadingState={loadingState} /> + +
{clickedGspId && ( -
+
{ setClickedGspId(undefined); @@ -420,25 +373,27 @@ const DeltaChart: FC = ({ className, combinedData, combinedErro >
)} -
+
-
-
{!hasGspPvInitialForSelectedTime && ( -
+
[ Delta values not available until PV Live output available ]
)} {hasGspPvInitialForSelectedTime && gspDeltas && ( - <> +
- +
)}
- -
+ {!className?.includes("hidden") && } + ); }; diff --git a/apps/nowcasting-app/components/charts/forecast-header/ui.tsx b/apps/nowcasting-app/components/charts/forecast-header/ui.tsx index a47354e4..6d19230f 100644 --- a/apps/nowcasting-app/components/charts/forecast-header/ui.tsx +++ b/apps/nowcasting-app/components/charts/forecast-header/ui.tsx @@ -164,7 +164,10 @@ const ForecastHeaderUI: React.FC = ({ forecastNextTimeOnly }) => { return ( -
+
National
diff --git a/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/index.tsx b/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/index.tsx index 8535cf07..7e13c448 100644 --- a/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/index.tsx +++ b/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/index.tsx @@ -39,21 +39,20 @@ const GspPvRemixChart: FC<{ visibleLines, deltaView = false }) => { - //when adding 4hour forecast data back in, add gsp4HourData to list in line 27 const { errors, pvRealDataAfter, pvRealDataIn, gspLocationInfo, gspForecastDataOneGSP, - gsp4HourData + gspNHourData } = useGetGspData(gspId); // const gspData = fcAll?.forecasts.find((fc) => fc.location.gspId === gspId); const gspInstalledCapacity = gspLocationInfo?.[0]?.installedCapacityMw; const gspName = gspLocationInfo?.[0]?.regionName; const chartData = useFormatChartData({ forecastData: gspForecastDataOneGSP, - fourHourData: gsp4HourData, + fourHourData: gspNHourData, pvRealDayInData: pvRealDataIn, pvRealDayAfterData: pvRealDataAfter, timeTrigger: selectedTime, @@ -71,7 +70,7 @@ const GspPvRemixChart: FC<{ const pvPercentage = (forecastAtSelectedTime.expectedPowerGenerationNormalized || 0) * 100; const fourHourForecastAtSelectedTime: ForecastValue = - gsp4HourData?.find((fc) => formatISODateString(fc?.targetTime) === now30min) || + gspNHourData?.find((fc) => formatISODateString(fc?.targetTime) === now30min) || ({} as ForecastValue); // @@ -141,7 +140,7 @@ const GspPvRemixChart: FC<{ MW
-
+
{dataMissing && (
diff --git a/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/use-get-gsp-data.ts b/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/use-get-gsp-data.ts index 49804294..18a271d9 100644 --- a/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/use-get-gsp-data.ts +++ b/apps/nowcasting-app/components/charts/gsp-pv-remix-chart/use-get-gsp-data.ts @@ -4,7 +4,8 @@ import useGlobalState from "../../helpers/globalState"; import { useLoadDataFromApi } from "../../hooks/useLoadDataFromApi"; const useGetGspData = (gspId: number) => { - const [show4hView] = useGlobalState("show4hView"); + const [show4hView] = useGlobalState("showNHourView"); + const [nHourForecast] = useGlobalState("nHourForecast"); const { data: pvRealDataIn, error: pvRealInDat } = useLoadDataFromApi( `${API_PREFIX}/solar/GB/gsp/pvlive/${gspId}?regime=in-day` @@ -16,16 +17,19 @@ const useGetGspData = (gspId: number) => { //add new useSWR for gspChartData const { data: gspForecastDataOneGSP, error: gspForecastDataOneGSPError } = - useLoadDataFromApi(`${API_PREFIX}/solar/GB/gsp/${gspId}/forecast?UI`); + useLoadDataFromApi(`${API_PREFIX}/solar/GB/gsp/${gspId}/forecast`, { + dedupingInterval: 1000 * 30 + }); //add new useSWR for gspLocationInfo since this is not const { data: gspLocationInfo, error: gspLocationError } = useLoadDataFromApi( `${API_PREFIX}/system/GB/gsp/?gsp_id=${gspId}` ); - const { data: gsp4HourData, error: pv4HourError } = useLoadDataFromApi( + const nMinuteForecast = nHourForecast * 60; + const { data: gspNHourData, error: pvNHourError } = useLoadDataFromApi( show4hView - ? `${API_PREFIX}/solar/GB/gsp/${gspId}/forecast?forecast_horizon_minutes=240&historic=true&only_forecast_values=true` + ? `${API_PREFIX}/solar/GB/gsp/${gspId}/forecast?forecast_horizon_minutes=${nMinuteForecast}&historic=true&only_forecast_values=true` : null ); @@ -35,9 +39,9 @@ const useGetGspData = (gspId: number) => { pvRealDayAfter, gspForecastDataOneGSPError, gspLocationError, - pv4HourError + pvNHourError ].filter((e) => !!e), - gsp4HourData, + gspNHourData: gspNHourData, pvRealDataIn, pvRealDataAfter, gspForecastDataOneGSP, diff --git a/apps/nowcasting-app/components/charts/pv-remix-chart.tsx b/apps/nowcasting-app/components/charts/pv-remix-chart.tsx index 1bde1f43..7e03f524 100644 --- a/apps/nowcasting-app/components/charts/pv-remix-chart.tsx +++ b/apps/nowcasting-app/components/charts/pv-remix-chart.tsx @@ -33,7 +33,7 @@ const PvRemixChart: FC<{ nationalForecastData, pvRealDayInData, pvRealDayAfterData, - national4HourData, + nationalNHourData, allGspForecastData, allGspRealData, gspDeltas @@ -42,7 +42,7 @@ const PvRemixChart: FC<{ nationalForecastError, pvRealDayInError, pvRealDayAfterError, - national4HourError, + nationalNHourError, allGspForecastError } = combinedErrors; @@ -60,7 +60,7 @@ const PvRemixChart: FC<{ const chartData = useFormatChartData({ forecastData: nationalForecastData, probabilisticRangeData: nationalForecastData, - fourHourData: national4HourData, + fourHourData: nationalNHourData, pvRealDayInData, pvRealDayAfterData, timeTrigger: selectedTime @@ -70,7 +70,7 @@ const PvRemixChart: FC<{ nationalForecastError || pvRealDayInError || pvRealDayAfterError || - national4HourError || + nationalNHourError || allGspForecastError ) return
failed to load
; @@ -81,15 +81,14 @@ const PvRemixChart: FC<{ }; return ( -
-
- - -
+ <> +
+
+ {(!nationalForecastData || !pvRealDayInData || !pvRealDayAfterData) && (
)} - - +
+ + +
-
- {clickedGspId && ( + {clickedGspId && ( +
{ setClickedGspId(undefined); @@ -121,11 +122,11 @@ const PvRemixChart: FC<{ resetTime={resetTime} visibleLines={visibleLines} > - )} -
+
+ )}
- -
+ {!className?.includes("hidden") && } + ); }; diff --git a/apps/nowcasting-app/components/charts/remix-line.tsx b/apps/nowcasting-app/components/charts/remix-line.tsx index e09a45e3..3544436e 100644 --- a/apps/nowcasting-app/components/charts/remix-line.tsx +++ b/apps/nowcasting-app/components/charts/remix-line.tsx @@ -18,7 +18,6 @@ import { convertToLocaleDateString, dateToLondonDateTimeString, formatISODateStringHumanNumbersOnly, - getRounded4HoursAgoString, dateToLondonDateTimeOnlyString, getRoundedTickBoundary, prettyPrintChartAxisLabelDate, @@ -42,8 +41,8 @@ export type ChartData = { GENERATION?: number; FORECAST?: number; PAST_FORECAST?: number; - "4HR_FORECAST"?: number; - "4HR_PAST_FORECAST"?: number; + N_HOUR_FORECAST?: number; + N_HOUR_PAST_FORECAST?: number; DELTA?: number; DELTA_BUCKET?: DELTA_BUCKET; PROBABILISTIC_UPPER_BOUND?: number; @@ -58,10 +57,9 @@ const toolTiplabels: Record = { PROBABILISTIC_UPPER_BOUND: "OCF 90%", FORECAST: "OCF Forecast", PAST_FORECAST: "OCF Forecast", - // "4HR_FORECAST": `OCF ${getRounded4HoursAgoString()} Forecast`, PROBABILISTIC_LOWER_BOUND: "OCF 10%", - "4HR_FORECAST": `OCF 4hr+ Forecast`, - "4HR_PAST_FORECAST": "OCF 4hr Forecast", + N_HOUR_FORECAST: `OCF N-hour Forecast`, + N_HOUR_PAST_FORECAST: "OCF N-hour Forecast", DELTA: "Delta" }; @@ -70,8 +68,8 @@ const toolTipColors: Record = { GENERATION: "white", FORECAST: yellow, PAST_FORECAST: yellow, - "4HR_FORECAST": orange, - "4HR_PAST_FORECAST": orange, + N_HOUR_FORECAST: orange, + N_HOUR_PAST_FORECAST: orange, DELTA: deltaPos, PROBABILISTIC_UPPER_BOUND: yellow, PROBABILISTIC_LOWER_BOUND: yellow @@ -148,20 +146,19 @@ const RemixLine: React.FC = ({ }) => { // Set the y max. If national then set to 12000, for gsp plot use 'auto' const preppedData = data.sort((a, b) => a.formattedDate.localeCompare(b.formattedDate)); - const [show4hView] = useGlobalState("show4hView"); + const [showNHourView] = useGlobalState("showNHourView"); const [view] = useGlobalState("view"); const [largeScreenMode] = useGlobalState("dashboardMode"); const currentTime = getNext30MinSlot(new Date()).toISOString().slice(0, 16); const localeTimeOfInterest = convertToLocaleDateString(timeOfInterest + "Z").slice(0, 16); - const fourHoursFromNow = new Date(currentTime); const defaultZoom = { x1: "", x2: "" }; const [filteredPreppedData, setFilteredPreppedData] = useState(preppedData); const [globalZoomArea, setGlobalZoomArea] = useGlobalState("globalZoomArea"); const [globalIsZooming, setGlobalIsZooming] = useGlobalState("globalChartIsZooming"); const [globalIsZoomed, setGlobalIsZoomed] = useGlobalState("globalChartIsZoomed"); const [temporaryZoomArea, setTemporaryZoomArea] = useState(defaultZoom); - - fourHoursFromNow.setHours(fourHoursFromNow.getHours() + 4); + const [nHourForecast] = useGlobalState("nHourForecast"); + const [clickedGspId] = useGlobalState("clickedGspId"); function prettyPrintYNumberWithCommas( x: string | number, @@ -175,13 +172,6 @@ const RemixLine: React.FC = ({ return roundedNumber.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } - const prettyPrintDate = (x: string | number) => { - if (typeof x === "number") { - return dateToLondonDateTimeOnlyString(new Date(x)); - } - return dateToLondonDateTimeOnlyString(new Date(x)); - }; - const CustomBar = (props: { DELTA: number }) => { const { DELTA } = props; let fill = DELTA > 0 ? deltaPos : deltaNeg; @@ -230,21 +220,58 @@ const RemixLine: React.FC = ({ setFilteredPreppedData(preppedData); } + const updateFilteredData = () => { + const { x1, x2 } = globalZoomArea; + + if (!x1 || !x2) return; + + const dataInAreaRange = preppedData.filter( + (d) => d?.formattedDate >= x1 && d?.formattedDate <= x2 + ); + setFilteredPreppedData(dataInAreaRange); + }; + useEffect(() => { if (!zoomEnabled) return; if (!globalIsZooming) { - const { x1, x2 } = globalZoomArea; + updateFilteredData(); + } + }, [globalZoomArea, globalIsZooming, preppedData, zoomEnabled]); - if (!x1 || !x2) return; + useEffect(() => { + updateFilteredData(); + }, [nHourForecast]); + + const DeltaTick: FC<{ x?: number; y?: number; stroke?: string; payload?: any }> = ({ + x, + y, + stroke, + payload + }) => { + return ( + + + {`${payload.value > 0 ? "+" : ""}${prettyPrintYNumberWithCommas(payload.value)}`} + + + ); + }; - const dataInAreaRange = preppedData.filter( - (d) => d?.formattedDate >= x1 && d?.formattedDate <= x2 - ); - setFilteredPreppedData(dataInAreaRange); - setGlobalZoomArea({ x1: "", x2: "" }); + let rightChartMargin = 16; + let deltaLabelOffset = roundTickMax ? -20 : -10; + if (deltaView) { + if (clickedGspId) { + rightChartMargin = 15; + if (roundTickMax) { + deltaLabelOffset = 0; + } else { + deltaLabelOffset = -5; + } + } else { + rightChartMargin = 0; } - }, [globalZoomArea, globalIsZooming, preppedData, zoomEnabled]); + } return (
@@ -260,392 +287,422 @@ const RemixLine: React.FC = ({
)} - - { - if (globalIsZooming) return; - - if (setTimeOfInterest && e?.activeLabel) { - view === VIEWS.SOLAR_SITES - ? setTimeOfInterest( - new Date(Number(e.activeLabel))?.toISOString() || new Date().toISOString() - ) - : setTimeOfInterest(e.activeLabel); - } - }} - onMouseDown={(e?: { activeLabel?: string }) => { - if (!zoomEnabled) return; - setTemporaryZoomArea(globalZoomArea); - setGlobalIsZooming(true); - let xValue = e?.activeLabel; - if (typeof xValue === "string" && xValue.length > 0) { - setGlobalZoomArea({ x1: xValue, x2: xValue }); - } - }} - onMouseMove={(e?: { activeLabel?: string }) => { - if (!zoomEnabled) return; - - if (globalIsZooming) { +
+ + { + if (globalIsZooming) return; + + if (setTimeOfInterest && e?.activeLabel) { + view === VIEWS.SOLAR_SITES + ? setTimeOfInterest( + new Date(Number(e.activeLabel))?.toISOString() || new Date().toISOString() + ) + : setTimeOfInterest(e.activeLabel); + } + }} + onMouseDown={(e?: { activeLabel?: string }) => { + if (!zoomEnabled) return; + setTemporaryZoomArea(globalZoomArea); + setGlobalIsZooming(true); let xValue = e?.activeLabel; - if (!xValue) return; - setGlobalZoomArea((zoom) => ({ ...zoom, x2: xValue || "" })); - } - }} - onMouseUp={(e?: { activeLabel?: string }) => { - if (!zoomEnabled) return; - - if (globalIsZooming) { - if (globalZoomArea.x1 === globalZoomArea.x2 && e?.activeLabel && setTimeOfInterest) { - setGlobalZoomArea(temporaryZoomArea); - setTimeOfInterest(e?.activeLabel); - } else if (globalZoomArea?.x1?.length && globalZoomArea?.x2?.length) { - let { x1 } = globalZoomArea; - let x2 = e?.activeLabel || ""; - if (x1 > x2) { - [x1, x2] = [x2, x1]; + if (typeof xValue === "string" && xValue.length > 0) { + setGlobalZoomArea({ x1: xValue, x2: xValue }); + } + }} + onMouseMove={(e?: { activeLabel?: string }) => { + if (!zoomEnabled) return; + + if (globalIsZooming) { + let xValue = e?.activeLabel; + if (!xValue) return; + setGlobalZoomArea((zoom) => ({ ...zoom, x2: xValue || "" })); + } + }} + onMouseUp={(e?: { activeLabel?: string }) => { + if (!zoomEnabled) return; + + if (globalIsZooming) { + if ( + globalZoomArea.x1 === globalZoomArea.x2 && + e?.activeLabel && + setTimeOfInterest + ) { + setGlobalZoomArea(temporaryZoomArea); + setTimeOfInterest(e?.activeLabel); + } else if (globalZoomArea?.x1?.length && globalZoomArea?.x2?.length) { + let { x1 } = globalZoomArea; + let x2 = e?.activeLabel || ""; + if (x1 > x2) { + [x1, x2] = [x2, x1]; + } + setGlobalZoomArea({ x1, x2 }); + setGlobalIsZoomed(true); } - setGlobalZoomArea({ x1, x2 }); - setGlobalIsZoomed(true); + setGlobalIsZooming(false); } - setGlobalIsZooming(false); - } - }} - > - - - - - - prettyPrintYNumberWithCommas(val) - } - yAxisId={"y-axis"} - tick={{ fill: "white", style: { fontSize: "12px" } }} - tickLine={false} - domain={ - globalIsZoomed && view !== VIEWS.SOLAR_SITES ? [0, Number(zoomYMax * 1.1)] : [0, yMax] - } - label={{ - value: view === VIEWS.SOLAR_SITES ? "Generation (KW)" : "Generation (MW)", - angle: 270, - position: "outsideLeft", - fill: "white", - style: { fontSize: "12px" }, - offset: 0, - dx: -26, - dy: 0 }} - /> - - {deltaView && ( - <> - prettyPrintYNumberWithCommas(val, roundTickMax ? 0 : 2)} - tick={{ - fill: "white", - style: { fontSize: "12px" }, - textAnchor: "end", - dx: roundTickMax ? 36 : 24 - }} - ticks={[deltaYMax, deltaYMax / 2, 0, -deltaYMax / 2, -deltaYMax]} - tickCount={5} - tickLine={false} - yAxisId={"delta"} - scale={"auto"} - orientation="right" - label={{ - value: `Delta (MW)`, - angle: 90, - position: "insideRight", - fill: "white", - style: { fontSize: "12px" }, - offset: 0, - dx: roundTickMax ? 0 : -10, - dy: 30 - }} - domain={[-deltaYMax, deltaYMax]} - /> - + + + + + + prettyPrintYNumberWithCommas(val) + } + yAxisId={"y-axis"} + tick={{ fill: "white", style: { fontSize: "12px" } }} + tickLine={false} + domain={ + globalIsZoomed && view !== VIEWS.SOLAR_SITES + ? [0, Number(zoomYMax * 1.1)] + : [0, yMax] + } + label={{ + value: view === VIEWS.SOLAR_SITES ? "Generation (KW)" : "Generation (MW)", + angle: 270, + position: "outsideLeft", + fill: "white", + style: { fontSize: "12px" }, + offset: 0, + dx: -26, + dy: 0 + }} + /> + + {deltaView && ( + <> + + prettyPrintYNumberWithCommas(val, roundTickMax ? 0 : 2) + } + tick={} + ticks={[deltaYMax, deltaYMax / 2, 0, -deltaYMax / 2, -deltaYMax]} + tickCount={5} + tickLine={false} + yAxisId={"delta"} + scale={"auto"} + orientation="right" + label={{ + value: `Delta (MW)`, + angle: 90, + position: "insideRight", + fill: "white", + style: { fontSize: "11px" }, + offset: 0, + dx: deltaLabelOffset, + dy: 29 + }} + domain={[-deltaYMax, deltaYMax]} + padding={{ top: 0, bottom: 0 }} + /> + + + )} + + + } + /> + + + } + /> + + {deltaView && ( + - - )} - - } + barSize={3} /> - } - /> - - - } - /> - - {deltaView && ( - + + + + )} + + } - barSize={3} + yAxisId={"y-axis"} + stroke={yellow} + fill={yellow} + fillOpacity={0.4} + strokeWidth={0} + hide={!visibleLines.includes("FORECAST")} + isAnimationActive={false} /> - )} - {show4hView && ( - <> - - - - )} - - - - - - - - {zoomEnabled && globalIsZooming && ( - - )} - { - const data = payload && payload[0]?.payload; - if (!data || (data["GENERATION"] === 0 && data["FORECAST"] === 0)) return
; - - let formattedDate = data?.formattedDate + ":00+00:00"; - if (view === VIEWS.SOLAR_SITES) { - const date = new Date(Number(data?.formattedDate)); - formattedDate = dateToLondonDateTimeString(date); - } + + + + {zoomEnabled && globalIsZooming && ( + + )} + { + const data = payload && payload[0]?.payload; + if (!data || (data["GENERATION"] === 0 && data["FORECAST"] === 0)) + return
; + + let formattedDate = data?.formattedDate + ":00+00:00"; + if (view === VIEWS.SOLAR_SITES) { + const date = new Date(Number(data?.formattedDate)); + formattedDate = dateToLondonDateTimeString(date); + } - return ( -
-
    - {Object.entries(toolTiplabels).map(([key, name]) => { - const value = data[key]; - if (key === "DELTA" && !deltaView) return null; - if (typeof value !== "number") return null; - if (deltaView && key === "GENERATION" && data["GENERATION_UPDATED"] >= 0) - return null; - if (key.includes("4HR") && (!show4hView || !visibleLines.includes(key))) - return null; - if (key.includes("PROBABILISTIC") && Math.round(value * 100) < 0) return null; - let textClass = "font-normal"; - if (["FORECAST", "PAST_FORECAST"].includes(key)) textClass = "font-semibold"; - if (["PROBABILISTIC_UPPER_BOUND", "PROBABILISTIC_LOWER_BOUND"].includes(key)) - textClass = "text-xs"; - const pvLiveTextClass = - data["GENERATION_UPDATED"] >= 0 && - data["GENERATION"] >= 0 && - key === "GENERATION" - ? "text-xs" - : ""; - const sign = ["DELTA"].includes(key) ? (Number(value) > 0 ? "+" : "") : ""; - const color = ["DELTA"].includes(key) - ? Number(value) > 0 - ? deltaPos - : deltaNeg - : toolTipColors[key]; - const computedValue = - key === "DELTA" && - !show4hView && - `${data["formattedDate"]}:00.000Z` >= currentTime - ? "-" - : prettyPrintYNumberWithCommas(String(value), 1); - - return ( -
  • -
    -
    {toolTiplabels[key]}:
    -
    - {(show4hView || key !== "DELTA") && sign} - {computedValue}{" "} + return ( +
    +
      + {Object.entries(toolTiplabels).map(([key, name]) => { + const value = data[key]; + if (key === "DELTA" && !deltaView) return null; + if (typeof value !== "number") return null; + if (deltaView && key === "GENERATION" && data["GENERATION_UPDATED"] >= 0) + return null; + if ( + key.includes("N_HOUR") && + (!showNHourView || !visibleLines.some((key) => key.includes("N_HOUR"))) + ) + return null; + if (key.includes("PROBABILISTIC") && Math.round(value * 100) < 0) + return null; + let textClass = "font-normal"; + if (["FORECAST", "PAST_FORECAST"].includes(key)) + textClass = "font-semibold"; + if ( + ["PROBABILISTIC_UPPER_BOUND", "PROBABILISTIC_LOWER_BOUND"].includes(key) + ) + textClass = "text-xs"; + const pvLiveTextClass = + data["GENERATION_UPDATED"] >= 0 && + data["GENERATION"] >= 0 && + key === "GENERATION" + ? "text-xs" + : ""; + const sign = ["DELTA"].includes(key) ? (Number(value) > 0 ? "+" : "") : ""; + const color = ["DELTA"].includes(key) + ? Number(value) > 0 + ? deltaPos + : deltaNeg + : toolTipColors[key]; + const computedValue = + key === "DELTA" && + !showNHourView && + `${data["formattedDate"]}:00.000Z` >= currentTime + ? "-" + : prettyPrintYNumberWithCommas(String(value), 1); + let title = toolTiplabels[key]; + if (key.includes("N_HOUR")) { + title = title.replace("N-hour", `${nHourForecast}-hour`); + } + + return ( +
    • +
      +
      {title}:
      +
      + {(showNHourView || key !== "DELTA") && sign} + {computedValue}{" "} +
      -
    -
  • - ); - })} -
  • -
    - {formatISODateStringHumanNumbersOnly(formattedDate)}{" "} -
    -
    {view === VIEWS.SOLAR_SITES ? "KW" : "MW"}
    -
  • -
-
- ); - }} - /> -
-
+ + ); + })} +
  • +
    + {formatISODateStringHumanNumbersOnly(formattedDate)}{" "} +
    +
    {view === VIEWS.SOLAR_SITES ? "KW" : "MW"}
    +
  • + +
    + ); + }} + /> +
    +
    +
    ); }; diff --git a/apps/nowcasting-app/components/charts/solar-site-view/solar-site-chart.tsx b/apps/nowcasting-app/components/charts/solar-site-view/solar-site-chart.tsx index 1bd715c7..25f40f85 100644 --- a/apps/nowcasting-app/components/charts/solar-site-view/solar-site-chart.tsx +++ b/apps/nowcasting-app/components/charts/solar-site-view/solar-site-chart.tsx @@ -5,7 +5,6 @@ import useGlobalState from "../../helpers/globalState"; import { convertISODateStringToLondonTime, formatISODateString, - getRounded4HoursAgoString, getRoundedTickBoundary } from "../../helpers/utils"; import { useStopAndResetTime } from "../../hooks/use-and-update-selected-time"; @@ -25,34 +24,8 @@ import { ForecastHeadlineFigure } from "../forecast-header/ui"; import { AggregatedDataTable } from "./solar-site-tables"; import ForecastHeaderSite from "./forecast-header"; import DataLoadingChartStatus from "../DataLoadingChartStatus"; - -const LegendItem: FC<{ - iconClasses: string; - label: string; - dashed?: boolean; - dataKey: string; -}> = ({ iconClasses, label, dashed, dataKey }) => { - const [visibleLines, setVisibleLines] = useGlobalState("visibleLines"); - const isVisible = visibleLines.includes(dataKey); - const [show4hView] = useGlobalState("show4hView"); - - const toggleLineVisibility = () => { - if (isVisible) { - setVisibleLines(visibleLines.filter((line) => line !== dataKey)); - } else { - setVisibleLines([...visibleLines, dataKey]); - } - }; - - return ( -
    - - -
    - ); -}; +import Link from "next/link"; +import LegendItem from "../LegendItem"; const SolarSiteChart: FC<{ combinedSitesData: CombinedSitesData; @@ -60,7 +33,6 @@ const SolarSiteChart: FC<{ date?: string; className?: string; }> = ({ combinedSitesData, aggregatedSitesData, className }) => { - const [show4hView] = useGlobalState("show4hView"); const [clickedGspId, setClickedGspId] = useGlobalState("clickedGspId"); const [clickedSiteGroupId, setClickedSiteGroupId] = useGlobalState("clickedSiteGroupId"); const [visibleLines] = useGlobalState("visibleLines"); @@ -190,7 +162,7 @@ const SolarSiteChart: FC<{ return gsp.name || ""; case AGGREGATION_LEVELS.SITE: const site = filteredSites.find((s) => s.site_uuid === clickedGroupId); - return site?.client_site_id || ""; + return site?.client_site_name || ""; } }; @@ -209,172 +181,200 @@ const SolarSiteChart: FC<{
    ); + if (!combinedSitesData.allSitesData?.length) { + return ( +
    +
    +
    +

    Welcome to Site View.

    +

    + It looks like you don't currently have any sites. +

    + {/* TODO: add func. to create sites from UI */} + {/*

    */} + {/* To add a site, you can use the "+" button in the top left corner.*/} + {/*

    */} +

    + To add a site, you can use our{" "} + + API + {" "} + or our{" "} + + Swagger UI + + . +

    +
    +

    + If you think you should have sites here, have any questions or need some further + information, please get in touch at{" "} + + quartz.support@openclimatefix.org + +

    +
    +
    +
    +
    + ); + } + const setSelectedTime = (time: string) => { stopTime(); setSelectedISOTime(time); }; - const fourHoursAgo = getRounded4HoursAgoString(); - const legendItemContainerClasses = "flex flex-initial flex-col xl:flex-col justify-between"; return ( -
    -
    -
    -
    - All Sites -
    -
    -
    - - {nationalPVActual?.toFixed(1)} - / - {nationalPVExpected?.toFixed(1)} - -
    -
    - {/**/} +
    +
    +
    +
    +
    + All Sites
    -
    -
    - {/*
    {children}
    */} -
    -
    - - -
    - {clickedSiteGroupId && aggregationLevel !== AGGREGATION_LEVELS.NATIONAL && ( -
    - <> - { - setClickedSiteGroupId(undefined); - }} - title={ - getSiteName(selectedSiteData, aggregationLevel, clickedSiteGroupId) || - "No name found for selected group" - } - > +
    +
    - - {getTotalPvActualGenerationForGroup( - selectedSiteData.map((site) => site.site_uuid), - selectedTime - ).toFixed(1) || "0"} - + {nationalPVActual?.toFixed(1)} / - {getTotalPvForecastGenerationForGroup( + {nationalPVExpected?.toFixed(1)} + +
    +
    + {/**/} +
    +
    +
    +
    + + +
    +
    + {clickedSiteGroupId && aggregationLevel !== AGGREGATION_LEVELS.NATIONAL && ( +
    + { + setClickedSiteGroupId(undefined); + }} + title={ + getSiteName(selectedSiteData, aggregationLevel, clickedSiteGroupId) || + "No name found for selected group" + } + > + + + {getTotalPvActualGenerationForGroup( selectedSiteData.map((site) => site.site_uuid), selectedTime ).toFixed(1) || "0"} - - -
    - -
    - + + / + {getTotalPvForecastGenerationForGroup( + selectedSiteData.map((site) => site.site_uuid), + selectedTime + ).toFixed(1) || "0"} + + +
    + +
    )} -
    - - - - - -
    +
    + + + + +
    +
    +
    -
    - -
    -
    - -
    -
    - -
    + +
    - {/* {show4hView && ( -
    - - -
    - )} */} -
    - } position="top" className={"text-right"} fullWidth> +
    + + +
    + } + position="top" + className={"text-right"} + fullWidth + >
    diff --git a/apps/nowcasting-app/components/charts/solar-site-view/solar-site-tables.tsx b/apps/nowcasting-app/components/charts/solar-site-view/solar-site-tables.tsx index 63da9699..728cf77a 100644 --- a/apps/nowcasting-app/components/charts/solar-site-view/solar-site-tables.tsx +++ b/apps/nowcasting-app/components/charts/solar-site-view/solar-site-tables.tsx @@ -81,7 +81,7 @@ const TableData: React.FC = ({ rows }) => { return ( <> -
    +
    {rows?.sort(sortFn).map((site) => { const mostAccurateGeneration = site.actualPV || site.expectedPV; return ( diff --git a/apps/nowcasting-app/components/charts/use-format-chart-data-sites.tsx b/apps/nowcasting-app/components/charts/use-format-chart-data-sites.tsx index 7ab829c1..94b1c615 100644 --- a/apps/nowcasting-app/components/charts/use-format-chart-data-sites.tsx +++ b/apps/nowcasting-app/components/charts/use-format-chart-data-sites.tsx @@ -13,8 +13,8 @@ const getForecastChartData = ( ) => { if (!fr) return {}; - const futureKey = forecast_horizon === 240 ? "4HR_FORECAST" : "FORECAST"; - const pastKey = forecast_horizon === 240 ? "4HR_PAST_FORECAST" : "PAST_FORECAST"; + const futureKey = forecast_horizon === 240 ? "N_HOUR_FORECAST" : "FORECAST"; + const pastKey = forecast_horizon === 240 ? "N_HOUR_PAST_FORECAST" : "PAST_FORECAST"; const generation = fr.expected_generation_kw > 20 ? fr.expected_generation_kw / 7000 : fr.expected_generation_kw; @@ -38,8 +38,8 @@ const getForecastChartData = ( // } else if (datum.GENERATION !== undefined) { // return Number(datum.GENERATION) - Number(datum.PAST_FORECAST); // } -// } else if (datum.FORECAST !== undefined && datum["4HR_FORECAST"] !== undefined) { -// return Number(datum.FORECAST) - Number(datum["4HR_FORECAST"]); +// } else if (datum.FORECAST !== undefined && datum["N_HOUR_FORECAST"] !== undefined) { +// return Number(datum.FORECAST) - Number(datum["N_HOUR_FORECAST"]); // } // return 0; // }; diff --git a/apps/nowcasting-app/components/charts/use-format-chart-data.tsx b/apps/nowcasting-app/components/charts/use-format-chart-data.tsx index 00726ae4..a6e15340 100644 --- a/apps/nowcasting-app/components/charts/use-format-chart-data.tsx +++ b/apps/nowcasting-app/components/charts/use-format-chart-data.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { get30MinNow } from "../helpers/globalState"; +import { get30MinNow, useGlobalState } from "../helpers/globalState"; import { ForecastData, PvRealData } from "../types"; import { formatISODateString, getDeltaBucket } from "../helpers/utils"; import { ChartData } from "./remix-line"; @@ -16,8 +16,8 @@ const getForecastChartData = ( ) => { if (!fr) return {}; - const futureKey = forecast_horizon === 240 ? "4HR_FORECAST" : "FORECAST"; - const pastKey = forecast_horizon === 240 ? "4HR_PAST_FORECAST" : "PAST_FORECAST"; + const futureKey = forecast_horizon ? "N_HOUR_FORECAST" : "FORECAST"; + const pastKey = forecast_horizon ? "N_HOUR_PAST_FORECAST" : "PAST_FORECAST"; if (new Date(fr.targetTime).getTime() > new Date(timeNow + ":00.000Z").getTime()) return { @@ -39,8 +39,8 @@ const getDelta: (datum: ChartData) => number = (datum) => { } else if (datum.GENERATION !== undefined) { return Number(datum.GENERATION) - Number(datum.PAST_FORECAST); } - } else if (datum.FORECAST !== undefined && datum["4HR_FORECAST"] !== undefined) { - return Number(datum.FORECAST) - Number(datum["4HR_FORECAST"]); + } else if (datum.FORECAST !== undefined && datum["N_HOUR_FORECAST"] !== undefined) { + return Number(datum.FORECAST) - Number(datum["N_HOUR_FORECAST"]); } return 0; }; @@ -62,6 +62,8 @@ const useFormatChartData = ({ timeTrigger?: string; delta?: boolean; }) => { + const [nHourForecast] = useGlobalState("nHourForecast"); + const data = useMemo(() => { if (forecastData && pvRealDayAfterData && pvRealDayInData && timeTrigger) { const timeNow = formatISODateString(get30MinNow()); @@ -148,7 +150,7 @@ const useFormatChartData = ({ addDataToMap( fc, (db) => db.targetTime, - (db) => getForecastChartData(timeNow, db, 240) + (db) => getForecastChartData(timeNow, db, nHourForecast * 60) ) ); } @@ -166,7 +168,7 @@ const useFormatChartData = ({ } return []; // timeTrigger is used to trigger chart calculation when time changes - }, [forecastData, fourHourData, pvRealDayInData, pvRealDayAfterData, timeTrigger]); + }, [forecastData, fourHourData, pvRealDayInData, pvRealDayAfterData, timeTrigger, nHourForecast]); return data; }; diff --git a/apps/nowcasting-app/components/delta-forecast-label.tsx b/apps/nowcasting-app/components/delta-forecast-label.tsx index e6b663b1..44995ee8 100644 --- a/apps/nowcasting-app/components/delta-forecast-label.tsx +++ b/apps/nowcasting-app/components/delta-forecast-label.tsx @@ -8,24 +8,28 @@ type DeltaForecastLabelProps = { const DeltaForecastLabel: React.FC = ({ children, tip, - position = "left", + position = "middle", className }) => { const getPositionClass = (position: "left" | "right" | "middle") => { if (position === "left") return "-left-10"; if (position === "right") return "-right-10"; - if (position === "middle") return ""; + if (position === "middle") return "left-[50%] transform -translate-x-1/2"; }; return (
    {children} -
    + -
    +
    = ({ view, setView }) => { view={VIEWS.DELTA} currentView={view} setViewFunc={setView} - text="Delta" + text={getViewTitle(VIEWS.DELTA)} />
    diff --git a/apps/nowcasting-app/components/layout/header/profile-dropdown.tsx b/apps/nowcasting-app/components/layout/header/profile-dropdown.tsx index e692049d..0a2c6847 100644 --- a/apps/nowcasting-app/components/layout/header/profile-dropdown.tsx +++ b/apps/nowcasting-app/components/layout/header/profile-dropdown.tsx @@ -15,7 +15,7 @@ interface IProfileDropDown {} const ProfileDropDown = ({}: IProfileDropDown) => { const { user } = useUser(); - const [show4hView, setShow4hView] = useGlobalState("show4hView"); + const [show4hView, setShow4hView] = useGlobalState("showNHourView"); const [dashboardMode, setDashboardMode] = useGlobalState("dashboardMode"); const toggleDashboardMode = () => { setDashboardMode(!dashboardMode); @@ -60,11 +60,11 @@ const ProfileDropDown = ({}: IProfileDropDown) => { )}
    )} diff --git a/apps/nowcasting-app/components/layout/layout.tsx b/apps/nowcasting-app/components/layout/layout.tsx index a9413fee..20ef5f3f 100644 --- a/apps/nowcasting-app/components/layout/layout.tsx +++ b/apps/nowcasting-app/components/layout/layout.tsx @@ -1,7 +1,8 @@ import Head from "next/head"; -import { API_PREFIX } from "../../constant"; +import { API_PREFIX, getViewTitle } from "../../constant"; import { useLoadDataFromApi } from "../hooks/useLoadDataFromApi"; import { SolarStatus } from "../types"; +import useGlobalState from "../helpers/globalState"; interface ILayout { children: React.ReactNode; @@ -10,14 +11,17 @@ interface ILayout { const Layout = ({ children }: ILayout) => { const { data: solarStatus } = useLoadDataFromApi(`${API_PREFIX}/solar/GB/status`); + const [view] = useGlobalState("view"); + const viewTitle = getViewTitle(view); + const pageTitle = view && viewTitle ? `Quartz Solar - ${viewTitle}` : "Quartz Solar"; return ( <> - Solar PV Forecast + {pageTitle} -
    +
    {!solarStatus || solarStatus?.status === "ok" ? null : (

    {solarStatus?.message}

    diff --git a/apps/nowcasting-app/components/map/deltaMap.tsx b/apps/nowcasting-app/components/map/deltaMap.tsx index c40cfd49..556fe3d3 100644 --- a/apps/nowcasting-app/components/map/deltaMap.tsx +++ b/apps/nowcasting-app/components/map/deltaMap.tsx @@ -17,7 +17,7 @@ import { ForecastValue, GspAllForecastData, GspDeltaValue, - National4HourData, + NationalNHourData, PvRealData } from "../types"; import { theme } from "../../tailwind.config"; @@ -31,21 +31,6 @@ import dynamic from "next/dynamic"; const yellow = theme.extend.colors["ocf-yellow"].DEFAULT; const ButtonGroup = dynamic(() => import("../../components/button-group"), { ssr: false }); -const getRoundedPv = (pv: number, round: boolean = true) => { - if (!round) return Math.round(pv); - // round To: 0, 100, 200, 300, 400, 500 - return Math.round(pv / 100) * 100; -}; -const getRoundedPvPercent = (per: number, round: boolean = true) => { - if (!round) return per; - // round to : 0, 0.2, 0.4, 0.6 0.8, 1 - let rounded = Math.round(per * 10); - if (rounded % 2) { - if (per * 10 > rounded) return (rounded + 1) / 10; - else return (rounded - 1) / 10; - } else return rounded / 10; -}; - type DeltaMapProps = { className?: string; combinedData: CombinedData; diff --git a/apps/nowcasting-app/components/map/loadingState.tsx b/apps/nowcasting-app/components/map/loadingState.tsx index 76a52e14..74f48436 100644 --- a/apps/nowcasting-app/components/map/loadingState.tsx +++ b/apps/nowcasting-app/components/map/loadingState.tsx @@ -1,8 +1,9 @@ const LoadStateMap = ({ children }: { children: React.ReactNode }) => ( -
    -
    {children}
    -
    -
    + <> +
    + {children} +
    + ); export default LoadStateMap; diff --git a/apps/nowcasting-app/components/map/map.tsx b/apps/nowcasting-app/components/map/map.tsx index 8f54273e..15ffaf8b 100644 --- a/apps/nowcasting-app/components/map/map.tsx +++ b/apps/nowcasting-app/components/map/map.tsx @@ -99,6 +99,7 @@ const Map: FC = ({ // TODO: unsure as to whether react cleans up/ends up with multiple maps when re-rendering // or whether removing will cause more issues elsewhere in the app. // Will just keep an eye on performance etc. for now. + // // return () => map.current?.remove(); }, []); diff --git a/apps/nowcasting-app/components/map/pvLatestMap.tsx b/apps/nowcasting-app/components/map/pvLatestMap.tsx index 28e36131..f62962d4 100644 --- a/apps/nowcasting-app/components/map/pvLatestMap.tsx +++ b/apps/nowcasting-app/components/map/pvLatestMap.tsx @@ -1,22 +1,21 @@ -import { SWRResponse } from "swr"; - -import React, { Dispatch, SetStateAction, useMemo } from "react"; +import React, { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; import mapboxgl, { Expression } from "mapbox-gl"; import { FailedStateMap, LoadStateMap, Map, MeasuringUnit } from "./"; import { ActiveUnit, SelectedData } from "./types"; import { MAX_POWER_GENERATED, VIEWS } from "../../constant"; -import gspShapeData from "../../data/gsp_regions_20220314.json"; import useGlobalState from "../helpers/globalState"; -import { formatISODateString, formatISODateStringHuman } from "../helpers/utils"; -import { CombinedData, CombinedErrors, GspAllForecastData } from "../types"; +import { formatISODateStringHuman } from "../helpers/utils"; +import { CombinedData, CombinedErrors, CombinedLoading, CombinedValidating } from "../types"; import { theme } from "../../tailwind.config"; import ColorGuideBar from "./color-guide-bar"; -import { FeatureCollection } from "geojson"; -import { safelyUpdateMapData } from "../helpers/mapUtils"; +import { getActiveUnitFromMap, safelyUpdateMapData, setActiveUnitOnMap } from "../helpers/mapUtils"; import { components } from "../../types/quartz-api"; import { generateGeoJsonForecastData } from "../helpers/data"; import dynamic from "next/dynamic"; +import throttle from "lodash/throttle"; +import Spinner from "../icons/spinner"; + const yellow = theme.extend.colors["ocf-yellow"].DEFAULT; const ButtonGroup = dynamic(() => import("../../components/button-group"), { ssr: false }); @@ -24,6 +23,8 @@ const ButtonGroup = dynamic(() => import("../../components/button-group"), { ssr type PvLatestMapProps = { className?: string; combinedData: CombinedData; + combinedLoading: CombinedLoading; + combinedValidating: CombinedValidating; combinedErrors: CombinedErrors; activeUnit: ActiveUnit; setActiveUnit: Dispatch>; @@ -32,24 +33,71 @@ type PvLatestMapProps = { const PvLatestMap: React.FC = ({ className, combinedData, + combinedLoading, + combinedValidating, combinedErrors, activeUnit, setActiveUnit }) => { const [selectedISOTime] = useGlobalState("selectedISOTime"); + const [shouldUpdateMap, setShouldUpdateMap] = useState(false); + const [mapDataLoading, setMapDataLoading] = useState(true); + + const getSelectedDataFromActiveUnit = (activeUnit: ActiveUnit) => { + switch (activeUnit) { + case ActiveUnit.MW: + return SelectedData.expectedPowerGenerationMegawattsRounded; + case ActiveUnit.percentage: + return SelectedData.expectedPowerGenerationNormalizedRounded; + case ActiveUnit.capacity: + return SelectedData.installedCapacityMw; + } + }; + const [selectedDataName, setSelectedDataName] = useState( + getSelectedDataFromActiveUnit(activeUnit) + ); + + useEffect(() => { + setMapDataLoading(true); + setSelectedDataName(getSelectedDataFromActiveUnit(activeUnit)); + // Add unit to map container so that it can be accessed by popup in the map event listeners + const map: HTMLDivElement | null = document.querySelector(`#Map-${VIEWS.FORECAST}`); + if (map) { + setActiveUnitOnMap(map, activeUnit); + } + }, [activeUnit]); const latestForecastValue = 0; const isNormalized = activeUnit === ActiveUnit.percentage; - let selectedDataName = SelectedData.expectedPowerGenerationMegawatts; - if (activeUnit === ActiveUnit.percentage) - selectedDataName = SelectedData.expectedPowerGenerationNormalized; - if (activeUnit === ActiveUnit.capacity) selectedDataName = SelectedData.installedCapacityMw; const forecastLoading = false; const initForecastData = combinedData?.allGspForecastData as components["schemas"]["OneDatetimeManyForecastValues"][]; const forecastError = combinedErrors?.allGspForecastError; + // Show loading spinner when selectedISOTime changes + useEffect(() => { + if (!combinedData?.allGspForecastData) return; + + setMapDataLoading(true); + }, [selectedISOTime]); + + // Update map data when forecast data is loaded + useEffect(() => { + if (!combinedData?.allGspForecastData) return; + + setShouldUpdateMap(true); + }, [combinedData, combinedLoading, combinedValidating, selectedISOTime]); + + // Hide loading spinner if there is an error to prevent infinite loading + useEffect(() => { + if (combinedErrors.allGspForecastError) { + setMapDataLoading(false); + } + }, [combinedErrors.allGspForecastError]); + + console.log("## shouldUpdateMap render", shouldUpdateMap); + const getFillOpacity = (selectedData: string, isNormalized: boolean): Expression => [ "interpolate", ["linear"], @@ -64,22 +112,57 @@ const PvLatestMap: React.FC = ({ const generatedGeoJsonForecastData = useMemo(() => { return generateGeoJsonForecastData(initForecastData, selectedISOTime, combinedData); - }, [initForecastData, selectedISOTime]); + }, [ + combinedData.allGspForecastData, + combinedLoading.allGspForecastLoading, + combinedValidating.allGspForecastValidating, + selectedISOTime, + combinedData.allGspSystemData + ]); + + // Create a popup, but don't add it to the map yet. + const popup = useMemo(() => { + return new mapboxgl.Popup({ + closeButton: false, + closeOnClick: false, + anchor: "bottom-right", + maxWidth: "none" + }); + }, []); + + const nationalCapacityMW = useMemo(() => { + return ( + (combinedData.allGspSystemData?.reduce( + (acc, gsp) => acc + (gsp.installedCapacityMw || 0), + 0 + ) || 0) / 1000 + ); + }, [combinedData.allGspSystemData]); const addOrUpdateMapData = (map: mapboxgl.Map) => { + const geoJsonHasData = + generatedGeoJsonForecastData.forecastGeoJson.features.length > 0 && + typeof generatedGeoJsonForecastData.forecastGeoJson?.features?.[0]?.properties + ?.expectedPowerGenerationMegawatts === "number"; + if (!geoJsonHasData) { + console.log("geoJsonForecastData empty, trying again..."); + setShouldUpdateMap(true); + return; + } + setShouldUpdateMap(false); + const source = map.getSource("latestPV") as unknown as mapboxgl.GeoJSONSource; if (!source) { - const { forecastGeoJson } = generateGeoJsonForecastData( - initForecastData, - selectedISOTime, - combinedData - ); + const { forecastGeoJson } = generatedGeoJsonForecastData; map.addSource("latestPV", { type: "geojson", data: forecastGeoJson }); + } else { + source.setData(generatedGeoJsonForecastData.forecastGeoJson); } + console.log("latestPV source set"); const pvForecastLayer = map.getLayer("latestPV-forecast"); if (!pvForecastLayer) { @@ -93,16 +176,125 @@ const PvLatestMap: React.FC = ({ "fill-opacity": getFillOpacity(selectedDataName, isNormalized) } }); + console.log("pvForecastLayer added"); + + // Also add map event listeners but only the first time + const popupFunction = throttle( + (e) => { + // Change the cursor style as a UI indicator. + map.getCanvas().style.cursor = "pointer"; + const currentActiveUnit = getActiveUnitFromMap(map); + + const properties = e.features?.[0].properties; + if (!properties) return; + let actualValue = ""; + let forecastValue = ""; + let unit = ""; + if (currentActiveUnit === ActiveUnit.MW) { + // Map in MW mode + actualValue = properties?.[SelectedData.actualPowerGenerationMegawatts] + ? properties?.[SelectedData.actualPowerGenerationMegawatts].toFixed(0) + : "-"; + forecastValue = + properties?.[SelectedData.expectedPowerGenerationMegawatts]?.toFixed(0) || 0; + unit = "MW"; + } else if (currentActiveUnit === ActiveUnit.percentage) { + // Map in % mode + actualValue = properties?.[SelectedData.actualPowerGenerationMegawatts] + ? ( + Number( + properties?.[SelectedData.actualPowerGenerationMegawatts] / + properties?.[SelectedData.installedCapacityMw] || 0 + ) * 100 + ).toFixed(0) + : "-"; + forecastValue = + ( + Number(properties?.[SelectedData.expectedPowerGenerationNormalized] || 0) * 100 + ).toFixed(0) || "-"; + unit = "%"; + } else if (currentActiveUnit === ActiveUnit.capacity) { + // Map in Capacity mode + actualValue = + ( + Number(properties?.[SelectedData.installedCapacityMw] || 0) / nationalCapacityMW + ).toFixed(1) || "-"; + forecastValue = "-"; + unit = "MW"; + } + + let actualAndForecastSection = `Actual / Forecast +
    + ${actualValue} / + ${forecastValue} ${unit} +
    `; + if (currentActiveUnit === ActiveUnit.capacity) { + actualAndForecastSection = `% of National +
    ${actualValue} %
    `; + } + + const popupContent = `
    +
    +
    ${properties?.gspDisplayName}
    +
    ${properties?.GSPs} • #${ + properties?.gsp_id + }
    +
    +
    + +
    + Capacity +
    ${ + properties?.[SelectedData.installedCapacityMw] + } MW
    +
    +
    + ${actualAndForecastSection} +
    +
    +
    `; + + // Populate the popup and set its coordinates + // based on the feature found. + popup.setHTML(popupContent).trackPointer().addTo(map); + }, + 32, + {} + ); + map.on("mousemove", "latestPV-forecast", popupFunction); + + map.on("mouseleave", "latestPV-forecast", () => { + map.getCanvas().style.cursor = ""; + popup.remove(); + }); + + map.on("data", (e) => { + if (e.sourceId === "latestPV" && e.isSourceLoaded) { + setMapDataLoading(false); + } + }); + + map.on("sourcedata", (e) => { + if (e.sourceId === "latestPV" && e.isSourceLoaded) { + setMapDataLoading(false); + } + }); } else { if (generatedGeoJsonForecastData && source) { + const currentActiveUnit = getActiveUnitFromMap(map); + const isNormalized = currentActiveUnit === ActiveUnit.percentage; source?.setData(generatedGeoJsonForecastData.forecastGeoJson); map.setPaintProperty( "latestPV-forecast", "fill-opacity", getFillOpacity(selectedDataName, isNormalized) ); + console.log("pvForecastLayer updated", generatedGeoJsonForecastData.forecastGeoJson); + } else { + console.log("pvForecastLayer not updated"); } } + console.log("pvForecastLayer set"); const pvForecastBordersLayer = map.getLayer("latestPV-forecast-borders"); if (!pvForecastBordersLayer) { @@ -134,36 +326,42 @@ const PvLatestMap: React.FC = ({ }; return ( -
    +
    {forecastError ? ( ) : ( // ) : !forecastError && !initForecastData ? ( - // - // - // - - safelyUpdateMapData(map.current, addOrUpdateMapData) - } - updateData={{ - newData: !!initForecastData, - updateMapData: (map) => safelyUpdateMapData(map, addOrUpdateMapData) - }} - controlOverlay={(map: { current?: mapboxgl.Map }) => ( - <> - - - + <> + {(!combinedData.allGspForecastData || + combinedLoading.allGspForecastLoading || + mapDataLoading) && ( + + + )} - title={VIEWS.FORECAST} - > - - + + safelyUpdateMapData(map.current, addOrUpdateMapData) + } + updateData={{ + newData: shouldUpdateMap, + updateMapData: (map) => safelyUpdateMapData(map, addOrUpdateMapData) + }} + controlOverlay={(map: { current?: mapboxgl.Map }) => ( + <> + + + + )} + title={VIEWS.FORECAST} + > + + + )}
    ); diff --git a/apps/nowcasting-app/components/map/sitesMap.tsx b/apps/nowcasting-app/components/map/sitesMap.tsx index 390d62de..f349d9b6 100644 --- a/apps/nowcasting-app/components/map/sitesMap.tsx +++ b/apps/nowcasting-app/components/map/sitesMap.tsx @@ -13,7 +13,12 @@ import { import gspShapeData from "../../data/gsp_regions_20220314.json"; import dnoShapeData from "../../data/dno_regions_lat_long_converted.json"; import useGlobalState from "../helpers/globalState"; -import { formatISODateString, formatISODateStringHuman } from "../helpers/utils"; +import { + formatISODateString, + formatISODateStringHuman, + getRoundedPv, + getRoundedPvPercent +} from "../helpers/utils"; import { AggregatedSitesCombinedData, AggregatedSitesDataGroupMap, @@ -23,28 +28,12 @@ import { import { theme } from "../../tailwind.config"; import { Feature, FeatureCollection } from "geojson"; import Slider from "./sitesMapFeatures/sitesZoomSlider"; -import SitesLegend from "./sitesMapFeatures/sitesLegend"; import { safelyUpdateMapData } from "../helpers/mapUtils"; import dynamic from "next/dynamic"; const yellow = theme.extend.colors["ocf-yellow"].DEFAULT; const ButtonGroup = dynamic(() => import("../../components/button-group"), { ssr: false }); -const getRoundedPv = (pv: number, round: boolean = true) => { - if (!round) return Math.round(pv); - // round To: 0, 100, 200, 300, 400, 500 - return Math.round(pv / 100) * 100; -}; -const getRoundedPvPercent = (per: number, round: boolean = true) => { - if (!round) return per; - // round to : 0, 0.2, 0.4, 0.6 0.8, 1 - let rounded = Math.round(per * 10); - if (rounded % 2) { - if (per * 10 > rounded) return (rounded + 1) / 10; - else return (rounded - 1) / 10; - } else return rounded / 10; -}; - type SitesMapProps = { className?: string; sitesData: CombinedSitesData; @@ -140,9 +129,9 @@ const SitesMap: React.FC = ({ ...featureObj, properties: { ...featureObj.properties, - [SelectedData.expectedPowerGenerationMegawatts]: + [SelectedData.expectedPowerGenerationMegawattsRounded]: selectedFCValue && getRoundedPv(selectedFCValue.expectedPowerGenerationMegawatts), - [SelectedData.expectedPowerGenerationNormalized]: + [SelectedData.expectedPowerGenerationNormalizedRounded]: selectedFCValue && getRoundedPvPercent(selectedFCValue?.expectedPowerGenerationNormalized || 0), [SelectedData.installedCapacityMw]: getRoundedPv( @@ -523,7 +512,7 @@ const SitesMap: React.FC = ({ )} title={VIEWS.SOLAR_SITES} > - + {/**/} )}
    diff --git a/apps/nowcasting-app/components/map/types.ts b/apps/nowcasting-app/components/map/types.ts index c08407b8..e08cd3bb 100644 --- a/apps/nowcasting-app/components/map/types.ts +++ b/apps/nowcasting-app/components/map/types.ts @@ -1,3 +1,5 @@ +import { ReactNode } from "react"; + export enum ActiveUnit { MW = "MW", percentage = "%", @@ -7,12 +9,15 @@ export enum ActiveUnit { export enum SelectedData { expectedPowerGenerationNormalized = "expectedPowerGenerationNormalized", expectedPowerGenerationMegawatts = "expectedPowerGenerationMegawatts", + expectedPowerGenerationMegawattsRounded = "expectedPowerGenerationMegawattsRounded", + expectedPowerGenerationNormalizedRounded = "expectedPowerGenerationNormalizedRounded", + actualPowerGenerationMegawatts = "actualPowerGenerationMegawatts", installedCapacityMw = "installedCapacityMw", delta = "delta" } export interface IMap { - children: React.ReactNode; + children?: ReactNode; loadDataOverlay: any; controlOverlay: any; bearing?: number; diff --git a/apps/nowcasting-app/components/side-layout/expand-button.tsx b/apps/nowcasting-app/components/side-layout/expand-button.tsx index 485c4198..0f034af2 100644 --- a/apps/nowcasting-app/components/side-layout/expand-button.tsx +++ b/apps/nowcasting-app/components/side-layout/expand-button.tsx @@ -5,15 +5,15 @@ type ExpandButtonProps = { isOpen: boolean; onClick: () => void }; const ExpandButton: React.FC = ({ onClick, isOpen }) => { return ( ); diff --git a/apps/nowcasting-app/components/side-layout/index.tsx b/apps/nowcasting-app/components/side-layout/index.tsx index 42faf6c8..55795ecf 100644 --- a/apps/nowcasting-app/components/side-layout/index.tsx +++ b/apps/nowcasting-app/components/side-layout/index.tsx @@ -1,23 +1,39 @@ -import { useState } from "react"; +import { FC, ReactNode, useEffect, useState } from "react"; import ExpandButton from "./expand-button"; import useGlobalState from "../helpers/globalState"; +import { ChartInfo } from "../../ChartInfo"; +import { InfoIcon } from "../icons/icons"; +import Tooltip, { TooltipPosition } from "../tooltip"; +import { VIEWS } from "../../constant"; type SideLayoutProps = { - children: React.ReactNode; + children: ReactNode; className?: string; dashboardModeActive?: boolean; bottomPadding?: boolean; }; -const SideLayout: React.FC = ({ +const SideLayout: FC = ({ children, className, dashboardModeActive = false, bottomPadding = true }) => { const [isOpen, setIsOpen] = useState(false); + const [view] = useGlobalState("view"); // const closedWidth = dashboardModeActive ? "50%" : "44%"; const closedWidth = "50%"; + const [isMobile, setIsMobile] = useState(false); + useEffect(() => { + if (window.innerWidth < 1024) { + setIsOpen(true); + setIsMobile(true); + } + }, []); + let position: TooltipPosition = "top"; + if (isMobile) { + position = isOpen ? "top-left" : "top-middle"; + } return (
    = ({ "focus:outline-none h-full text-white justify-between flex flex-col bg-mapbox-black-500 z-20 " } > -
    - {children} -
    +
    {children}
    -
    +
    setIsOpen((o) => !o)} />
    + + {view !== VIEWS.SOLAR_SITES && ( +
    + + +
    + } + position={position} + className={"text-right"} + fullWidth + > + + +
    + )}
    ); }; diff --git a/apps/nowcasting-app/components/tooltip.tsx b/apps/nowcasting-app/components/tooltip.tsx index e899d6b1..bdfe3535 100644 --- a/apps/nowcasting-app/components/tooltip.tsx +++ b/apps/nowcasting-app/components/tooltip.tsx @@ -1,7 +1,8 @@ +export type TooltipPosition = "left" | "right" | "middle" | "top" | "top-middle" | "top-left"; type TooltipProps = { children: React.ReactNode; tip: string | React.ReactNode; - position?: "left" | "right" | "middle" | "top"; + position?: TooltipPosition; className?: string; fullWidth?: boolean; }; @@ -32,6 +33,15 @@ const Tooltip: React.FC = ({ case "top": containerPositionClass = "bottom-5 right-2"; tipPositionClass = "-right-2 bottom-0"; + break; + case "top-middle": + containerPositionClass = "bottom-6 -left-32 transform translate-x-2"; + tipPositionClass = "-left-1 bottom-0"; + break; + case "top-left": + containerPositionClass = "bottom-8 right-64 transform -translate-x-2"; + tipPositionClass = "-left-1 bottom-0"; + break; } return (
    { + switch (view) { + case VIEWS.FORECAST: + return "PV Forecast"; + case VIEWS.DELTA: + return "Delta"; + case VIEWS.SOLAR_SITES: + return "Solar Sites"; + } +}; export enum AGGREGATION_LEVELS { NATIONAL = "NATIONAL", @@ -102,3 +112,5 @@ export enum DELTA_BUCKET { POS4 = 100 } export const getDeltaBucketKeys = () => Object.keys(DELTA_BUCKET).filter((k) => isNaN(Number(k))); + +export const N_HOUR_FORECAST_OPTIONS = [1, 2, 4, 8]; diff --git a/apps/nowcasting-app/cypress/e2e/pvLatest.cy.js b/apps/nowcasting-app/cypress/e2e/pvLatest.cy.js index 98d8769f..54be736e 100644 --- a/apps/nowcasting-app/cypress/e2e/pvLatest.cy.js +++ b/apps/nowcasting-app/cypress/e2e/pvLatest.cy.js @@ -41,9 +41,9 @@ describe("Load the page", () => { cy.get("header #UserMenu-4hViewBtn").should("not.exist"); cy.get("header button").contains("Open user menu").should("exist"); cy.get("header button").contains("Open user menu").parent().click(); - cy.get("header #UserMenu-4hViewBtn").should("exist"); - cy.get("header #UserMenu-4hViewBtn").should("be.visible"); - cy.get("header #UserMenu-4hViewBtn").should("contain", "4-hour forecast"); + cy.get("header #UserMenu-NhViewBtn").should("exist"); + cy.get("header #UserMenu-NhViewBtn").should("be.visible"); + cy.get("header #UserMenu-NhViewBtn").should("contain", "N-hour forecast"); cy.get("header #UserMenu-DashboardModeBtn").should("contain", "Dashboard mode"); cy.get("header #UserMenu-DocumentationBtn").should("contain", "Documentation"); cy.get("header #UserMenu-ContactBtn").should("contain", "Contact"); diff --git a/apps/nowcasting-app/package.json b/apps/nowcasting-app/package.json index befb1c4f..b1de0328 100644 --- a/apps/nowcasting-app/package.json +++ b/apps/nowcasting-app/package.json @@ -1,6 +1,6 @@ { "name": "@openclimatefix/nowcasting-app", - "version": "0.5.1", + "version": "0.5.2", "private": true, "scripts": { "dev": "next dev -p 3002", diff --git a/apps/nowcasting-app/pages/_document.tsx b/apps/nowcasting-app/pages/_document.tsx index 05c50568..d84ee9de 100644 --- a/apps/nowcasting-app/pages/_document.tsx +++ b/apps/nowcasting-app/pages/_document.tsx @@ -3,7 +3,7 @@ import Document, { Html, Head, Main, NextScript } from "next/document"; class MyDocument extends Document { render() { return ( - + {/*// @ts-ignore*/} @@ -15,7 +15,7 @@ class MyDocument extends Document { - +
    diff --git a/apps/nowcasting-app/pages/index.tsx b/apps/nowcasting-app/pages/index.tsx index 060174a7..ea249ca7 100644 --- a/apps/nowcasting-app/pages/index.tsx +++ b/apps/nowcasting-app/pages/index.tsx @@ -20,7 +20,7 @@ import { CombinedValidating, ForecastData, GspAllForecastData, - National4HourData, + NationalNHourData, PvRealData, SitePvActual, SitePvForecast, @@ -57,7 +57,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str useAndUpdateSelectedTime(); const [view, setView] = useGlobalState("view"); const [activeUnit, setActiveUnit] = useState(ActiveUnit.MW); - const [show4hView] = useGlobalState("show4hView"); + const [showNHourView] = useGlobalState("showNHourView"); const [selectedISOTime] = useGlobalState("selectedISOTime"); const selectedTime = formatISODateString(selectedISOTime || new Date().toISOString()); const [timeNow] = useGlobalState("timeNow"); @@ -70,6 +70,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str const [visibleLines] = useGlobalState("visibleLines"); const [, setSitesLoadingState] = useGlobalState("sitesLoadingState"); const [, setLoadingState] = useGlobalState("loadingState"); + const [nHourForecast] = useGlobalState("nHourForecast"); const [forecastLastFetch30MinISO, setForecastLastFetch30MinISO] = useState(get30MinNow(-30)); const [forecastHistoricBackwardIntervalMinutes, setForecastHistoricBackwardIntervalMinutes] = @@ -129,16 +130,6 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str }); }, [view, maps]); - useEffect(() => { - // Clear any previous map timeouts on initial page load - for (const map of maps) { - localStorage.getItem(`MapTimeoutId-${map.getContainer().dataset.title}`) && - clearTimeout( - Number(localStorage.getItem(`MapTimeoutId-${map.getContainer().dataset.title}`)) - ); - } - }, [maps]); - useEffect(() => { maps.forEach((map) => { console.log("-- -- -- resizing map"); @@ -180,14 +171,15 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str isValidating: pvRealDayAfterValidating, error: pvRealDayAfterError } = useLoadDataFromApi(`${API_PREFIX}/solar/GB/national/pvlive?regime=day-after`); + const nMinuteForecast = nHourForecast * 60; const { - data: national4HourData, - isLoading: national4HourLoading, - isValidating: national4HourValidating, - error: national4HourError - } = useLoadDataFromApi( - show4hView - ? `${API_PREFIX}/solar/GB/national/forecast?forecast_horizon_minutes=240&historic=true&only_forecast_values=true` + data: nationalNHourData, + isLoading: nationalNHourLoading, + isValidating: nationalNHourValidating, + error: nationalNHourError + } = useLoadDataFromApi( + showNHourView + ? `${API_PREFIX}/solar/GB/national/forecast?forecast_horizon_minutes=${nMinuteForecast}&historic=true&only_forecast_values=true` : null ); const { @@ -216,6 +208,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str `${forecastLastFetch30MinISO.slice(0, 19)}+00:00` )}`, { + keepPreviousData: true, onSuccess: (data) => { const forecastHistoricStart = get30MinNow(forecastHistoricBackwardIntervalMinutes); const prev30MinFromNowISO = `${get30MinNow(-30)}:00+00:00`; @@ -244,6 +237,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str `${forecastLastFetch30MinISO.slice(0, 19)}+00:00` )}`, { + keepPreviousData: true, refreshInterval: 0, // Only load this once at beginning onSuccess: (data) => { if (!data) return; @@ -444,7 +438,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str nationalForecastData, pvRealDayInData, pvRealDayAfterData, - national4HourData, + nationalNHourData, allGspSystemData, allGspForecastData, allGspRealData, @@ -455,7 +449,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str nationalForecastLoading, pvRealDayInLoading, pvRealDayAfterLoading, - national4HourLoading, + nationalNHourLoading: nationalNHourLoading, allGspSystemLoading, allGspForecastLoading, allGspRealLoading @@ -464,7 +458,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str nationalForecastLoading, pvRealDayInLoading, pvRealDayAfterLoading, - national4HourLoading, + nationalNHourLoading, allGspSystemLoading, allGspForecastLoading, allGspRealLoading @@ -475,7 +469,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str nationalForecastValidating, pvRealDayInValidating, pvRealDayAfterValidating, - national4HourValidating, + nationalNHourValidating, allGspSystemValidating, allGspForecastValidating, allGspRealValidating: allGspActualValidating @@ -484,7 +478,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str nationalForecastValidating, pvRealDayInValidating, pvRealDayAfterValidating, - national4HourValidating, + nationalNHourValidating, allGspSystemValidating, allGspForecastValidating, allGspActualValidating @@ -494,7 +488,7 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str nationalForecastError, pvRealDayInError, pvRealDayAfterError, - national4HourError, + nationalNHourError, allGspSystemError, allGspForecastError, allGspRealError: allGspActualError @@ -642,6 +636,8 @@ export default function Home({ dashboardModeServer }: { dashboardModeServer: str