From a8bd1df3afbbe15bf34cf80f445e5ad8dec272dd Mon Sep 17 00:00:00 2001 From: Thibault Reidy <147397675+ReidyT@users.noreply.github.com> Date: Thu, 28 Nov 2024 16:18:46 +0100 Subject: [PATCH] feat: allow users to change the insulation of walls and windows (#57) * feat: add new wall materials * feat: add window size and insulations * feat: allow to update the composition of materials --- src/config/buildingMaterials.ts | 52 ++++ src/config/houseInsulations.ts | 64 ++++ src/config/simulation.ts | 46 ++- src/context/HouseComponentsContext.tsx | 171 ++++++++-- src/context/SimulationContext.tsx | 4 +- src/context/WindowSizeContext.tsx | 68 ++++ src/hooks/useDialogControl.tsx | 25 ++ src/hooks/useHeatLoss.tsx | 14 +- src/hooks/useHouseMaterial.tsx | 49 +++ src/hooks/useWallMaterial.tsx | 44 +++ src/hooks/useWindowMaterial.tsx | 43 +++ src/models/BuildingMaterial.ts | 58 ++++ src/models/HouseComponents.test.ts | 158 ---------- src/models/HouseComponents.ts | 135 -------- .../HouseComponentsConfigurator.test.ts | 291 ++++++++++++++++++ src/models/HouseComponentsConfigurator.ts | 232 ++++++++++++++ .../models/House/ResidentialHouse/Roof.tsx | 105 ++++--- .../models/House/ResidentialHouse/Wall.tsx | 10 +- .../House/ResidentialHouse/WindowFrame.tsx | 23 +- .../ResidentialHouse/useResidentialHouse.tsx | 16 +- src/modules/scenes/FirstScene.tsx | 94 +++--- .../SimulationControlPanel/HouseControl.tsx | 130 ++++++++ .../FormControlValidator.tsx | 132 ++++++++ .../MaterialControlDialog.tsx | 133 ++++++++ .../useMaterialControlDialog.tsx | 70 +++++ .../SimulationControlPanel.tsx | 21 ++ .../WindowControlDialog.tsx | 122 ++++++++ .../SimulationInformations.tsx | 11 +- .../useSimulationInformations.tsx | 15 + src/types/houseComponent.ts | 17 +- src/types/houseComponentInsulation.ts | 18 ++ src/types/material.ts | 5 - src/types/utils.ts | 8 + src/utils/colors.ts | 10 + src/utils/formatComponentSize.tsx | 20 ++ src/utils/heatLoss.ts | 7 +- 36 files changed, 1923 insertions(+), 498 deletions(-) create mode 100644 src/config/buildingMaterials.ts create mode 100644 src/config/houseInsulations.ts create mode 100644 src/context/WindowSizeContext.tsx create mode 100644 src/hooks/useDialogControl.tsx create mode 100644 src/hooks/useHouseMaterial.tsx create mode 100644 src/hooks/useWallMaterial.tsx create mode 100644 src/hooks/useWindowMaterial.tsx create mode 100644 src/models/BuildingMaterial.ts delete mode 100644 src/models/HouseComponents.test.ts delete mode 100644 src/models/HouseComponents.ts create mode 100644 src/models/HouseComponentsConfigurator.test.ts create mode 100644 src/models/HouseComponentsConfigurator.ts create mode 100644 src/modules/scenes/SimulationControlPanel/HouseControl.tsx create mode 100644 src/modules/scenes/SimulationControlPanel/MaterialControlDialog/FormControlValidator.tsx create mode 100644 src/modules/scenes/SimulationControlPanel/MaterialControlDialog/MaterialControlDialog.tsx create mode 100644 src/modules/scenes/SimulationControlPanel/MaterialControlDialog/useMaterialControlDialog.tsx create mode 100644 src/modules/scenes/SimulationControlPanel/SimulationControlPanel.tsx create mode 100644 src/modules/scenes/SimulationControlPanel/WindowControlDialog/WindowControlDialog.tsx create mode 100644 src/types/houseComponentInsulation.ts delete mode 100644 src/types/material.ts create mode 100644 src/utils/formatComponentSize.tsx diff --git a/src/config/buildingMaterials.ts b/src/config/buildingMaterials.ts new file mode 100644 index 0000000..74ac003 --- /dev/null +++ b/src/config/buildingMaterials.ts @@ -0,0 +1,52 @@ +import { BuildingMaterial } from '@/models/BuildingMaterial'; + +export const BUILDING_MATERIALS = { + Aerogel: BuildingMaterial.create({ + name: 'Aerogel', + price: 10_000, + thermalConductivity: 0.021, + thickness: 0.16, + }), + FiberGlass: BuildingMaterial.create({ + name: 'Fiber Glass', + price: 3_000, + thermalConductivity: 0.115, + thickness: 0.16, + }), + XPSFoam: BuildingMaterial.create({ + name: 'XPS Foam', + price: 10, + thermalConductivity: 0.024, + thickness: 0.16, + }), + MineralWool: BuildingMaterial.create({ + name: 'Mineral Wool', + price: 7, + thermalConductivity: 0.03, + thickness: 0.16, + }), + Brick: BuildingMaterial.create({ + name: 'Brick', + price: 0, + thermalConductivity: 0.6, + thickness: 0.2, + }), + WindowGlass: BuildingMaterial.create({ + name: 'Window Glass', + price: 150, + thermalConductivity: 0.8, + thickness: 0.004, + }), + Argon: BuildingMaterial.create({ + name: 'Argon', + price: 0, + thermalConductivity: 0.018, + thickness: 0.006, + }), + Wood: BuildingMaterial.create({ + name: 'Wood', + price: 0, + thermalConductivity: 0.08, + thickness: 0.2, + }), +} as const; diff --git a/src/config/houseInsulations.ts b/src/config/houseInsulations.ts new file mode 100644 index 0000000..130d62b --- /dev/null +++ b/src/config/houseInsulations.ts @@ -0,0 +1,64 @@ +import { UnionOfConst } from '@graasp/sdk'; + +import { BuildingMaterial } from '@/models/BuildingMaterial'; +import { HouseComponent } from '@/types/houseComponent'; +import { NonEmptyArray } from '@/types/utils'; + +import { BUILDING_MATERIALS } from './buildingMaterials'; + +export const HouseInsulationPerComponent = { + [HouseComponent.Wall]: { + Brick: 'Brick', + Aerogel: 'Aerogel', + Fiberglass: 'Fiberglass', + XPSFoam: 'XPSFoam', + MineralWool: 'MineralWool', + }, + [HouseComponent.Door]: { + Wood: 'Wood', + }, + [HouseComponent.Window]: { + SinglePane: 'SinglePane', + DoublePane: 'DoublePane', + TriplePane: 'TriplePane', + }, +} as const; + +export type HouseInsulation = + | UnionOfConst<(typeof HouseInsulationPerComponent)[HouseComponent.Wall]> + | UnionOfConst<(typeof HouseInsulationPerComponent)[HouseComponent.Door]> + | UnionOfConst<(typeof HouseInsulationPerComponent)[HouseComponent.Window]>; + +export const HOUSE_INSULATIONS: { + [houseComponent in HouseComponent]: { + [componentInsulationName in keyof (typeof HouseInsulationPerComponent)[houseComponent]]: NonEmptyArray; + }; +} = { + [HouseComponent.Wall]: { + Brick: [BUILDING_MATERIALS.Brick], + Aerogel: [BUILDING_MATERIALS.Brick, BUILDING_MATERIALS.Aerogel], + Fiberglass: [BUILDING_MATERIALS.Brick, BUILDING_MATERIALS.FiberGlass], + XPSFoam: [BUILDING_MATERIALS.Brick, BUILDING_MATERIALS.XPSFoam], + MineralWool: [BUILDING_MATERIALS.Brick, BUILDING_MATERIALS.MineralWool], + }, + + [HouseComponent.Door]: { + Wood: [BUILDING_MATERIALS.Wood.from({ thickness: 0.1 })], + }, + + [HouseComponent.Window]: { + SinglePane: [BUILDING_MATERIALS.WindowGlass], + DoublePane: [ + BUILDING_MATERIALS.WindowGlass, + BUILDING_MATERIALS.Argon, + BUILDING_MATERIALS.WindowGlass, + ], + TriplePane: [ + BUILDING_MATERIALS.WindowGlass, + BUILDING_MATERIALS.Argon, + BUILDING_MATERIALS.WindowGlass, + BUILDING_MATERIALS.Argon, + BUILDING_MATERIALS.WindowGlass, + ], + }, +} as const; diff --git a/src/config/simulation.ts b/src/config/simulation.ts index 5fdd57d..82e833b 100644 --- a/src/config/simulation.ts +++ b/src/config/simulation.ts @@ -1,6 +1,10 @@ -import { Material } from '@/types/material'; +import { HouseComponentInsulation } from '@/types/houseComponentInsulation'; import { TimeUnit } from '@/types/time'; -import { NonEmptyArray } from '@/types/utils'; + +import { + HOUSE_INSULATIONS, + HouseInsulationPerComponent, +} from './houseInsulations'; export const SIMULATION_FRAME_MS = 150; export const SIMULATION_SLIDING_WINDOW = { window: 2, unit: TimeUnit.Days }; @@ -11,29 +15,19 @@ export const SIMULATION_CSV_FILE = { export const SIMULATION_INDOOR_TEMPERATURE_CELCIUS = 22; export const SIMULATION_PRICE_KWH = 0.22; -export const AEROGEL_MATERIAL: Material = { - price: 0, - thermalConductivity: 0.021, - thickness: 0.25, -}; -export const SINGLE_WINDOW_PANE_MATERIAL: Material = { - price: 0, - thermalConductivity: 0.8, - thickness: 0.005, + +export const SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION: Pick< + HouseComponentInsulation, + 'insulationName' | 'buildingMaterials' +> = { + insulationName: HouseInsulationPerComponent.Wall.Aerogel, + buildingMaterials: HOUSE_INSULATIONS.Wall.Aerogel, }; -export const DOUBLE_WINDOW_PANE_MATERIAL: NonEmptyArray = [ - SINGLE_WINDOW_PANE_MATERIAL, - // Air - { - price: 0, - thermalConductivity: 0.024, - thickness: 0.005, - }, - SINGLE_WINDOW_PANE_MATERIAL, -]; -export const SIMULATION_DEFAULT_WALL_MATERIALS: NonEmptyArray = [ - AEROGEL_MATERIAL, -]; -export const SIMULATION_DEFAULT_WINDOW_MATERIALS: NonEmptyArray = - DOUBLE_WINDOW_PANE_MATERIAL; +export const SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION: Pick< + HouseComponentInsulation, + 'insulationName' | 'buildingMaterials' +> = { + insulationName: HouseInsulationPerComponent.Window.DoublePane, + buildingMaterials: HOUSE_INSULATIONS.Window.DoublePane, +}; diff --git a/src/context/HouseComponentsContext.tsx b/src/context/HouseComponentsContext.tsx index e90634c..f0171fb 100644 --- a/src/context/HouseComponentsContext.tsx +++ b/src/context/HouseComponentsContext.tsx @@ -8,25 +8,48 @@ import { } from 'react'; import { - SIMULATION_DEFAULT_WALL_MATERIALS, - SIMULATION_DEFAULT_WINDOW_MATERIALS, + HOUSE_INSULATIONS, + HouseInsulationPerComponent, +} from '@/config/houseInsulations'; +import { + SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, } from '@/config/simulation'; -import { HouseComponents } from '@/models/HouseComponents'; -import { HouseComponentType, Size } from '@/types/houseComponent'; -import { Material } from '@/types/material'; -import { NonEmptyArray } from '@/types/utils'; +import { FromBuildingMaterial } from '@/models/BuildingMaterial'; +import { HouseComponentsConfigurator } from '@/models/HouseComponentsConfigurator'; +import { HouseComponent, Size } from '@/types/houseComponent'; +import { CreateNonEmptyArray } from '@/types/utils'; import { undefinedContextErrorFactory } from '@/utils/context'; export type RegisterComponentParams = { componentId: string; parentId?: string; size: Size; - componentType: HouseComponentType; + componentType: HouseComponent.Wall | HouseComponent.Window; }; type HouseComponentsContextType = { - houseComponents: HouseComponents; + houseComponentsConfigurator: HouseComponentsConfigurator; registerComponent: (params: RegisterComponentParams) => void; + + changeComponentInsulation: < + T extends HouseComponent, + K extends keyof (typeof HouseInsulationPerComponent)[T], + >({ + componentType, + newInsulation, + }: { + componentType: T; + newInsulation: K; + }) => void; + + updateCompositionOfInsulation: ({ + componentType, + materialProps, + }: { + componentType: T; + materialProps: { name: string } & FromBuildingMaterial; + }) => void; }; const HouseComponentsContext = createContext( @@ -37,25 +60,18 @@ type Props = { children: ReactNode; }; +// An house component can be composed with multiple materials +// For example, a double-glazed window is made up of two panes of glass and air. +const DEFAULT_COMPONENTS_INSULATION = { + [HouseComponent.Wall]: SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + [HouseComponent.Window]: SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, +}; + export const HouseComponentsProvider = ({ children }: Props): ReactNode => { - // TODO: will be updated by users - // An house component can be composed with multiple materials - // For example, a double-glazed window is made up of two panes of glass and air. - const componentMaterials: Map< - HouseComponentType, - NonEmptyArray - > = useMemo( - () => - new Map([ - // TODO: load form a CSV - [HouseComponentType.Wall, SIMULATION_DEFAULT_WALL_MATERIALS], - [HouseComponentType.Window, SIMULATION_DEFAULT_WINDOW_MATERIALS], - ]), - [], - ); - const [houseComponents, setHouseComponents] = useState(() => - HouseComponents.create(), - ); + const [houseComponentsConfigurator, setHouseComponentsConfigurator] = + useState(() => + HouseComponentsConfigurator.create(), + ); const registerComponent = useCallback( ({ @@ -64,36 +80,125 @@ export const HouseComponentsProvider = ({ children }: Props): ReactNode => { size, componentType, }: RegisterComponentParams): void => { - const materials = componentMaterials.get(componentType); + const { buildingMaterials, insulationName } = + houseComponentsConfigurator.getFirstOfType(componentType) ?? + DEFAULT_COMPONENTS_INSULATION[componentType]; - if (!materials?.length) { + if (!buildingMaterials?.length) { throw new Error( `No material was found for the component ${componentType}`, ); } - setHouseComponents((curr) => + setHouseComponentsConfigurator((curr) => curr.cloneWith({ componentId, parentId, component: { size, - materials, + insulationName, + buildingMaterials, componentType, actualArea: size.height * size.width, }, }), ); }, - [componentMaterials], + [houseComponentsConfigurator], + ); + + const changeComponentInsulation = useCallback( + < + T extends HouseComponent, + K extends keyof (typeof HouseInsulationPerComponent)[T], + >({ + componentType, + newInsulation, + }: { + componentType: T; + newInsulation: K; + }): void => { + if (!(newInsulation in HOUSE_INSULATIONS[componentType])) { + throw new Error( + `Invalid material "${newInsulation.toString()}" for component type "${componentType.toString()}". Valid materials are: ${Object.keys( + HOUSE_INSULATIONS[componentType], + ).join(', ')}.`, + ); + } + + // TODO: use state on home insulations to not reset updated insulations? + const buildingMaterials = HOUSE_INSULATIONS[componentType][newInsulation]; + + setHouseComponentsConfigurator((curr) => + curr.cloneWithNewInsulation({ + componentType, + insulation: { name: newInsulation, buildingMaterials }, + }), + ); + }, + [], + ); + + const updateCompositionOfInsulation = useCallback( + ({ + componentType, + materialProps, + }: { + componentType: T; + materialProps: { name: string } & FromBuildingMaterial; + }): void => { + const component = + houseComponentsConfigurator.getFirstOfType(componentType); + + if (!component) { + throw new Error(`No ${componentType} component was found!`); + } + + const insulationName = + component.insulationName as keyof (typeof HouseInsulationPerComponent)[T]; + const currMaterials = HOUSE_INSULATIONS[componentType][insulationName]; + + if (!currMaterials?.length) { + throw new Error( + `No material was found for insulation "${insulationName.toString()}"!`, + ); + } + + const newMaterials = currMaterials.map((m) => { + if (m.name === materialProps.name) { + return m.from(materialProps); + } + + return m; + }); + + setHouseComponentsConfigurator((curr) => + curr.cloneWithNewInsulation({ + componentType, + insulation: { + name: insulationName, + buildingMaterials: CreateNonEmptyArray(newMaterials), + }, + }), + ); + }, + [houseComponentsConfigurator], ); const contextValue = useMemo( () => ({ - houseComponents, + houseComponentsConfigurator, registerComponent, + + changeComponentInsulation, + updateCompositionOfInsulation, }), - [houseComponents, registerComponent], + [ + houseComponentsConfigurator, + registerComponent, + changeComponentInsulation, + updateCompositionOfInsulation, + ], ); return ( diff --git a/src/context/SimulationContext.tsx b/src/context/SimulationContext.tsx index ae80441..2d170d2 100644 --- a/src/context/SimulationContext.tsx +++ b/src/context/SimulationContext.tsx @@ -81,10 +81,10 @@ export const SimulationProvider = ({ const pricekWh = SIMULATION_PRICE_KWH; - const { houseComponents } = useHouseComponents(); + const { houseComponentsConfigurator } = useHouseComponents(); const { heatLosses, totalHeatLoss } = useHeatLoss({ - houseComponents, + houseComponentsConfigurator, indoorTemperature, measurementFrequency: csv.measurementFrequency, temperatures: currentWindow.temperatures, diff --git a/src/context/WindowSizeContext.tsx b/src/context/WindowSizeContext.tsx new file mode 100644 index 0000000..5de95b9 --- /dev/null +++ b/src/context/WindowSizeContext.tsx @@ -0,0 +1,68 @@ +import { + ReactNode, + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import { Vector3 } from 'three'; + +import { undefinedContextErrorFactory } from '@/utils/context'; + +const WindowScaleSize = { + Small: new Vector3(0.8, 0.8, 1), + Medium: new Vector3(1, 1, 1), + Large: new Vector3(1.2, 1.2, 1), +} as const; + +export const WindowSizes = Object.keys(WindowScaleSize); + +export type WindowSizeType = keyof typeof WindowScaleSize; + +type WindowSizeContextType = { + windowScaleSize: Vector3; + windowSize: WindowSizeType; + changeWindowSize: (newSize: WindowSizeType) => void; +}; + +const WindowSizeContext = createContext(null); + +type Props = { + children: ReactNode; +}; + +export const WindowSizeProvider = ({ children }: Props): ReactNode => { + const [windowSize, setWindowSize] = useState('Medium'); + + const changeWindowSize = useCallback( + (newSize: WindowSizeType): void => setWindowSize(newSize), + [], + ); + + const contextValue = useMemo( + () => ({ + windowSize, + windowScaleSize: WindowScaleSize[windowSize], + changeWindowSize, + }), + [windowSize, changeWindowSize], + ); + + return ( + + {children} + + ); +}; + +export const useWindowSize = (): WindowSizeContextType => { + const context = useContext(WindowSizeContext); + + if (!context) { + throw undefinedContextErrorFactory('WindowSize'); + } + + return context; +}; diff --git a/src/hooks/useDialogControl.tsx b/src/hooks/useDialogControl.tsx new file mode 100644 index 0000000..bc405db --- /dev/null +++ b/src/hooks/useDialogControl.tsx @@ -0,0 +1,25 @@ +import { useState } from 'react'; + +type UseDialogControlReturnType = { + open: boolean; + handleOpen: () => void; + handleClose: () => void; +}; + +export const useDialogControl = (): UseDialogControlReturnType => { + const [open, setOpen] = useState(false); + + const handleOpen = (): void => { + setOpen(true); + }; + + const handleClose = (): void => { + setOpen(false); + }; + + return { + open, + handleOpen, + handleClose, + }; +}; diff --git a/src/hooks/useHeatLoss.tsx b/src/hooks/useHeatLoss.tsx index 56fe1ff..0b4b18e 100644 --- a/src/hooks/useHeatLoss.tsx +++ b/src/hooks/useHeatLoss.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; -import { HouseComponents } from '@/models/HouseComponents'; +import { HouseComponentsConfigurator } from '@/models/HouseComponentsConfigurator'; import { HeatLossPerComponent } from '@/types/houseComponent'; import { TimeUnitType } from '@/types/time'; import { @@ -9,7 +9,7 @@ import { } from '@/utils/heatLoss'; type Props = { - houseComponents: HouseComponents; + houseComponentsConfigurator: HouseComponentsConfigurator; indoorTemperature: number; measurementFrequency: TimeUnitType; temperatures: number[]; @@ -21,7 +21,7 @@ type UseHeatLossReturnType = { }; export const useHeatLoss = ({ - houseComponents, + houseComponentsConfigurator, indoorTemperature, measurementFrequency, temperatures, @@ -33,17 +33,17 @@ export const useHeatLoss = ({ // Compute the constant factors per house's components const heatLossConstantFactors = useMemo( () => - houseComponents.getAll().reduce( + houseComponentsConfigurator.getAll().reduce( (acc, c) => ({ ...acc, - [c.id]: calculateHeatLossConstantFactor({ + [c.houseComponentId]: calculateHeatLossConstantFactor({ area: c.actualArea, - materials: c.materials, + materials: c.buildingMaterials, }), }), {}, ), - [houseComponents], + [houseComponentsConfigurator], ); useEffect(() => { diff --git a/src/hooks/useHouseMaterial.tsx b/src/hooks/useHouseMaterial.tsx new file mode 100644 index 0000000..a5e5b1b --- /dev/null +++ b/src/hooks/useHouseMaterial.tsx @@ -0,0 +1,49 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { Color, Material, MeshStandardMaterial } from 'three'; + +import { useHouseComponents } from '@/context/HouseComponentsContext'; +import { HouseComponent } from '@/types/houseComponent'; + +type Props = { + houseMaterial: Material; + houseComponent: HouseComponent; + colors: { [name: string]: Color }; +}; + +export const useHouseMaterial = ({ + houseMaterial, + houseComponent, + colors, +}: Props): Material => { + const { houseComponentsConfigurator } = useHouseComponents(); + + // Use memo to avoid too many renrenders + const houseComponentMaterials = useMemo( + () => houseComponentsConfigurator.getFirstOfType(houseComponent), + [houseComponent, houseComponentsConfigurator], + ); + + const [newMaterial, setNewMaterial] = useState(() => houseMaterial); + + useEffect(() => { + if (houseComponentMaterials) { + setNewMaterial((curr) => { + const copiedMaterial = new MeshStandardMaterial().copy(curr); + const color = colors[houseComponentMaterials.insulationName]; + + if (!color) { + throw new Error( + `No color was found for the ${houseComponent} insulation ${houseComponentMaterials.insulationName}!`, + ); + } + + copiedMaterial.color = color; + + return copiedMaterial; + }); + } + }, [colors, houseComponent, houseComponentMaterials]); + + return newMaterial; +}; diff --git a/src/hooks/useWallMaterial.tsx b/src/hooks/useWallMaterial.tsx new file mode 100644 index 0000000..94df071 --- /dev/null +++ b/src/hooks/useWallMaterial.tsx @@ -0,0 +1,44 @@ +import { Color, Material } from 'three'; + +import { HouseInsulationPerComponent } from '@/config/houseInsulations'; +import { HouseComponent } from '@/types/houseComponent'; +import { fromHSL } from '@/utils/colors'; + +import { useHouseMaterial } from './useHouseMaterial'; + +const COLORS: { + [insulation in keyof typeof HouseInsulationPerComponent.Wall]: Color; +} = { + [HouseInsulationPerComponent.Wall.Aerogel]: fromHSL({ + h: 30, + s: 0.8, + l: 0.7, + }), + [HouseInsulationPerComponent.Wall.Fiberglass]: fromHSL({ + h: 100, + s: 0.7, + l: 0.6, + }), + [HouseInsulationPerComponent.Wall.MineralWool]: fromHSL({ + h: 200, + s: 0.6, + l: 0.7, + }), + [HouseInsulationPerComponent.Wall.XPSFoam]: fromHSL({ + h: 240, + s: 0.5, + l: 0.8, + }), + [HouseInsulationPerComponent.Wall.Brick]: fromHSL({ h: 20, s: 0.6, l: 0.4 }), +}; + +export const useWallMaterial = ({ + wallMaterial, +}: { + wallMaterial: Material; +}): Material => + useHouseMaterial({ + houseMaterial: wallMaterial, + houseComponent: HouseComponent.Wall, + colors: COLORS, + }); diff --git a/src/hooks/useWindowMaterial.tsx b/src/hooks/useWindowMaterial.tsx new file mode 100644 index 0000000..4a5aebd --- /dev/null +++ b/src/hooks/useWindowMaterial.tsx @@ -0,0 +1,43 @@ +import { Color, Material } from 'three'; + +import { HouseInsulationPerComponent } from '@/config/houseInsulations'; +import { HouseComponent } from '@/types/houseComponent'; +import { fromRGB } from '@/utils/colors'; + +import { useHouseMaterial } from './useHouseMaterial'; + +const COLORS: { + [insulation in keyof typeof HouseInsulationPerComponent.Window]: Color; +} = { + [HouseInsulationPerComponent.Window.SinglePane]: fromRGB({ + r: 0.57, + g: 0.28, + b: 0.114, + }), + [HouseInsulationPerComponent.Window.DoublePane]: fromRGB({ + r: 0.47, + g: 0.18, + b: 0.064, + }), + [HouseInsulationPerComponent.Window.TriplePane]: fromRGB({ + r: 0.37, + g: 0.08, + b: 0.014, + }), +}; + +type UseWindowMaterialReturnType = { + frameMaterial: Material; +}; + +export const useWindowMaterial = ({ + windowMaterial, +}: { + windowMaterial: Material; +}): UseWindowMaterialReturnType => ({ + frameMaterial: useHouseMaterial({ + houseMaterial: windowMaterial, + houseComponent: HouseComponent.Window, + colors: COLORS, + }), +}); diff --git a/src/models/BuildingMaterial.ts b/src/models/BuildingMaterial.ts new file mode 100644 index 0000000..474bce5 --- /dev/null +++ b/src/models/BuildingMaterial.ts @@ -0,0 +1,58 @@ +type Constructor = { + name: string; + thermalConductivity: number; + price: number; + thickness: number; +}; + +export type FromBuildingMaterial = Partial< + Pick +>; + +export class BuildingMaterial { + /** + * Name of the material. + */ + public readonly name: string; + + /** + * Thermal conductivity W/m^2*K. + */ + public readonly thermalConductivity: number; + + /** + * Price in m^3. + */ + public readonly price: number; + + /** + * Thickness in m + */ + public readonly thickness: number; + + private constructor({ + name, + thermalConductivity, + price, + thickness, + }: Constructor) { + // TODO: validates the constructor! + this.name = name; + this.thermalConductivity = thermalConductivity; + this.price = price; + this.thickness = thickness; + } + + public static create(constructor: Constructor): BuildingMaterial { + return new BuildingMaterial(constructor); + } + + public from({ price, thickness }: FromBuildingMaterial): BuildingMaterial { + return BuildingMaterial.create({ + name: this.name, + thermalConductivity: this.thermalConductivity, + price: price ?? this.price, + thickness: thickness ?? this.thickness, + }); + } +} diff --git a/src/models/HouseComponents.test.ts b/src/models/HouseComponents.test.ts deleted file mode 100644 index 2efb935..0000000 --- a/src/models/HouseComponents.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { HouseComponent, HouseComponentType } from '@/types/houseComponent'; -import { Material } from '@/types/material'; - -import { HouseComponents } from './HouseComponents'; - -const WALL_MATERIAL_1: Material = { - thermalConductivity: 0.25, - thickness: 0.02, - price: 10, -}; - -const WALL_COMPONENT: HouseComponent = { - materials: [WALL_MATERIAL_1], - actualArea: 10, - componentType: HouseComponentType.Wall, - size: { width: 5, height: 2 }, -}; - -const WINDOW_MATERIAL: Material = { - thermalConductivity: 0.25, - thickness: 0.02, - price: 10, -}; - -const WINDOW_COMPONENT: HouseComponent = { - materials: [WINDOW_MATERIAL], - actualArea: 2, - componentType: HouseComponentType.Wall, - size: { width: 1, height: 2 }, -}; - -describe('HouseComponents', () => { - it('should create an empty instance using the static create method', () => { - const houseComponents = HouseComponents.create(); - expect(houseComponents.getAll().length).eq(0); - }); - - it('should add a new component and returns a new instance', () => { - const houseComponents = HouseComponents.create(); - const newHouseComponents = houseComponents.cloneWith({ - componentId: 'wall1', - component: WALL_COMPONENT, - }); - - expect(newHouseComponents.getAll().length).eq(1); - expect(newHouseComponents.get('wall1')).toEqual(WALL_COMPONENT); - - // Original instance should remain unchanged - expect(houseComponents.getAll().length).eq(0); - }); - - it('should add a child component', () => { - const houseComponents = HouseComponents.create().cloneWith({ - componentId: 'wall1', - component: WALL_COMPONENT, - }); - const newHouseComponents = houseComponents.cloneWith({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT, - }); - - expect(newHouseComponents.getAll().length).eq(2); - expect(newHouseComponents.get('window1')).toEqual(WINDOW_COMPONENT); - expect(newHouseComponents.get('wall1').actualArea).eq( - WALL_COMPONENT.actualArea - WINDOW_COMPONENT.actualArea, - ); - }); - - it('should get a component', () => { - const houseComponents = HouseComponents.create() - .cloneWith({ componentId: 'wall1', component: WALL_COMPONENT }) - .cloneWith({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT, - }); - - const wall = houseComponents.get('wall1'); - expect(wall.actualArea).eq( - WALL_COMPONENT.actualArea - WINDOW_COMPONENT.actualArea, - ); - }); - - it('should get all components', () => { - const houseComponents = HouseComponents.create() - .cloneWith({ componentId: 'wall1', component: WALL_COMPONENT }) - .cloneWith({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT, - }); - - const allComponents = houseComponents.getAll(); - expect(allComponents).toEqual([ - { - id: 'wall1', - ...WALL_COMPONENT, - actualArea: WALL_COMPONENT.actualArea - WINDOW_COMPONENT.actualArea, - }, - { id: 'window1', ...WINDOW_COMPONENT }, - ]); - }); - - it('should throw an error if a component is its own parent', () => { - const houseComponents = HouseComponents.create(); - expect(() => - houseComponents.cloneWith({ - parentId: 'comp1', - componentId: 'comp1', - component: WALL_COMPONENT, - }), - ).throw('A component cannot be its own parent!'); - }); - - it('should throw an error if a component is already assigned to a different parent', () => { - const houseComponents = HouseComponents.create() - .cloneWith({ - parentId: 'wall1', - componentId: 'window1', - component: WINDOW_COMPONENT, - }) - .cloneWith({ componentId: 'wall2', component: WINDOW_COMPONENT }); - - expect(() => - houseComponents.cloneWith({ - parentId: 'wall2', - componentId: 'window1', - component: WALL_COMPONENT, - }), - ).throw("The component 'window1' is already assigned to a parent."); - }); - - it('should throw an error if a component does not exist', () => { - expect(() => HouseComponents.create().get('nonExistent')).throw( - "The house component 'nonExistent' was not found!", - ); - }); - - it('should throw an error if actual area is incorrect after accounting for children', () => { - const houseComponents = HouseComponents.create() - .cloneWith({ - componentId: 'wall1', - component: { ...WALL_COMPONENT, actualArea: 2 }, - }) - .cloneWith({ - parentId: 'wall1', - componentId: 'window1', - component: { ...WINDOW_COMPONENT, actualArea: 3 }, - }); - - expect(() => houseComponents.get('wall1')).throw( - "The actual area of house component 'wall1' is incorrect!", - ); - }); -}); diff --git a/src/models/HouseComponents.ts b/src/models/HouseComponents.ts deleted file mode 100644 index 047dfbf..0000000 --- a/src/models/HouseComponents.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { HouseComponent } from '@/types/houseComponent'; - -type HouseComponentResult = HouseComponent & { id: string }; - -/** - * Manages a tree-like structure of house components, ensuring immutability for efficient React updates. - */ -export class HouseComponents { - /** - * A map storing all house components, keyed by their unique ID. - */ - private readonly components: Map = new Map(); - - /** - * A map storing the parent ID of each component. - * It is useful to know which wall the window is associated with. - */ - private readonly parentComponentIds: Map = new Map(); - - /** - * Private constructor; instances should be created using the `create()` factory method. - * This allows to abstract the internal structure of the Class and to faciliate the instantiation of immutable object. - * @param initialComponents An optional initial set of components. - * @param initialParentComponentIds An optional initial set of parent-child relationships. - */ - private constructor( - initialComponents?: Map, - initialParentComponentIds?: Map, - ) { - this.components = initialComponents || new Map(); - this.parentComponentIds = initialParentComponentIds || new Map(); - } - - public static create(): HouseComponents { - return new HouseComponents(); - } - - /** - * Creates a new `HouseComponents` instance with the given component added or updated. The original instance remains unchanged. This method is designed to support immutable updates in React applications. - * @param parentId The ID of the parent component, or `undefined` if it's a root component (like a wall). - * @param componentId The unique ID of the component. - * @param component The component object itself. - * @throws {Error} If `parentId` is the same as `componentId` (a component cannot be its own parent), or if the component is already assigned to a different parent. - * @returns A new `HouseComponents` instance with the component added or updated. - */ - public cloneWith({ - parentId, - componentId, - component, - }: { - parentId?: string; - componentId: string; - component: HouseComponent; - }): HouseComponents { - if (parentId === componentId) { - throw new Error('A component cannot be its own parent!'); - } - - const newComponents = new Map(this.components); - newComponents.set(componentId, component); - - const newParentComponentIds = new Map(this.parentComponentIds); - - const currentParentId = newParentComponentIds.get(componentId); - - if (currentParentId && currentParentId !== parentId) { - throw new Error( - `The component '${componentId}' is already assigned to a parent.`, - ); - } - - if (parentId) { - newParentComponentIds.set(componentId, parentId); - } else { - newParentComponentIds.delete(componentId); - } - - return new HouseComponents(newComponents, newParentComponentIds); - } - - /** - * Retrieves all child components of a given parent component. - * @param parentId The ID of the parent component. - * @returns An array of child components. Returns an empty array if no children are found or the parent doesn't exist. - */ - private getChildren(parentId: string): HouseComponent[] { - return Array.from(this.parentComponentIds.entries()) - .filter(([_, v]) => v === parentId) - .map(([k, _]) => this.components.get(k)) - .filter((c): c is HouseComponent => Boolean(c)); - } - - /** - * Retrieves a component and calculates its actual area by subtracting the area of its children like the windows for a wall. - * @param componentId The ID of the component to retrieve. - * @returns The component object with its actual area calculated. - * @throws Error if the component is not found or if its actual area is incorrect (less than or equal to zero after accounting for children). - */ - public get(componentId: string): HouseComponent { - const component = this.components.get(componentId); - - if (!component) { - throw new Error(`The house component '${componentId}' was not found!`); - } - - const children = this.getChildren(componentId); - const totalChildrenArea = children.reduce( - (acc, comp) => acc + comp.actualArea, - 0, - ); - const actualArea = component.actualArea - totalChildrenArea; - - if (actualArea <= 0) { - throw new Error( - `The actual area of house component '${componentId}' is incorrect!`, - ); - } - - return { - ...component, - actualArea, - }; - } - - /** - * Retrieves all components along with their IDs. - * @returns An array of all components, each with its ID. - */ - public getAll(): HouseComponentResult[] { - return Array.from(this.components.keys()).map((k) => ({ - id: k, - ...this.get(k), - })); - } -} diff --git a/src/models/HouseComponentsConfigurator.test.ts b/src/models/HouseComponentsConfigurator.test.ts new file mode 100644 index 0000000..0e9bce3 --- /dev/null +++ b/src/models/HouseComponentsConfigurator.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from 'vitest'; + +import { BuildingMaterial } from '@/models/BuildingMaterial'; +import { HouseComponent } from '@/types/houseComponent'; +import { HouseComponentInsulation } from '@/types/houseComponentInsulation'; + +import { HouseComponentsConfigurator } from './HouseComponentsConfigurator'; + +const WALL_MATERIAL_1 = BuildingMaterial.create({ + thermalConductivity: 0.25, + thickness: 0.02, + price: 10, + name: 'random wall brick', +}); + +const WALL_COMPONENT_INSULATION: HouseComponentInsulation = { + insulationName: 'Brick', + buildingMaterials: [WALL_MATERIAL_1], + actualArea: 10, + componentType: HouseComponent.Wall, + size: { width: 5, height: 2 }, +}; + +const WINDOW_MATERIAL = BuildingMaterial.create({ + thermalConductivity: 0.25, + thickness: 0.02, + price: 10, + name: 'glass', +}); + +const WINDOW_COMPONENT_INSULATION: HouseComponentInsulation = { + insulationName: 'SinglePane', + buildingMaterials: [WINDOW_MATERIAL], + actualArea: 2, + componentType: HouseComponent.Window, + size: { width: 1, height: 2 }, +}; + +describe('HouseComponentsConfigurator', () => { + it('should create an empty instance using the static create method', () => { + const houseComponents = HouseComponentsConfigurator.create(); + expect(houseComponents.getAll().length).eq(0); + }); + + it('should add a new component and returns a new instance', () => { + const houseComponents = HouseComponentsConfigurator.create(); + const newHouseComponents = houseComponents.cloneWith({ + componentId: 'wall1', + component: WALL_COMPONENT_INSULATION, + }); + + expect(newHouseComponents.getAll().length).eq(1); + expect(newHouseComponents.get('wall1')).toEqual(WALL_COMPONENT_INSULATION); + + // Original instance should remain unchanged + expect(houseComponents.getAll().length).eq(0); + }); + + it('should add a child component', () => { + const houseComponents = HouseComponentsConfigurator.create().cloneWith({ + componentId: 'wall1', + component: WALL_COMPONENT_INSULATION, + }); + const newHouseComponents = houseComponents.cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT_INSULATION, + }); + + expect(newHouseComponents.getAll().length).eq(2); + expect(newHouseComponents.get('window1')).toEqual( + WINDOW_COMPONENT_INSULATION, + ); + expect(newHouseComponents.get('wall1').actualArea).eq( + WALL_COMPONENT_INSULATION.actualArea - + WINDOW_COMPONENT_INSULATION.actualArea, + ); + }); + + it('should get a component', () => { + const houseComponents = HouseComponentsConfigurator.create() + .cloneWith({ componentId: 'wall1', component: WALL_COMPONENT_INSULATION }) + .cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT_INSULATION, + }); + + const wall = houseComponents.get('wall1'); + expect(wall.actualArea).eq( + WALL_COMPONENT_INSULATION.actualArea - + WINDOW_COMPONENT_INSULATION.actualArea, + ); + }); + + it('should get all components', () => { + const houseComponents = HouseComponentsConfigurator.create() + .cloneWith({ componentId: 'wall1', component: WALL_COMPONENT_INSULATION }) + .cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT_INSULATION, + }); + + const allComponents = houseComponents.getAll(); + expect(allComponents).toEqual([ + { + houseComponentId: 'wall1', + ...WALL_COMPONENT_INSULATION, + actualArea: + WALL_COMPONENT_INSULATION.actualArea - + WINDOW_COMPONENT_INSULATION.actualArea, + }, + { houseComponentId: 'window1', ...WINDOW_COMPONENT_INSULATION }, + ]); + }); + + it('should get the good component by type', () => { + const houseConfigurator = HouseComponentsConfigurator.create() + .cloneWith({ + componentId: 'wall1', + component: WALL_COMPONENT_INSULATION, + }) + .cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT_INSULATION, + }); + + expect(houseConfigurator.getAll().length).eq(2); + expect( + houseConfigurator.getFirstOfType(HouseComponent.Wall)?.houseComponentId, + ).toEqual('wall1'); + expect( + houseConfigurator.getFirstOfType(HouseComponent.Wall)?.actualArea, + ).toEqual( + WALL_COMPONENT_INSULATION.actualArea - + WINDOW_COMPONENT_INSULATION.actualArea, + ); + expect( + houseConfigurator.getFirstOfType(HouseComponent.Window)?.houseComponentId, + ).toEqual('window1'); + }); + + it('should update the wall components but not the windows', () => { + const houseComponents = HouseComponentsConfigurator.create().cloneWith({ + componentId: 'wall1', + component: WALL_COMPONENT_INSULATION, + }); + const newHouseComponents = houseComponents.cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT_INSULATION, + }); + + expect(newHouseComponents.getAll().length).eq(2); + expect(newHouseComponents.get('window1')).toEqual( + WINDOW_COMPONENT_INSULATION, + ); + expect(newHouseComponents.get('wall1').actualArea).eq( + WALL_COMPONENT_INSULATION.actualArea - + WINDOW_COMPONENT_INSULATION.actualArea, + ); + + // update the house component + const newMaterial = WALL_MATERIAL_1.from({ price: 15, thickness: 2 }); + const updatedHouseComponents = newHouseComponents.cloneWithNewInsulation({ + componentType: HouseComponent.Wall, + insulation: { + name: 'Aerogel', + buildingMaterials: [newMaterial], + }, + }); + + // check that the walls have been updated + const allWalls = updatedHouseComponents.getByType(HouseComponent.Wall); + + expect(allWalls.length).toBe(1); + expect(allWalls[0].buildingMaterials.length).toBe(1); + expect(allWalls[0].buildingMaterials[0]).toBe(newMaterial); + + // check that the windows have not been updated + const allWindows = updatedHouseComponents.getByType(HouseComponent.Window); + + expect(allWindows.length).toBe(1); + expect(allWindows[0].buildingMaterials.length).toBe(1); + expect(allWindows[0].buildingMaterials[0]).not.toBe(newMaterial); + expect(allWindows[0].buildingMaterials[0]).toBe(WINDOW_MATERIAL); + }); + + it('should update the windows components but not the walls', () => { + const houseComponents = HouseComponentsConfigurator.create().cloneWith({ + componentId: 'wall1', + component: WALL_COMPONENT_INSULATION, + }); + const newHouseComponents = houseComponents.cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT_INSULATION, + }); + + expect(newHouseComponents.getAll().length).eq(2); + expect(newHouseComponents.get('window1')).toEqual( + WINDOW_COMPONENT_INSULATION, + ); + expect(newHouseComponents.get('wall1').actualArea).eq( + WALL_COMPONENT_INSULATION.actualArea - + WINDOW_COMPONENT_INSULATION.actualArea, + ); + + // update the house component + const newMaterial = WINDOW_MATERIAL.from({ price: 15, thickness: 2 }); + const updatedHouseComponents = newHouseComponents.cloneWithNewInsulation({ + componentType: HouseComponent.Window, + insulation: { + name: 'DoublePane', + buildingMaterials: [newMaterial], + }, + }); + + // check that the walls have not been updated + const allWalls = updatedHouseComponents.getByType(HouseComponent.Wall); + + expect(allWalls.length).toBe(1); + expect(allWalls[0].buildingMaterials.length).toBe(1); + expect(allWalls[0].buildingMaterials[0]).not.toBe(newMaterial); + expect(allWalls[0].buildingMaterials[0]).toBe(WALL_MATERIAL_1); + + // check that the windows have been updated + const allWindows = updatedHouseComponents.getByType(HouseComponent.Window); + + expect(allWindows.length).toBe(1); + expect(allWindows[0].buildingMaterials.length).toBe(1); + expect(allWindows[0].buildingMaterials[0]).toBe(newMaterial); + }); + + it('should throw an error if a component is its own parent', () => { + const houseComponents = HouseComponentsConfigurator.create(); + expect(() => + houseComponents.cloneWith({ + parentId: 'comp1', + componentId: 'comp1', + component: WALL_COMPONENT_INSULATION, + }), + ).throw('A component cannot be its own parent!'); + }); + + it('should throw an error if a component is already assigned to a different parent', () => { + const houseComponents = HouseComponentsConfigurator.create() + .cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: WINDOW_COMPONENT_INSULATION, + }) + .cloneWith({ + componentId: 'wall2', + component: WINDOW_COMPONENT_INSULATION, + }); + + expect(() => + houseComponents.cloneWith({ + parentId: 'wall2', + componentId: 'window1', + component: WALL_COMPONENT_INSULATION, + }), + ).throw("The component 'window1' is already assigned to a parent."); + }); + + it('should throw an error if a component does not exist', () => { + expect(() => HouseComponentsConfigurator.create().get('nonExistent')).throw( + "The house component 'nonExistent' was not found!", + ); + }); + + it('should throw an error if actual area is incorrect after accounting for children', () => { + const houseComponents = HouseComponentsConfigurator.create() + .cloneWith({ + componentId: 'wall1', + component: { ...WALL_COMPONENT_INSULATION, actualArea: 2 }, + }) + .cloneWith({ + parentId: 'wall1', + componentId: 'window1', + component: { ...WINDOW_COMPONENT_INSULATION, actualArea: 3 }, + }); + + expect(() => houseComponents.get('wall1')).throw( + "The actual area of house component 'wall1' is incorrect!", + ); + }); +}); diff --git a/src/models/HouseComponentsConfigurator.ts b/src/models/HouseComponentsConfigurator.ts new file mode 100644 index 0000000..c3d8647 --- /dev/null +++ b/src/models/HouseComponentsConfigurator.ts @@ -0,0 +1,232 @@ +import { HouseInsulationPerComponent } from '@/config/houseInsulations'; +import { HouseComponent } from '@/types/houseComponent'; +import { HouseComponentInsulation } from '@/types/houseComponentInsulation'; +import { NonEmptyArray } from '@/types/utils'; + +import { BuildingMaterial } from './BuildingMaterial'; + +type HouseComponentInsulationResult = HouseComponentInsulation & { + houseComponentId: string; +}; + +/** + * Manages a tree-like structure of house component insulations, ensuring immutability for efficient React updates. + */ +export class HouseComponentsConfigurator { + /** + * A map storing all house component insulations, keyed by their unique ID. + */ + private readonly components: Map = + new Map(); + + /** + * A map storing the parent ID of each component. + * It is useful to know which wall the window is associated with. + */ + private readonly parentComponentIds: Map = new Map(); + + /** + * Private constructor; instances should be created using the `create()` factory method. + * This allows to abstract the internal structure of the Class and to faciliate the instantiation of immutable object. + * @param initialComponents An optional initial set of components. + * @param initialParentComponentIds An optional initial set of parent-child relationships. + */ + private constructor( + initialComponents?: Map, + initialParentComponentIds?: Map, + ) { + this.components = initialComponents || new Map(); + this.parentComponentIds = initialParentComponentIds || new Map(); + } + + public static create(): HouseComponentsConfigurator { + return new HouseComponentsConfigurator(); + } + + /** + * Creates a new `HouseComponentsConfigurator` instance with the given component added or updated. The original instance remains unchanged. This method is designed to support immutable updates in React applications. + * @param parentId The ID of the parent component, or `undefined` if it's a root component (like a wall). + * @param componentId The unique ID of the component. + * @param component The component object itself. + * @throws {Error} If `parentId` is the same as `componentId` (a component cannot be its own parent), or if the component is already assigned to a different parent. + * @returns A new `HouseComponentsConfigurator` instance with the component added or updated. + */ + public cloneWith({ + parentId, + componentId, + component, + }: { + parentId?: string; + componentId: string; + component: HouseComponentInsulation; + }): HouseComponentsConfigurator { + if (parentId === componentId) { + throw new Error('A component cannot be its own parent!'); + } + + const newComponents = new Map(this.components); + newComponents.set(componentId, component); + + const newParentComponentIds = new Map(this.parentComponentIds); + + const currentParentId = newParentComponentIds.get(componentId); + + if (currentParentId && currentParentId !== parentId) { + throw new Error( + `The component '${componentId}' is already assigned to a parent.`, + ); + } + + if (parentId) { + newParentComponentIds.set(componentId, parentId); + } else { + newParentComponentIds.delete(componentId); + } + + return new HouseComponentsConfigurator( + newComponents, + newParentComponentIds, + ); + } + + /** + * Creates a new `HouseComponentsConfigurator` instance with the given component's insulation updated. The original instance remains unchanged. This method is designed to support immutable updates in React applications. + * @param componentType The component type to udpate with the new insulation. + * @param insulation The new insulation to use to update the components of the given type. + * @returns A new `HouseComponentsConfigurator` instance with the components' insulation of the given type updated. + */ + public cloneWithNewInsulation< + T extends HouseComponent, + K extends keyof (typeof HouseInsulationPerComponent)[T], + >({ + componentType, + insulation, + }: { + componentType: T; + insulation: { + name: K; + buildingMaterials: NonEmptyArray; + }; + }): HouseComponentsConfigurator { + if (!insulation.name) { + throw new Error( + `The insulation should be defined for component ${componentType}!`, + ); + } + + const newComponents = Array.from(this.components.entries()).map( + ([k, v]) => { + const shouldUdpate = v.componentType === componentType; + const update = { + insulationName: shouldUdpate ? insulation.name : v.insulationName, + buildingMaterials: shouldUdpate + ? insulation.buildingMaterials + : v.buildingMaterials, + }; + + return [k, { ...v, ...update }] as [string, HouseComponentInsulation]; + }, + ); + + return new HouseComponentsConfigurator( + new Map(newComponents), + new Map(this.parentComponentIds), + ); + } + + /** + * Retrieves all child components of a given parent component. + * @param parentId The ID of the parent component. + * @returns An array of child components. Returns an empty array if no children are found or the parent doesn't exist. + */ + private getChildren(parentId: string): HouseComponentInsulation[] { + return Array.from(this.parentComponentIds.entries()) + .filter(([_, v]) => v === parentId) + .map(([k, _]) => this.components.get(k)) + .filter((c): c is HouseComponentInsulation => Boolean(c)); + } + + /** + * Retrieves a component and calculates its actual area by subtracting the area of its children like the windows for a wall. + * @param componentId The ID of the component to retrieve. + * @returns The component object with its actual area calculated. + * @throws Error if the component is not found or if its actual area is incorrect (less than or equal to zero after accounting for children). + */ + public get(componentId: string): HouseComponentInsulation { + const component = this.components.get(componentId); + + if (!component) { + throw new Error(`The house component '${componentId}' was not found!`); + } + + const children = this.getChildren(componentId); + const totalChildrenArea = children.reduce( + (acc, comp) => acc + comp.actualArea, + 0, + ); + const actualArea = component.actualArea - totalChildrenArea; + + if (actualArea <= 0) { + throw new Error( + `The actual area of house component '${componentId}' is incorrect!`, + ); + } + + return { + ...component, + actualArea, + }; + } + + /** + * Retrieves all components along with their IDs. + * @returns An array of all components, each with its ID. + */ + public getAll(): HouseComponentInsulationResult[] { + return Array.from(this.components.keys()).map((k) => ({ + houseComponentId: k, + // Use the get method to return with the correct actual area. + ...this.get(k), + })); + } + + /** + * Retrieves all the components of the given type. + * @param componentType The type of the searched components. + * @returns An array of components, each with its ID. + */ + public getByType( + componentType: HouseComponent, + ): HouseComponentInsulationResult[] { + return Array.from(this.components.entries()) + .filter(([_, v]) => v.componentType === componentType) + .map(([k, _]) => ({ + houseComponentId: k, + // Use the get method to return with the correct actual area. + ...this.get(k), + })); + } + + /** + * Retrieves the first component of the given type or undefined. + * @param componentType The type of the searched components. + * @returns The first component with its ID or undefined. + */ + public getFirstOfType( + componentType: HouseComponent, + ): HouseComponentInsulationResult | undefined { + const first = Array.from(this.components.entries()).find( + ([_, v]) => v.componentType === componentType, + ); + + if (!first) { + return undefined; + } + + return { + houseComponentId: first[0], + // Use the get method to return with the correct actual area. + ...this.get(first[0]), + }; + } +} diff --git a/src/modules/models/House/ResidentialHouse/Roof.tsx b/src/modules/models/House/ResidentialHouse/Roof.tsx index 0e26d68..019213a 100644 --- a/src/modules/models/House/ResidentialHouse/Roof.tsx +++ b/src/modules/models/House/ResidentialHouse/Roof.tsx @@ -1,3 +1,6 @@ +import { useWallMaterial } from '@/hooks/useWallMaterial'; +import { useWindowMaterial } from '@/hooks/useWindowMaterial'; + import { GLTFResult } from './useResidentialHouse'; const RoofWindows = ({ @@ -6,38 +9,44 @@ const RoofWindows = ({ }: { nodes: GLTFResult['nodes']; materials: GLTFResult['materials']; -}): JSX.Element => ( - <> - - - - - - - - - - - -); +}): JSX.Element => { + const { frameMaterial } = useWindowMaterial({ + windowMaterial: materials.Roof, + }); + + return ( + <> + + + + + + + + + + + + ); +}; export const Roof = ({ nodes, @@ -45,18 +54,22 @@ export const Roof = ({ }: { nodes: GLTFResult['nodes']; materials: GLTFResult['materials']; -}): JSX.Element => ( - - - - - +}): JSX.Element => { + const wallMaterial = useWallMaterial({ wallMaterial: materials.Wall }); + + return ( + + + + + - - -); + + + ); +}; diff --git a/src/modules/models/House/ResidentialHouse/Wall.tsx b/src/modules/models/House/ResidentialHouse/Wall.tsx index 9edc28a..addead6 100644 --- a/src/modules/models/House/ResidentialHouse/Wall.tsx +++ b/src/modules/models/House/ResidentialHouse/Wall.tsx @@ -2,7 +2,8 @@ import { memo, useEffect } from 'react'; import { useHouseComponents } from '@/context/HouseComponentsContext'; import { useSimulation } from '@/context/SimulationContext'; -import { HouseComponentType } from '@/types/houseComponent'; +import { useWallMaterial } from '@/hooks/useWallMaterial'; +import { HouseComponent } from '@/types/houseComponent'; import { WallProps } from '@/types/wall'; import { HeatLossArrow } from '../../HeatLossArrow/HeatLossArrow'; @@ -28,12 +29,15 @@ const WallComponent = ({ const { registerComponent } = useHouseComponents(); const heatLoss = heatLosses[id] ?? 0; + const material = useWallMaterial({ wallMaterial: materials.Wall }); + useEffect(() => { registerComponent({ componentId: id, size: getComponentSize(nodes[wallProps.geometryKey].geometry), - componentType: HouseComponentType.Wall, + componentType: HouseComponent.Wall, }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -42,7 +46,7 @@ const WallComponent = ({ {/* The wall Mesh */} {/* Window Frame */} - + {/* Windows Glasses */} { +export const getComponentSize = ( + geometry: BufferGeometry, + scale: Vector3 = new Vector3(1, 1, 1), +): Size => { const size = new Vector3(); geometry.boundingBox?.getSize(size); - const { x, y: height, z } = size; + const { x, y: height, z } = size.multiply(scale); // We only want the height and width of the component, not the thickness. // Because it depends on the axes, the thickness can be x or z. @@ -66,8 +62,6 @@ export const getComponentSize = (geometry: BufferGeometry): Size => { export const useResidentialHouse = (): UseResidentialHouse => { const { nodes, materials } = useGLTF(GLB_FILE_PATH) as GLTFResult; - materials.Wall.color = COLORS.aerogel; - return { nodes, materials, diff --git a/src/modules/scenes/FirstScene.tsx b/src/modules/scenes/FirstScene.tsx index 4510012..c4ec6f1 100644 --- a/src/modules/scenes/FirstScene.tsx +++ b/src/modules/scenes/FirstScene.tsx @@ -9,61 +9,69 @@ import { SIMULATION_CSV_FILE, SIMULATION_FRAME_MS } from '@/config/simulation'; import { HouseComponentsProvider } from '@/context/HouseComponentsContext'; import { SeasonProvider } from '@/context/SeasonContext'; import { SimulationProvider, useSimulation } from '@/context/SimulationContext'; +import { WindowSizeProvider } from '@/context/WindowSizeContext'; import { SimulationStatus } from '@/types/simulation'; import { Forest } from '../models/Forest'; import { Garden } from '../models/Garden'; import { ResidentialHouse } from '../models/House/ResidentialHouse/ResidentialHouse'; import { Tree } from '../models/Tree/Tree'; +import { SimulationControlPanel } from './SimulationControlPanel/SimulationControlPanel'; import { SimulationInformations } from './SimulationInformations/SimulationInformations'; const FirstSceneComponent = (): JSX.Element => { const { startSimulation, status } = useSimulation(); return ( - - + + + - - - {/* Ambient Light for overall illumination */} - - {/* Main Sunlight Simulation */} - - - - - - - - - - - {status === SimulationStatus.RUNNING ? ( - - ) : ( - - )} - + + {/* Ambient Light for overall illumination */} + + {/* Main Sunlight Simulation */} + + + + + + + + + + + {status === SimulationStatus.RUNNING ? ( + + ) : ( + + )} + + + + + + ); @@ -76,7 +84,9 @@ const FirstScene = (): JSX.Element => ( simulationFrameMS={SIMULATION_FRAME_MS} > - + + + diff --git a/src/modules/scenes/SimulationControlPanel/HouseControl.tsx b/src/modules/scenes/SimulationControlPanel/HouseControl.tsx new file mode 100644 index 0000000..c5d5dae --- /dev/null +++ b/src/modules/scenes/SimulationControlPanel/HouseControl.tsx @@ -0,0 +1,130 @@ +import { + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + Stack, +} from '@mui/material'; + +import { SlidersHorizontal } from 'lucide-react'; + +import { + HOUSE_INSULATIONS, + HouseInsulationPerComponent, +} from '@/config/houseInsulations'; +import { + SIMULATION_DEFAULT_WALL_COMPONENT_INSULATION, + SIMULATION_DEFAULT_WINDOW_COMPONENT_INSULATION, +} from '@/config/simulation'; +import { useHouseComponents } from '@/context/HouseComponentsContext'; +import { useDialogControl } from '@/hooks/useDialogControl'; +import { HouseComponent } from '@/types/houseComponent'; + +import { MaterialControlDialog } from './MaterialControlDialog/MaterialControlDialog'; +import { WindowControlDialog } from './WindowControlDialog/WindowControlDialog'; + +export const HouseControl = (): JSX.Element => { + const { changeComponentInsulation } = useHouseComponents(); + + const { + open: openMaterials, + handleOpen: handleOpenMaterials, + handleClose: handleCloseMaterials, + } = useDialogControl(); + + const { + open: openWindows, + handleOpen: handleOpenWindows, + handleClose: handleCloseWindows, + } = useDialogControl(); + + const handleInsulationChange = (newValue: string): void => { + changeComponentInsulation({ + componentType: HouseComponent.Wall, + newInsulation: newValue as keyof typeof HouseInsulationPerComponent.Wall, + }); + }; + + const handleWindowChange = (newValue: string): void => { + changeComponentInsulation({ + componentType: HouseComponent.Window, + newInsulation: + newValue as keyof typeof HouseInsulationPerComponent.Window, + }); + }; + + return ( + <> + + + + + + Material + + + + {/* Avoid to deform the icon button */} + + + + + + + + + Window Insulation + + + + + {/* Avoid to deform the icon button */} + + + + + + + ); +}; diff --git a/src/modules/scenes/SimulationControlPanel/MaterialControlDialog/FormControlValidator.tsx b/src/modules/scenes/SimulationControlPanel/MaterialControlDialog/FormControlValidator.tsx new file mode 100644 index 0000000..1722922 --- /dev/null +++ b/src/modules/scenes/SimulationControlPanel/MaterialControlDialog/FormControlValidator.tsx @@ -0,0 +1,132 @@ +import { useMemo, useState } from 'react'; + +import { + FormControl, + FormHelperText, + InputAdornment, + InputLabel, + OutlinedInput, +} from '@mui/material'; + +const FORM_ERROR_KEYS = { + Required: 'Required', + InvalidNumber: 'InvalidNumber', + Min: 'Min', + Max: 'Max', +} as const; + +export type ValidationRule = { + test: (value: string) => boolean; + message: string; +}; + +type ValidationRulesProps = { + required?: boolean; + isNumber?: boolean; + min?: number; + max?: number; + customRules?: ValidationRule[]; +}; + +type FormControlValidatorProps = { + label: string; + value: string; + onChange: (value: string) => void; + validationRules?: ValidationRulesProps; + inputType?: 'number' | 'text'; + unit?: React.ReactNode; +}; + +const ValidationRulesFactory = ( + validationRules?: ValidationRulesProps, +): ValidationRule[] => { + if (!validationRules) { + return []; + } + + const { required, isNumber, min, max, customRules } = validationRules; + const rules: ValidationRule[] = []; + + if (required) { + rules.push({ + test: (v: string) => Boolean(v), + message: FORM_ERROR_KEYS.Required, + }); + } + + if (min !== undefined || max !== undefined || isNumber) { + rules.push({ + test: (v: string) => !Number.isNaN(Number.parseFloat(v)), + message: FORM_ERROR_KEYS.InvalidNumber, + }); + } + + if (min !== undefined) { + rules.push({ + test: (v: string) => Number.parseFloat(v) >= min, + message: FORM_ERROR_KEYS.Min, + }); + } + + if (max !== undefined) { + rules.push({ + test: (v: string) => Number.parseFloat(v) < max, + message: FORM_ERROR_KEYS.Max, + }); + } + + if (customRules) { + rules.push(...customRules); + } + + return rules; +}; + +export const FormControlValidator = ({ + label, + value, + onChange, + validationRules, + inputType, + unit, +}: FormControlValidatorProps): JSX.Element => { + const [controlledValue, setControlledValue] = useState(String(value)); + const [error, setError] = useState(); + const rules = useMemo( + () => ValidationRulesFactory(validationRules), + [validationRules], + ); + + const handleValueChange = (newValue: string): void => { + setControlledValue(newValue); + + const { message } = rules.find((rule) => !rule.test(newValue)) ?? {}; + + setError(message); + + if (!message) { + onChange(newValue); + } + }; + + return ( + + + {label} + + handleValueChange(e.target.value)} + type={inputType} + endAdornment={ + unit ? ( + {unit} + ) : undefined + } + /> + {error && {error}} + + ); +}; diff --git a/src/modules/scenes/SimulationControlPanel/MaterialControlDialog/MaterialControlDialog.tsx b/src/modules/scenes/SimulationControlPanel/MaterialControlDialog/MaterialControlDialog.tsx new file mode 100644 index 0000000..298fe45 --- /dev/null +++ b/src/modules/scenes/SimulationControlPanel/MaterialControlDialog/MaterialControlDialog.tsx @@ -0,0 +1,133 @@ +import { TabContext, TabList, TabPanel } from '@mui/lab'; +import { + FormControl, + InputAdornment, + InputLabel, + OutlinedInput, + Stack, + Tab, +} from '@mui/material'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; + +import { FormControlValidator } from './FormControlValidator'; +import { useMaterialControlDialog } from './useMaterialControlDialog'; + +type Props = { + open: boolean; + handleClose: () => void; +}; + +export const MaterialControlDialog = ({ + open, + handleClose, +}: Props): JSX.Element => { + const { + currTab, + updateTab, + wallMaterials, + handleThicknessChange, + handlePriceChange, + } = useMaterialControlDialog(); + + return ( + + + House Wall Materials + + + + + + updateTab(v)}> + {wallMaterials?.map((w) => ( + + ))} + + + + {wallMaterials?.map((w) => ( + + + + handlePriceChange(w.name, Number.parseFloat(newValue)) + } + validationRules={{ + required: true, + isNumber: true, + min: 0, + max: 100_000, + }} + inputType="number" + unit={ + <> + CHF/m3 + + } + /> + + + handleThicknessChange( + w.name, + Number.parseFloat(newValue) / 100, + ) + } + validationRules={{ + required: true, + isNumber: true, + min: 1e-5, + max: 100, + customRules: [ + // Should not equals to 0 because of the equation: + // We divide by the thickness, so we should not divide by 0! + { + test: (v) => Number.parseFloat(v) !== 0, + message: 'Cannot equals to 0.', + }, + ], + }} + inputType="number" + unit="cm" + /> + + + + Thermal Conductivity + + W/m·K + } + /> + + + + ))} + + + + + + + + ); +}; diff --git a/src/modules/scenes/SimulationControlPanel/MaterialControlDialog/useMaterialControlDialog.tsx b/src/modules/scenes/SimulationControlPanel/MaterialControlDialog/useMaterialControlDialog.tsx new file mode 100644 index 0000000..7ab3e0f --- /dev/null +++ b/src/modules/scenes/SimulationControlPanel/MaterialControlDialog/useMaterialControlDialog.tsx @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useHouseComponents } from '@/context/HouseComponentsContext'; +import { BuildingMaterial } from '@/models/BuildingMaterial'; +import { HouseComponent } from '@/types/houseComponent'; +import { NonEmptyArray } from '@/types/utils'; + +type UseMaterialControlDialogReturnType = { + currTab: string; + updateTab: (tab: string) => void; + handleThicknessChange: (materialName: string, newThickness: number) => void; + handlePriceChange: (materialName: string, newPrice: number) => void; + wallMaterials?: NonEmptyArray; +}; + +export const useMaterialControlDialog = + (): UseMaterialControlDialogReturnType => { + const { houseComponentsConfigurator, updateCompositionOfInsulation } = + useHouseComponents(); + const [currTab, setCurrTab] = useState(''); + + const wallComponents = useMemo( + () => houseComponentsConfigurator.getFirstOfType(HouseComponent.Wall), + [houseComponentsConfigurator], + ); + const wallMaterials = wallComponents?.buildingMaterials; + + useEffect(() => { + if ( + wallMaterials?.length && + !wallMaterials.some((m) => m.name === currTab) + ) { + setCurrTab(wallMaterials[0].name); + } + }, [currTab, wallMaterials]); + + const handleThicknessChange = ( + materialName: string, + newThickness: number, + ): void => { + updateCompositionOfInsulation({ + componentType: HouseComponent.Wall, + materialProps: { + name: materialName, + thickness: newThickness, + }, + }); + }; + + const handlePriceChange = ( + materialName: string, + newPrice: number, + ): void => { + updateCompositionOfInsulation({ + componentType: HouseComponent.Wall, + materialProps: { + name: materialName, + price: newPrice, + }, + }); + }; + + return { + currTab, + wallMaterials, + updateTab: (tab) => setCurrTab(tab), + handleThicknessChange, + handlePriceChange, + }; + }; diff --git a/src/modules/scenes/SimulationControlPanel/SimulationControlPanel.tsx b/src/modules/scenes/SimulationControlPanel/SimulationControlPanel.tsx new file mode 100644 index 0000000..3983ad9 --- /dev/null +++ b/src/modules/scenes/SimulationControlPanel/SimulationControlPanel.tsx @@ -0,0 +1,21 @@ +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; + +import { HouseControl } from './HouseControl'; + +export const SimulationControlPanel = (): JSX.Element => ( + + } + aria-controls="panel-house-content" + id="panel-house-header" + > + House + + + + + +); diff --git a/src/modules/scenes/SimulationControlPanel/WindowControlDialog/WindowControlDialog.tsx b/src/modules/scenes/SimulationControlPanel/WindowControlDialog/WindowControlDialog.tsx new file mode 100644 index 0000000..2aa5644 --- /dev/null +++ b/src/modules/scenes/SimulationControlPanel/WindowControlDialog/WindowControlDialog.tsx @@ -0,0 +1,122 @@ +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@mui/material'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; + +import { useHouseComponents } from '@/context/HouseComponentsContext'; +import { + WindowSizeType, + WindowSizes, + useWindowSize, +} from '@/context/WindowSizeContext'; +import { HouseComponent } from '@/types/houseComponent'; +import { formatComponentSize } from '@/utils/formatComponentSize'; + +type Props = { + open: boolean; + handleClose: () => void; +}; + +export const WindowControlDialog = ({ + open, + handleClose, +}: Props): JSX.Element | null => { + const { changeWindowSize, windowSize } = useWindowSize(); + const { houseComponentsConfigurator } = useHouseComponents(); + const windowComponent = houseComponentsConfigurator.getFirstOfType( + HouseComponent.Window, + ); + + if (!windowComponent) { + return null; + } + + const handleSizeChange = (newSize: string): void => { + changeWindowSize(newSize as WindowSizeType); + }; + + return ( + + House Windows + + + + + Window Size + + + Dimensions:{' '} + {formatComponentSize({ componentSize: windowComponent.size })} + + + + + + + + + Name + Tickness + Thermal Conductivity + + + + {windowComponent.buildingMaterials.map((material, idx) => ( + + {material.name} + + {material.thickness * 100} cm + + + {material.thermalConductivity} W/m·K + + + ))} + +
+ Composition of the {windowComponent.insulationName} Windows +
+
+
+
+
+ + + +
+ ); +}; diff --git a/src/modules/scenes/SimulationInformations/SimulationInformations.tsx b/src/modules/scenes/SimulationInformations/SimulationInformations.tsx index d5dab0d..e4ab12d 100644 --- a/src/modules/scenes/SimulationInformations/SimulationInformations.tsx +++ b/src/modules/scenes/SimulationInformations/SimulationInformations.tsx @@ -25,7 +25,8 @@ export const SimulationInformations = (): JSX.Element => { electricityCost, } = useSimulation(); - const { seasonIcon, heatLoss } = useSimulationInformations(); + const { seasonIcon, heatLoss, formattedWallSize } = + useSimulationInformations(); return ( { - House Walls - - 8.1 x 2.85 m2 - + + House Walls + {formattedWallSize} +
diff --git a/src/modules/scenes/SimulationInformations/useSimulationInformations.tsx b/src/modules/scenes/SimulationInformations/useSimulationInformations.tsx index 07a6da4..d8f0308 100644 --- a/src/modules/scenes/SimulationInformations/useSimulationInformations.tsx +++ b/src/modules/scenes/SimulationInformations/useSimulationInformations.tsx @@ -1,9 +1,12 @@ import { Flower, Leaf, Snowflake, Sun } from 'lucide-react'; +import { useHouseComponents } from '@/context/HouseComponentsContext'; import { useSeason } from '@/context/SeasonContext'; import { useSimulation } from '@/context/SimulationContext'; import { FormattedHeatLoss } from '@/types/heatLoss'; +import { HouseComponent } from '@/types/houseComponent'; import { Season, Seasons } from '@/types/seasons'; +import { formatComponentSize } from '@/utils/formatComponentSize'; import { formatHeatLossRate } from '@/utils/heatLoss'; type IconBySeasonType = { [s in Season]: JSX.Element }; @@ -18,6 +21,7 @@ const iconsBySeason: IconBySeasonType = { type UseSimulationInformationsReturnType = { heatLoss: FormattedHeatLoss; seasonIcon: JSX.Element; + formattedWallSize: React.ReactNode; }; export const useSimulationInformations = @@ -26,6 +30,12 @@ export const useSimulationInformations = const { heatLosses } = useSimulation(); + const { houseComponentsConfigurator } = useHouseComponents(); + + const wallComponent = houseComponentsConfigurator.getFirstOfType( + HouseComponent.Wall, + ); + const heatLoss = formatHeatLossRate( Object.values(heatLosses).reduce((acc, heat) => acc + heat, 0), ); @@ -33,5 +43,10 @@ export const useSimulationInformations = return { heatLoss, seasonIcon: iconsBySeason[season], + formattedWallSize: wallComponent + ? formatComponentSize({ + componentSize: wallComponent.size, + }) + : '-', }; }; diff --git a/src/types/houseComponent.ts b/src/types/houseComponent.ts index feb01f6..3986f74 100644 --- a/src/types/houseComponent.ts +++ b/src/types/houseComponent.ts @@ -1,7 +1,4 @@ -import { Material } from './material'; -import { NonEmptyArray } from './utils'; - -export enum HouseComponentType { +export enum HouseComponent { Wall = 'Wall', Window = 'Window', Door = 'Door', @@ -15,15 +12,3 @@ export type Size = { width: number; height: number; }; - -export type HouseComponent = { - materials: NonEmptyArray; - size: Size; - componentType: HouseComponentType; - /** - * The actual surface area, taking into account other elements. - * For example, for a wall, potential windows or doors must be taken into account, - * which means that the effective surface area is the surface area of the wall - the surface area of the windows. - * */ - actualArea: number; -}; diff --git a/src/types/houseComponentInsulation.ts b/src/types/houseComponentInsulation.ts new file mode 100644 index 0000000..c405162 --- /dev/null +++ b/src/types/houseComponentInsulation.ts @@ -0,0 +1,18 @@ +import { HouseInsulation } from '@/config/houseInsulations'; +import { BuildingMaterial } from '@/models/BuildingMaterial'; + +import { HouseComponent, Size } from './houseComponent'; +import { NonEmptyArray } from './utils'; + +export type HouseComponentInsulation = { + insulationName: HouseInsulation; + componentType: HouseComponent; + buildingMaterials: NonEmptyArray; + size: Size; + /** + * The actual surface area, taking into account other elements. + * For example, for a wall, potential windows or doors must be taken into account, + * which means that the effective surface area is the surface area of the wall - the surface area of the windows. + * */ + actualArea: number; +}; diff --git a/src/types/material.ts b/src/types/material.ts deleted file mode 100644 index afe225f..0000000 --- a/src/types/material.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type Material = { - price: number; - thermalConductivity: number; - thickness: number; -}; diff --git a/src/types/utils.ts b/src/types/utils.ts index 0c6620a..7969cfd 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -1 +1,9 @@ export type NonEmptyArray = readonly [T, ...T[]]; +export const CreateNonEmptyArray = (arr: T[]): NonEmptyArray => { + const [first, ...rest] = arr; + if (!first) { + throw new Error('Cannot create a NonEmptyArray from an empy array!'); + } + + return [first, ...rest]; +}; diff --git a/src/utils/colors.ts b/src/utils/colors.ts index e8fda99..a5428f9 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -9,3 +9,13 @@ export const fromRGB = ({ g: number; b: number; }): Color => new Color(...[r, g, b]); + +export const fromHSL = ({ + h, + s, + l, +}: { + h: number; + s: number; + l: number; +}): Color => new Color().setHSL(...[h, s, l]); diff --git a/src/utils/formatComponentSize.tsx b/src/utils/formatComponentSize.tsx new file mode 100644 index 0000000..6295581 --- /dev/null +++ b/src/utils/formatComponentSize.tsx @@ -0,0 +1,20 @@ +import { Size } from '@/types/houseComponent'; + +type Props = { componentSize: Size; precision?: number }; +export const formatComponentSize = ({ + componentSize, + precision = 2, +}: Props): JSX.Element => { + const formattedWidth = parseFloat(componentSize.width.toString()).toFixed( + precision, + ); + const formattedHeight = parseFloat(componentSize.height.toString()).toFixed( + precision, + ); + + return ( + <> + {formattedWidth} x {formattedHeight} m2 + + ); +}; diff --git a/src/utils/heatLoss.ts b/src/utils/heatLoss.ts index ac55edb..761706b 100644 --- a/src/utils/heatLoss.ts +++ b/src/utils/heatLoss.ts @@ -1,5 +1,5 @@ +import { BuildingMaterial } from '@/models/BuildingMaterial'; import { FormattedHeatLoss, HeatLossUnit } from '@/types/heatLoss'; -import { Material } from '@/types/material'; import { TimeUnitType } from '@/types/time'; import { NonEmptyArray } from '@/types/utils'; @@ -19,7 +19,9 @@ export const calculateHeatLossConstantFactor = ({ materials, }: { area: number; - materials: NonEmptyArray>; + materials: NonEmptyArray< + Pick + >; }): number => { if (materials.some((m) => m.thermalConductivity <= 0)) { throw new Error('The thermal conductivity (k) must be greater than 0.'); @@ -44,7 +46,6 @@ export const calculateHeatLossConstantFactor = ({ /** * Calculates the rate of heat loss based on the constant factor and temperature difference. - * TODO: confirm that negative heat loss (so we should cool the house) are negligated * When a heat loss negative is, the rate of heat loss returned is 0. * * @param constantFactor - The heat loss constant factor (W/K).