diff --git a/.eslintrc.json b/.eslintrc.json index 2c4f53e..8a3b35d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,7 +12,7 @@ "createDefaultProgram": true, "ecmaFeatures": { "ecmaVersion": 2017, - "jsx": false + "jsx": true }, "noWatch": true }, diff --git a/mt-next/pages/components/FinancialResultBreakdown.tsx b/mt-next/pages/components/FinancialResultBreakdown.tsx new file mode 100644 index 0000000..a3dabbe --- /dev/null +++ b/mt-next/pages/components/FinancialResultBreakdown.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { ResultTableProps } from './ResultTable' +import { Col, Divider, Row } from 'antd' +import { formatRupiah } from '../services/Formatters' +import { InfoCircleOutlined } from '@ant-design/icons' +import { Documentation } from '../services/DocumentationService' + + +interface BreakEvenPoint { + years: number + months: number +} + +export const FinancialResultBreakdown: React.FunctionComponent = (props) => { + const { t } = useTranslation() + const { results, onOpenDocumentation } = props + if (!results) { + return
Invalid Data
+ } + + const monthsInYear = 12 + const breakEven: BreakEvenPoint = { + years: Math.floor(results.breakEvenPointInMonths / monthsInYear), + months: Math.round(results.breakEvenPointInMonths % monthsInYear) + } + + return (
+ {t('resultTable.financialHeading')} + + {t('resultTable.currentMonthlyCosts')} + {formatRupiah(results.currentMonthlyCosts)} + + + {t('resultTable.remainingMonthlyCosts')}  + onOpenDocumentation(Documentation.MinimalPayment,t('resultTable.remainingMonthlyCosts'))}/> + + - {formatRupiah(results.remainingMonthlyCosts)} + + + {t('resultTable.monthlyProfit')} + {formatRupiah(results.monthlyProfit)} + + +   + + + {t('resultTable.yearlyProfit')} + {formatRupiah(results.yearlyProfit)} + + + {t('resultTable.breakEven')}  + onOpenDocumentation(Documentation.RoiExplanation,t('resultTable.breakEven'))}/> + + {t('resultTable.breakEvenExplanation', { breakEven })} + + + + +
) +} + + diff --git a/mt-next/pages/components/InfoPane.tsx b/mt-next/pages/components/InfoPane.tsx new file mode 100644 index 0000000..d63e173 --- /dev/null +++ b/mt-next/pages/components/InfoPane.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import { Documentation, documentation, Locale } from '../services/DocumentationService' +import { useTranslation } from 'react-i18next' + +interface InfoPaneProps { + documentation: Documentation +} + +export const InfoPane: React.FunctionComponent = (props) => { + + const { i18n } = useTranslation() + + function createMarkup(body: string) { + return { __html: body } + } + + return ( +
+ ) +} \ No newline at end of file diff --git a/mt-next/pages/components/InputForm.scss b/mt-next/pages/components/InputForm.scss new file mode 100644 index 0000000..7ddd206 --- /dev/null +++ b/mt-next/pages/components/InputForm.scss @@ -0,0 +1,19 @@ +@import '../styles/theme.scss'; + +.numberCircle { + display: inline-block; + line-height: 0; + font-weight: bold; + /*border-radius: 50%;*/ + /*border: 2px solid;*/ + font-size: 16px; + color: $primary +} + +.numberCircle span { + display: inline-block; + padding-top: 50%; + padding-bottom: 50%; + margin-left: 4px; + margin-right: 4px; +} \ No newline at end of file diff --git a/mt-next/pages/components/InputForm.tsx b/mt-next/pages/components/InputForm.tsx new file mode 100644 index 0000000..972e5ee --- /dev/null +++ b/mt-next/pages/components/InputForm.tsx @@ -0,0 +1,485 @@ +import { Col, Divider, Form, InputNumber, Row, Select, Switch } from 'antd' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { MapState } from '../util/mapStore' +import { + formatDigits, formatKwh, + formatPercentage, + formatRupiah, parseKwh, + parseNumber, + parsePercentage, + parseRupiah +} from '../services/Formatters' +import { MapPicker } from './MapPicker' +import { + CALCULATOR_SETTINGS, + CalculatorSettings, INITIAL_INPUT_DATA, + InverterPrice, + MonthlyUsage, + OptimizationTarget, + PowerOption, + powerOptions +} from '../constants' +import { + FacebookOutlined, + InfoCircleOutlined, + LinkedinOutlined, + ShareAltOutlined, + TwitterOutlined +} from '@ant-design/icons' +import { Documentation } from '../services/DocumentationService' +import { NumberParam, useQueryParam, withDefault } from 'use-query-params' +import { BooleanParam, createEnumParam } from 'serialize-query-params/lib/params' +import './InputForm.css' +import * as Analytics from '../services/Analytics' +import { Category } from '../services/Analytics' + +export interface InputData { + monthlyCostEstimateInRupiah: number + monthlyUsageInKwh: number + connectionPower: number + pvOut?: number + optimizationTarget: OptimizationTarget + calculatorSettings: CalculatorSettings +} + +export interface InputFormProps { + initialValue: InputData, + onOpenDocumentation: (d: Documentation, title: string) => void + onChange: (data: InputData) => void, + expertMode: boolean, + mobile: boolean +} + +const createLink = () => { + return `${window.location}`.replace('expertMode=1&', '') +} + +const createFacebookLink = () => { + const link = createLink() + return `https://www.facebook.com/sharer.php?u=${encodeURI(link)}` +} + +const createTwitterLink = () => { + const link = createLink() + return `https://twitter.com/intent/tweet?url=${encodeURI(link)}` +} + +const createLinkedinLink = () => { + const link = createLink() + return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURI(link)}` +} + +export const InputForm: React.FunctionComponent = (props) => { + + const { t, i18n } = useTranslation() + const [form] = Form.useForm() + + const renderOption = (option: PowerOption) => { + return {option.name} + } + + const init = props.initialValue + const calcSettings = init.calculatorSettings + const plnSettings = calcSettings.plnSettings + const priceSettings = calcSettings.priceSettings + + const [priorityEnabled, setPriorityEnabled] = useQueryParam('priorityEnabled', withDefault(BooleanParam, calcSettings.priorityEnabled)) + const [monthlyUsageType, setMonthlyUsageType] = useQueryParam('monthlyUsageType', withDefault(createEnumParam(Object.values(MonthlyUsage)), priceSettings.monthlyUsageType)) + + const [lowTariff, setLowTariff] = useQueryParam('lowTariff', withDefault(NumberParam, plnSettings.lowTariff)) + const [highTariff, setHighTariff] = useQueryParam('highTariff', withDefault(NumberParam, plnSettings.highTariff)) + const [energyTax, setEnergyTax] = useQueryParam('energyTax', withDefault(NumberParam, plnSettings.energyTax)) + + const [lowTariffThreshold, setLowTariffThreshold] = useQueryParam('lowTariffThreshold', withDefault(NumberParam, plnSettings.lowTariffThreshold)) + const [minimalMonthlyConsumptionHours, setMinimalMonthlyConsumptionHours] = useQueryParam('minimalMonthlyConsumptionHours', withDefault(NumberParam, plnSettings.minimalMonthlyConsumptionHours)) + const [minimalMonthlyConsumptionPrice, setMinimalMonthlyConsumptionPrice] = useQueryParam('minimalMonthlyConsumptionPrice', withDefault(NumberParam, plnSettings.minimalMonthlyConsumptionPrice)) + + const [pricePerPanel, setPricePerPanel] = useQueryParam('pricePerPanel', withDefault(NumberParam, priceSettings.pricePerPanel)) + const [electricityPriceInflationRate, setElectricityPriceInflationRate] = useQueryParam('electricityPriceInflationRate', withDefault(NumberParam, priceSettings.electricityPriceInflationRate)) + const [capacityLossRate, setCapacityLossRate] = useQueryParam('capacityLossRate', withDefault(NumberParam, priceSettings.capacityLossRate)) + const [kiloWattPeakPerPanel, setKiloWattPeakPerPanel] = useQueryParam('kiloWattPeakPerPanel', withDefault(NumberParam, calcSettings.kiloWattPeakPerPanel)) + const [areaPerPanel, setAreaPerPanel] = useQueryParam('areaPerPanel', withDefault(NumberParam, calcSettings.areaPerPanel)) + const [lossFromInverter, setLossFromInverter] = useQueryParam('lossFromInverter', withDefault(NumberParam, calcSettings.lossFromInverter)) + + const [inverterPrice, setInverterPrice] = useQueryParam('inverterPrice', withDefault(createEnumParam(Object.values(InverterPrice)), priceSettings.inverterPrice)) + const [inverterLifetimeInYears, setInverterLifetimeInYears] = useQueryParam('inverterLifetimeInYears', withDefault(NumberParam, calcSettings.inverterLifetimeInYears)) + const [priceOfInverterFactor, setPriceOfInverterFactor] = useQueryParam('priceOfInverterFactor', withDefault(NumberParam, priceSettings.priceOfInverterFactor)) + const [priceOfInverterAbsolute, setPriceOfInverterAbsolute] = useQueryParam('priceOfInverterAbsolute', withDefault(NumberParam, priceSettings.priceOfInverterAbsolute)) + const [installationCosts, setInstallationCosts] = useQueryParam('installationCosts', withDefault(NumberParam, priceSettings.installationCosts)) + + + return ( +
{ + const firstFields = changedFields[0] + const name = JSON.stringify(firstFields.name).replace('["', '').replace('"]', '') + Analytics.event(Category.Form, name, JSON.stringify(firstFields.value) ) + const monthlyBill = form.getFieldValue('monthlyBill') + const monthlyUsageInKwh = form.getFieldValue('monthlyUsageInKwh') + + const connectionPower = form.getFieldValue('connectionPower') + const location = form.getFieldValue('location') as MapState + const pvOut = location.info?.pvout + const targetValue = form.getFieldValue('optimizationTarget') + const optimizationTarget = targetValue === undefined || targetValue ? OptimizationTarget.Money : OptimizationTarget.Green + + const calculatorSettings: CalculatorSettings = { + plnSettings: { + lowTariff, + highTariff, + lowTariffThreshold, + energyTax, + minimalMonthlyConsumptionHours, + minimalMonthlyConsumptionPrice + }, + priceSettings: { + pricePerPanel, + electricityPriceInflationRate, + priceOfInverterFactor, + priceOfInverterAbsolute, + installationCosts, + capacityLossRate, + inverterPrice, + monthlyUsageType + }, + kiloWattPeakPerPanel, + areaPerPanel, + lossFromInverter, + inverterLifetimeInYears, + kiloWattHourPerYearPerKWp: CALCULATOR_SETTINGS.kiloWattHourPerYearPerKWp, + priorityEnabled + } + + props.onChange({ + monthlyCostEstimateInRupiah: monthlyBill, + monthlyUsageInKwh, + connectionPower, + pvOut, + optimizationTarget, + calculatorSettings + }) + }}> + + <>1 {t('inputForm.location')}
} initialValue={INITIAL_INPUT_DATA.location} + tooltip={{ + trigger: 'click', + overlay: '', + icon: props.onOpenDocumentation(Documentation.Location, t('inputForm.location'))}/> + }} + > + + + + + {monthlyUsageType === MonthlyUsage.Rupiah ? + (<>2 {t('inputForm.monthlyBill')}} + initialValue={init.monthlyCostEstimateInRupiah} + tooltip={{ + trigger: 'click', + overlay: '', + icon: props.onOpenDocumentation(Documentation.MonthlyBill, t('inputForm.monthlyBill'))}/> + }}> + + ) : ({t('inputForm.monthlyUsage')}} + initialValue={init.monthlyUsageInKwh} + > + formatKwh(value)} + parser={(displayValue) => parseKwh(displayValue)} + step={10}/> + ) + } + + + <>3 {t('inputForm.connectionPower')}} + initialValue={init.connectionPower} tooltip={{ + trigger: 'click', + overlay: '', + icon: props.onOpenDocumentation(Documentation.ConnectionPower, t('inputForm.connectionPower'))}/> + }}> + + + + + {priorityEnabled && + + {t('inputForm.priority')}} + tooltip={{ + overlay: '', + trigger: 'click', + icon: props.onOpenDocumentation(Documentation.Priority, t('inputForm.priority'))}/> + }}> + {t('inputForm.priorityMoney')}} + unCheckedChildren={<>{t('inputForm.priorityEarth')}} + defaultChecked={true} + /> + + + } + + + {props.expertMode && <><>{t('inputForm.expertMode.title.plnSettings')}  props.onOpenDocumentation(Documentation.PlnSettings, t('inputForm.expertMode.title.plnSettings'))}/> + + + {t('inputForm.expertMode.lowTariff')}} + initialValue={lowTariff} + > + + + + + {t('inputForm.expertMode.highTariff')}} + initialValue={highTariff} + > + + + + + {t('inputForm.expertMode.lowTariffThreshold')}} + initialValue={lowTariffThreshold} + > + + + + + + {t('inputForm.expertMode.energyTax')}} + initialValue={energyTax} + > + formatPercentage(value, i18n.language)} + parser={(displayValue) => parsePercentage(displayValue)} + onChange={setEnergyTax} + step={0.01}/> + + + + {t('inputForm.expertMode.minimalMonthlyConsumptionHours')}} + initialValue={minimalMonthlyConsumptionHours} + > + + + + + {t('inputForm.expertMode.minimalMonthlyConsumptionPrice')}} + initialValue={minimalMonthlyConsumptionPrice} + > + + + + + {<>{t('inputForm.expertMode.title.systemSettings')}} + + + {t('inputForm.expertMode.pricePerPanel')}} + initialValue={pricePerPanel} + > + + + + + {t('inputForm.expertMode.electricityPriceInflationRate')}} + initialValue={electricityPriceInflationRate} + > + formatPercentage(value, i18n.language, 2)} + parser={(displayValue) => parsePercentage(displayValue)} + step={0.01} + onChange={setElectricityPriceInflationRate} + /> + + + + + {t('inputForm.expertMode.kiloWattPeakPerPanel')}} + initialValue={kiloWattPeakPerPanel} + > + formatDigits(value, 3, i18n.language)} + parser={(displayValue) => parseNumber(displayValue)} + onChange={setKiloWattPeakPerPanel} + step={0.01}/> + + + + {t('inputForm.expertMode.areaPerPanel')}} + initialValue={areaPerPanel} + > + formatDigits(value, 2, i18n.language)} + parser={(displayValue) => parseNumber(displayValue)} + step={0.1} + onChange={setAreaPerPanel} + /> + + + + {t('inputForm.expertMode.lossFromInverter')}} + initialValue={lossFromInverter} + > + formatDigits(value, 4, i18n.language)} + parser={(displayValue) => parseNumber(displayValue)} + step={0.1} + onChange={setLossFromInverter} + /> + + + + {t('inputForm.expertMode.capacityLossRate')}} + initialValue={capacityLossRate} + > + formatPercentage(value, i18n.language, 2)} + parser={(displayValue) => parsePercentage(displayValue)} + step={0.001} + onChange={setCapacityLossRate} + /> + + + + + + {t('inputForm.expertMode.inverterPrice')}}> + setInverterPrice(newValue ? InverterPrice.Relative : InverterPrice.Absolute)} + /> + + + { inverterPrice === InverterPrice.Absolute ? + + {t('inputForm.expertMode.priceOfInverterAbsolute')}} + initialValue={priceOfInverterAbsolute} + > + + + : + + {t('inputForm.expertMode.priceOfInverterFactor')}} + initialValue={priceOfInverterFactor} + > + formatPercentage(value, i18n.language)} + parser={(displayValue) => parsePercentage(displayValue)} + step={0.01} + onChange={setPriceOfInverterFactor} + /> + + + } + + {t('inputForm.expertMode.installationCosts')}} + initialValue={installationCosts} + > + + + + + {t('inputForm.expertMode.inverterLifeTime')}} + initialValue={inverterLifetimeInYears} + > + formatDigits(value, 1, i18n.language)} + parser={(displayValue) => parseNumber(displayValue)} + step={1} + onChange={setInverterLifetimeInYears} + /> + + + + {<>{t('inputForm.expertMode.title.appSettings')}} + + + Share settings
  +   +   + + + + {t('inputForm.expertMode.priorityEnabled')}}> + setPriorityEnabled(newValue)} + /> + + + + {t('inputForm.expertMode.usageType')}}> + setMonthlyUsageType(newValue ? MonthlyUsage.Rupiah : MonthlyUsage.KWh)} + /> + + +
+ + } + + ) +} \ No newline at end of file diff --git a/mt-next/pages/components/IrradiationGauge.module.scss b/mt-next/pages/components/IrradiationGauge.module.scss new file mode 100644 index 0000000..c52a848 --- /dev/null +++ b/mt-next/pages/components/IrradiationGauge.module.scss @@ -0,0 +1,139 @@ +@import '../../styles/theme.scss'; + +.ant-form-item-label.solarIntensity { + padding-bottom: 0; + padding-top: 16px; +} + +.map-picker-irradiation-gauge { + display: block; + position: absolute; + width: 100%; + top: 310px; + left: 0; + z-index: 2000; + background-image: linear-gradient(to right, $primary-dark 1%, #a2eb7b 30%, #fcf967 40%, #c84430 75%, #2e0527 100% ); +} + +@media (max-width : 480px) { + .map-picker-irradiation-gauge { + top: 156px; + } +} + + +.map-picker-irradiation-gauge-legend { + display: flex; + justify-content: space-between; + background-color: white; + border-top: 1px solid $input-border; + margin-left: -1px; + margin-right: -1px; + /*height: 60px;*/ +} + +.map-picker-irradiation-gauge-legend > span { + position: relative; + justify-content: center; + color: #40a9ff; + font-size: 12px; + width: 3em; +} + +.map-picker-irradiation-gauge-slider { + position: relative; +} + +.wrap { + display: flex; + align-items: center; + position: relative; + height: 5.25em; + font: 1em/1 arial, sans-serif; +} + +.map-picker [type=range] { + flex: 1; + margin: 0; + padding: 0; + min-height: 1.5em; + background: transparent; + color: transparent; + font: inherit; +} +.map-picker [type=range], .map-picker [type=range]::-webkit-slider-thumb { + -webkit-appearance: none; +} +.map-picker [type=range]::-webkit-slider-runnable-track { + box-sizing: border-box; + border: none; + width: 12.5em; + height: 0.25em; + background: transparent; +} +.map-picker [type=range]::-moz-range-track { + box-sizing: border-box; + border: none; + width: 12.5em; + height: 0.25em; + background: transparent; +} +.map-picker [type=range]::-ms-track { + box-sizing: border-box; + border: none; + width: 12.5em; + height: 0.25em; + background: transparent; +} +.map-picker [type=range]::-webkit-slider-thumb { + margin-top: -15px; + box-sizing: border-box; + z-index: 200; + background-image: url('/images/sunrise-logo.png'); + background-size: contain; + background-position: center center; + background-repeat: no-repeat; + border-style: solid; + border-color: white; + width: 30px; + height: 30px; + border-radius: 50%; +} +.map-picker [type=range]::-moz-range-thumb { + box-sizing: border-box; + border: none; + width: 1.5em; + height: 1.5em; + border-radius: 50%; + background: #f90; +} +.map-picker [type=range]::-ms-thumb { + margin-top: 0; + box-sizing: border-box; + border: none; + width: 1.5em; + height: 1.5em; + border-radius: 50%; + background: #f90; +} +.map-picker [type=range]::-ms-tooltip { + display: none; +} +.map-picker [type=range] ~ output { + display: none; +} +.map-picker-irradiation-gauge .map-picker [type=range] ~ output { + display: block; + position: absolute; + top: 23px; + padding-top: 0.25em; + padding-bottom: 0.25em; + transform: translate(calc( (var(--val) - var(--min)) / (var(--max) - var(--min)) * 650px - ((var(--val) - var(--min)) / (var(--max) - var(--min)) * 100%))); + color: #40a9ff; +} + +@media (max-width : 480px) { + .map-picker-irradiation-gauge .map-picker [type=range] ~ output { + transform: translate(calc( (var(--val) - var(--min)) / (var(--max) - var(--min)) * 300px - ((var(--val) - var(--min)) / (var(--max) - var(--min)) * 100%))); + } +} diff --git a/mt-next/pages/components/IrradiationGauge.tsx b/mt-next/pages/components/IrradiationGauge.tsx new file mode 100644 index 0000000..86a7c41 --- /dev/null +++ b/mt-next/pages/components/IrradiationGauge.tsx @@ -0,0 +1,56 @@ +import { Animate } from 'react-move' +import { easeExpOut } from 'd3-ease' +import React from 'react' +import './IrradiationGauge.module.scss' + +interface IrradiationGaugeProps { + irradiation: number + mobile: boolean +} + +export const IrradiationGauge: React.FunctionComponent = ({ irradiation, mobile }) => { + const min = 594 + const max = 2200 + return( +
+
+ {mobile ? + (<>600 + 1000 + 1400 + 1800 + 2200) : + (<>600 + 800 + 1000 + 1200 + 1400 + 1600 + 1800 + 2000 + 2200) + + } +
+ ({ + x: [irradiation], + timing: { duration: 750, ease: easeExpOut } + })} + > + {(state) => { + const { x } = state + return (
+ {}} + value={x} className="slider" list="tickmarks" + id="myRange"/> + {Math.round(x)} kWh/㎡ +
) } + } +
+ +
 
+ +
) +} diff --git a/mt-next/pages/components/MapMarker.tsx b/mt-next/pages/components/MapMarker.tsx new file mode 100644 index 0000000..4fe098f --- /dev/null +++ b/mt-next/pages/components/MapMarker.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import MarkerIcon from '../assets/icons/sunrise-marker.svg' + +export interface MapMarkerProps { +} + +export const MapMarker: React.FunctionComponent = (props) => { + return ( +
+ +
+ ) +} diff --git a/mt-next/pages/components/MapPicker.scss b/mt-next/pages/components/MapPicker.scss new file mode 100644 index 0000000..d0da634 --- /dev/null +++ b/mt-next/pages/components/MapPicker.scss @@ -0,0 +1,74 @@ +@import '../theme.scss'; + +.map-picker { + overflow: hidden; + transition: height 250ms; +} + +.map-picker.collapsed { + height: 42px; +} + +.map-picker:hover { + border-color: $primary-light; + border-right-width: 1px !important; +} + +.map-picker-header { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + display: flex; + flex-direction: row; + padding-right: 4px; + height: 42px; +} + +.map-picker-irradiation { + color: $primary-light; + margin-left: 4px; +} + +.map-picker-header > button.ant-btn-sm > span { + font-size: 12px; +} + +.map-picker-address { + cursor: text; + flex: 1; + width: 0; + display: flex; + align-items: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + overflow-wrap: break-word; +} + +.map-picker-view { + position: relative; + height: 380px; + border-left: 1px solid $input-border; + border-right: 1px solid $input-border; + border-bottom: 1px solid $input-border; + background-color: #F5F5F5; +} + +.map-picker.expanded { + height: 414px; +} + +@media (max-width : 480px) { + .map-picker-view { + height: 200px; + } + .map-picker.expanded { + height: 264px; + } +} + +#map { + position: absolute; + top: 0; + bottom: 0; + width: 100%; +} \ No newline at end of file diff --git a/mt-next/pages/components/MapPicker.tsx b/mt-next/pages/components/MapPicker.tsx new file mode 100644 index 0000000..e76ff95 --- /dev/null +++ b/mt-next/pages/components/MapPicker.tsx @@ -0,0 +1,180 @@ +import { DownOutlined, UpOutlined } from '@ant-design/icons' +import { AutoComplete, Button } from 'antd' +import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react' +import { DEFAULT_ZOOM, GOOGLE_MAPS_KEY, INITIAL_INPUT_DATA } from '../constants' +import { Coords, MapState, mapStore } from '../util/mapStore' +import { MapMarker } from './MapMarker' +import './MapPicker.css' +import { MapContainer, Marker, TileLayer, useMapEvents } from 'react-leaflet' +import 'leaflet/dist/leaflet.css' +import * as ReactDOMServer from 'react-dom/server' +import L from 'leaflet' +import { useTranslation } from 'react-i18next' +import { GoogleProvider } from 'leaflet-geosearch' +import debounce from 'lodash/debounce' +import { IrradiationGauge } from './IrradiationGauge' +import { DefaultOptionType } from 'rc-select/lib/Select' + +export interface MapPickerProps { + value?: MapState + onChange?: (value: MapState) => void + mobile: boolean +} + +interface SurtsResult { + value: Coords + label: string +} + + +export const MapPicker: React.FunctionComponent = ({ value, onChange, mobile }) => { + + const { t } = useTranslation() + + const [mapState, setMapState] = useState(value!) + const [position, setPosition] = useState(value!.location) + const [zoom] = useState(DEFAULT_ZOOM) + const [collapsed, setCollapsed] = useState(false) + + const provider = new GoogleProvider({ + params: { + key: GOOGLE_MAPS_KEY, + region: 'id' + } + }) + + useLayoutEffect(() => { + mapStore.subscribe((state) => { + setMapState(state) + setPosition(state.location) + if (state.geoEnabled === false) { + setEditMode(true) + } + if (onChange) { onChange(state) } + }) + }, []) + + const updatePosition = async (location: Coords, enabled?: boolean) => { + mapStore.setLocation(location, enabled) + console.log('mapStore.setLocation(location)', location) + setPosition(location) + } + + const locationNotFound = () => { + console.log('no location') + setCollapsed(false) + setEditMode(true) + } + + function LocationMarker() { + const mapInstance: L.Map = useMapEvents({ + click(e) { + console.log('clickie') + // setZoom(mapInstance.getZoom()) + updatePosition(e.latlng, mapState.geoEnabled) + console.log('fly') + }, + locationfound(e) { + updatePosition(e.latlng, mapState.geoEnabled) + console.log('locationfound', e) + console.log('flyt', e) + console.log('flyto', position, mapInstance.getZoom()) + // mapInstance.flyTo(e.latlng, zoom, { animate: true, duration: 1 }) + }, + locationerror(e) { + if (!mobile) { + locationNotFound() + } + } + }) + + useEffect(() => { + if (position === INITIAL_INPUT_DATA.location.location) { + if (mobile) { + if (window && typeof window['postMessage'] === 'function') + window.postMessage('location') + } else { + const lok = mapInstance.locate() + console.log('lok', lok) + } + } + }) + + useMemo(() => { + if (mapInstance.getCenter() !== position || mapInstance.getZoom() !== zoom) { + console.log('memo-update', mapInstance.getCenter(), position, mapInstance.getZoom(), zoom) + mapInstance.setView(position, DEFAULT_ZOOM) + } + }, [position]) + + return position ? ( + ) })}> + + ): null + } + + const [options, setOptions] = useState([]) + const previewOptions = useMemo(() => options.map((x) => {return { value: JSON.stringify(x.value), label : x.label }}), [options]) + + const findResults = async (s: string) => { + const results = await provider.search({ query: s }) + const res: SurtsResult[] = results.map((x) => { + const coords: Coords = { lat: x.y, lng: x.x } + return { value: coords, label: `${x.label}` } }) + setOptions(res) + } + + const [editMode, setEditMode] = useState(false) + + + return ( +
+
+
+ {editMode && !collapsed ? + // eslint-disable-next-line @typescript-eslint/no-misused-promises + setEditMode(false)} + onSelect={(x: string, y: DefaultOptionType) => { + console.log('x', x, y.label) + const coords = JSON.parse(x) + // console.log('coords', coords) + updatePosition(coords) + setEditMode(false) + setMapState((prev) => { return { location: prev.location, address: `${y.label}`, info: prev.info } }) + } + }/> : +
{ + if (collapsed) { + setCollapsed(false) + setEditMode(true) + } else { + setEditMode(true) + } + }}> + {mapState.address === '' ? (mapState.info ? t('inputForm.findingLocation') : t('inputForm.chooseLocation')) : mapState.address} +
+ } +
+ +
+ + + + + +
+
+ +
+ ) +} diff --git a/mt-next/pages/components/ROIBreakdown.css b/mt-next/pages/components/ROIBreakdown.css new file mode 100644 index 0000000..cb1019c --- /dev/null +++ b/mt-next/pages/components/ROIBreakdown.css @@ -0,0 +1,15 @@ + +.roiBreakdown .ant-table table { + white-space: nowrap; + font-size: 10px; +} + +.roiBreakdown .ant-table-thead > tr > th { + padding: 6px; +} + +.roiBreakdown .ant-table-tbody > tr > td { + height: 5px; + text-align: right; + padding: 6px; +} \ No newline at end of file diff --git a/mt-next/pages/components/ROIBreakdown.tsx b/mt-next/pages/components/ROIBreakdown.tsx new file mode 100644 index 0000000..f197619 --- /dev/null +++ b/mt-next/pages/components/ROIBreakdown.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { formatNumber, formatRupiah } from '../services/Formatters' +import { ReturnOnInvestment } from '../services/CalculationService' +import { useTranslation } from 'react-i18next' +import './ROIBreakdown.css' +import { Table } from 'antd' + + +export interface ROIBreakdownProps { + yearly: ReturnOnInvestment[] +} + +export const ROIBreakdown: React.FunctionComponent = (props) => { + const { t, i18n } = useTranslation() + + const columns = [ + { + title: t('roiTable.year'), + dataIndex: 'index' + }, + { + title: t('roiTable.output'), + dataIndex: 'output', + render: ((output: number) => `${formatNumber(output, i18n.language)} kWh`) + }, + { + title: t('roiTable.tariff'), + dataIndex: 'tariff', + render: ((tariff: number) => formatRupiah(tariff)) + }, + { + title: t('roiTable.profit'), + dataIndex: 'cumulativeProfit', + render: ((cumulativeProfit: number) => formatRupiah(cumulativeProfit)) + } + ] + + + return ( + res.index}> + +
+ ) +} \ No newline at end of file diff --git a/mt-next/pages/components/ROIChart.tsx b/mt-next/pages/components/ROIChart.tsx new file mode 100644 index 0000000..2f645b6 --- /dev/null +++ b/mt-next/pages/components/ROIChart.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { Bar } from 'react-chartjs-2' +import { ChartData, ChartOptions, TooltipItem } from 'chart.js' +import { useTranslation } from 'react-i18next' +import { ReturnOnInvestment } from '../services/CalculationService' +import { formatRupiah } from '../services/Formatters' + +export interface ROIChartProps { + yearly: ReturnOnInvestment[] + inverterLifetimeInYears: number + cacheBuster: number +} + + + +export const ROIChart: React.FunctionComponent = (props) => { + const { t } = useTranslation() + + const colors = props.yearly.map((value) => value.cumulativeProfit < 0 ? 'rgb(255, 99, 132)' : 'rgb(99, 255, 132)') + + const data: ChartData<'bar', number[]> = { + labels: props.yearly.map(x => x.index), + datasets: [ + { + label: 'Jt. Rupiah' + props.cacheBuster, + data: props.yearly.map((x) => x.cumulativeProfit / 1000000), + backgroundColor: colors, + borderColor: 'rgba(255, 99, 132, 0.2)' + } + ] + } + + const title = (toolTipItems: TooltipItem<'bar'>[]) => { + const year = toolTipItems[0].label + return t('chart.tooltipTitle', { year }) + } + + const label = (toolTipItem: TooltipItem<'bar'>) => { + return formatRupiah((toolTipItem.raw as number) * 1000000) + } + + const footer = (toolTipItems: TooltipItem<'bar'>[]) => { + const year = toolTipItems[0].label + return year === `${props.inverterLifetimeInYears + 1}` ? t('chart.inverterReplacement') : '' + } + + const options: ChartOptions<'bar'> = { + plugins: { + legend: { + display: false + }, + tooltip: { + callbacks: { + title: title, + label: label, + footer: footer + } + } + }, + scales: { + y: { + title: { + text: t('chart.labelProfit'), + display: true + }, + ticks: { + callback: (val: number| string): string => { + return 'Jt. ' + val + } + } + }, + x: { + title: { + text: t('chart.labelYear'), + display: true + } + } + } + } + + return () +} \ No newline at end of file diff --git a/mt-next/pages/components/ResultTable.tsx b/mt-next/pages/components/ResultTable.tsx new file mode 100644 index 0000000..b8027f4 --- /dev/null +++ b/mt-next/pages/components/ResultTable.tsx @@ -0,0 +1,64 @@ +import { Col, Row } from 'antd' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { ResultData } from '../services/CalculationService' +import { formatDigits, formatNumber } from '../services/Formatters' +import { CalculatorSettings, OptimizationTarget } from '../constants' +import { Panel, renderPanel } from './SolarPanel' +import { InfoCircleOutlined } from '@ant-design/icons' +import { Documentation, toExplanation } from '../services/DocumentationService' + +export interface ResultTableProps { + results?: ResultData, + calculatorSettings: CalculatorSettings, + onOpenDocumentation: (d: Documentation, title: string) => void +} + +export const ResultTable: React.FunctionComponent = (props) => { + const { t, i18n } = useTranslation() + const { results, calculatorSettings, onOpenDocumentation } = props + if (!results) { + return
Invalid Data
+ } + + const panels: Panel[] = Array.from(Array(results?.numberOfPanels).keys()) + .map(x =>{ return { index: (x + 1), panelType: x >= results.numberOfPanelsFinancial ? OptimizationTarget.Green: OptimizationTarget.Money } } ) + + return ( +
+ + {t('resultTable.numberOfPanels')} {panels.length} onOpenDocumentation(Documentation.NumberOfPanels, t('resultTable.recommendedPanels'))}/> +
{panels.map(renderPanel)}
+
+ + {t('resultTable.installedCapacity')} + {formatDigits(results.numberOfPanels * calculatorSettings.kiloWattPeakPerPanel, 2, i18n.language)} kWp + + + {t('resultTable.limitingFactor')} +   + onOpenDocumentation(toExplanation(results.limitingFactor), t('resultTable.limitingFactor'))}/> + + {t('resultTable.limitingFactorEnum.' + results.limitingFactor)} + + + {t('resultTable.areaRequired')}  + onOpenDocumentation(Documentation.AreaRequired,t('resultTable.areaRequired'))}/> + + {formatDigits(results.numberOfPanels * calculatorSettings.areaPerPanel, 0, i18n.language)} ㎡ + + + {t('resultTable.monthlyConsumption')} + {`${formatNumber(results.consumptionPerMonthInKwh, i18n.language)} kWh`} + + + {t('resultTable.monthlyProduction')} + {`${formatNumber(results.productionPerMonthInKwh, i18n.language)} kWh`} + +
+ ) +} \ No newline at end of file diff --git a/mt-next/pages/components/SolarPanel.module.scss b/mt-next/pages/components/SolarPanel.module.scss new file mode 100644 index 0000000..4dd0bfd --- /dev/null +++ b/mt-next/pages/components/SolarPanel.module.scss @@ -0,0 +1,45 @@ +.panelPane .panel { + color: white; + position: relative; + height:auto; + display:block; + margin:auto; + float: left; + cursor:pointer; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} + +.panelPane .panel div { + width: 50px; +} + +@media (max-width : 480px) { + .panelPane .panel IMG { + width: 25px; + } +} + + +.panelPane .panel .number-overlay { + position: absolute; + font-size: 38px; + color: #E5E5E5; + text-align: center; + left: -3px; + right: 0; + top: -webkit-calc(50% - 22px); + top: -moz-calc(50% - 22px); + top: calc(50% - 22px); +} + +@media (max-width : 480px) { + .panelPane .panel .number-overlay { + font-size: 18px; + top: -webkit-calc(50% - 10px); + top: -moz-calc(50% - 10px); + top: calc(50% - 10px); + left: 0px; + } +} diff --git a/mt-next/pages/components/SolarPanel.tsx b/mt-next/pages/components/SolarPanel.tsx new file mode 100644 index 0000000..303c77a --- /dev/null +++ b/mt-next/pages/components/SolarPanel.tsx @@ -0,0 +1,41 @@ +import React from 'react' +// import panelImage from '../../public/images/panel-monocrystaline.png' +import panelImageWebp from '../../public/images/panel-monocrystaline.webp' +import panelImageGreen from '../assets/images/panel-monocrystaline-green.png' +import panelImageWebpGreen from '../../public/images/panel-monocrystaline-green.webp' +import './SolarPanel.module.scss' +import { OptimizationTarget } from '../constants' +import Image from 'next/image' + +interface SolarPanelProps { + index: number, + panelType: OptimizationTarget +} + + +export interface Panel { + index: number, + panelType: OptimizationTarget +} + +export const SolarPanel: React.FunctionComponent = (props) => { + // const image = props.panelType === OptimizationTarget.Money ? panelImage : panelImageGreen + const imageWebp = props.panelType === OptimizationTarget.Money ? panelImageWebp : panelImageWebpGreen + return ( +
+ {/**/} + {/* */} + {/* */} + {/**/} + + +
+ +
+
+ ) +} + +export const renderPanel = (panel: Panel) => { + return +} \ No newline at end of file diff --git a/mt-next/pages/constants.ts b/mt-next/pages/constants.ts new file mode 100644 index 0000000..30abb24 --- /dev/null +++ b/mt-next/pages/constants.ts @@ -0,0 +1,125 @@ +import { InputData } from './components/InputForm' +import { MapState } from './util/mapStore' + +export const GOOGLE_MAPS_KEY = 'AIzaSyA191Lgk8nZhKo4E81LbtwUHCz7-wl3Ea0' +export const GOOGLE_MAPS_MOBILE_KEY = 'AIzaSyCsXHX6Yd2tY8Ppz2STVOUgCn79T5Ut0Rw' +export const GOOGLE_ANALYTICS_TRACKING_ID = 'G-606Y7ZSBFV' +export const DEFAULT_ZOOM = 18 + +export interface PowerOption { + name: string + value: number +} + +export enum OptimizationTarget { + Money, + Green +} + +export enum InverterPrice { + Absolute = 'Absolute', + Relative = 'Relative' +} + +export enum MonthlyUsage { + Rupiah = 'Rupiah', + KWh = 'KWh' +} + +export const powerOptions: PowerOption[] = [ + { name: '450 VA', value: 450 }, + { name: '900 VA', value: 900 }, + { name: '1.300 VA', value: 1300 }, + { name: '2.200 VA', value: 2200 }, + { name: '3.500 VA', value: 3500 }, + { name: '3.900 VA', value: 3900 }, + { name: '4.400 VA', value: 4400 }, + { name: '5.500 VA', value: 5500 }, + { name: '6.600 VA', value: 6600 }, + { name: '7.700 VA', value: 7700 }, + { name: '10.600 VA', value: 10600 }, + { name: '11.000 VA', value: 11000 }, + { name: '13.200 VA', value: 13200 }, + { name: '16.500 VA', value: 16500 }, + { name: '23.000 VA', value: 23000 }, + { name: '33.000 VA', value: 33000 }, + { name: '41.500 VA', value: 41500 }, + { name: '53.000 VA', value: 53000 } +] + +interface InitialInputData extends InputData { + location: MapState +} + +export interface PlnSettings { + lowTariff: number + highTariff: number, + lowTariffThreshold: number, + energyTax: number, + minimalMonthlyConsumptionHours: number, + minimalMonthlyConsumptionPrice: number +} + +export interface PriceSettings { + pricePerPanel: number, + electricityPriceInflationRate: number, + priceOfInverterFactor: number, + priceOfInverterAbsolute: number, + installationCosts: number, + capacityLossRate: number, + inverterPrice: InverterPrice, + monthlyUsageType: MonthlyUsage +} + +export interface CalculatorSettings { + plnSettings: PlnSettings + priceSettings: PriceSettings + areaPerPanel: number, + inverterLifetimeInYears: number, + kiloWattPeakPerPanel: number, + kiloWattHourPerYearPerKWp: number, + lossFromInverter: number, + priorityEnabled: boolean +} + + +export const CALCULATOR_SETTINGS : CalculatorSettings = { + plnSettings: { + lowTariff: 1300, + highTariff: 1444.70, + lowTariffThreshold: 1300, + energyTax : 0.1 + 0.05, //PPN + PPJ + minimalMonthlyConsumptionHours: 40, // number of hours per month * connection power + minimalMonthlyConsumptionPrice: 1500.0 // energy price (untaxed) for minimal monthly consumption + }, + priceSettings: { + pricePerPanel: 7875000, + electricityPriceInflationRate: 0.05, + priceOfInverterFactor: 0.10, + priceOfInverterAbsolute: 8000000, + installationCosts: 0, + capacityLossRate: 0.0075, + inverterPrice: InverterPrice.Relative, + monthlyUsageType: MonthlyUsage.Rupiah + }, + areaPerPanel: 2, + inverterLifetimeInYears: 9, + // https://globalsolaratlas.info/map?c=-8.674473,115.030093,11&s=-8.702747,115.26267&m=site&pv=small,0,12,1 + // Square meters 450. 225 Watts / m2. Maybe add effective m2 needed vs panel surface + kiloWattPeakPerPanel: 0.450, + kiloWattHourPerYearPerKWp: 1732, + // Based on https://globalsolaratlas.info PVOUT vs Annual average + lossFromInverter: 0.9628, + priorityEnabled: true +} + +export const INITIAL_INPUT_DATA: InitialInputData = { + monthlyCostEstimateInRupiah: 1000000, + monthlyUsageInKwh: 1000, + connectionPower: 7700, + location: { location: { lat: -6.174903208804339, lng: 106.82721867845525 }, address: '' }, + optimizationTarget: OptimizationTarget.Money, + calculatorSettings: CALCULATOR_SETTINGS +} + + diff --git a/mt-next/pages/i18n.tsx b/mt-next/pages/i18n.tsx new file mode 100644 index 0000000..ce832da --- /dev/null +++ b/mt-next/pages/i18n.tsx @@ -0,0 +1,29 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +import LanguageDetector from 'i18next-browser-languagedetector' +import Backend from 'i18next-http-backend' + +// eslint-disable-next-line import/no-named-as-default-member +i18n +// load translation using http -> see /public/locales (i.e. https://github.com/i18next/react-i18next/tree/master/example/react/public/locales) +// learn more: https://github.com/i18next/i18next-http-backend +// want your translations to be loaded from a professional CDN? => https://github.com/locize/react-tutorial#step-2---use-the-locize-cdn + .use(Backend) +// detect user language +// learn more: https://github.com/i18next/i18next-browser-languageDetector + .use(LanguageDetector) +// pass the i18n instance to react-i18next. + .use(initReactI18next).init({ + detection: { + order: ['querystring', 'navigator'] + }, + fallbackLng: 'en', + debug: false, + load: 'languageOnly', + interpolation: { + escapeValue: false // not needed for react as it escapes by default + } + }) + +export default i18n \ No newline at end of file diff --git a/mt-next/pages/services/Analytics.ts b/mt-next/pages/services/Analytics.ts new file mode 100644 index 0000000..8938033 --- /dev/null +++ b/mt-next/pages/services/Analytics.ts @@ -0,0 +1,21 @@ +import ReactGA from 'react-ga4' + +export enum Category { + Documentation = 'Documentation', + NativeEvent = 'NativeEvent', + Wizard = 'Wizard', + Navigation = 'Navigation', + Form = 'Form' +} + + +export const event = (category: Category, action: string, label?: string) => { + valueEvent(category, action, label, undefined) +} + +export const valueEvent = (category: Category, action: string, label?: string, value?: number) => { + console.log('event', category, action, label, value) + ReactGA.event({ category: category, action: action, label: label, value: value }) +} + + diff --git a/mt-next/pages/services/CalculationService.spec.ts b/mt-next/pages/services/CalculationService.spec.ts new file mode 100644 index 0000000..a045269 --- /dev/null +++ b/mt-next/pages/services/CalculationService.spec.ts @@ -0,0 +1,105 @@ +import { calculateResultData } from './CalculationService' +import { InputData } from '../components/InputForm' +import { CALCULATOR_SETTINGS, MonthlyUsage, OptimizationTarget } from '../constants' + +describe('Calculate system characteristics', () => { + it('Should calculate system size in absence of irradiance information, using Sanur numbers', async () => { + const data: InputData = { + monthlyCostEstimateInRupiah: 1000000.0, + monthlyUsageInKwh: 1000, + connectionPower: 7700.0, + optimizationTarget: OptimizationTarget.Money, + calculatorSettings: CALCULATOR_SETTINGS + } + const result = calculateResultData(data) + expect(result.numberOfPanels).toBe(6) + }) + + it('Should require more panels when location is Jakarta', async () => { + const data: InputData = { + monthlyCostEstimateInRupiah: 1000000.0, + monthlyUsageInKwh: 1000, + connectionPower: 7700.0, + pvOut: 885, + optimizationTarget: OptimizationTarget.Money, + calculatorSettings: CALCULATOR_SETTINGS + } + const result = calculateResultData(data) + expect(result.numberOfPanels).toBe(10) + }) + + it('Should cap panels to connection size', async () => { + const smallConnection = 2200.0 + const data: InputData = { + monthlyCostEstimateInRupiah: 1000000.0, + monthlyUsageInKwh: 1000, + connectionPower: smallConnection, + pvOut: 885, + optimizationTarget: OptimizationTarget.Money, + calculatorSettings: CALCULATOR_SETTINGS + } + const result = calculateResultData(data) + expect(result.numberOfPanels * CALCULATOR_SETTINGS.kiloWattPeakPerPanel).toBeLessThan(smallConnection) + expect(result.numberOfPanels).toBe(4) + }) + + it('Should recommend no panels if negative profit', async () => { + const bigConnection = 7700.0 + const data: InputData = { + monthlyCostEstimateInRupiah: 500000.0, + monthlyUsageInKwh: 1000, + connectionPower: bigConnection, + pvOut: 885, + optimizationTarget: OptimizationTarget.Money, + calculatorSettings: CALCULATOR_SETTINGS + } + const result = calculateResultData(data) + expect(result.numberOfPanels * CALCULATOR_SETTINGS.kiloWattPeakPerPanel).toBeLessThan(bigConnection) + expect(result.numberOfPanels).toBe(0) + }) + + it('Should calculate all fields correctly', async () => { + const smallConnection = 2200.0 + const data: InputData = { + monthlyCostEstimateInRupiah: 1000000.0, + monthlyUsageInKwh: 1000, + connectionPower: smallConnection, + pvOut: 1800, + optimizationTarget: OptimizationTarget.Money, + calculatorSettings: CALCULATOR_SETTINGS + } + const results = calculateResultData(data) + + expect(results.currentMonthlyCosts).toBe(1000000) + expect(results.numberOfPanels).toBe(4) + expect(Math.round(results.monthlyProfit)).toBe(431892) + expect(Math.round(results.yearlyProfit)).toBe(Math.round(results.monthlyProfit * 12.0)) + + expect(results.totalSystemCosts).toBe(results.numberOfPanels * CALCULATOR_SETTINGS.priceSettings.pricePerPanel) + expect(results.remainingMonthlyCosts).toBe(results.currentMonthlyCosts - results.monthlyProfit) + }) + + it('Should calculate based on usage in kWh', async () => { + const smallConnection = 2200.0 + const data: InputData = { + monthlyCostEstimateInRupiah: 1000000.0, + monthlyUsageInKwh: 1000000.0 / (CALCULATOR_SETTINGS.plnSettings.highTariff * (1.0 + CALCULATOR_SETTINGS.plnSettings.energyTax)), + connectionPower: smallConnection, + pvOut: 1800, + optimizationTarget: OptimizationTarget.Money, + calculatorSettings: { + ...CALCULATOR_SETTINGS, + priceSettings: { + ...CALCULATOR_SETTINGS.priceSettings, + monthlyUsageType: MonthlyUsage.KWh + } + } + } + const results = calculateResultData(data) + + expect(results.currentMonthlyCosts).toBeCloseTo(1000000) + expect(results.numberOfPanels).toBe(4) + expect(Math.round(results.monthlyProfit)).toBe(431892) + expect(Math.round(results.yearlyProfit)).toBe(Math.round(results.monthlyProfit * 12.0)) + }) +}) \ No newline at end of file diff --git a/mt-next/pages/services/CalculationService.ts b/mt-next/pages/services/CalculationService.ts new file mode 100644 index 0000000..36d202b --- /dev/null +++ b/mt-next/pages/services/CalculationService.ts @@ -0,0 +1,190 @@ +import { InputData } from '../components/InputForm' +import { InverterPrice, MonthlyUsage, OptimizationTarget, PriceSettings } from '../constants' + +export enum LimitingFactor { + ConnectionSize = 'ConnectionSize', + Consumption = 'Consumption', + MinimumPayment = 'MinimumPayment' +} + +export interface ResultData { + consumptionPerMonthInKwh: number + taxedPricePerKwh: number + productionPerMonthInKwh: number + numberOfPanels: number + numberOfPanelsFinancial: number + numberOfPanelsGreen: number + remainingMonthlyCosts: number + currentMonthlyCosts: number + totalSystemCosts: number + monthlyProfit: number + yearlyProfit: number + breakEvenPointInMonths: number + limitingFactor: LimitingFactor + projection: ReturnOnInvestment[] +} + +const monthsInYear = 12.0 + +export interface SuggestedPanels { + limitedByConnection: boolean + numberOfPanels: number +} + +function panelsLimitedByConnection(expectedMonthlyProduction: number, kiloWattHourPerMonthPerPanel: number, kiloWattPeakPerPanel: number, connectionPower: number): SuggestedPanels { + const numberOfPanelsWithoutConnectionLimit = Math.round(Math.max(0, expectedMonthlyProduction / kiloWattHourPerMonthPerPanel)) + const suggestedCapacity = numberOfPanelsWithoutConnectionLimit * kiloWattPeakPerPanel * 1000 + const installableCapacity = Math.min(suggestedCapacity, connectionPower) + const suggestedPanels = Math.floor(installableCapacity / kiloWattPeakPerPanel / 1000) + + const limitedByConnection = (suggestedPanels + 1) * kiloWattPeakPerPanel * 1000 > connectionPower + const numberOfPanels = limitedByConnection ? suggestedPanels : suggestedPanels + 1 + return { limitedByConnection, numberOfPanels } +} + +export function calculateResultData({ + monthlyCostEstimateInRupiah, + monthlyUsageInKwh, + connectionPower, + pvOut, + optimizationTarget, + calculatorSettings +}: InputData): ResultData { + const { + plnSettings, + priceSettings, + inverterLifetimeInYears, + kiloWattPeakPerPanel, + kiloWattHourPerYearPerKWp, + lossFromInverter + } = calculatorSettings + + const { + energyTax, + highTariff, + lowTariff, + lowTariffThreshold, + minimalMonthlyConsumptionHours, + minimalMonthlyConsumptionPrice + } = plnSettings + + const pvOutputInkWhPerkWpPerYear = pvOut + const yieldPerKWp = (pvOutputInkWhPerkWpPerYear ? pvOutputInkWhPerkWpPerYear : kiloWattHourPerYearPerKWp) * lossFromInverter + + const taxFactor = 1.0 + energyTax + const pricePerKwh = connectionPower < lowTariffThreshold ? lowTariff : highTariff + const taxedPricePerKwh = pricePerKwh * taxFactor + + const minimalMonthlyConsumption = minimalMonthlyConsumptionHours * (connectionPower / 1000) + const minimalMonthlyCostsIncludingTax = minimalMonthlyConsumption * minimalMonthlyConsumptionPrice * taxFactor + + const kiloWattHourPerMonthPerPanel = yieldPerKWp * kiloWattPeakPerPanel / monthsInYear + const costEstimate = priceSettings.monthlyUsageType === MonthlyUsage.Rupiah ? monthlyCostEstimateInRupiah : monthlyUsageInKwh * taxedPricePerKwh + const effectiveCostsPerMonth = costEstimate - minimalMonthlyCostsIncludingTax + const requiredMonthlyProduction = effectiveCostsPerMonth / taxedPricePerKwh + const totalMonthlyConsumption = costEstimate / taxedPricePerKwh + + const limited = panelsLimitedByConnection(requiredMonthlyProduction, kiloWattHourPerMonthPerPanel, kiloWattPeakPerPanel, connectionPower) + const unlimited = panelsLimitedByConnection(totalMonthlyConsumption, kiloWattHourPerMonthPerPanel, kiloWattPeakPerPanel, connectionPower) + + const numberOfPanels = optimizationTarget === OptimizationTarget.Money ? limited.numberOfPanels : unlimited.numberOfPanels + + const productionPerMonthInKwh = limited.numberOfPanels * kiloWattHourPerMonthPerPanel + const yieldPerMonthFromPanelsInRupiah = productionPerMonthInKwh * taxedPricePerKwh + const remainingMonthlyCosts = Math.max(minimalMonthlyCostsIncludingTax, costEstimate - yieldPerMonthFromPanelsInRupiah) + + const monthlyProfit = costEstimate - remainingMonthlyCosts + const yearlyProfit = monthlyProfit * monthsInYear + const panelsCosts = numberOfPanels * priceSettings.pricePerPanel + const inverterCosts = priceSettings.inverterPrice === InverterPrice.Relative ? (panelsCosts * priceSettings.priceOfInverterFactor) : priceSettings.priceOfInverterAbsolute + + const flooredNumberOfPanels = monthlyProfit < 0 ? 0 : numberOfPanels + const limitingFactor = limited.limitedByConnection && unlimited.limitedByConnection ? LimitingFactor.ConnectionSize : (!limited.limitedByConnection && unlimited.limitedByConnection ? LimitingFactor.Consumption : LimitingFactor.MinimumPayment) + + const range = 25 + const investmentParameters: InvestmentParameters = { + taxedPricePerKwh, + productionPerMonthInKwh, + yearlyProfit, + panelsCosts, + inverterCosts, + priceSettings + } + const projection: ReturnOnInvestment[] = roiProjection(range, inverterLifetimeInYears, investmentParameters) + const firstMonthAboveZero = roiProjection(range, inverterLifetimeInYears, investmentParameters, monthsInYear).find(x => x.cumulativeProfit > 0) + const breakEvenPointInMonths = firstMonthAboveZero ? firstMonthAboveZero.index : range + + return { + consumptionPerMonthInKwh: totalMonthlyConsumption, + taxedPricePerKwh, + productionPerMonthInKwh, + numberOfPanels: flooredNumberOfPanels, + numberOfPanelsGreen: unlimited.numberOfPanels, + numberOfPanelsFinancial: limited.numberOfPanels, + remainingMonthlyCosts, + currentMonthlyCosts: costEstimate, + totalSystemCosts: panelsCosts, + monthlyProfit, + yearlyProfit, + projection, + limitingFactor, + breakEvenPointInMonths + } +} + +export interface ReturnOnInvestment { + index: number + output: number + tariff: number + income: number + cumulativeProfit: number + pvOutputPercentage: number + stepSizeInMonths: number +} + +interface InvestmentParameters { + taxedPricePerKwh: number + productionPerMonthInKwh: number + yearlyProfit: number + panelsCosts: number, + inverterCosts: number, + priceSettings: PriceSettings +} + +export function roiProjection(numberOfYears: number, lifetimeInverterInYears: number, result: InvestmentParameters, divider: number = 1.0): ReturnOnInvestment[] { + const years = Array.from(Array(numberOfYears * divider).keys()).map(x => x + 1) + + const { + electricityPriceInflationRate, + capacityLossRate + } = result.priceSettings + + const electricityPriceInflation = 1.0 + (electricityPriceInflationRate / divider) + const capacityLoss = 1.0 - (capacityLossRate / divider) + const priceOfInverterIndexed = result.inverterCosts * Math.pow(electricityPriceInflation, lifetimeInverterInYears) + + const startYear = { + index: 0, + tariff: result.taxedPricePerKwh, + output: result.productionPerMonthInKwh * (monthsInYear / divider), + income: result.productionPerMonthInKwh * (monthsInYear / divider) * result.taxedPricePerKwh, + cumulativeProfit: result.yearlyProfit - result.panelsCosts - result.inverterCosts - result.priceSettings.installationCosts, + pvOutputPercentage: 1.0 + } as ReturnOnInvestment + return years.reduce((acc, currentValue, currentIndex) => { + const previous = acc[currentIndex] + const invertReplacementCosts = currentIndex === (lifetimeInverterInYears * divider) ? priceOfInverterIndexed : 0 + return acc.concat({ + index: currentValue, + tariff: previous.tariff * electricityPriceInflation, + output: previous.output * capacityLoss, + income: previous.income * electricityPriceInflation, + cumulativeProfit: previous.cumulativeProfit + (previous.income * electricityPriceInflation) - invertReplacementCosts, + pvOutputPercentage: previous.pvOutputPercentage * capacityLoss, + stepSizeInMonths: monthsInYear / divider + } as ReturnOnInvestment) + }, [startYear]) + +} + + diff --git a/mt-next/pages/services/DocumentationService.ts b/mt-next/pages/services/DocumentationService.ts new file mode 100644 index 0000000..ea4eeb8 --- /dev/null +++ b/mt-next/pages/services/DocumentationService.ts @@ -0,0 +1,105 @@ +import ConnectionPowerMarkdownId from '../assets/documentation/id/inputform/ConnectionPower.md' +import ConnectionPowerMarkdownEn from '../assets/documentation/en/inputform/ConnectionPower.md' +import MonthlyBillEn from '../assets/documentation/en/inputform/MonthlyBill.md' +import MonthlyBillId from '../assets/documentation/id/inputform/MonthlyBill.md' +import NumberOfPanelsEn from '../assets/documentation/en/results/NumberOfPanels.md' +import NumberOfPanelsId from '../assets/documentation/id/results/NumberOfPanels.md' +import NumberOfPanelsConnectionSizeEn from '../assets/documentation/en/results/NumberOfPanelsConnectionSize.md' +import NumberOfPanelsConnectionSizeId from '../assets/documentation/id/results/NumberOfPanelsConnectionSize.md' +import NumberOfPanelsConsumptionEn from '../assets/documentation/en/results/NumberOfPanelsConsumption.md' +import NumberOfPanelsConsumptionId from '../assets/documentation/id/results/NumberOfPanelsConsumption.md' +import NumberOfPanelsMinimumPaymentEn from '../assets/documentation/en/results/NumberOfPanelsMinimumPayment.md' +import NumberOfPanelsMinimumPaymentId from '../assets/documentation/id/results/NumberOfPanelsMinimumPayment.md' +import AreaRequiredEn from '../assets/documentation/en/results/AreaRequired.md' +import AreaRequiredId from '../assets/documentation/id/results/AreaRequired.md' +import MinimalPaymentEn from '../assets/documentation/en/results/MinimalPayment.md' +import MinimalPaymentId from '../assets/documentation/id/results/MinimalPayment.md' +import LocationEn from '../assets/documentation/en/inputform/Location.md' +import LocationId from '../assets/documentation/id/inputform/Location.md' +import RoiExplanationEn from '../assets/documentation/en/results/RoiExplanation.md' +import RoiExplanationId from '../assets/documentation/id/results/RoiExplanation.md' +import PriorityEn from '../assets/documentation/en/inputform/Priority.md' +import PriorityId from '../assets/documentation/id/inputform/Priority.md' +import PlnSettingsEn from '../assets/documentation/en/expert/PLNSettings.md' +import PlnSettingsId from '../assets/documentation/id/expert/PLNSettings.md' +import AppInfoEn from '../assets/documentation/en/app/AppInfo.md' +import AppInfoId from '../assets/documentation/id/app/AppInfo.md' +import { LimitingFactor } from './CalculationService' +import * as Analytics from './Analytics' +import { Category } from './Analytics' + +export enum Documentation { + ConnectionPower, + MonthlyBill, + Location, + NumberOfPanels, + NumberOfPanelsConnectionSize, + NumberOfPanelsConsumption, + NumberOfPanelsMinimumPayment, + AreaRequired, + MinimalPayment, + RoiExplanation, + Priority, + PlnSettings, + AppInfo +} + +export enum Locale { + Indonesian = 'id', + English = 'en' +} + +function getIndonesian(doc: Documentation): string { + switch (doc) { + case Documentation.ConnectionPower: return ConnectionPowerMarkdownId.body + case Documentation.MonthlyBill: return MonthlyBillId.body + case Documentation.NumberOfPanels: return NumberOfPanelsId.body + case Documentation.AreaRequired: return AreaRequiredId.body + case Documentation.MinimalPayment: return MinimalPaymentId.body + case Documentation.NumberOfPanelsConnectionSize: return NumberOfPanelsConnectionSizeId.body + case Documentation.NumberOfPanelsConsumption: return NumberOfPanelsConsumptionId.body + case Documentation.NumberOfPanelsMinimumPayment: return NumberOfPanelsMinimumPaymentId.body + case Documentation.Location: return LocationId.body + case Documentation.RoiExplanation: return RoiExplanationId.body + case Documentation.Priority: return PriorityId.body + case Documentation.PlnSettings: return PlnSettingsId.body + case Documentation.AppInfo: return AppInfoId.body + } +} + +function getEnglish(doc: Documentation): string { + switch (doc) { + case Documentation.ConnectionPower: return ConnectionPowerMarkdownEn.body + case Documentation.MonthlyBill: return MonthlyBillEn.body + case Documentation.NumberOfPanels: return NumberOfPanelsEn.body + case Documentation.AreaRequired: return AreaRequiredEn.body + case Documentation.MinimalPayment: return MinimalPaymentEn.body + case Documentation.NumberOfPanelsConnectionSize: return NumberOfPanelsConnectionSizeEn.body + case Documentation.NumberOfPanelsConsumption: return NumberOfPanelsConsumptionEn.body + case Documentation.NumberOfPanelsMinimumPayment: return NumberOfPanelsMinimumPaymentEn.body + case Documentation.Location: return LocationEn.body + case Documentation.RoiExplanation: return RoiExplanationEn.body + case Documentation.Priority: return PriorityEn.body + case Documentation.PlnSettings: return PlnSettingsEn.body + case Documentation.AppInfo: return AppInfoEn.body + } +} + + +export function documentation(locale: Locale, doc: Documentation): string { + if (doc) { + Analytics.event(Category.Documentation, Documentation[doc], locale) + } + switch (locale) { + case Locale.Indonesian: return getIndonesian(doc) + case Locale.English: return getEnglish(doc) + } +} + +export function toExplanation(limitingFactor: LimitingFactor): Documentation { + switch (limitingFactor) { + case LimitingFactor.ConnectionSize: return Documentation.NumberOfPanelsConnectionSize + case LimitingFactor.Consumption: return Documentation.NumberOfPanelsConsumption + case LimitingFactor.MinimumPayment: return Documentation.NumberOfPanelsMinimumPayment + } +} diff --git a/mt-next/pages/services/Formatters.ts b/mt-next/pages/services/Formatters.ts new file mode 100644 index 0000000..422ecf5 --- /dev/null +++ b/mt-next/pages/services/Formatters.ts @@ -0,0 +1,74 @@ + +export const formatRupiah = (value: string | number | undefined): string => { + const amount = +`${value ?? 0}` + return `Rp. ${Math.round(amount)}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') +} + +export const parseRupiah = (value: string | number | undefined): number => { + if (value === undefined) { + return 0 + } + if (typeof value === 'string') { + return +value.replace(/Rp\.\s?|(,*)/g, '') + } + return value +} + + +export const formatNumber = (value: string | number | undefined, locale: string): string => { + return formatDigits(value, 0, locale) +} + +export const parseNumber = (value: string | number | undefined): number => { + if (value === undefined) { + return 0 + } + if (typeof value === 'string') { + return parseFloat(value.replace(',', '.')) + } + return value +} + +export const formatPercentage = (value: string | number | undefined, locale: string, digits = 0): string => { + if (value === undefined) { + return '0 %' + } + if (typeof value === 'string') { + const val = parseFloat(value) * 100.0 + const percentage = Intl.NumberFormat(locale, { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(val) + return `${percentage} %` + } + return `${value} %` +} + +export const parsePercentage = (value: string | number | undefined): number => { + if (value === undefined) { + return 0.0 + } + + if (typeof value === 'string') { + return parseFloat(value.replace(' %', '').replace(',', '.')) / 100.0 + } + + return value +} + +export const formatDigits = (value: string | number | undefined, digits: number | undefined, locale: string): string => { + const amount = value === undefined ? 0 : +value + return Intl.NumberFormat(locale, { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(amount) +} + +export const formatKwh = (value: string | number | undefined): string => { + const amount = +`${value ?? 0}` + return `kWh ${Math.round(amount)}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') +} + +export const parseKwh = (value: string | number | undefined): number => { + if (value === undefined) { + return 0 + } + if (typeof value === 'string') { + return +value.replace(/kWh\s?|(,*)/g, '') + } + return value +} \ No newline at end of file diff --git a/mt-next/pages/util/mapStore.ts b/mt-next/pages/util/mapStore.ts new file mode 100644 index 0000000..4fed2f0 --- /dev/null +++ b/mt-next/pages/util/mapStore.ts @@ -0,0 +1,45 @@ +import { debounceTime, forkJoin, map, mergeMap, Subject } from 'rxjs' +import { INITIAL_INPUT_DATA } from '../constants' +import { geocode, irradiance, IrradianceInfo } from './maps' + +export interface Coords { + lat: number + lng: number +} + +export interface MapState { + location: Coords + geoEnabled?: boolean + address?: string + info?: IrradianceInfo +} + +export type SetStateFn = (state: MapState) => void + +const subject = new Subject() + +let state: MapState = INITIAL_INPUT_DATA.location + +export const mapStore = { + subscribe: (setState: SetStateFn) => { + subject.pipe( + debounceTime(500), + mergeMap(({ location, geoEnabled }) => forkJoin([ + geocode(location), + irradiance(location), + Promise.resolve(geoEnabled) + ])), + map(([geo, info, enabled]) => ({ + location: { lat: geo.geometry.location.lat, lng: geo.geometry.location.lng } , + geoEnabled: enabled, + address: geo.formatted_address, + info + })) + ).subscribe(setState) + }, + setLocation: (location: Coords, geoEnabled?: boolean) => { + state = { ...state, location, geoEnabled } + subject.next(state) + }, + initialState: INITIAL_INPUT_DATA.pvOut +} diff --git a/mt-next/pages/util/maps.ts b/mt-next/pages/util/maps.ts new file mode 100644 index 0000000..b0c7aea --- /dev/null +++ b/mt-next/pages/util/maps.ts @@ -0,0 +1,55 @@ +import Geocode from 'react-geocode' +import { GOOGLE_MAPS_KEY } from '../constants' +import { Coords } from './mapStore' + +export async function geocode(location: Coords) { + const { results } = await Geocode.fromLatLng(location.lat.toString(), location.lng.toString(), GOOGLE_MAPS_KEY) + console.log('Got Geocode results', results) + return results[0] +} + +export interface LtaResponse { + annual: { + data: { + PVOUT_csi: number + DIF: number + DNI: number + ELE: number + GHI: number + GTI_opta: number + OPTA: number + TEMP: number + } + } +} + +export interface IrradianceInfo { + // [PVOUT] Yield in kWh per kWp installed PV + pvout: number + // [DNI] Direct normal irradiation in kWh/m2 + dni: number + // [GHI] Global horizontal irradiation in kWh/m2 + ghi: number + // [DIF] Diffuse horizontal irradiation in kWh/m2 + dif: number + // [GTIO_opta] Global tilted irradiation at optimum angle in kWh/m2 + gtio: number + // [OPTA] Optimum tilt of PV modules in degrees + opta: number + // [TEMP] Air temperature in celsius + temperature: number + // [ELE] Terrain elevation in meters + elevation: number +} + +export async function irradiance({ lat, lng }: Coords): Promise { + const result = await fetch(`https://api.globalsolaratlas.info/data/lta?loc=${lat},${lng}`, { + method: 'GET', + headers: { 'accept': 'application/json', 'content-type': 'application/json' } + }) + if (result.status < 200 || result.status >= 300) { throw new Error(result.statusText) } + const response: LtaResponse = await result.json() + if (!response?.annual?.data) { throw new Error('Invalid data') } + const { PVOUT_csi: pvout, DNI: dni, GHI: ghi, DIF: dif, GTI_opta: gtio, OPTA: opta, TEMP: temperature, ELE: elevation } = response.annual.data + return { pvout, dni, ghi, dif, gtio, opta, temperature, elevation } +} diff --git a/mt-next/public/images/agony.png b/mt-next/public/images/agony.png new file mode 100644 index 0000000..198731d Binary files /dev/null and b/mt-next/public/images/agony.png differ diff --git a/mt-next/public/images/agony2.png b/mt-next/public/images/agony2.png new file mode 100644 index 0000000..e041a66 Binary files /dev/null and b/mt-next/public/images/agony2.png differ diff --git a/mt-next/public/images/background.png b/mt-next/public/images/background.png new file mode 100644 index 0000000..2ae574c Binary files /dev/null and b/mt-next/public/images/background.png differ diff --git a/mt-next/public/images/background.svg b/mt-next/public/images/background.svg new file mode 100644 index 0000000..7843fd9 --- /dev/null +++ b/mt-next/public/images/background.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mt-next/public/images/dithered-image.png b/mt-next/public/images/dithered-image.png new file mode 100644 index 0000000..13c8c65 Binary files /dev/null and b/mt-next/public/images/dithered-image.png differ diff --git a/mt-next/public/images/dithered-image1.png b/mt-next/public/images/dithered-image1.png new file mode 100644 index 0000000..e79bde8 Binary files /dev/null and b/mt-next/public/images/dithered-image1.png differ diff --git a/mt-next/public/images/dithered-image2.png b/mt-next/public/images/dithered-image2.png new file mode 100644 index 0000000..f248b6e Binary files /dev/null and b/mt-next/public/images/dithered-image2.png differ diff --git a/mt-next/public/images/gradient.jpeg b/mt-next/public/images/gradient.jpeg new file mode 100644 index 0000000..b4b3a05 Binary files /dev/null and b/mt-next/public/images/gradient.jpeg differ diff --git a/mt-next/public/images/logo.png b/mt-next/public/images/logo.png new file mode 100644 index 0000000..5dce313 Binary files /dev/null and b/mt-next/public/images/logo.png differ diff --git a/mt-next/public/images/panel-monocrystaline-green.png b/mt-next/public/images/panel-monocrystaline-green.png new file mode 100644 index 0000000..2441f96 Binary files /dev/null and b/mt-next/public/images/panel-monocrystaline-green.png differ diff --git a/mt-next/public/images/panel-monocrystaline-green.webp b/mt-next/public/images/panel-monocrystaline-green.webp new file mode 100644 index 0000000..9d59776 Binary files /dev/null and b/mt-next/public/images/panel-monocrystaline-green.webp differ diff --git a/mt-next/public/images/panel-monocrystaline.png b/mt-next/public/images/panel-monocrystaline.png new file mode 100644 index 0000000..5822269 Binary files /dev/null and b/mt-next/public/images/panel-monocrystaline.png differ diff --git a/mt-next/public/images/panel-monocrystaline.webp b/mt-next/public/images/panel-monocrystaline.webp new file mode 100644 index 0000000..89bbf0e Binary files /dev/null and b/mt-next/public/images/panel-monocrystaline.webp differ diff --git a/mt-next/public/images/social-preview-min.png b/mt-next/public/images/social-preview-min.png new file mode 100644 index 0000000..c8de106 Binary files /dev/null and b/mt-next/public/images/social-preview-min.png differ diff --git a/mt-next/public/images/social-preview-min.webp b/mt-next/public/images/social-preview-min.webp new file mode 100644 index 0000000..6eebc31 Binary files /dev/null and b/mt-next/public/images/social-preview-min.webp differ diff --git a/mt-next/public/images/sunrise-logo.png b/mt-next/public/images/sunrise-logo.png new file mode 100644 index 0000000..1351aa4 Binary files /dev/null and b/mt-next/public/images/sunrise-logo.png differ diff --git a/mt-next/styles/theme.scss b/mt-next/styles/theme.scss new file mode 100644 index 0000000..2b2f413 --- /dev/null +++ b/mt-next/styles/theme.scss @@ -0,0 +1,7 @@ +$primary : #5689CE; +$primary-dark : #48709d; +$primary-light : #40a9ff; + +$input-border: #d9d9d9; + +$background: #FFF2BD; \ No newline at end of file diff --git a/src/components/InputForm.tsx b/src/components/InputForm.tsx index 0458328..972e5ee 100644 --- a/src/components/InputForm.tsx +++ b/src/components/InputForm.tsx @@ -160,9 +160,10 @@ export const InputForm: React.FunctionComponent = (props) => { }) }}> - 1 {t('inputForm.location')}} initialValue={INITIAL_INPUT_DATA.location} + <>1 {t('inputForm.location')}} initialValue={INITIAL_INPUT_DATA.location} tooltip={{ trigger: 'click', + overlay: '', icon: props.onOpenDocumentation(Documentation.Location, t('inputForm.location'))}/> }} @@ -172,10 +173,11 @@ export const InputForm: React.FunctionComponent = (props) => { {monthlyUsageType === MonthlyUsage.Rupiah ? - (2 {t('inputForm.monthlyBill')}} + (<>2 {t('inputForm.monthlyBill')}} initialValue={init.monthlyCostEstimateInRupiah} tooltip={{ trigger: 'click', + overlay: '', icon: props.onOpenDocumentation(Documentation.MonthlyBill, t('inputForm.monthlyBill'))}/> }}> @@ -183,7 +185,7 @@ export const InputForm: React.FunctionComponent = (props) => { formatter={formatRupiah} parser={parseRupiah} step={100000} inputMode="numeric"/> - ) : () : ({t('inputForm.monthlyUsage')}} initialValue={init.monthlyUsageInKwh} > = (props) => { } - 3 {t('inputForm.connectionPower')}} + <>3 {t('inputForm.connectionPower')}} initialValue={init.connectionPower} tooltip={{ trigger: 'click', + overlay: '', icon: props.onOpenDocumentation(Documentation.ConnectionPower, t('inputForm.connectionPower'))}/> }}> @@ -205,29 +208,30 @@ export const InputForm: React.FunctionComponent = (props) => { {priorityEnabled && - - props.onOpenDocumentation(Documentation.Priority, t('inputForm.priority'))}/> - }}> - - - + + {t('inputForm.priority')}} + tooltip={{ + overlay: '', + trigger: 'click', + icon: props.onOpenDocumentation(Documentation.Priority, t('inputForm.priority'))}/> + }}> + {t('inputForm.priorityMoney')}} + unCheckedChildren={<>{t('inputForm.priorityEarth')}} + defaultChecked={true} + /> + + } - {props.expertMode && <>{t('inputForm.expertMode.title.plnSettings')}  props.onOpenDocumentation(Documentation.PlnSettings, t('inputForm.expertMode.title.plnSettings'))}/> + {props.expertMode && <><>{t('inputForm.expertMode.title.plnSettings')}  props.onOpenDocumentation(Documentation.PlnSettings, t('inputForm.expertMode.title.plnSettings'))}/> - {t('inputForm.expertMode.lowTariff')}} initialValue={lowTariff} > = (props) => { - {t('inputForm.expertMode.highTariff')}} initialValue={highTariff} > = (props) => { - {t('inputForm.expertMode.lowTariffThreshold')}} initialValue={lowTariffThreshold} > = (props) => { - {t('inputForm.expertMode.energyTax')}} initialValue={energyTax} > = (props) => { {t('inputForm.expertMode.minimalMonthlyConsumptionHours')}} initialValue={minimalMonthlyConsumptionHours} > = (props) => { {t('inputForm.expertMode.minimalMonthlyConsumptionPrice')}} initialValue={minimalMonthlyConsumptionPrice} > = (props) => { - {t('inputForm.expertMode.title.systemSettings')} + {<>{t('inputForm.expertMode.title.systemSettings')}} - {t('inputForm.expertMode.pricePerPanel')}} initialValue={pricePerPanel} > = (props) => { {t('inputForm.expertMode.electricityPriceInflationRate')}} initialValue={electricityPriceInflationRate} > = (props) => { - {t('inputForm.expertMode.kiloWattPeakPerPanel')}} initialValue={kiloWattPeakPerPanel} > = (props) => { - {t('inputForm.expertMode.areaPerPanel')}} initialValue={areaPerPanel} > = (props) => { - {t('inputForm.expertMode.lossFromInverter')}} initialValue={lossFromInverter} > = (props) => { - {t('inputForm.expertMode.capacityLossRate')}} initialValue={capacityLossRate} > = (props) => { + label={<>{t('inputForm.expertMode.inverterPrice')}}> = (props) => { { inverterPrice === InverterPrice.Absolute ? - {t('inputForm.expertMode.priceOfInverterAbsolute')}} initialValue={priceOfInverterAbsolute} > = (props) => { : - {t('inputForm.expertMode.priceOfInverterFactor')}} initialValue={priceOfInverterFactor} > = (props) => { } - {t('inputForm.expertMode.installationCosts')}} initialValue={installationCosts} > = (props) => { - {t('inputForm.expertMode.inverterLifeTime')}} initialValue={inverterLifetimeInYears} > = (props) => { - {t('inputForm.expertMode.title.appSettings')} + {<>{t('inputForm.expertMode.title.appSettings')}} - Share settings
  -   -   - + Share settings
  +   +   + + label={<>{t('inputForm.expertMode.priorityEnabled')}}> setPriorityEnabled(newValue)} @@ -464,7 +468,7 @@ export const InputForm: React.FunctionComponent = (props) => { + label={<>{t('inputForm.expertMode.usageType')}}>