@salsita-ai/sdk 0.50.0-develop.f6390cb584 → 0.50.0-develop.f9cd9b3a12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application-spec/errors/diagnostic-codes.d.ts +9 -0
- package/dist/application-spec/errors/diagnostic-codes.js +1 -1
- package/dist/application-spec/errors/diagnostic-messages.d.ts +10 -1
- package/dist/application-spec/errors/diagnostic-messages.js +1 -1
- package/dist/application-spec/generate-application/code/domain-runtime-files.js +1 -1
- package/dist/application-spec/generate-application/code/templates/application-types.js +6 -6
- package/dist/application-spec/generate-application/code/templates/common/doc-comment.d.ts +2 -0
- package/dist/application-spec/generate-application/code/templates/common/doc-comment.js +1 -0
- package/dist/application-spec/generate-application/code/templates/configurator-globals.d.ts +2 -1
- package/dist/application-spec/generate-application/code/templates/configurator-globals.js +19 -6
- package/dist/application-spec/generate-application/code/templates/global-definition/generate-action-types.js +7 -7
- package/dist/application-spec/generate-application/code/templates/global-definition/generate-assistant-form-element-types.d.ts +12 -0
- package/dist/application-spec/generate-application/code/templates/global-definition/generate-assistant-form-element-types.js +7 -0
- package/dist/application-spec/generate-application/code/templates/global-definition/generate-phx-types.js +24 -22
- package/dist/application-spec/generate-application/code/templates/global-definition/index.d.ts +1 -0
- package/dist/application-spec/generate-application/code/templates/global-definition/index.js +1 -1
- package/dist/application-spec/generate-application/code/templates/skills/sdk-content.d.ts +13 -6
- package/dist/application-spec/generate-application/code/templates/skills/sdk-content.js +2 -2
- package/dist/application-spec/parse-application-files/raw-spec/raw-assistant.d.ts +33 -3
- package/dist/application-spec/parse-application-files/raw-spec/raw-assistant.js +1 -1
- package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-form-elements.d.ts +9 -0
- package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-form-elements.js +1 -0
- package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.d.ts +4 -1
- package/dist/application-spec/parse-application-files/spec/sections/assistant/assistant-schema.js +1 -1
- package/dist/configurator/application-runtime/extensions/selectors.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.d.ts +4 -3
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/assistant-system.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.d.ts +33 -7
- package/dist/configurator/application-runtime/extensions/systems/assistant-system/graphql-tools.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/canvas-system.d.ts +8 -0
- package/dist/configurator/application-runtime/extensions/systems/canvas-system.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/export-system/export-busy-error.d.ts +8 -0
- package/dist/configurator/application-runtime/extensions/systems/export-system/export-busy-error.js +1 -0
- package/dist/configurator/application-runtime/extensions/systems/export-system/export-system.d.ts +23 -1
- package/dist/configurator/application-runtime/extensions/systems/export-system/export-system.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.d.ts +14 -1
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/entity-meta.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.d.ts +45 -4
- package/dist/configurator/application-runtime/extensions/systems/graphql-system/graphql-system.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/image-rendering-system/image-rendering-system.d.ts +14 -5
- package/dist/configurator/application-runtime/extensions/systems/image-rendering-system/image-rendering-system.js +1 -1
- package/dist/configurator/application-runtime/extensions/systems/index.d.ts +1 -1
- package/dist/configurator/common/configurator-interface.d.ts +38 -2
- package/dist/configurator/common/configurator-interface.js +1 -1
- package/dist/configurator/configurator-context/ConfiguratorResponsiveProvider.d.ts +3 -3
- package/dist/configurator/configurator-context/ConfiguratorResponsiveProvider.js +1 -1
- package/dist/configurator/configurator-context/PreviewContextProvider.js +1 -1
- package/dist/configurator/configurator-context/configurator-hooks/layout-hooks.d.ts +4 -0
- package/dist/configurator/configurator-context/configurator-routing/ConfiguratorRoutingProvider.js +1 -1
- package/dist/configurator/pages/default-configurator-elements.d.ts +3 -2
- package/dist/configurator/pages/default-configurator-elements.js +1 -1
- package/dist/configurator/pages/default-elements/index.d.ts +1 -0
- package/dist/configurator/pages/default-elements/index.js +1 -1
- package/dist/configurator/pages/default-elements/scene-controls/BuiltInAddEntityFab.d.ts +3 -0
- package/dist/configurator/pages/default-elements/scene-controls/BuiltInAddEntityFab.js +1 -0
- package/dist/configurator/pages/default-elements/scene-controls/BuiltInDeleteEntityFab.d.ts +3 -0
- package/dist/configurator/pages/default-elements/scene-controls/BuiltInDeleteEntityFab.js +1 -0
- package/dist/configurator/pages/default-elements/scene-controls/BuiltInNavigationHotspot.d.ts +3 -0
- package/dist/configurator/pages/default-elements/scene-controls/BuiltInNavigationHotspot.js +1 -0
- package/dist/configurator/pages/default-elements/scene-controls/BuiltInSelectionHotspot.d.ts +3 -0
- package/dist/configurator/pages/default-elements/scene-controls/BuiltInSelectionHotspot.js +1 -0
- package/dist/configurator/pages/default-elements/scene-controls/BuiltInToolMenuFab.d.ts +3 -0
- package/dist/configurator/pages/default-elements/scene-controls/BuiltInToolMenuFab.js +1 -0
- package/dist/configurator/pages/default-elements/scene-controls/index.d.ts +6 -0
- package/dist/configurator/pages/default-elements/scene-controls/index.js +1 -0
- package/dist/configurator/pages/default-elements/scene-controls/scene-control-elements.d.ts +6 -0
- package/dist/configurator/pages/default-elements/scene-controls/scene-control-elements.js +1 -0
- package/dist/configurator/scene/index.d.ts +1 -0
- package/dist/configurator/scene/index.js +1 -1
- package/dist/configurator/scene/scene-controls/ToolFab.d.ts +4 -0
- package/dist/configurator/scene/scene-controls/ToolFab.js +1 -1
- package/dist/configurator/scene/scene-controls/use-scene-control-tooltip.d.ts +2 -1
- package/dist/configurator/scene/scene-entity/scene-controls/SceneEntityHtmlHotspots.js +1 -1
- package/dist/configurator/toolbar-actions/index.d.ts +1 -0
- package/dist/configurator/toolbar-actions/index.js +1 -1
- package/dist/configurator/toolbar-actions/use-toolbar-actions.d.ts +100 -0
- package/dist/configurator/toolbar-actions/use-toolbar-actions.js +1 -0
- package/dist/configurator/toolbar-actions/use-toolbar-help.d.ts +7 -0
- package/dist/configurator/toolbar-actions/use-toolbar-help.js +1 -0
- package/dist/configurator/ui/assistant/AssistantChips.d.ts +3 -2
- package/dist/configurator/ui/assistant/AssistantChips.js +1 -1
- package/dist/configurator/ui/assistant/AssistantMarkdown.d.ts +1 -1
- package/dist/configurator/ui/assistant/AssistantMarkdown.js +1 -1
- package/dist/configurator/ui/assistant/alert/AssistantAlert.d.ts +8 -0
- package/dist/configurator/ui/assistant/alert/AssistantAlert.js +1 -0
- package/dist/configurator/ui/assistant/alert/index.d.ts +2 -0
- package/dist/configurator/ui/assistant/alert/index.js +1 -0
- package/dist/configurator/ui/assistant/alert/remark-alerts.d.ts +10 -0
- package/dist/configurator/ui/assistant/alert/remark-alerts.js +1 -0
- package/dist/configurator/ui/assistant/form/AssistantForm.js +1 -1
- package/dist/configurator/ui/assistant/form/compose-answer.d.ts +1 -1
- package/dist/configurator/ui/assistant/form/compose-answer.js +1 -1
- package/dist/configurator/ui/assistant/form/form-model.d.ts +3 -4
- package/dist/configurator/ui/assistant/form/form-state.d.ts +1 -1
- package/dist/configurator/ui/assistant/form/index.d.ts +2 -0
- package/dist/configurator/ui/assistant/form/index.js +1 -1
- package/dist/configurator/ui/assistant/form/parse-form-block.d.ts +3 -2
- package/dist/configurator/ui/assistant/form/parse-form-block.js +1 -1
- package/dist/configurator/ui/assistant/form/read-form-attributes.d.ts +1 -1
- package/dist/configurator/ui/assistant/form/read-form-attributes.js +1 -1
- package/dist/configurator/ui/assistant/form/registry/built-in-form-elements.d.ts +3 -0
- package/dist/configurator/ui/assistant/form/registry/built-in-form-elements.js +1 -0
- package/dist/configurator/ui/assistant/form/registry/create-assistant-form-element.d.ts +31 -0
- package/dist/configurator/ui/assistant/form/registry/create-assistant-form-element.js +1 -0
- package/dist/configurator/ui/assistant/form/{form-element-definition.d.ts → registry/form-element-definition.d.ts} +3 -1
- package/dist/configurator/ui/assistant/form/registry/form-element-definition.js +1 -0
- package/dist/configurator/ui/assistant/form/{form-registry.d.ts → registry/form-registry.d.ts} +2 -2
- package/dist/configurator/ui/assistant/form/registry/form-registry.js +1 -0
- package/dist/configurator/ui/assistant/form/registry/index.d.ts +6 -0
- package/dist/configurator/ui/assistant/form/registry/index.js +1 -0
- package/dist/configurator/ui/assistant/form/registry/use-form-registry.d.ts +8 -0
- package/dist/configurator/ui/assistant/form/registry/use-form-registry.js +1 -0
- package/dist/configurator/ui/assistant/form-elements/FormCard.d.ts +9 -0
- package/dist/configurator/ui/assistant/form-elements/FormCard.js +1 -0
- package/dist/configurator/ui/assistant/form-elements/FormConfirm.d.ts +9 -0
- package/dist/configurator/ui/assistant/form-elements/FormConfirm.js +1 -0
- package/dist/configurator/ui/assistant/form-elements/FormGroup.d.ts +4 -1
- package/dist/configurator/ui/assistant/form-elements/FormNumber.d.ts +9 -0
- package/dist/configurator/ui/assistant/form-elements/FormNumber.js +1 -0
- package/dist/configurator/ui/assistant/form-elements/FormOptions.d.ts +4 -1
- package/dist/configurator/ui/assistant/form-elements/FormReply.d.ts +4 -1
- package/dist/configurator/ui/assistant/form-elements/FormReply.js +1 -1
- package/dist/configurator/ui/assistant/form-elements/FormSwatch.d.ts +10 -0
- package/dist/configurator/ui/assistant/form-elements/FormSwatch.js +1 -0
- package/dist/configurator/ui/assistant/form-elements/FormText.d.ts +4 -1
- package/dist/configurator/ui/assistant/form-elements/FormText.js +1 -1
- package/dist/configurator/ui/assistant/form-elements/clamp-form-number.d.ts +10 -0
- package/dist/configurator/ui/assistant/form-elements/clamp-form-number.js +1 -0
- package/dist/configurator/ui/assistant/form-elements/form-element-props.d.ts +20 -16
- package/dist/configurator/ui/assistant/form-elements/index.d.ts +4 -0
- package/dist/configurator/ui/assistant/form-elements/index.js +1 -1
- package/dist/configurator/ui/assistant/index.d.ts +3 -0
- package/dist/configurator/ui/assistant/index.js +1 -1
- package/dist/configurator/ui/assistant/resource-image/index.d.ts +1 -1
- package/dist/configurator/ui/assistant/resource-image/index.js +1 -1
- package/dist/configurator/ui/assistant/resource-image/resource-path.d.ts +0 -9
- package/dist/configurator/ui/assistant/resource-image/resource-path.js +1 -1
- package/dist/configurator/ui/assistant/screenshots/ScreenshotThumbnails.d.ts +9 -0
- package/dist/configurator/ui/assistant/screenshots/ScreenshotThumbnails.js +1 -0
- package/dist/configurator/ui/assistant/screenshots/index.d.ts +2 -0
- package/dist/configurator/ui/assistant/screenshots/index.js +1 -0
- package/dist/configurator/ui/assistant/screenshots/tool-screenshots.d.ts +14 -0
- package/dist/configurator/ui/assistant/screenshots/tool-screenshots.js +1 -0
- package/dist/configurator/ui/assistant/streamed-markdown.d.ts +2 -2
- package/dist/configurator/ui/assistant/streamed-markdown.js +2 -2
- package/dist/configurator/ui/assistant/tool-chip.js +1 -1
- package/dist/configurator/ui/configurator-layout/universal/ExportingOverlay.d.ts +5 -0
- package/dist/configurator/ui/configurator-layout/universal/ExportingOverlay.js +1 -0
- package/dist/configurator/ui/configurator-layout/universal/Layout.js +1 -1
- package/dist/configurator/ui/configurator-layout/universal/use-layout-scene-safe-area.js +1 -1
- package/dist/configurator/ui/index.d.ts +1 -0
- package/dist/configurator/ui/index.js +1 -1
- package/dist/configurator/ui/save-message-snackbars/SaveMessageSnackbars.js +1 -1
- package/dist/docs/complex-use-cases/focus-mode.mdx +2 -0
- package/dist/docs/fabs/basic.mdx +4 -0
- package/dist/docs/index.json +62 -1
- package/dist/docs/integrations/webcomponent-api/basics.mdx +2 -0
- package/dist/docs/layouts/custom-layout.mdx +320 -158
- package/dist/docs/layouts/gallery/ardent-compressor.mdx +3516 -0
- package/dist/docs/layouts/gallery/fenwick-window.mdx +2803 -0
- package/dist/docs/layouts/gallery/ferrule-bar-station.mdx +6567 -0
- package/dist/docs/layouts/gallery/kestrel-kettle.mdx +1858 -0
- package/dist/docs/layouts/gallery/sunfold-solar.mdx +2683 -0
- package/dist/docs/other-features/client-side-rendering/declarative.mdx +1 -1
- package/dist/docs/other-features/navigation-hotspot.mdx +2 -0
- package/dist/docs/phoenix/configurator-interface.mdx +68 -11
- package/dist/docs/phoenix/sdk-builder-workflow.mdx +1 -1
- package/dist/docs/phoenix/sessions-and-access.mdx +2 -0
- package/dist/docs/selection-and-highlight/hotspots.mdx +2 -0
- package/dist/domain/application/assistant/assistant-manifest.d.ts +16 -1
- package/dist/domain/application/assistant/assistant-resolved-manifest.d.ts +8 -1
- package/dist/domain/application/assistant/build-assistant-manifest.js +1 -1
- package/dist/domain/application/assistant/form/assistant-form-element-name.d.ts +11 -0
- package/dist/domain/application/assistant/form/assistant-form-element-name.js +8 -0
- package/dist/domain/application/assistant/form/assistant-form-elements.d.ts +174 -0
- package/dist/domain/application/assistant/form/assistant-form-elements.js +1 -0
- package/dist/domain/application/assistant/form/assistant-resource-path.d.ts +10 -0
- package/dist/domain/application/assistant/form/assistant-resource-path.js +1 -0
- package/dist/domain/application/assistant/form/index.d.ts +4 -0
- package/dist/domain/application/assistant/form/index.js +1 -0
- package/dist/domain/application/assistant/generate-assistant-prompt.d.ts +3 -0
- package/dist/domain/application/assistant/generate-assistant-prompt.js +1 -1
- package/dist/domain/application/assistant/index.d.ts +2 -3
- package/dist/domain/application/assistant/index.js +1 -1
- package/dist/domain/application/assistant/session-setup/apply-assistant-setup.js +1 -1
- package/dist/domain/application/assistant/session-setup/assistant-setup-manifest.d.ts +2 -0
- package/dist/domain/application/assistant/session-setup/assistant-setup-manifest.js +1 -1
- package/dist/domain/application/web-component-property.d.ts +3 -1
- package/dist/domain/application/web-component-property.js +1 -1
- package/dist/domain/available-system-components.d.ts +1 -1
- package/dist/domain/available-system-components.js +1 -1
- package/dist/domain/domain-context/store-system/store-system.d.ts +5 -0
- package/dist/domain/domain-context/store-system/store-system.js +1 -1
- package/dist/domain/domain-runtime/domain-context/selectors.d.ts +1 -0
- package/dist/domain/domain-runtime/domain-context/selectors.js +1 -1
- package/dist/domain/domain-runtime/domain-runtime.d.ts +8 -0
- package/dist/domain/domain-runtime/environment/types.d.ts +1 -1
- package/dist/domain/domain-runtime/spec/index.d.ts +1 -1
- package/dist/domain/domain-runtime/spec/index.js +1 -1
- package/dist/domain/domain-runtime/spec/initialize.js +1 -1
- package/dist/domain/domain-runtime/spec/module-checks/element-registration.d.ts +15 -1
- package/dist/domain/domain-runtime/spec/module-checks/element-registration.js +1 -1
- package/dist/domain/domain-runtime/spec/module-checks/find-assistant-form-element-errors.d.ts +12 -0
- package/dist/domain/domain-runtime/spec/module-checks/find-assistant-form-element-errors.js +1 -0
- package/dist/domain/domain-runtime/spec/module-checks/index.d.ts +2 -1
- package/dist/domain/domain-runtime/spec/module-checks/index.js +1 -1
- package/dist/domain/domain-runtime/spec/selectors.d.ts +3 -1
- package/dist/domain/domain-runtime/spec/selectors.js +1 -1
- package/dist/domain/extensions/export-images/agent-screenshot.d.ts +24 -0
- package/dist/domain/extensions/export-images/agent-screenshot.js +1 -0
- package/dist/domain/extensions/{client-side-image.d.ts → export-images/client-side-image.d.ts} +2 -2
- package/dist/domain/extensions/export-images/client-side-image.js +1 -0
- package/dist/domain/extensions/export-images/index.d.ts +3 -0
- package/dist/domain/extensions/export-images/index.js +1 -0
- package/dist/domain/extensions/{save-template-thumbnail.d.ts → export-images/save-template-thumbnail.d.ts} +2 -2
- package/dist/domain/extensions/export-images/save-template-thumbnail.js +1 -0
- package/dist/domain/extensions/index.d.ts +1 -2
- package/dist/domain/extensions/index.js +1 -1
- package/dist/domain/mocks/create-mock-domain-runtime.d.ts +4 -0
- package/dist/editor/domain-editors/DomainSpecContext.js +1 -1
- package/dist/editor/domain-editors/apply-spec-update.d.ts +8 -1
- package/dist/editor/domain-editors/apply-spec-update.js +1 -1
- package/dist/editor/domain-editors/use-implementation-files.js +1 -1
- package/dist/editor/screenshot/ScreenshotHandler.js +1 -1
- package/dist/i18n/locales/cs/messages.js +1 -1
- package/dist/i18n/locales/da/messages.js +1 -1
- package/dist/i18n/locales/de/messages.js +1 -1
- package/dist/i18n/locales/de-AT/messages.js +1 -1
- package/dist/i18n/locales/en/messages.js +1 -1
- package/dist/i18n/locales/en-AU/messages.js +1 -1
- package/dist/i18n/locales/en-GB/messages.js +1 -1
- package/dist/i18n/locales/es/messages.js +1 -1
- package/dist/i18n/locales/fr/messages.js +1 -1
- package/dist/i18n/locales/hu/messages.js +1 -1
- package/dist/i18n/locales/it/messages.js +1 -1
- package/dist/i18n/locales/nl/messages.js +1 -1
- package/dist/i18n/locales/pl/messages.js +1 -1
- package/dist/i18n/locales/pt/messages.js +1 -1
- package/dist/i18n/locales/ro/messages.js +1 -1
- package/dist/i18n/locales/sk/messages.js +1 -1
- package/dist/i18n/locales/tr/messages.js +1 -1
- package/dist/legacy/domain-cli/utils/cache.d.ts +1 -1
- package/dist/legacy/domain-cli/utils/cache.js +1 -1
- package/dist/legacy/domain-spec/__generated/domain-schema.d.ts +16 -0
- package/dist/legacy/domain-spec/spec/domain-spec.js +1 -1
- package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/claude-md.d.ts +1 -1
- package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/cursor-rules.d.ts +1 -1
- package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/shared-sdk-content.d.ts +1 -1
- package/dist/legacy/phoenix-cli/project-gen/generators/file-templates/agents/shared-sdk-content.js +11 -5
- package/dist/react-sdk/application-runtime/hooks/index.d.ts +1 -0
- package/dist/react-sdk/application-runtime/hooks/index.js +1 -1
- package/dist/react-sdk/application-runtime/hooks/responsive/index.d.ts +3 -0
- package/dist/react-sdk/application-runtime/hooks/responsive/index.js +1 -0
- package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-actions.d.ts +9 -0
- package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-actions.js +1 -0
- package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-application.d.ts +26 -0
- package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-application.js +1 -0
- package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-state.d.ts +10 -0
- package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-state.js +1 -0
- package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-store.d.ts +24 -0
- package/dist/react-sdk/application-runtime/hooks/responsive/use-responsive-store.js +1 -0
- package/dist/react-sdk/application-runtime/hooks/use-access-service.d.ts +5 -0
- package/dist/react-sdk/application-runtime/hooks/use-access-service.js +1 -1
- package/dist/react-sdk/application-runtime/hooks/use-phoenix-element-slots.d.ts +2 -1
- package/dist/react-sdk/application-runtime/index.d.ts +1 -0
- package/dist/react-sdk/application-runtime/integration/initialize.d.ts +7 -6
- package/dist/react-sdk/application-runtime/integration/initialize.js +1 -1
- package/dist/react-sdk/application-runtime/integration/selectors.d.ts +11 -2
- package/dist/react-sdk/application-runtime/integration/selectors.js +1 -1
- package/dist/react-sdk/application-runtime/integration/types.d.ts +5 -2
- package/dist/react-sdk/application-runtime/mocks/create-mock-application-runtime.js +1 -1
- package/dist/react-sdk/application-runtime/responsive/actions.d.ts +47 -0
- package/dist/react-sdk/application-runtime/responsive/actions.js +1 -0
- package/dist/react-sdk/application-runtime/responsive/index.d.ts +4 -0
- package/dist/react-sdk/application-runtime/responsive/index.js +1 -0
- package/dist/react-sdk/application-runtime/responsive/initialize.d.ts +3 -0
- package/dist/react-sdk/application-runtime/responsive/initialize.js +1 -0
- package/dist/react-sdk/application-runtime/responsive/selectors.d.ts +20 -0
- package/dist/react-sdk/application-runtime/responsive/selectors.js +1 -0
- package/dist/react-sdk/application-runtime/responsive/types.d.ts +104 -0
- package/dist/react-sdk/application-runtime/routing/routing-service.d.ts +34 -2
- package/dist/react-sdk/application-runtime/routing/routing-service.js +1 -1
- package/dist/react-sdk/application-runtime/runtime/create-application-runtime.d.ts +95 -2
- package/dist/react-sdk/application-runtime/runtime/create-application-runtime.js +1 -1
- package/dist/react-sdk/domain-context/common-hooks/index.d.ts +1 -0
- package/dist/react-sdk/domain-context/common-hooks/index.js +1 -1
- package/dist/react-sdk/domain-context/common-hooks/use-configuration-save-state.d.ts +23 -0
- package/dist/react-sdk/domain-context/common-hooks/use-configuration-save-state.js +1 -0
- package/dist/react-sdk/domain-context/common-hooks/use-domain-context.d.ts +1 -0
- package/dist/react-sdk/domain-context/index.d.ts +1 -0
- package/dist/react-sdk/domain-context/index.js +1 -1
- package/dist/react-sdk/domain-context/system-hooks/undo-redo-hooks.js +1 -1
- package/dist/react-sdk/index.d.ts +1 -1
- package/dist/react-sdk/index.js +1 -1
- package/dist/react-sdk/scene-control-elements/AddEntityFabElement.d.ts +36 -0
- package/dist/react-sdk/scene-control-elements/AddEntityFabElement.js +1 -0
- package/dist/react-sdk/scene-control-elements/DeleteEntityFabElement.d.ts +44 -0
- package/dist/react-sdk/scene-control-elements/DeleteEntityFabElement.js +1 -0
- package/dist/react-sdk/scene-control-elements/NavigationHotspotElement.d.ts +38 -0
- package/dist/react-sdk/scene-control-elements/NavigationHotspotElement.js +1 -0
- package/dist/react-sdk/scene-control-elements/SelectionHotspotElement.d.ts +30 -0
- package/dist/react-sdk/scene-control-elements/SelectionHotspotElement.js +1 -0
- package/dist/react-sdk/scene-control-elements/ToolMenuFabElement.d.ts +30 -0
- package/dist/react-sdk/scene-control-elements/ToolMenuFabElement.js +1 -0
- package/dist/react-sdk/scene-control-elements/create-add-entity-fab.d.ts +9 -0
- package/dist/react-sdk/scene-control-elements/create-add-entity-fab.js +1 -0
- package/dist/react-sdk/scene-control-elements/create-delete-entity-fab.d.ts +9 -0
- package/dist/react-sdk/scene-control-elements/create-delete-entity-fab.js +1 -0
- package/dist/react-sdk/scene-control-elements/create-navigation-hotspot.d.ts +10 -0
- package/dist/react-sdk/scene-control-elements/create-navigation-hotspot.js +1 -0
- package/dist/react-sdk/scene-control-elements/create-selection-hotspot.d.ts +9 -0
- package/dist/react-sdk/scene-control-elements/create-selection-hotspot.js +1 -0
- package/dist/react-sdk/scene-control-elements/create-tool-menu-fab.d.ts +9 -0
- package/dist/react-sdk/scene-control-elements/create-tool-menu-fab.js +1 -0
- package/dist/react-sdk/scene-control-elements/index.d.ts +11 -0
- package/dist/react-sdk/scene-control-elements/index.js +1 -0
- package/dist/react-sdk/scene-control-elements/scene-control-tooltip-visibility.d.ts +5 -0
- package/dist/react-sdk/web-component/create-web-component.js +1 -1
- package/dist/react-sdk/web-component/god-mode/god-mode-registry.d.ts +30 -0
- package/dist/react-sdk/web-component/god-mode/god-mode-registry.js +1 -0
- package/dist/react-sdk/web-component/god-mode/index.d.ts +1 -0
- package/dist/react-sdk/web-component/god-mode/index.js +1 -0
- package/dist/ui-sdk/icons/__generated/Lightbulb.d.ts +3 -0
- package/dist/ui-sdk/icons/__generated/Lightbulb.js +1 -0
- package/dist/ui-sdk/icons/__generated/XCircle.d.ts +3 -0
- package/dist/ui-sdk/icons/__generated/XCircle.js +1 -0
- package/dist/ui-sdk/icons/__generated/index.d.ts +4 -0
- package/dist/ui-sdk/icons/__generated/index.js +1 -1
- package/dist/ui-sdk/icons/icons-registry.js +1 -1
- package/dist/ui-sdk/responsive-application/ResponsiveApplicationProvider.d.ts +2 -2
- package/dist/ui-sdk/responsive-application/responsive-state-store.d.ts +11 -9
- package/dist/ui-sdk/responsive-application/use-store-responsive-info.js +1 -1
- package/dist/ui-sdk/theme/types.d.ts +5 -1
- package/package.json +13 -3
- package/schema/domain-spec.json +140 -0
- package/dist/configurator/ui/assistant/form/built-in-form-registry.d.ts +0 -10
- package/dist/configurator/ui/assistant/form/built-in-form-registry.js +0 -1
- package/dist/configurator/ui/assistant/form/form-registry.js +0 -8
- package/dist/domain/application/assistant/assistant-form-elements.d.ts +0 -28
- package/dist/domain/application/assistant/assistant-form-elements.js +0 -1
- package/dist/domain/extensions/client-side-image.js +0 -1
- package/dist/domain/extensions/save-template-thumbnail.js +0 -1
- package/dist/react-sdk/application-runtime/runtime/god-mode.d.ts +0 -19
- package/dist/react-sdk/application-runtime/runtime/god-mode.js +0 -1
- package/dist/react-sdk/responsive-state/ResponsiveStateProvider.d.ts +0 -25
- package/dist/react-sdk/responsive-state/ResponsiveStateProvider.js +0 -1
- package/dist/react-sdk/responsive-state/canvas-padding.d.ts +0 -14
- package/dist/react-sdk/responsive-state/index.d.ts +0 -5
- package/dist/react-sdk/responsive-state/index.js +0 -1
- package/dist/react-sdk/responsive-state/responsive-state-manager.d.ts +0 -111
- package/dist/react-sdk/responsive-state/responsive-state-manager.js +0 -1
- package/dist/react-sdk/responsive-state/use-responsive-state-manager.d.ts +0 -10
- package/dist/react-sdk/responsive-state/use-responsive-state-manager.js +0 -1
- package/dist/react-sdk/responsive-state/use-responsive-state.d.ts +0 -11
- package/dist/react-sdk/responsive-state/use-responsive-state.js +0 -1
- /package/dist/{configurator/ui/assistant/form/form-element-definition.js → react-sdk/application-runtime/responsive/types.js} +0 -0
- /package/dist/react-sdk/{responsive-state/canvas-padding.js → scene-control-elements/scene-control-tooltip-visibility.js} +0 -0
|
@@ -0,0 +1,2683 @@
|
|
|
1
|
+
# Sunfold Solar
|
|
2
|
+
|
|
3
|
+
Help a household decide on a system by showing what it would do for them. A solar installer, Sunfold, lets a homeowner plan panels and a battery for their roof: the roof's area and direction, the number of panels, the battery, the tariff and the home's yearly use. The page answers with a dashboard: four figures, the solar year month by month, the year the system pays for itself, and how much of the power the home uses. Every figure is an expression of the spec, so the dashboard changes the moment an input does, and **Book a home survey** is a finalize action. There is no 3D model; the tiles and charts are React and hand-written SVG.
|
|
4
|
+
|
|
5
|
+
- [Try it](#try-it)
|
|
6
|
+
- [What the example demonstrates](#what-the-example-demonstrates)
|
|
7
|
+
- [A dashboard instead of a scene](#a-dashboard-instead-of-a-scene)
|
|
8
|
+
- [Every figure is an expression](#every-figure-is-an-expression)
|
|
9
|
+
- [Figures on tiles](#figures-on-tiles)
|
|
10
|
+
- [Charts in SVG](#charts-in-svg)
|
|
11
|
+
- [A warning and a notice](#a-warning-and-a-notice)
|
|
12
|
+
- [Book a home survey](#book-a-home-survey)
|
|
13
|
+
- [On a phone](#on-a-phone)
|
|
14
|
+
- [Test cases](#test-cases)
|
|
15
|
+
- [What this layout leaves out](#what-this-layout-leaves-out)
|
|
16
|
+
- [Related pages](#related-pages)
|
|
17
|
+
- [Complete Implementation](#complete-implementation)
|
|
18
|
+
|
|
19
|
+
## Try it
|
|
20
|
+
|
|
21
|
+
The phone version is under [On a phone](#on-a-phone).
|
|
22
|
+
|
|
23
|
+
## What the example demonstrates
|
|
24
|
+
|
|
25
|
+
| Piece | Where |
|
|
26
|
+
| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
27
|
+
| A `ConfiguratorLayout` that mounts no `LayoutSlots.SharedCanvas` | [A dashboard instead of a scene](#a-dashboard-instead-of-a-scene) |
|
|
28
|
+
| Numbers and series declared in `expressions:` and read with `phx.frontend.useRootEntityState()` | [Every figure is an expression](#every-figure-is-an-expression) |
|
|
29
|
+
| KPI tiles | `KpiTiles`, [Figures on tiles](#figures-on-tiles) |
|
|
30
|
+
| A stacked column chart with a line, a payback curve and a split bar, with tooltips | `MonthlyEnergyChart`, `PaybackChart`, `EnergySplitBar`, [Charts in SVG](#charts-in-svg) |
|
|
31
|
+
| A `warning` that blocks finalizing beside a `notice` that does not | `PlannerInputs`, [A warning and a notice](#a-warning-and-a-notice) |
|
|
32
|
+
| `phx.frontend.useFinalizeActions()` for the spec's `survey` action | `SurveyPanel`, [Book a home survey](#book-a-home-survey) |
|
|
33
|
+
|
|
34
|
+
## A dashboard instead of a scene
|
|
35
|
+
|
|
36
|
+
`src/elements/ConfiguratorLayout.tsx` mounts the services and a page, never `LayoutSlots.SharedCanvas`, so no scene loads and the app loader does not wait for one. On a desktop the inputs are a sidebar with the cost and the booking at its foot, and the dashboard fills the rest; on a phone everything is one scrolling column with the cost in a bar at the bottom.
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { Box } from '@mui/material'
|
|
40
|
+
|
|
41
|
+
import { LayoutSlots } from '@salsita-ai/sdk/configurator'
|
|
42
|
+
|
|
43
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
44
|
+
import { DesktopPage } from '../_shared/DesktopPage.js'
|
|
45
|
+
import { PhonePage } from '../_shared/PhonePage.js'
|
|
46
|
+
import { SUNFOLD } from '../_shared/tokens.js'
|
|
47
|
+
|
|
48
|
+
// A planning dashboard with no scene: the layout never mounts `LayoutSlots.SharedCanvas`, and every tile and chart is
|
|
49
|
+
// React and SVG drawn from the spec's expressions
|
|
50
|
+
const ConfiguratorLayout = () => {
|
|
51
|
+
// `null` until the container has been measured
|
|
52
|
+
const viewports = phx.frontend.useActiveViewports()
|
|
53
|
+
const isDesktop = viewports?.includes('desktop') ?? false
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Box component="div" sx={{ position: 'absolute', inset: 0, overflow: 'clip', bgcolor: SUNFOLD.linen }}>
|
|
57
|
+
<LayoutSlots.Services />
|
|
58
|
+
{viewports !== null && (isDesktop ? <DesktopPage /> : <PhonePage />)}
|
|
59
|
+
</Box>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Every figure is an expression
|
|
67
|
+
|
|
68
|
+
The dashboard computes nothing itself. The system's size, its production month by month, how the production splits between the home and the grid, the yearly saving, the cost and the money returned year by year are expressions of the root entity. Declaring them in `expressions:` gives them their types, so the layout reads `$monthly` as an array of months and `$cumulativeSavings` as an array of numbers with `phx.frontend.useRootEntityState()`.
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
expressions:
|
|
72
|
+
SolarPlan:
|
|
73
|
+
$systemKwp:
|
|
74
|
+
type: number
|
|
75
|
+
$roofPanelLimit:
|
|
76
|
+
type: number
|
|
77
|
+
$panelAreaM2:
|
|
78
|
+
type: number
|
|
79
|
+
$annualProductionKwh:
|
|
80
|
+
type: number
|
|
81
|
+
$monthly:
|
|
82
|
+
type: array
|
|
83
|
+
items:
|
|
84
|
+
type: object
|
|
85
|
+
properties:
|
|
86
|
+
month:
|
|
87
|
+
type: string
|
|
88
|
+
productionKwh:
|
|
89
|
+
type: number
|
|
90
|
+
consumptionKwh:
|
|
91
|
+
type: number
|
|
92
|
+
selfUseKwh:
|
|
93
|
+
type: number
|
|
94
|
+
exportKwh:
|
|
95
|
+
type: number
|
|
96
|
+
required:
|
|
97
|
+
- month
|
|
98
|
+
- productionKwh
|
|
99
|
+
- consumptionKwh
|
|
100
|
+
- selfUseKwh
|
|
101
|
+
- exportKwh
|
|
102
|
+
additionalProperties: false
|
|
103
|
+
$selfConsumption:
|
|
104
|
+
type: number
|
|
105
|
+
$selfSufficiency:
|
|
106
|
+
type: number
|
|
107
|
+
$annualSavings:
|
|
108
|
+
type: number
|
|
109
|
+
$systemCost:
|
|
110
|
+
type: number
|
|
111
|
+
$cumulativeSavings:
|
|
112
|
+
type: array
|
|
113
|
+
items:
|
|
114
|
+
type: number
|
|
115
|
+
$paybackYears:
|
|
116
|
+
type: number
|
|
117
|
+
$co2Tonnes:
|
|
118
|
+
type: number
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The model in `src/expressions/solar.ts` is deliberately simple: a south-facing kWp yields 1,000 kWh a year, spread over the months by a typical central European sun curve. Without a battery the home uses solar power only while the sun shines; a battery carries the day's surplus into the evening. The saving counts the power the home no longer buys and the power it sells, and the running total grows with prices rising 2 % a year while the panels lose half a percent.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
125
|
+
|
|
126
|
+
const PANEL_KWP = 0.43
|
|
127
|
+
const PANEL_AREA_M2 = 1.95
|
|
128
|
+
// Vents, chimneys and the edge clearance leave about 85 % of a roof for panels
|
|
129
|
+
const USABLE_ROOF = 0.85
|
|
130
|
+
// A south-facing kWp in central Europe yields about 1,000 kWh a year
|
|
131
|
+
const YIELD_KWH_PER_KWP = 1000
|
|
132
|
+
const ORIENTATION_FACTOR = { South: 1, SouthEastWest: 0.95, EastWest: 0.82, North: 0.6 }
|
|
133
|
+
|
|
134
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
135
|
+
// The share of the year's sun and of the household's use that falls in each month
|
|
136
|
+
const SUN_SHARE = [0.04, 0.045, 0.08, 0.105, 0.125, 0.13, 0.13, 0.115, 0.09, 0.065, 0.04, 0.035]
|
|
137
|
+
const USE_SHARE = [0.1, 0.09, 0.088, 0.08, 0.075, 0.07, 0.07, 0.072, 0.076, 0.085, 0.094, 0.1]
|
|
138
|
+
const DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
139
|
+
|
|
140
|
+
const BATTERY_KWH = { None: 0, Kwh5: 5, Kwh10: 10, Kwh15: 15 }
|
|
141
|
+
const BATTERY_PRICE = { None: 0, Kwh5: 3600, Kwh10: 6400, Kwh15: 8900 }
|
|
142
|
+
const TARIFF = {
|
|
143
|
+
Flat: { buy: 0.32, sell: 0.08, shiftBonus: 0 },
|
|
144
|
+
TimeOfUse: { buy: 0.29, sell: 0.1, shiftBonus: 0.09 },
|
|
145
|
+
ExportPlus: { buy: 0.32, sell: 0.15, shiftBonus: 0 },
|
|
146
|
+
}
|
|
147
|
+
// Panels lose half a percent a year; electricity gets 2 % dearer a year
|
|
148
|
+
const DEGRADATION = 0.995
|
|
149
|
+
const PRICE_GROWTH = 1.02
|
|
150
|
+
const YEARS = 25
|
|
151
|
+
|
|
152
|
+
export default phx.domain.implementCustomExpressions({
|
|
153
|
+
SolarPlan: {
|
|
154
|
+
$systemKwp: s => {
|
|
155
|
+
return s.panels * PANEL_KWP
|
|
156
|
+
},
|
|
157
|
+
$roofPanelLimit: s => {
|
|
158
|
+
return Math.floor((s.roofArea * USABLE_ROOF) / PANEL_AREA_M2)
|
|
159
|
+
},
|
|
160
|
+
// The roof the panels cover, rounded up to whole square metres, as the tiles and the roof warning quote it
|
|
161
|
+
$panelAreaM2: s => {
|
|
162
|
+
return Math.ceil(s.panels * PANEL_AREA_M2)
|
|
163
|
+
},
|
|
164
|
+
$annualProductionKwh: s => {
|
|
165
|
+
return s.$systemKwp * YIELD_KWH_PER_KWP * ORIENTATION_FACTOR[s.orientation]
|
|
166
|
+
},
|
|
167
|
+
// Each month's production and use, and how the production splits between the home and the grid. Without a
|
|
168
|
+
// battery the home uses solar power only while the sun shines; a battery carries the surplus into the evening,
|
|
169
|
+
// one charge a day, and covers the evening but not the early morning.
|
|
170
|
+
$monthly: s => {
|
|
171
|
+
const batteryKwh = BATTERY_KWH[s.battery] * 0.9
|
|
172
|
+
|
|
173
|
+
return MONTHS.map((month, index) => {
|
|
174
|
+
const productionKwh = s.$annualProductionKwh * (SUN_SHARE[index] ?? 0)
|
|
175
|
+
const consumptionKwh = s.annualUse * (USE_SHARE[index] ?? 0)
|
|
176
|
+
const direct = Math.min(productionKwh * 0.55, consumptionKwh * 0.45)
|
|
177
|
+
const stored = Math.min(
|
|
178
|
+
productionKwh - direct,
|
|
179
|
+
batteryKwh * (DAYS[index] ?? 30),
|
|
180
|
+
(consumptionKwh - direct) * 0.6
|
|
181
|
+
)
|
|
182
|
+
const selfUseKwh = direct + stored
|
|
183
|
+
|
|
184
|
+
return { month, productionKwh, consumptionKwh, selfUseKwh, exportKwh: productionKwh - selfUseKwh }
|
|
185
|
+
})
|
|
186
|
+
},
|
|
187
|
+
$selfConsumption: s => {
|
|
188
|
+
const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
|
|
189
|
+
|
|
190
|
+
return s.$annualProductionKwh > 0 ? selfUse / s.$annualProductionKwh : 0
|
|
191
|
+
},
|
|
192
|
+
$selfSufficiency: s => {
|
|
193
|
+
const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
|
|
194
|
+
|
|
195
|
+
return selfUse / s.annualUse
|
|
196
|
+
},
|
|
197
|
+
// A year's saving: power the home no longer buys, power sold to the grid, and on a time-of-use tariff the
|
|
198
|
+
// evening power a battery shifts away from the dear hours
|
|
199
|
+
$annualSavings: s => {
|
|
200
|
+
const tariff = TARIFF[s.tariff]
|
|
201
|
+
const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
|
|
202
|
+
const exported = s.$monthly.reduce((sum, month) => sum + month.exportKwh, 0)
|
|
203
|
+
const shifted = s.battery === 'None' ? 0 : selfUse * 0.4
|
|
204
|
+
|
|
205
|
+
return selfUse * tariff.buy + exported * tariff.sell + shifted * tariff.shiftBonus
|
|
206
|
+
},
|
|
207
|
+
$systemCost: s => {
|
|
208
|
+
return Math.round(1500 + s.$systemKwp * 1050 + BATTERY_PRICE[s.battery])
|
|
209
|
+
},
|
|
210
|
+
// The money the system has returned at the end of each year, starting at minus its cost in year 0
|
|
211
|
+
$cumulativeSavings: s => {
|
|
212
|
+
return Array.from({ length: YEARS + 1 }, (_, year) => {
|
|
213
|
+
const earned = Array.from(
|
|
214
|
+
{ length: year },
|
|
215
|
+
(__, past) => s.$annualSavings * (PRICE_GROWTH * DEGRADATION) ** past
|
|
216
|
+
).reduce((sum, value) => sum + value, 0)
|
|
217
|
+
|
|
218
|
+
return earned - s.$systemCost
|
|
219
|
+
})
|
|
220
|
+
},
|
|
221
|
+
// The year the running total crosses zero, between whole years; beyond the panels' 25 years it is `Infinity`
|
|
222
|
+
$paybackYears: s => {
|
|
223
|
+
const totals = s.$cumulativeSavings
|
|
224
|
+
const year = totals.findIndex(total => total >= 0)
|
|
225
|
+
|
|
226
|
+
if (year <= 0) {
|
|
227
|
+
return year === 0 ? 0 : Infinity
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const before = totals[year - 1] ?? 0
|
|
231
|
+
const after = totals[year] ?? 0
|
|
232
|
+
|
|
233
|
+
return year - 1 + -before / (after - before)
|
|
234
|
+
},
|
|
235
|
+
$co2Tonnes: s => {
|
|
236
|
+
return (s.$annualProductionKwh * 0.35) / 1000
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The cost is the `price` component's value, so `phx.frontend.useRootEntityPrice()` formats it in euros:
|
|
243
|
+
|
|
244
|
+
```yaml
|
|
245
|
+
components:
|
|
246
|
+
SolarPlan:
|
|
247
|
+
price:
|
|
248
|
+
main:
|
|
249
|
+
price:
|
|
250
|
+
$read: $systemCost
|
|
251
|
+
currency: EUR
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Figures on tiles
|
|
255
|
+
|
|
256
|
+
`KpiTiles` shows the four figures a household asks first, each with the context that makes it mean something: the panels and roof behind the system size, the CO₂ behind the production, what the self-consumption share means, and the first year's saving behind the payback.
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
import { Box, Typography } from '@mui/material'
|
|
260
|
+
|
|
261
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
262
|
+
import { CARD_SX, SUNFOLD } from './tokens.js'
|
|
263
|
+
|
|
264
|
+
const euro = (value: number) => {
|
|
265
|
+
return value.toLocaleString('en-IE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
interface KpiTilesProps {
|
|
269
|
+
readonly columns: number
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The four figures a household asks first, each with the context that makes it mean something. Every value is an
|
|
273
|
+
// expression of the spec, read from the root entity.
|
|
274
|
+
export const KpiTiles = ({ columns }: KpiTilesProps) => {
|
|
275
|
+
const plan = phx.frontend.useRootEntityState()
|
|
276
|
+
const payback = plan.$paybackYears
|
|
277
|
+
const tiles = [
|
|
278
|
+
{
|
|
279
|
+
label: 'System size',
|
|
280
|
+
value: `${plan.$systemKwp.toFixed(1)} kWp`,
|
|
281
|
+
context: `${plan.panels} panels, ${plan.$panelAreaM2} m² of roof`,
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
label: 'Annual production',
|
|
285
|
+
value: `${Math.round(plan.$annualProductionKwh).toLocaleString('en')} kWh`,
|
|
286
|
+
context: `About ${plan.$co2Tonnes.toFixed(1)} t of CO₂ avoided a year`,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
label: 'Self-consumption',
|
|
290
|
+
value: `${Math.round(plan.$selfConsumption * 100)} %`,
|
|
291
|
+
context: 'of the solar power is used at home',
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
label: 'Payback',
|
|
295
|
+
value: Number.isFinite(payback) ? `${payback.toFixed(1)} years` : 'Over 25 years',
|
|
296
|
+
context: `${euro(plan.$annualSavings)} saved in the first year`,
|
|
297
|
+
},
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<Box component="div" sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gap: 2 }}>
|
|
302
|
+
{tiles.map(tile => (
|
|
303
|
+
<Box
|
|
304
|
+
component="section"
|
|
305
|
+
key={tile.label}
|
|
306
|
+
aria-label={tile.label}
|
|
307
|
+
sx={{ ...CARD_SX, display: 'grid', gap: 0.5, padding: 2 }}
|
|
308
|
+
>
|
|
309
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 13, color: SUNFOLD.muted }}>{tile.label}</Typography>
|
|
310
|
+
<Typography
|
|
311
|
+
sx={{
|
|
312
|
+
fontFamily: SUNFOLD.font,
|
|
313
|
+
fontSize: columns > 2 ? 30 : 24,
|
|
314
|
+
fontWeight: 600,
|
|
315
|
+
lineHeight: 1.15,
|
|
316
|
+
color: SUNFOLD.ink,
|
|
317
|
+
}}
|
|
318
|
+
>
|
|
319
|
+
{tile.value}
|
|
320
|
+
</Typography>
|
|
321
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>{tile.context}</Typography>
|
|
322
|
+
</Box>
|
|
323
|
+
))}
|
|
324
|
+
</Box>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Charts in SVG
|
|
330
|
+
|
|
331
|
+
The charts are hand-written SVG; the example adds no chart library. Each chart measures its width with a `ResizeObserver` and draws its text at its real size.
|
|
332
|
+
|
|
333
|
+
- **Your solar year.** `MonthlyEnergyChart` draws each month's production as a column split into what the home uses and what goes to the grid, with a 2-pixel gap between the parts and a rounded top, and the home's use as a line across it. A legend names the three series, hovering a month shows its figures, and **Show figures** swaps the chart for a table of the same numbers.
|
|
334
|
+
- **When it pays back.** `PaybackChart` draws `$cumulativeSavings`, which starts at minus the system's cost, marks the year it crosses zero, and reads off any year under the pointer.
|
|
335
|
+
- **Where the power goes.** `EnergySplitBar` splits a year of production between the home and the grid, and says how much of the home's use solar covers.
|
|
336
|
+
|
|
337
|
+
The series colours are a fixed set checked together for colour-vision deficiencies; two are lighter than 3:1 against white, which is why every chart also has a legend, a tooltip or the figures written out.
|
|
338
|
+
|
|
339
|
+
```tsx
|
|
340
|
+
import { Box, ButtonBase, Typography } from '@mui/material'
|
|
341
|
+
import { useState } from 'react'
|
|
342
|
+
|
|
343
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
344
|
+
import { CHART, SERIES } from './chart-style.js'
|
|
345
|
+
import { niceScale } from './nice-scale.js'
|
|
346
|
+
import { useWidth } from './use-width.js'
|
|
347
|
+
|
|
348
|
+
const MARGIN = { top: 12, right: 8, bottom: 26, left: 48 }
|
|
349
|
+
const GAP = 2
|
|
350
|
+
const RADIUS = 4
|
|
351
|
+
|
|
352
|
+
const kwh = (value: number) => {
|
|
353
|
+
return `${Math.round(value).toLocaleString('en')} kWh`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// A column segment whose top corners are rounded, so the stack ends in a 4px data-end and stays square at the
|
|
357
|
+
// baseline
|
|
358
|
+
const roundedTop = (x: number, y: number, width: number, height: number) => {
|
|
359
|
+
const r = Math.min(RADIUS, height, width / 2)
|
|
360
|
+
|
|
361
|
+
return `M${x},${y + height} V${y + r} Q${x},${y} ${x + r},${y} H${x + width - r} Q${x + width},${y} ${x + width},${y + r} V${y + height} Z`
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const LEGEND = [
|
|
365
|
+
{ key: 'selfUse', label: 'Solar used at home', color: SERIES.selfUse, line: false },
|
|
366
|
+
{ key: 'exported', label: 'Solar sent to the grid', color: SERIES.exported, line: false },
|
|
367
|
+
{ key: 'consumption', label: 'Home use', color: SERIES.consumption, line: true },
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
interface MonthlyEnergyChartProps {
|
|
371
|
+
readonly height: number
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Each month's solar production as a column split into what the home uses and what goes to the grid, with the
|
|
375
|
+
// home's use as a line across it. All figures come from the spec's `$monthly` expression. Hovering a month shows its
|
|
376
|
+
// figures; "Show figures" swaps the chart for a table of the same numbers.
|
|
377
|
+
export const MonthlyEnergyChart = ({ height }: MonthlyEnergyChartProps) => {
|
|
378
|
+
const plan = phx.frontend.useRootEntityState()
|
|
379
|
+
const { ref, width } = useWidth()
|
|
380
|
+
const [hovered, setHovered] = useState<number | null>(null)
|
|
381
|
+
const [asTable, setAsTable] = useState(false)
|
|
382
|
+
const months = plan.$monthly
|
|
383
|
+
const peak = Math.max(...months.map(month => Math.max(month.productionKwh, month.consumptionKwh)))
|
|
384
|
+
const { high, ticks } = niceScale(0, peak, 4)
|
|
385
|
+
const plotWidth = Math.max(width - MARGIN.left - MARGIN.right, 0)
|
|
386
|
+
const plotHeight = height - MARGIN.top - MARGIN.bottom
|
|
387
|
+
const band = plotWidth / months.length
|
|
388
|
+
const barWidth = Math.min(24, band * 0.62)
|
|
389
|
+
const y = (value: number) => MARGIN.top + plotHeight - (value / high) * plotHeight
|
|
390
|
+
const x = (index: number) => MARGIN.left + band * index + band / 2
|
|
391
|
+
const active = hovered === null ? undefined : months[hovered]
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<Box component="div" sx={{ display: 'grid', gap: 1.5 }}>
|
|
395
|
+
<Box component="div" sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', columnGap: 2, rowGap: 0.5 }}>
|
|
396
|
+
{LEGEND.map(item => (
|
|
397
|
+
<Box component="span" key={item.key} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}>
|
|
398
|
+
<Box
|
|
399
|
+
component="span"
|
|
400
|
+
aria-hidden
|
|
401
|
+
sx={
|
|
402
|
+
item.line
|
|
403
|
+
? { inlineSize: 14, blockSize: 2, borderRadius: 1, bgcolor: item.color }
|
|
404
|
+
: { inlineSize: 10, blockSize: 10, borderRadius: 0.5, bgcolor: item.color }
|
|
405
|
+
}
|
|
406
|
+
/>
|
|
407
|
+
<Typography component="span" sx={{ fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}>
|
|
408
|
+
{item.label}
|
|
409
|
+
</Typography>
|
|
410
|
+
</Box>
|
|
411
|
+
))}
|
|
412
|
+
<ButtonBase
|
|
413
|
+
onClick={() => setAsTable(shown => !shown)}
|
|
414
|
+
sx={{
|
|
415
|
+
marginInlineStart: 'auto',
|
|
416
|
+
fontFamily: CHART.font,
|
|
417
|
+
fontSize: 12,
|
|
418
|
+
color: CHART.ink,
|
|
419
|
+
textDecoration: 'underline',
|
|
420
|
+
}}
|
|
421
|
+
>
|
|
422
|
+
{asTable ? 'Show chart' : 'Show figures'}
|
|
423
|
+
</ButtonBase>
|
|
424
|
+
</Box>
|
|
425
|
+
|
|
426
|
+
{/* Measured whether the chart or the table shows, so the chart comes back at the current width */}
|
|
427
|
+
<Box component="div" ref={ref}>
|
|
428
|
+
{asTable ? (
|
|
429
|
+
<Box
|
|
430
|
+
component="table"
|
|
431
|
+
sx={{
|
|
432
|
+
inlineSize: '100%',
|
|
433
|
+
borderCollapse: 'collapse',
|
|
434
|
+
fontFamily: CHART.font,
|
|
435
|
+
fontSize: 12,
|
|
436
|
+
color: CHART.ink,
|
|
437
|
+
}}
|
|
438
|
+
>
|
|
439
|
+
<thead>
|
|
440
|
+
<tr>
|
|
441
|
+
{['Month', 'Produced', 'Used at home', 'Sent to grid', 'Home use'].map(heading => (
|
|
442
|
+
<Box
|
|
443
|
+
component="th"
|
|
444
|
+
key={heading}
|
|
445
|
+
sx={{
|
|
446
|
+
paddingBlock: 0.5,
|
|
447
|
+
textAlign: 'end',
|
|
448
|
+
fontWeight: 500,
|
|
449
|
+
color: CHART.inkSoft,
|
|
450
|
+
'&:first-of-type': { textAlign: 'start' },
|
|
451
|
+
}}
|
|
452
|
+
>
|
|
453
|
+
{heading}
|
|
454
|
+
</Box>
|
|
455
|
+
))}
|
|
456
|
+
</tr>
|
|
457
|
+
</thead>
|
|
458
|
+
<tbody>
|
|
459
|
+
{months.map(month => (
|
|
460
|
+
<Box component="tr" key={month.month} sx={{ borderBlockStart: `1px solid ${CHART.grid}` }}>
|
|
461
|
+
<Box component="td" sx={{ paddingBlock: 0.5 }}>
|
|
462
|
+
{month.month}
|
|
463
|
+
</Box>
|
|
464
|
+
{[month.productionKwh, month.selfUseKwh, month.exportKwh, month.consumptionKwh].map(
|
|
465
|
+
(value, index) => (
|
|
466
|
+
<Box component="td" key={index} sx={{ textAlign: 'end', fontVariantNumeric: 'tabular-nums' }}>
|
|
467
|
+
{Math.round(value).toLocaleString('en')}
|
|
468
|
+
</Box>
|
|
469
|
+
)
|
|
470
|
+
)}
|
|
471
|
+
</Box>
|
|
472
|
+
))}
|
|
473
|
+
</tbody>
|
|
474
|
+
</Box>
|
|
475
|
+
) : (
|
|
476
|
+
<Box component="div" sx={{ position: 'relative', blockSize: height }}>
|
|
477
|
+
{width > 0 && (
|
|
478
|
+
<svg
|
|
479
|
+
width={width}
|
|
480
|
+
height={height}
|
|
481
|
+
role="img"
|
|
482
|
+
aria-label="Solar production and home use by month"
|
|
483
|
+
onMouseLeave={() => setHovered(null)}
|
|
484
|
+
>
|
|
485
|
+
{ticks.map(tick => (
|
|
486
|
+
<g key={tick}>
|
|
487
|
+
<line
|
|
488
|
+
x1={MARGIN.left}
|
|
489
|
+
x2={width - MARGIN.right}
|
|
490
|
+
y1={y(tick)}
|
|
491
|
+
y2={y(tick)}
|
|
492
|
+
stroke={tick === 0 ? CHART.axis : CHART.grid}
|
|
493
|
+
strokeWidth={1}
|
|
494
|
+
/>
|
|
495
|
+
<text
|
|
496
|
+
x={MARGIN.left - 8}
|
|
497
|
+
y={y(tick)}
|
|
498
|
+
textAnchor="end"
|
|
499
|
+
dominantBaseline="middle"
|
|
500
|
+
fill={CHART.inkSoft}
|
|
501
|
+
fontFamily={CHART.font}
|
|
502
|
+
fontSize={11}
|
|
503
|
+
>
|
|
504
|
+
{tick.toLocaleString('en')}
|
|
505
|
+
</text>
|
|
506
|
+
</g>
|
|
507
|
+
))}
|
|
508
|
+
|
|
509
|
+
{hovered !== null && (
|
|
510
|
+
<rect
|
|
511
|
+
x={MARGIN.left + band * hovered}
|
|
512
|
+
y={MARGIN.top}
|
|
513
|
+
width={band}
|
|
514
|
+
height={plotHeight}
|
|
515
|
+
fill={CHART.grid}
|
|
516
|
+
opacity={0.6}
|
|
517
|
+
/>
|
|
518
|
+
)}
|
|
519
|
+
|
|
520
|
+
{months.map((month, index) => {
|
|
521
|
+
const left = x(index) - barWidth / 2
|
|
522
|
+
const selfTop = y(month.selfUseKwh)
|
|
523
|
+
const exportTop = y(month.productionKwh)
|
|
524
|
+
const exportHeight = Math.max(selfTop - exportTop - GAP, 0)
|
|
525
|
+
|
|
526
|
+
return (
|
|
527
|
+
<g key={month.month}>
|
|
528
|
+
{exportHeight > 0.5 ? (
|
|
529
|
+
<>
|
|
530
|
+
<rect x={left} y={selfTop} width={barWidth} height={y(0) - selfTop} fill={SERIES.selfUse} />
|
|
531
|
+
<path d={roundedTop(left, exportTop, barWidth, exportHeight)} fill={SERIES.exported} />
|
|
532
|
+
</>
|
|
533
|
+
) : (
|
|
534
|
+
<path d={roundedTop(left, selfTop, barWidth, y(0) - selfTop)} fill={SERIES.selfUse} />
|
|
535
|
+
)}
|
|
536
|
+
<text
|
|
537
|
+
x={x(index)}
|
|
538
|
+
y={height - 8}
|
|
539
|
+
textAnchor="middle"
|
|
540
|
+
fill={CHART.inkSoft}
|
|
541
|
+
fontFamily={CHART.font}
|
|
542
|
+
fontSize={11}
|
|
543
|
+
>
|
|
544
|
+
{/* A narrow chart keeps only the initial */}
|
|
545
|
+
{band < 34 ? month.month.slice(0, 1) : month.month}
|
|
546
|
+
</text>
|
|
547
|
+
</g>
|
|
548
|
+
)
|
|
549
|
+
})}
|
|
550
|
+
|
|
551
|
+
<polyline
|
|
552
|
+
points={months.map((month, index) => `${x(index)},${y(month.consumptionKwh)}`).join(' ')}
|
|
553
|
+
fill="none"
|
|
554
|
+
stroke={SERIES.consumption}
|
|
555
|
+
strokeWidth={2}
|
|
556
|
+
strokeLinejoin="round"
|
|
557
|
+
strokeLinecap="round"
|
|
558
|
+
/>
|
|
559
|
+
{months.map((month, index) => (
|
|
560
|
+
<circle
|
|
561
|
+
key={month.month}
|
|
562
|
+
cx={x(index)}
|
|
563
|
+
cy={y(month.consumptionKwh)}
|
|
564
|
+
r={4}
|
|
565
|
+
fill={SERIES.consumption}
|
|
566
|
+
stroke={CHART.surface}
|
|
567
|
+
strokeWidth={2}
|
|
568
|
+
/>
|
|
569
|
+
))}
|
|
570
|
+
|
|
571
|
+
{/* Hit areas a full band wide and the plot's height, larger than the marks */}
|
|
572
|
+
{months.map((month, index) => (
|
|
573
|
+
<rect
|
|
574
|
+
key={month.month}
|
|
575
|
+
x={MARGIN.left + band * index}
|
|
576
|
+
y={MARGIN.top}
|
|
577
|
+
width={band}
|
|
578
|
+
height={plotHeight}
|
|
579
|
+
fill="transparent"
|
|
580
|
+
onMouseEnter={() => setHovered(index)}
|
|
581
|
+
/>
|
|
582
|
+
))}
|
|
583
|
+
</svg>
|
|
584
|
+
)}
|
|
585
|
+
|
|
586
|
+
{active !== undefined && hovered !== null && (
|
|
587
|
+
<Box
|
|
588
|
+
component="div"
|
|
589
|
+
role="tooltip"
|
|
590
|
+
sx={{
|
|
591
|
+
position: 'absolute',
|
|
592
|
+
insetBlockStart: 0,
|
|
593
|
+
insetInlineStart: Math.min(Math.max(x(hovered) - 90, 0), Math.max(width - 180, 0)),
|
|
594
|
+
inlineSize: 180,
|
|
595
|
+
padding: 1.25,
|
|
596
|
+
borderRadius: 1.5,
|
|
597
|
+
bgcolor: CHART.surface,
|
|
598
|
+
boxShadow: '0 6px 24px rgba(28, 40, 38, 0.16)',
|
|
599
|
+
pointerEvents: 'none',
|
|
600
|
+
fontFamily: CHART.font,
|
|
601
|
+
}}
|
|
602
|
+
>
|
|
603
|
+
<Typography sx={{ fontFamily: CHART.font, fontSize: 12, fontWeight: 600, color: CHART.ink }}>
|
|
604
|
+
{active.month}
|
|
605
|
+
</Typography>
|
|
606
|
+
{[
|
|
607
|
+
{ label: 'Used at home', value: active.selfUseKwh, color: SERIES.selfUse },
|
|
608
|
+
{ label: 'Sent to grid', value: active.exportKwh, color: SERIES.exported },
|
|
609
|
+
{ label: 'Home use', value: active.consumptionKwh, color: SERIES.consumption },
|
|
610
|
+
].map(row => (
|
|
611
|
+
<Box component="div" key={row.label} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
612
|
+
<Box
|
|
613
|
+
component="span"
|
|
614
|
+
aria-hidden
|
|
615
|
+
sx={{ inlineSize: 8, blockSize: 8, borderRadius: '50%', bgcolor: row.color }}
|
|
616
|
+
/>
|
|
617
|
+
<Typography
|
|
618
|
+
component="span"
|
|
619
|
+
sx={{ flex: 1, fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}
|
|
620
|
+
>
|
|
621
|
+
{row.label}
|
|
622
|
+
</Typography>
|
|
623
|
+
<Typography
|
|
624
|
+
component="span"
|
|
625
|
+
sx={{
|
|
626
|
+
fontFamily: CHART.font,
|
|
627
|
+
fontSize: 12,
|
|
628
|
+
color: CHART.ink,
|
|
629
|
+
fontVariantNumeric: 'tabular-nums',
|
|
630
|
+
}}
|
|
631
|
+
>
|
|
632
|
+
{kwh(row.value)}
|
|
633
|
+
</Typography>
|
|
634
|
+
</Box>
|
|
635
|
+
))}
|
|
636
|
+
</Box>
|
|
637
|
+
)}
|
|
638
|
+
</Box>
|
|
639
|
+
)}
|
|
640
|
+
</Box>
|
|
641
|
+
</Box>
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
## A warning and a notice
|
|
647
|
+
|
|
648
|
+
The plan has two rules. More panels than the roof holds is a `warning`: the dashboard keeps showing the figures, so the household sees what the larger system would do, but a survey cannot be booked until the panels fit. A north-facing roof is a `notice`: it informs and never blocks.
|
|
649
|
+
|
|
650
|
+
```yaml
|
|
651
|
+
invariants:
|
|
652
|
+
SolarPlan:
|
|
653
|
+
panelsFitRoof:
|
|
654
|
+
types:
|
|
655
|
+
- panelsExceedRoof
|
|
656
|
+
northFacing:
|
|
657
|
+
type: northFacing
|
|
658
|
+
severity: notice
|
|
659
|
+
message: A north-facing roof yields about 40 % less. An east-west layout on another roof usually pays back sooner.
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
664
|
+
|
|
665
|
+
// More panels than the roof holds is a warning: the dashboard keeps showing the figures, and a survey cannot be
|
|
666
|
+
// booked until the panel count fits. A north-facing roof is only a notice, so it never blocks the booking.
|
|
667
|
+
export default phx.domain.implementCustomInvariants({
|
|
668
|
+
SolarPlan: {
|
|
669
|
+
panelsFitRoof: s => {
|
|
670
|
+
if (s.panels <= s.$roofPanelLimit) {
|
|
671
|
+
return []
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return [
|
|
675
|
+
{
|
|
676
|
+
type: 'panelsExceedRoof',
|
|
677
|
+
severity: 'warning',
|
|
678
|
+
message: `${s.panels} panels need about ${s.$panelAreaM2} m² of roof; ${s.roofArea} m² holds at most ${s.$roofPanelLimit}. Lower the panel count or measure the roof again.`,
|
|
679
|
+
},
|
|
680
|
+
]
|
|
681
|
+
},
|
|
682
|
+
northFacing: s => {
|
|
683
|
+
return s.orientation !== 'North'
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
})
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
`PlannerInputs` shows both under the roof's inputs, from `phx.frontend.useFinalizeViolations()`, which lists the violations of the committed plan with their severity: the warning in amber, the notice in blue. `phx.frontend.useFinalizeActions()` reports the survey action `disabled` only for the warning.
|
|
690
|
+
|
|
691
|
+
```tsx
|
|
692
|
+
import { Box, Typography } from '@mui/material'
|
|
693
|
+
import type { ReactNode } from 'react'
|
|
694
|
+
|
|
695
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
696
|
+
import { PillField } from './PillField.js'
|
|
697
|
+
import { SliderField } from './SliderField.js'
|
|
698
|
+
import { SUNFOLD } from './tokens.js'
|
|
699
|
+
|
|
700
|
+
interface GroupProps {
|
|
701
|
+
readonly title: string
|
|
702
|
+
// The invariant types whose message belongs under this group
|
|
703
|
+
readonly messageTypes?: readonly string[]
|
|
704
|
+
readonly children: ReactNode
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// A group of inputs with the message of an invariant the group can resolve: a warning, which blocks the booking, in
|
|
708
|
+
// amber, a notice, which only informs, in blue. `useFinalizeViolations` lists both.
|
|
709
|
+
const Group = ({ title, messageTypes = [], children }: GroupProps) => {
|
|
710
|
+
const { violations } = phx.frontend.useFinalizeViolations()
|
|
711
|
+
const resolve = phx.frontend.useResolveLocalizable()
|
|
712
|
+
const messages = (violations ?? []).filter(({ violation }) => messageTypes.includes(violation.type))
|
|
713
|
+
|
|
714
|
+
return (
|
|
715
|
+
<Box component="section" sx={{ display: 'grid', gap: 2 }}>
|
|
716
|
+
<Typography component="h3" sx={{ fontFamily: SUNFOLD.font, fontSize: 13, fontWeight: 600, color: SUNFOLD.muted }}>
|
|
717
|
+
{title}
|
|
718
|
+
</Typography>
|
|
719
|
+
{children}
|
|
720
|
+
{messages.map(({ violation }) => {
|
|
721
|
+
const isNotice = violation.severity === 'notice'
|
|
722
|
+
|
|
723
|
+
return (
|
|
724
|
+
<Typography
|
|
725
|
+
key={violation.type}
|
|
726
|
+
role={isNotice ? 'status' : 'alert'}
|
|
727
|
+
sx={{
|
|
728
|
+
paddingInline: 1.5,
|
|
729
|
+
paddingBlock: 1,
|
|
730
|
+
borderRadius: 2,
|
|
731
|
+
bgcolor: isNotice ? SUNFOLD.infoTint : SUNFOLD.warningTint,
|
|
732
|
+
color: isNotice ? SUNFOLD.info : SUNFOLD.warning,
|
|
733
|
+
fontFamily: SUNFOLD.font,
|
|
734
|
+
fontSize: 13,
|
|
735
|
+
lineHeight: 1.45,
|
|
736
|
+
}}
|
|
737
|
+
>
|
|
738
|
+
{resolve(violation.message)}
|
|
739
|
+
</Typography>
|
|
740
|
+
)
|
|
741
|
+
})}
|
|
742
|
+
</Box>
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
interface PlannerInputsProps {
|
|
747
|
+
readonly readOnly: boolean
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// The household's inputs: the roof, the panels on it, the home's use and tariff, and the battery
|
|
751
|
+
export const PlannerInputs = ({ readOnly }: PlannerInputsProps) => {
|
|
752
|
+
const plan = phx.frontend.useRootEntityState()
|
|
753
|
+
|
|
754
|
+
return (
|
|
755
|
+
<Box component="div" sx={{ display: 'grid', gap: 3.5 }}>
|
|
756
|
+
<Group title="Your roof" messageTypes={['panelsExceedRoof', 'northFacing']}>
|
|
757
|
+
<SliderField instanceName="roofArea" label="Usable roof area" step={1} readOnly={readOnly} />
|
|
758
|
+
<PillField instanceName="orientation" label="Facing" readOnly={readOnly} />
|
|
759
|
+
<SliderField instanceName="panels" label="Panels" step={1} readOnly={readOnly} />
|
|
760
|
+
<Typography sx={{ marginBlockStart: -1.5, fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>
|
|
761
|
+
430 W panels, 1.95 m² each. This roof holds up to {plan.$roofPanelLimit}.
|
|
762
|
+
</Typography>
|
|
763
|
+
</Group>
|
|
764
|
+
<Group title="Your home">
|
|
765
|
+
<SliderField instanceName="annualUse" label="Electricity used a year" step={100} readOnly={readOnly} />
|
|
766
|
+
<PillField instanceName="tariff" label="Tariff" readOnly={readOnly} />
|
|
767
|
+
</Group>
|
|
768
|
+
<Group title="Storage">
|
|
769
|
+
<PillField instanceName="battery" label="Home battery" readOnly={readOnly} />
|
|
770
|
+
</Group>
|
|
771
|
+
</Box>
|
|
772
|
+
)
|
|
773
|
+
}
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
## Book a home survey
|
|
777
|
+
|
|
778
|
+
The spec declares one finalize action. Its handler, `src/handlers/book-survey.ts`, stands in for the installer's booking service: it waits, then enters the `booked` navigation state. In that state the panel confirms the request and the inputs are locked until **Back to the plan**. A viewer of a shared configuration sees the cost and no button.
|
|
779
|
+
|
|
780
|
+
```yaml
|
|
781
|
+
components:
|
|
782
|
+
SolarPlan:
|
|
783
|
+
finalizeAction:
|
|
784
|
+
survey:
|
|
785
|
+
variant: primary
|
|
786
|
+
label: Book a home survey
|
|
787
|
+
title: Ask an installer to survey the roof and confirm the plan
|
|
788
|
+
icon: CalendarCheck
|
|
789
|
+
handler: bookSurvey
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
```tsx
|
|
793
|
+
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
|
|
794
|
+
|
|
795
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
796
|
+
import { SUNFOLD } from './tokens.js'
|
|
797
|
+
|
|
798
|
+
interface SurveyPanelProps {
|
|
799
|
+
// On a phone the panel is a bar: the cost beside the button, without the hints
|
|
800
|
+
readonly compact: boolean
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// The system's cost, the `price` component's value, and the spec's `survey` finalize action. A warning blocks the
|
|
804
|
+
// booking and its `hints` say why; a notice does not. In the `booked` navigation state the panel confirms the
|
|
805
|
+
// request instead.
|
|
806
|
+
export const SurveyPanel = ({ compact }: SurveyPanelProps) => {
|
|
807
|
+
const cost = phx.frontend.useRootEntityPrice()
|
|
808
|
+
const { primary, hints } = phx.frontend.useFinalizeActions()
|
|
809
|
+
const navigationState = phx.frontend.useActiveNavigationState()
|
|
810
|
+
const navigate = phx.frontend.useNavigate()
|
|
811
|
+
const { accessInfo } = phx.frontend.useAccessService()
|
|
812
|
+
const isPending = primary?.isPending ?? false
|
|
813
|
+
|
|
814
|
+
if (navigationState === 'booked') {
|
|
815
|
+
return (
|
|
816
|
+
<Box component="div" role="status" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
817
|
+
<Box component="div" sx={{ display: 'grid', flex: 1 }}>
|
|
818
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 16, fontWeight: 600, color: SUNFOLD.green }}>
|
|
819
|
+
Survey requested
|
|
820
|
+
</Typography>
|
|
821
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 13, color: SUNFOLD.muted }}>
|
|
822
|
+
An installer calls within two working days.
|
|
823
|
+
</Typography>
|
|
824
|
+
</Box>
|
|
825
|
+
<Button
|
|
826
|
+
onClick={() => navigate('plan')}
|
|
827
|
+
sx={{
|
|
828
|
+
fontFamily: SUNFOLD.font,
|
|
829
|
+
fontSize: 13,
|
|
830
|
+
color: SUNFOLD.ink,
|
|
831
|
+
textTransform: 'none',
|
|
832
|
+
textDecoration: 'underline',
|
|
833
|
+
}}
|
|
834
|
+
>
|
|
835
|
+
Back to the plan
|
|
836
|
+
</Button>
|
|
837
|
+
</Box>
|
|
838
|
+
)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const showButton = primary !== null && !accessInfo.viewMode
|
|
842
|
+
|
|
843
|
+
return (
|
|
844
|
+
<Box component="div" sx={{ display: 'grid', gap: 1.25 }}>
|
|
845
|
+
<Box
|
|
846
|
+
component="div"
|
|
847
|
+
sx={{
|
|
848
|
+
display: 'flex',
|
|
849
|
+
flexDirection: compact ? 'row' : 'column',
|
|
850
|
+
alignItems: compact ? 'center' : 'stretch',
|
|
851
|
+
justifyContent: 'space-between',
|
|
852
|
+
gap: compact ? 2 : 1.5,
|
|
853
|
+
}}
|
|
854
|
+
>
|
|
855
|
+
<Box component="div" sx={{ display: 'grid' }}>
|
|
856
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>Installed cost</Typography>
|
|
857
|
+
<Typography
|
|
858
|
+
sx={{ fontFamily: SUNFOLD.font, fontSize: compact ? 20 : 26, fontWeight: 600, color: SUNFOLD.ink }}
|
|
859
|
+
>
|
|
860
|
+
{cost}
|
|
861
|
+
</Typography>
|
|
862
|
+
</Box>
|
|
863
|
+
{showButton && (
|
|
864
|
+
<Button
|
|
865
|
+
variant="contained"
|
|
866
|
+
disableElevation
|
|
867
|
+
disabled={primary.disabled || isPending}
|
|
868
|
+
onClick={() => void primary.run()}
|
|
869
|
+
title={primary.title}
|
|
870
|
+
startIcon={isPending ? <CircularProgress size={16} color="inherit" /> : undefined}
|
|
871
|
+
sx={{
|
|
872
|
+
minBlockSize: 46,
|
|
873
|
+
paddingInline: 2.5,
|
|
874
|
+
borderRadius: 999,
|
|
875
|
+
bgcolor: SUNFOLD.green,
|
|
876
|
+
fontFamily: SUNFOLD.font,
|
|
877
|
+
fontSize: 14,
|
|
878
|
+
fontWeight: 500,
|
|
879
|
+
textTransform: 'none',
|
|
880
|
+
'&:hover': { bgcolor: SUNFOLD.greenDark },
|
|
881
|
+
'&.Mui-disabled': { bgcolor: SUNFOLD.line, color: SUNFOLD.muted },
|
|
882
|
+
}}
|
|
883
|
+
>
|
|
884
|
+
{isPending ? 'Booking…' : primary.label}
|
|
885
|
+
</Button>
|
|
886
|
+
)}
|
|
887
|
+
</Box>
|
|
888
|
+
{!compact && showButton && primary.disabled && hints && (
|
|
889
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.warning }}>{hints}</Typography>
|
|
890
|
+
)}
|
|
891
|
+
{primary?.error && <Alert severity="error">The survey could not be booked: {primary.error.message}</Alert>}
|
|
892
|
+
</Box>
|
|
893
|
+
)
|
|
894
|
+
}
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
## On a phone
|
|
898
|
+
|
|
899
|
+
The same layout in a frame 390 pixels wide: the four figures in two rows, the inputs in a card below them, the charts stacked under the inputs, and the cost with **Book a home survey** in a bar at the bottom. Opened on its own from the sidebar, the **Phone** story sets Storybook's viewport to 390 × 844.
|
|
900
|
+
|
|
901
|
+
## Test cases
|
|
902
|
+
|
|
903
|
+
- The plan opens with 14 panels on a 42 m² south-facing roof, no battery, a flat rate and 4,500 kWh a year. The tiles read **6.0 kWp**, **6,020 kWh**, **30 %** and **8.2 years**, the installed cost is **€7,821.00**, and the split bar shows 30 % used at home.
|
|
904
|
+
- In **Your solar year**, the summer columns are mostly sent to the grid and the winter ones mostly used at home, and the home's use is a line that is higher in winter. Hovering June shows 142 kWh used at home, 641 kWh sent to the grid and 315 kWh of home use.
|
|
905
|
+
- Moving the pointer across **When it pays back** reads off the year; the curve crosses zero at the marked payback year.
|
|
906
|
+
- A 10 kWh battery, the time-of-use tariff and 18 panels change every tile and chart: 7.7 kWp, 7,740 kWh, 44 % used at home and 9.8 years, at €16,027.00. The columns' home part grows.
|
|
907
|
+
- 22 panels on the 42 m² roof show "22 panels need about 43 m² of roof; 42 m² holds at most 18. …" and disable **Book a home survey**; a roof of 52 m² clears it.
|
|
908
|
+
- **North** shows the notice "A north-facing roof yields about 40 % less. …" in blue, lowers the production and lengthens the payback, and leaves **Book a home survey** enabled.
|
|
909
|
+
- **Show figures** shows a table of the twelve months; **Show chart** brings the chart back.
|
|
910
|
+
- **Book a home survey** shows a spinner and "Booking…", then "Survey requested"; the inputs are locked until **Back to the plan**.
|
|
911
|
+
- In the **Phone** story the tiles sit in two rows, the inputs and charts scroll below, the same changes update every figure, and the bottom bar confirms the booking.
|
|
912
|
+
- The browser console shows no errors, the page has no `canvas` element, and no model or texture is requested.
|
|
913
|
+
|
|
914
|
+
## What this layout leaves out
|
|
915
|
+
|
|
916
|
+
- **Read mode.** The inputs are inert and **Book a home survey** is hidden while `phx.frontend.useAccessService().accessInfo.viewMode` is set, but the example ships no read template; [Custom Layout → Serve read mode](?path=/docs/layouts-custom-layout--docs#serve-read-mode) shows one.
|
|
917
|
+
- **Real yields and prices.** The model is an illustration. A deployed planner would read the site's irradiance and the household's tariff from data the project publishes, see [Data Sources](?path=/docs/phoenix-data-sources--docs).
|
|
918
|
+
- **A dark theme.** The page is the installer's own light design; the configurator's theme does not switch it.
|
|
919
|
+
- **The built-in arrangement.** Header, sidebar, footer, tabs and the preview start screen are not mounted; [Layout Slots](?path=/docs/phoenix-layout-slots--docs) lists every slot a project layout can mount.
|
|
920
|
+
|
|
921
|
+
## Related pages
|
|
922
|
+
|
|
923
|
+
- [Ardent Compressor](?path=/docs/layouts-gallery-ardent-compressor--docs) is a quote form with steps, and [Fenwick Window](?path=/docs/layouts-gallery-fenwick-window--docs) a live shop drawing; neither mounts a canvas either.
|
|
924
|
+
- [Custom Layout](?path=/docs/layouts-custom-layout--docs) explains a project layout step by step.
|
|
925
|
+
- [Configurator Interface → Hooks for a Project Layout](?path=/docs/phoenix-configurator-interface--docs#hooks-for-a-project-layout) lists the hooks used here.
|
|
926
|
+
- [Domain Model → Expressions](?path=/docs/phoenix-domain-model--docs#expressions) and [Domain Model → Invariants](?path=/docs/phoenix-domain-model--docs#invariants) describe declared expressions and severities.
|
|
927
|
+
|
|
928
|
+
## Complete Implementation
|
|
929
|
+
|
|
930
|
+
{/* AUTO-GENERATED SECTION: This section is automatically updated by 'phoenix story build' */}
|
|
931
|
+
|
|
932
|
+
<details>
|
|
933
|
+
<summary>Click to view the complete implementation files</summary>
|
|
934
|
+
|
|
935
|
+
### ./spec/domain.yaml
|
|
936
|
+
|
|
937
|
+
```yaml
|
|
938
|
+
model:
|
|
939
|
+
latest:
|
|
940
|
+
entityTypes:
|
|
941
|
+
- SolarPlan
|
|
942
|
+
aliases:
|
|
943
|
+
RootEntity:
|
|
944
|
+
- SolarPlan
|
|
945
|
+
commonTypes:
|
|
946
|
+
Orientation:
|
|
947
|
+
enum:
|
|
948
|
+
- South
|
|
949
|
+
- SouthEastWest
|
|
950
|
+
- EastWest
|
|
951
|
+
- North
|
|
952
|
+
Battery:
|
|
953
|
+
enum:
|
|
954
|
+
- None
|
|
955
|
+
- Kwh5
|
|
956
|
+
- Kwh10
|
|
957
|
+
- Kwh15
|
|
958
|
+
Tariff:
|
|
959
|
+
enum:
|
|
960
|
+
- Flat
|
|
961
|
+
- TimeOfUse
|
|
962
|
+
- ExportPlus
|
|
963
|
+
shapes:
|
|
964
|
+
SolarPlan:
|
|
965
|
+
roofArea:
|
|
966
|
+
type: number
|
|
967
|
+
orientation:
|
|
968
|
+
$ref: '#/model/latest/commonTypes/Orientation'
|
|
969
|
+
panels:
|
|
970
|
+
type: number
|
|
971
|
+
battery:
|
|
972
|
+
$ref: '#/model/latest/commonTypes/Battery'
|
|
973
|
+
tariff:
|
|
974
|
+
$ref: '#/model/latest/commonTypes/Tariff'
|
|
975
|
+
annualUse:
|
|
976
|
+
type: number
|
|
977
|
+
|
|
978
|
+
presets:
|
|
979
|
+
- rootEntityType: SolarPlan
|
|
980
|
+
initialDomainEntities:
|
|
981
|
+
- id: SolarPlan-1
|
|
982
|
+
type: SolarPlan
|
|
983
|
+
roofArea: 42
|
|
984
|
+
orientation: South
|
|
985
|
+
panels: 14
|
|
986
|
+
battery: None
|
|
987
|
+
tariff: Flat
|
|
988
|
+
annualUse: 4500
|
|
989
|
+
|
|
990
|
+
navigationStates:
|
|
991
|
+
- plan
|
|
992
|
+
- booked
|
|
993
|
+
|
|
994
|
+
# Every figure on the dashboard: declared here, computed in src/expressions/solar.ts from the roof, the panels, the
|
|
995
|
+
# battery, the tariff and the household's use
|
|
996
|
+
expressions:
|
|
997
|
+
SolarPlan:
|
|
998
|
+
$systemKwp:
|
|
999
|
+
type: number
|
|
1000
|
+
$roofPanelLimit:
|
|
1001
|
+
type: number
|
|
1002
|
+
$panelAreaM2:
|
|
1003
|
+
type: number
|
|
1004
|
+
$annualProductionKwh:
|
|
1005
|
+
type: number
|
|
1006
|
+
$monthly:
|
|
1007
|
+
type: array
|
|
1008
|
+
items:
|
|
1009
|
+
type: object
|
|
1010
|
+
properties:
|
|
1011
|
+
month:
|
|
1012
|
+
type: string
|
|
1013
|
+
productionKwh:
|
|
1014
|
+
type: number
|
|
1015
|
+
consumptionKwh:
|
|
1016
|
+
type: number
|
|
1017
|
+
selfUseKwh:
|
|
1018
|
+
type: number
|
|
1019
|
+
exportKwh:
|
|
1020
|
+
type: number
|
|
1021
|
+
required:
|
|
1022
|
+
- month
|
|
1023
|
+
- productionKwh
|
|
1024
|
+
- consumptionKwh
|
|
1025
|
+
- selfUseKwh
|
|
1026
|
+
- exportKwh
|
|
1027
|
+
additionalProperties: false
|
|
1028
|
+
$selfConsumption:
|
|
1029
|
+
type: number
|
|
1030
|
+
$selfSufficiency:
|
|
1031
|
+
type: number
|
|
1032
|
+
$annualSavings:
|
|
1033
|
+
type: number
|
|
1034
|
+
$systemCost:
|
|
1035
|
+
type: number
|
|
1036
|
+
$cumulativeSavings:
|
|
1037
|
+
type: array
|
|
1038
|
+
items:
|
|
1039
|
+
type: number
|
|
1040
|
+
$paybackYears:
|
|
1041
|
+
type: number
|
|
1042
|
+
$co2Tonnes:
|
|
1043
|
+
type: number
|
|
1044
|
+
|
|
1045
|
+
invariants:
|
|
1046
|
+
SolarPlan:
|
|
1047
|
+
panelsFitRoof:
|
|
1048
|
+
types:
|
|
1049
|
+
- panelsExceedRoof
|
|
1050
|
+
northFacing:
|
|
1051
|
+
type: northFacing
|
|
1052
|
+
severity: notice
|
|
1053
|
+
message: A north-facing roof yields about 40 % less. An east-west layout on another roof usually pays back sooner.
|
|
1054
|
+
|
|
1055
|
+
components:
|
|
1056
|
+
SolarPlan:
|
|
1057
|
+
price:
|
|
1058
|
+
main:
|
|
1059
|
+
price:
|
|
1060
|
+
$read: $systemCost
|
|
1061
|
+
currency: EUR
|
|
1062
|
+
finalizeAction:
|
|
1063
|
+
survey:
|
|
1064
|
+
variant: primary
|
|
1065
|
+
label: Book a home survey
|
|
1066
|
+
title: Ask an installer to survey the roof and confirm the plan
|
|
1067
|
+
icon: CalendarCheck
|
|
1068
|
+
handler: bookSurvey
|
|
1069
|
+
genericDimension:
|
|
1070
|
+
roofArea:
|
|
1071
|
+
value:
|
|
1072
|
+
$bind: roofArea
|
|
1073
|
+
min: 10
|
|
1074
|
+
max: 120
|
|
1075
|
+
unit: m²
|
|
1076
|
+
decimalPlaces: 0
|
|
1077
|
+
panels:
|
|
1078
|
+
value:
|
|
1079
|
+
$bind: panels
|
|
1080
|
+
min: 4
|
|
1081
|
+
max: 40
|
|
1082
|
+
unit: panels
|
|
1083
|
+
decimalPlaces: 0
|
|
1084
|
+
annualUse:
|
|
1085
|
+
value:
|
|
1086
|
+
$bind: annualUse
|
|
1087
|
+
min: 1500
|
|
1088
|
+
max: 12000
|
|
1089
|
+
unit: kWh
|
|
1090
|
+
decimalPlaces: 0
|
|
1091
|
+
attributeSelection:
|
|
1092
|
+
orientation:
|
|
1093
|
+
value:
|
|
1094
|
+
$bind: orientation
|
|
1095
|
+
options:
|
|
1096
|
+
- label: South
|
|
1097
|
+
value: South
|
|
1098
|
+
- label: South-east or south-west
|
|
1099
|
+
value: SouthEastWest
|
|
1100
|
+
- label: East and west
|
|
1101
|
+
value: EastWest
|
|
1102
|
+
- label: North
|
|
1103
|
+
value: North
|
|
1104
|
+
battery:
|
|
1105
|
+
value:
|
|
1106
|
+
$bind: battery
|
|
1107
|
+
options:
|
|
1108
|
+
- label: None
|
|
1109
|
+
value: None
|
|
1110
|
+
- label: 5 kWh
|
|
1111
|
+
value: Kwh5
|
|
1112
|
+
- label: 10 kWh
|
|
1113
|
+
value: Kwh10
|
|
1114
|
+
- label: 15 kWh
|
|
1115
|
+
value: Kwh15
|
|
1116
|
+
tariff:
|
|
1117
|
+
value:
|
|
1118
|
+
$bind: tariff
|
|
1119
|
+
options:
|
|
1120
|
+
- label: Flat rate
|
|
1121
|
+
description: €0.32 per kWh bought, €0.08 per kWh sold
|
|
1122
|
+
value: Flat
|
|
1123
|
+
- label: Time of use
|
|
1124
|
+
description: Cheap nights, dear evenings; a battery shifts the evening
|
|
1125
|
+
value: TimeOfUse
|
|
1126
|
+
- label: Export plus
|
|
1127
|
+
description: €0.15 per kWh sold to the grid
|
|
1128
|
+
value: ExportPlus
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
### ./src/\_charts/EnergySplitBar.tsx
|
|
1132
|
+
|
|
1133
|
+
```tsx
|
|
1134
|
+
import { Box, Typography } from '@mui/material'
|
|
1135
|
+
|
|
1136
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
1137
|
+
import { CHART, SERIES } from './chart-style.js'
|
|
1138
|
+
|
|
1139
|
+
// Where a year of solar power goes, as one bar split between the home and the grid, from the spec's `$monthly` and
|
|
1140
|
+
// `$selfConsumption` expressions. A battery moves the split towards the home.
|
|
1141
|
+
export const EnergySplitBar = () => {
|
|
1142
|
+
const plan = phx.frontend.useRootEntityState()
|
|
1143
|
+
const selfUse = plan.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
|
|
1144
|
+
const exported = plan.$monthly.reduce((sum, month) => sum + month.exportKwh, 0)
|
|
1145
|
+
const share = plan.$selfConsumption
|
|
1146
|
+
const parts = [
|
|
1147
|
+
{ key: 'home', label: 'Used at home', kwh: selfUse, share, color: SERIES.selfUse },
|
|
1148
|
+
{ key: 'grid', label: 'Sent to the grid', kwh: exported, share: 1 - share, color: SERIES.exported },
|
|
1149
|
+
]
|
|
1150
|
+
|
|
1151
|
+
return (
|
|
1152
|
+
<Box component="div" sx={{ display: 'grid', gap: 1.5 }}>
|
|
1153
|
+
<Box
|
|
1154
|
+
component="div"
|
|
1155
|
+
role="img"
|
|
1156
|
+
aria-label={`${Math.round(share * 100)} % of the solar power is used at home`}
|
|
1157
|
+
sx={{ display: 'flex', gap: '2px', blockSize: 20 }}
|
|
1158
|
+
>
|
|
1159
|
+
{parts.map((part, index) => (
|
|
1160
|
+
<Box
|
|
1161
|
+
component="span"
|
|
1162
|
+
key={part.key}
|
|
1163
|
+
sx={{
|
|
1164
|
+
flexBasis: `${part.share * 100}%`,
|
|
1165
|
+
minInlineSize: part.share > 0 ? 4 : 0,
|
|
1166
|
+
bgcolor: part.color,
|
|
1167
|
+
borderStartStartRadius: index === 0 ? 4 : 0,
|
|
1168
|
+
borderEndStartRadius: index === 0 ? 4 : 0,
|
|
1169
|
+
borderStartEndRadius: index === parts.length - 1 ? 4 : 0,
|
|
1170
|
+
borderEndEndRadius: index === parts.length - 1 ? 4 : 0,
|
|
1171
|
+
transition: 'flex-basis 240ms ease-out',
|
|
1172
|
+
}}
|
|
1173
|
+
/>
|
|
1174
|
+
))}
|
|
1175
|
+
</Box>
|
|
1176
|
+
<Box component="div" sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
|
1177
|
+
{parts.map(part => (
|
|
1178
|
+
<Box component="div" key={part.key} sx={{ display: 'grid', gap: 0.25 }}>
|
|
1179
|
+
<Box component="div" sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
1180
|
+
<Box
|
|
1181
|
+
component="span"
|
|
1182
|
+
aria-hidden
|
|
1183
|
+
sx={{ inlineSize: 10, blockSize: 10, borderRadius: 0.5, bgcolor: part.color }}
|
|
1184
|
+
/>
|
|
1185
|
+
<Typography sx={{ fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}>{part.label}</Typography>
|
|
1186
|
+
</Box>
|
|
1187
|
+
<Typography sx={{ fontFamily: CHART.font, fontSize: 20, fontWeight: 600, color: CHART.ink }}>
|
|
1188
|
+
{Math.round(part.share * 100)} %
|
|
1189
|
+
</Typography>
|
|
1190
|
+
<Typography sx={{ fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}>
|
|
1191
|
+
{Math.round(part.kwh).toLocaleString('en')} kWh a year
|
|
1192
|
+
</Typography>
|
|
1193
|
+
</Box>
|
|
1194
|
+
))}
|
|
1195
|
+
</Box>
|
|
1196
|
+
<Typography sx={{ fontFamily: CHART.font, fontSize: 13, color: CHART.ink }}>
|
|
1197
|
+
Solar covers {Math.round(plan.$selfSufficiency * 100)} % of the home's {plan.annualUse.toLocaleString('en')}{' '}
|
|
1198
|
+
kWh.
|
|
1199
|
+
</Typography>
|
|
1200
|
+
</Box>
|
|
1201
|
+
)
|
|
1202
|
+
}
|
|
1203
|
+
```
|
|
1204
|
+
|
|
1205
|
+
### ./src/\_charts/MonthlyEnergyChart.tsx
|
|
1206
|
+
|
|
1207
|
+
```tsx
|
|
1208
|
+
import { Box, ButtonBase, Typography } from '@mui/material'
|
|
1209
|
+
import { useState } from 'react'
|
|
1210
|
+
|
|
1211
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
1212
|
+
import { CHART, SERIES } from './chart-style.js'
|
|
1213
|
+
import { niceScale } from './nice-scale.js'
|
|
1214
|
+
import { useWidth } from './use-width.js'
|
|
1215
|
+
|
|
1216
|
+
const MARGIN = { top: 12, right: 8, bottom: 26, left: 48 }
|
|
1217
|
+
const GAP = 2
|
|
1218
|
+
const RADIUS = 4
|
|
1219
|
+
|
|
1220
|
+
const kwh = (value: number) => {
|
|
1221
|
+
return `${Math.round(value).toLocaleString('en')} kWh`
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// A column segment whose top corners are rounded, so the stack ends in a 4px data-end and stays square at the
|
|
1225
|
+
// baseline
|
|
1226
|
+
const roundedTop = (x: number, y: number, width: number, height: number) => {
|
|
1227
|
+
const r = Math.min(RADIUS, height, width / 2)
|
|
1228
|
+
|
|
1229
|
+
return `M${x},${y + height} V${y + r} Q${x},${y} ${x + r},${y} H${x + width - r} Q${x + width},${y} ${x + width},${y + r} V${y + height} Z`
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const LEGEND = [
|
|
1233
|
+
{ key: 'selfUse', label: 'Solar used at home', color: SERIES.selfUse, line: false },
|
|
1234
|
+
{ key: 'exported', label: 'Solar sent to the grid', color: SERIES.exported, line: false },
|
|
1235
|
+
{ key: 'consumption', label: 'Home use', color: SERIES.consumption, line: true },
|
|
1236
|
+
]
|
|
1237
|
+
|
|
1238
|
+
interface MonthlyEnergyChartProps {
|
|
1239
|
+
readonly height: number
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Each month's solar production as a column split into what the home uses and what goes to the grid, with the
|
|
1243
|
+
// home's use as a line across it. All figures come from the spec's `$monthly` expression. Hovering a month shows its
|
|
1244
|
+
// figures; "Show figures" swaps the chart for a table of the same numbers.
|
|
1245
|
+
export const MonthlyEnergyChart = ({ height }: MonthlyEnergyChartProps) => {
|
|
1246
|
+
const plan = phx.frontend.useRootEntityState()
|
|
1247
|
+
const { ref, width } = useWidth()
|
|
1248
|
+
const [hovered, setHovered] = useState<number | null>(null)
|
|
1249
|
+
const [asTable, setAsTable] = useState(false)
|
|
1250
|
+
const months = plan.$monthly
|
|
1251
|
+
const peak = Math.max(...months.map(month => Math.max(month.productionKwh, month.consumptionKwh)))
|
|
1252
|
+
const { high, ticks } = niceScale(0, peak, 4)
|
|
1253
|
+
const plotWidth = Math.max(width - MARGIN.left - MARGIN.right, 0)
|
|
1254
|
+
const plotHeight = height - MARGIN.top - MARGIN.bottom
|
|
1255
|
+
const band = plotWidth / months.length
|
|
1256
|
+
const barWidth = Math.min(24, band * 0.62)
|
|
1257
|
+
const y = (value: number) => MARGIN.top + plotHeight - (value / high) * plotHeight
|
|
1258
|
+
const x = (index: number) => MARGIN.left + band * index + band / 2
|
|
1259
|
+
const active = hovered === null ? undefined : months[hovered]
|
|
1260
|
+
|
|
1261
|
+
return (
|
|
1262
|
+
<Box component="div" sx={{ display: 'grid', gap: 1.5 }}>
|
|
1263
|
+
<Box component="div" sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', columnGap: 2, rowGap: 0.5 }}>
|
|
1264
|
+
{LEGEND.map(item => (
|
|
1265
|
+
<Box component="span" key={item.key} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}>
|
|
1266
|
+
<Box
|
|
1267
|
+
component="span"
|
|
1268
|
+
aria-hidden
|
|
1269
|
+
sx={
|
|
1270
|
+
item.line
|
|
1271
|
+
? { inlineSize: 14, blockSize: 2, borderRadius: 1, bgcolor: item.color }
|
|
1272
|
+
: { inlineSize: 10, blockSize: 10, borderRadius: 0.5, bgcolor: item.color }
|
|
1273
|
+
}
|
|
1274
|
+
/>
|
|
1275
|
+
<Typography component="span" sx={{ fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}>
|
|
1276
|
+
{item.label}
|
|
1277
|
+
</Typography>
|
|
1278
|
+
</Box>
|
|
1279
|
+
))}
|
|
1280
|
+
<ButtonBase
|
|
1281
|
+
onClick={() => setAsTable(shown => !shown)}
|
|
1282
|
+
sx={{
|
|
1283
|
+
marginInlineStart: 'auto',
|
|
1284
|
+
fontFamily: CHART.font,
|
|
1285
|
+
fontSize: 12,
|
|
1286
|
+
color: CHART.ink,
|
|
1287
|
+
textDecoration: 'underline',
|
|
1288
|
+
}}
|
|
1289
|
+
>
|
|
1290
|
+
{asTable ? 'Show chart' : 'Show figures'}
|
|
1291
|
+
</ButtonBase>
|
|
1292
|
+
</Box>
|
|
1293
|
+
|
|
1294
|
+
{/* Measured whether the chart or the table shows, so the chart comes back at the current width */}
|
|
1295
|
+
<Box component="div" ref={ref}>
|
|
1296
|
+
{asTable ? (
|
|
1297
|
+
<Box
|
|
1298
|
+
component="table"
|
|
1299
|
+
sx={{
|
|
1300
|
+
inlineSize: '100%',
|
|
1301
|
+
borderCollapse: 'collapse',
|
|
1302
|
+
fontFamily: CHART.font,
|
|
1303
|
+
fontSize: 12,
|
|
1304
|
+
color: CHART.ink,
|
|
1305
|
+
}}
|
|
1306
|
+
>
|
|
1307
|
+
<thead>
|
|
1308
|
+
<tr>
|
|
1309
|
+
{['Month', 'Produced', 'Used at home', 'Sent to grid', 'Home use'].map(heading => (
|
|
1310
|
+
<Box
|
|
1311
|
+
component="th"
|
|
1312
|
+
key={heading}
|
|
1313
|
+
sx={{
|
|
1314
|
+
paddingBlock: 0.5,
|
|
1315
|
+
textAlign: 'end',
|
|
1316
|
+
fontWeight: 500,
|
|
1317
|
+
color: CHART.inkSoft,
|
|
1318
|
+
'&:first-of-type': { textAlign: 'start' },
|
|
1319
|
+
}}
|
|
1320
|
+
>
|
|
1321
|
+
{heading}
|
|
1322
|
+
</Box>
|
|
1323
|
+
))}
|
|
1324
|
+
</tr>
|
|
1325
|
+
</thead>
|
|
1326
|
+
<tbody>
|
|
1327
|
+
{months.map(month => (
|
|
1328
|
+
<Box component="tr" key={month.month} sx={{ borderBlockStart: `1px solid ${CHART.grid}` }}>
|
|
1329
|
+
<Box component="td" sx={{ paddingBlock: 0.5 }}>
|
|
1330
|
+
{month.month}
|
|
1331
|
+
</Box>
|
|
1332
|
+
{[month.productionKwh, month.selfUseKwh, month.exportKwh, month.consumptionKwh].map(
|
|
1333
|
+
(value, index) => (
|
|
1334
|
+
<Box component="td" key={index} sx={{ textAlign: 'end', fontVariantNumeric: 'tabular-nums' }}>
|
|
1335
|
+
{Math.round(value).toLocaleString('en')}
|
|
1336
|
+
</Box>
|
|
1337
|
+
)
|
|
1338
|
+
)}
|
|
1339
|
+
</Box>
|
|
1340
|
+
))}
|
|
1341
|
+
</tbody>
|
|
1342
|
+
</Box>
|
|
1343
|
+
) : (
|
|
1344
|
+
<Box component="div" sx={{ position: 'relative', blockSize: height }}>
|
|
1345
|
+
{width > 0 && (
|
|
1346
|
+
<svg
|
|
1347
|
+
width={width}
|
|
1348
|
+
height={height}
|
|
1349
|
+
role="img"
|
|
1350
|
+
aria-label="Solar production and home use by month"
|
|
1351
|
+
onMouseLeave={() => setHovered(null)}
|
|
1352
|
+
>
|
|
1353
|
+
{ticks.map(tick => (
|
|
1354
|
+
<g key={tick}>
|
|
1355
|
+
<line
|
|
1356
|
+
x1={MARGIN.left}
|
|
1357
|
+
x2={width - MARGIN.right}
|
|
1358
|
+
y1={y(tick)}
|
|
1359
|
+
y2={y(tick)}
|
|
1360
|
+
stroke={tick === 0 ? CHART.axis : CHART.grid}
|
|
1361
|
+
strokeWidth={1}
|
|
1362
|
+
/>
|
|
1363
|
+
<text
|
|
1364
|
+
x={MARGIN.left - 8}
|
|
1365
|
+
y={y(tick)}
|
|
1366
|
+
textAnchor="end"
|
|
1367
|
+
dominantBaseline="middle"
|
|
1368
|
+
fill={CHART.inkSoft}
|
|
1369
|
+
fontFamily={CHART.font}
|
|
1370
|
+
fontSize={11}
|
|
1371
|
+
>
|
|
1372
|
+
{tick.toLocaleString('en')}
|
|
1373
|
+
</text>
|
|
1374
|
+
</g>
|
|
1375
|
+
))}
|
|
1376
|
+
|
|
1377
|
+
{hovered !== null && (
|
|
1378
|
+
<rect
|
|
1379
|
+
x={MARGIN.left + band * hovered}
|
|
1380
|
+
y={MARGIN.top}
|
|
1381
|
+
width={band}
|
|
1382
|
+
height={plotHeight}
|
|
1383
|
+
fill={CHART.grid}
|
|
1384
|
+
opacity={0.6}
|
|
1385
|
+
/>
|
|
1386
|
+
)}
|
|
1387
|
+
|
|
1388
|
+
{months.map((month, index) => {
|
|
1389
|
+
const left = x(index) - barWidth / 2
|
|
1390
|
+
const selfTop = y(month.selfUseKwh)
|
|
1391
|
+
const exportTop = y(month.productionKwh)
|
|
1392
|
+
const exportHeight = Math.max(selfTop - exportTop - GAP, 0)
|
|
1393
|
+
|
|
1394
|
+
return (
|
|
1395
|
+
<g key={month.month}>
|
|
1396
|
+
{exportHeight > 0.5 ? (
|
|
1397
|
+
<>
|
|
1398
|
+
<rect x={left} y={selfTop} width={barWidth} height={y(0) - selfTop} fill={SERIES.selfUse} />
|
|
1399
|
+
<path d={roundedTop(left, exportTop, barWidth, exportHeight)} fill={SERIES.exported} />
|
|
1400
|
+
</>
|
|
1401
|
+
) : (
|
|
1402
|
+
<path d={roundedTop(left, selfTop, barWidth, y(0) - selfTop)} fill={SERIES.selfUse} />
|
|
1403
|
+
)}
|
|
1404
|
+
<text
|
|
1405
|
+
x={x(index)}
|
|
1406
|
+
y={height - 8}
|
|
1407
|
+
textAnchor="middle"
|
|
1408
|
+
fill={CHART.inkSoft}
|
|
1409
|
+
fontFamily={CHART.font}
|
|
1410
|
+
fontSize={11}
|
|
1411
|
+
>
|
|
1412
|
+
{/* A narrow chart keeps only the initial */}
|
|
1413
|
+
{band < 34 ? month.month.slice(0, 1) : month.month}
|
|
1414
|
+
</text>
|
|
1415
|
+
</g>
|
|
1416
|
+
)
|
|
1417
|
+
})}
|
|
1418
|
+
|
|
1419
|
+
<polyline
|
|
1420
|
+
points={months.map((month, index) => `${x(index)},${y(month.consumptionKwh)}`).join(' ')}
|
|
1421
|
+
fill="none"
|
|
1422
|
+
stroke={SERIES.consumption}
|
|
1423
|
+
strokeWidth={2}
|
|
1424
|
+
strokeLinejoin="round"
|
|
1425
|
+
strokeLinecap="round"
|
|
1426
|
+
/>
|
|
1427
|
+
{months.map((month, index) => (
|
|
1428
|
+
<circle
|
|
1429
|
+
key={month.month}
|
|
1430
|
+
cx={x(index)}
|
|
1431
|
+
cy={y(month.consumptionKwh)}
|
|
1432
|
+
r={4}
|
|
1433
|
+
fill={SERIES.consumption}
|
|
1434
|
+
stroke={CHART.surface}
|
|
1435
|
+
strokeWidth={2}
|
|
1436
|
+
/>
|
|
1437
|
+
))}
|
|
1438
|
+
|
|
1439
|
+
{/* Hit areas a full band wide and the plot's height, larger than the marks */}
|
|
1440
|
+
{months.map((month, index) => (
|
|
1441
|
+
<rect
|
|
1442
|
+
key={month.month}
|
|
1443
|
+
x={MARGIN.left + band * index}
|
|
1444
|
+
y={MARGIN.top}
|
|
1445
|
+
width={band}
|
|
1446
|
+
height={plotHeight}
|
|
1447
|
+
fill="transparent"
|
|
1448
|
+
onMouseEnter={() => setHovered(index)}
|
|
1449
|
+
/>
|
|
1450
|
+
))}
|
|
1451
|
+
</svg>
|
|
1452
|
+
)}
|
|
1453
|
+
|
|
1454
|
+
{active !== undefined && hovered !== null && (
|
|
1455
|
+
<Box
|
|
1456
|
+
component="div"
|
|
1457
|
+
role="tooltip"
|
|
1458
|
+
sx={{
|
|
1459
|
+
position: 'absolute',
|
|
1460
|
+
insetBlockStart: 0,
|
|
1461
|
+
insetInlineStart: Math.min(Math.max(x(hovered) - 90, 0), Math.max(width - 180, 0)),
|
|
1462
|
+
inlineSize: 180,
|
|
1463
|
+
padding: 1.25,
|
|
1464
|
+
borderRadius: 1.5,
|
|
1465
|
+
bgcolor: CHART.surface,
|
|
1466
|
+
boxShadow: '0 6px 24px rgba(28, 40, 38, 0.16)',
|
|
1467
|
+
pointerEvents: 'none',
|
|
1468
|
+
fontFamily: CHART.font,
|
|
1469
|
+
}}
|
|
1470
|
+
>
|
|
1471
|
+
<Typography sx={{ fontFamily: CHART.font, fontSize: 12, fontWeight: 600, color: CHART.ink }}>
|
|
1472
|
+
{active.month}
|
|
1473
|
+
</Typography>
|
|
1474
|
+
{[
|
|
1475
|
+
{ label: 'Used at home', value: active.selfUseKwh, color: SERIES.selfUse },
|
|
1476
|
+
{ label: 'Sent to grid', value: active.exportKwh, color: SERIES.exported },
|
|
1477
|
+
{ label: 'Home use', value: active.consumptionKwh, color: SERIES.consumption },
|
|
1478
|
+
].map(row => (
|
|
1479
|
+
<Box component="div" key={row.label} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
|
1480
|
+
<Box
|
|
1481
|
+
component="span"
|
|
1482
|
+
aria-hidden
|
|
1483
|
+
sx={{ inlineSize: 8, blockSize: 8, borderRadius: '50%', bgcolor: row.color }}
|
|
1484
|
+
/>
|
|
1485
|
+
<Typography
|
|
1486
|
+
component="span"
|
|
1487
|
+
sx={{ flex: 1, fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}
|
|
1488
|
+
>
|
|
1489
|
+
{row.label}
|
|
1490
|
+
</Typography>
|
|
1491
|
+
<Typography
|
|
1492
|
+
component="span"
|
|
1493
|
+
sx={{
|
|
1494
|
+
fontFamily: CHART.font,
|
|
1495
|
+
fontSize: 12,
|
|
1496
|
+
color: CHART.ink,
|
|
1497
|
+
fontVariantNumeric: 'tabular-nums',
|
|
1498
|
+
}}
|
|
1499
|
+
>
|
|
1500
|
+
{kwh(row.value)}
|
|
1501
|
+
</Typography>
|
|
1502
|
+
</Box>
|
|
1503
|
+
))}
|
|
1504
|
+
</Box>
|
|
1505
|
+
)}
|
|
1506
|
+
</Box>
|
|
1507
|
+
)}
|
|
1508
|
+
</Box>
|
|
1509
|
+
</Box>
|
|
1510
|
+
)
|
|
1511
|
+
}
|
|
1512
|
+
```
|
|
1513
|
+
|
|
1514
|
+
### ./src/\_charts/PaybackChart.tsx
|
|
1515
|
+
|
|
1516
|
+
```tsx
|
|
1517
|
+
import { Box, Typography } from '@mui/material'
|
|
1518
|
+
import type { MouseEvent } from 'react'
|
|
1519
|
+
import { useState } from 'react'
|
|
1520
|
+
|
|
1521
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
1522
|
+
import { CHART } from './chart-style.js'
|
|
1523
|
+
import { niceScale } from './nice-scale.js'
|
|
1524
|
+
import { useWidth } from './use-width.js'
|
|
1525
|
+
|
|
1526
|
+
const MARGIN = { top: 16, right: 16, bottom: 26, left: 56 }
|
|
1527
|
+
const LINE = '#1f6f54'
|
|
1528
|
+
const YEAR_TICKS = [0, 5, 10, 15, 20, 25]
|
|
1529
|
+
|
|
1530
|
+
const euro = (value: number) => {
|
|
1531
|
+
return value.toLocaleString('en-IE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
interface PaybackChartProps {
|
|
1535
|
+
readonly height: number
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// The money the system has returned, year by year over the panels' 25 years, from the spec's `$cumulativeSavings`
|
|
1539
|
+
// expression: it starts at minus the system's cost and crosses zero in the payback year, which is marked. Moving the
|
|
1540
|
+
// pointer across the chart reads off any year.
|
|
1541
|
+
export const PaybackChart = ({ height }: PaybackChartProps) => {
|
|
1542
|
+
const plan = phx.frontend.useRootEntityState()
|
|
1543
|
+
const { ref, width } = useWidth()
|
|
1544
|
+
const [year, setYear] = useState<number | null>(null)
|
|
1545
|
+
const totals = plan.$cumulativeSavings
|
|
1546
|
+
const lastYear = totals.length - 1
|
|
1547
|
+
const { low, high, ticks } = niceScale(Math.min(...totals), Math.max(...totals, 0), 4)
|
|
1548
|
+
const plotWidth = Math.max(width - MARGIN.left - MARGIN.right, 0)
|
|
1549
|
+
const plotHeight = height - MARGIN.top - MARGIN.bottom
|
|
1550
|
+
const x = (value: number) => MARGIN.left + (value / lastYear) * plotWidth
|
|
1551
|
+
const y = (value: number) => MARGIN.top + ((high - value) / (high - low)) * plotHeight
|
|
1552
|
+
const points = totals.map((total, index) => `${x(index)},${y(total)}`)
|
|
1553
|
+
const payback = plan.$paybackYears
|
|
1554
|
+
const paysBack = Number.isFinite(payback) && payback <= lastYear
|
|
1555
|
+
const hoveredTotal = year === null ? null : totals[year]
|
|
1556
|
+
|
|
1557
|
+
const handleMove = (event: MouseEvent<SVGRectElement>) => {
|
|
1558
|
+
const bounds = event.currentTarget.getBoundingClientRect()
|
|
1559
|
+
const fraction = (event.clientX - bounds.left) / bounds.width
|
|
1560
|
+
|
|
1561
|
+
setYear(Math.min(lastYear, Math.max(0, Math.round(fraction * lastYear))))
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
return (
|
|
1565
|
+
<Box component="div" ref={ref} sx={{ position: 'relative', blockSize: height }}>
|
|
1566
|
+
{width > 0 && (
|
|
1567
|
+
<svg
|
|
1568
|
+
width={width}
|
|
1569
|
+
height={height}
|
|
1570
|
+
role="img"
|
|
1571
|
+
aria-label={
|
|
1572
|
+
paysBack
|
|
1573
|
+
? `The system pays back after ${payback.toFixed(1)} years`
|
|
1574
|
+
: 'The system does not pay back within 25 years'
|
|
1575
|
+
}
|
|
1576
|
+
>
|
|
1577
|
+
{ticks.map(tick => (
|
|
1578
|
+
<g key={tick}>
|
|
1579
|
+
<line
|
|
1580
|
+
x1={MARGIN.left}
|
|
1581
|
+
x2={width - MARGIN.right}
|
|
1582
|
+
y1={y(tick)}
|
|
1583
|
+
y2={y(tick)}
|
|
1584
|
+
stroke={tick === 0 ? CHART.axis : CHART.grid}
|
|
1585
|
+
strokeWidth={1}
|
|
1586
|
+
/>
|
|
1587
|
+
<text
|
|
1588
|
+
x={MARGIN.left - 8}
|
|
1589
|
+
y={y(tick)}
|
|
1590
|
+
textAnchor="end"
|
|
1591
|
+
dominantBaseline="middle"
|
|
1592
|
+
fill={CHART.inkSoft}
|
|
1593
|
+
fontFamily={CHART.font}
|
|
1594
|
+
fontSize={11}
|
|
1595
|
+
>
|
|
1596
|
+
{tick === 0 ? '€0' : `${tick < 0 ? '−' : ''}€${Math.abs(tick / 1000)}k`}
|
|
1597
|
+
</text>
|
|
1598
|
+
</g>
|
|
1599
|
+
))}
|
|
1600
|
+
{YEAR_TICKS.map(tick => (
|
|
1601
|
+
<text
|
|
1602
|
+
key={tick}
|
|
1603
|
+
x={x(tick)}
|
|
1604
|
+
y={height - 8}
|
|
1605
|
+
textAnchor="middle"
|
|
1606
|
+
fill={CHART.inkSoft}
|
|
1607
|
+
fontFamily={CHART.font}
|
|
1608
|
+
fontSize={11}
|
|
1609
|
+
>
|
|
1610
|
+
{tick === 0 ? 'Year 0' : tick}
|
|
1611
|
+
</text>
|
|
1612
|
+
))}
|
|
1613
|
+
|
|
1614
|
+
{/* A wash between the line and zero: what is still owed below, what is earned above */}
|
|
1615
|
+
<polygon points={`${x(0)},${y(0)} ${points.join(' ')} ${x(lastYear)},${y(0)}`} fill={LINE} opacity={0.1} />
|
|
1616
|
+
<polyline
|
|
1617
|
+
points={points.join(' ')}
|
|
1618
|
+
fill="none"
|
|
1619
|
+
stroke={LINE}
|
|
1620
|
+
strokeWidth={2}
|
|
1621
|
+
strokeLinejoin="round"
|
|
1622
|
+
strokeLinecap="round"
|
|
1623
|
+
/>
|
|
1624
|
+
|
|
1625
|
+
{paysBack && (
|
|
1626
|
+
<g>
|
|
1627
|
+
<line
|
|
1628
|
+
x1={x(payback)}
|
|
1629
|
+
x2={x(payback)}
|
|
1630
|
+
y1={y(0)}
|
|
1631
|
+
y2={MARGIN.top + plotHeight}
|
|
1632
|
+
stroke={CHART.axis}
|
|
1633
|
+
strokeWidth={1}
|
|
1634
|
+
/>
|
|
1635
|
+
<circle cx={x(payback)} cy={y(0)} r={5} fill={LINE} stroke={CHART.surface} strokeWidth={2} />
|
|
1636
|
+
{/* Right of the point the line runs above zero and left of it below, so the label takes the empty side */}
|
|
1637
|
+
<text
|
|
1638
|
+
x={x(payback) + (x(payback) > width * 0.7 ? -10 : 10)}
|
|
1639
|
+
y={x(payback) > width * 0.7 ? y(0) - 10 : y(0) + 20}
|
|
1640
|
+
textAnchor={x(payback) > width * 0.7 ? 'end' : 'start'}
|
|
1641
|
+
fill={CHART.ink}
|
|
1642
|
+
fontFamily={CHART.font}
|
|
1643
|
+
fontSize={12}
|
|
1644
|
+
fontWeight={600}
|
|
1645
|
+
>
|
|
1646
|
+
Pays back in year {Math.ceil(payback)}
|
|
1647
|
+
</text>
|
|
1648
|
+
</g>
|
|
1649
|
+
)}
|
|
1650
|
+
|
|
1651
|
+
{year !== null && hoveredTotal !== undefined && hoveredTotal !== null && (
|
|
1652
|
+
<g>
|
|
1653
|
+
<line
|
|
1654
|
+
x1={x(year)}
|
|
1655
|
+
x2={x(year)}
|
|
1656
|
+
y1={MARGIN.top}
|
|
1657
|
+
y2={MARGIN.top + plotHeight}
|
|
1658
|
+
stroke={CHART.inkSoft}
|
|
1659
|
+
strokeWidth={1}
|
|
1660
|
+
/>
|
|
1661
|
+
<circle cx={x(year)} cy={y(hoveredTotal)} r={4} fill={LINE} stroke={CHART.surface} strokeWidth={2} />
|
|
1662
|
+
</g>
|
|
1663
|
+
)}
|
|
1664
|
+
<rect
|
|
1665
|
+
x={MARGIN.left}
|
|
1666
|
+
y={MARGIN.top}
|
|
1667
|
+
width={plotWidth}
|
|
1668
|
+
height={plotHeight}
|
|
1669
|
+
fill="transparent"
|
|
1670
|
+
onMouseMove={handleMove}
|
|
1671
|
+
onMouseLeave={() => setYear(null)}
|
|
1672
|
+
/>
|
|
1673
|
+
</svg>
|
|
1674
|
+
)}
|
|
1675
|
+
|
|
1676
|
+
{year !== null && hoveredTotal !== undefined && hoveredTotal !== null && (
|
|
1677
|
+
<Box
|
|
1678
|
+
component="div"
|
|
1679
|
+
role="tooltip"
|
|
1680
|
+
sx={{
|
|
1681
|
+
position: 'absolute',
|
|
1682
|
+
insetBlockStart: 0,
|
|
1683
|
+
insetInlineStart: Math.min(Math.max(x(year) - 80, 0), Math.max(width - 160, 0)),
|
|
1684
|
+
inlineSize: 160,
|
|
1685
|
+
padding: 1.25,
|
|
1686
|
+
borderRadius: 1.5,
|
|
1687
|
+
bgcolor: CHART.surface,
|
|
1688
|
+
boxShadow: '0 6px 24px rgba(28, 40, 38, 0.16)',
|
|
1689
|
+
pointerEvents: 'none',
|
|
1690
|
+
}}
|
|
1691
|
+
>
|
|
1692
|
+
<Typography sx={{ fontFamily: CHART.font, fontSize: 12, fontWeight: 600, color: CHART.ink }}>
|
|
1693
|
+
After year {year}
|
|
1694
|
+
</Typography>
|
|
1695
|
+
<Typography sx={{ fontFamily: CHART.font, fontSize: 12, color: CHART.inkSoft }}>
|
|
1696
|
+
{hoveredTotal >= 0 ? `${euro(hoveredTotal)} ahead` : `${euro(-hoveredTotal)} still to recover`}
|
|
1697
|
+
</Typography>
|
|
1698
|
+
</Box>
|
|
1699
|
+
)}
|
|
1700
|
+
</Box>
|
|
1701
|
+
)
|
|
1702
|
+
}
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
### ./src/\_charts/chart-style.ts
|
|
1706
|
+
|
|
1707
|
+
```ts
|
|
1708
|
+
// The charts' series colours, in a fixed order and checked as a set for colour-vision deficiencies on the white
|
|
1709
|
+
// card. Two of them sit below 3:1 against white, so every chart keeps a legend, a tooltip and a table of its figures.
|
|
1710
|
+
export const SERIES = {
|
|
1711
|
+
selfUse: '#e8770e',
|
|
1712
|
+
exported: '#1baf7a',
|
|
1713
|
+
consumption: '#2a78d6',
|
|
1714
|
+
} as const
|
|
1715
|
+
|
|
1716
|
+
export const CHART = {
|
|
1717
|
+
surface: '#ffffff',
|
|
1718
|
+
ink: '#1c2826',
|
|
1719
|
+
inkSoft: '#5b6664',
|
|
1720
|
+
grid: '#e9e6df',
|
|
1721
|
+
axis: '#c9c4b8',
|
|
1722
|
+
font: "'Poppins', 'Helvetica Neue', Arial, sans-serif",
|
|
1723
|
+
} as const
|
|
1724
|
+
```
|
|
1725
|
+
|
|
1726
|
+
### ./src/\_charts/nice-scale.ts
|
|
1727
|
+
|
|
1728
|
+
```ts
|
|
1729
|
+
// Round axis bounds and ticks for a value range: steps of 1, 2 or 5 times a power of ten, about `count` of them
|
|
1730
|
+
export const niceScale = (min: number, max: number, count: number) => {
|
|
1731
|
+
const span = Math.max(max - min, 1)
|
|
1732
|
+
const raw = span / count
|
|
1733
|
+
const magnitude = 10 ** Math.floor(Math.log10(raw))
|
|
1734
|
+
const step = [1, 2, 5, 10].map(factor => factor * magnitude).find(candidate => candidate >= raw) ?? 10 * magnitude
|
|
1735
|
+
const low = Math.floor(min / step) * step
|
|
1736
|
+
const high = Math.ceil(max / step) * step
|
|
1737
|
+
const ticks = Array.from({ length: Math.round((high - low) / step) + 1 }, (_, index) => low + index * step)
|
|
1738
|
+
|
|
1739
|
+
return { low, high, ticks }
|
|
1740
|
+
}
|
|
1741
|
+
```
|
|
1742
|
+
|
|
1743
|
+
### ./src/\_charts/use-width.ts
|
|
1744
|
+
|
|
1745
|
+
```ts
|
|
1746
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
1747
|
+
|
|
1748
|
+
// The element's width in pixels, measured again whenever it changes, so a chart draws its text at its real size
|
|
1749
|
+
// instead of scaling a fixed drawing
|
|
1750
|
+
export const useWidth = () => {
|
|
1751
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
1752
|
+
const [width, setWidth] = useState(0)
|
|
1753
|
+
|
|
1754
|
+
useLayoutEffect(() => {
|
|
1755
|
+
const element = ref.current
|
|
1756
|
+
|
|
1757
|
+
if (element === null) {
|
|
1758
|
+
return
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
1762
|
+
if (entry) {
|
|
1763
|
+
setWidth(entry.contentRect.width)
|
|
1764
|
+
}
|
|
1765
|
+
})
|
|
1766
|
+
observer.observe(element)
|
|
1767
|
+
|
|
1768
|
+
return () => observer.disconnect()
|
|
1769
|
+
}, [])
|
|
1770
|
+
|
|
1771
|
+
return { ref, width }
|
|
1772
|
+
}
|
|
1773
|
+
```
|
|
1774
|
+
|
|
1775
|
+
### ./src/\_shared/ChartCards.tsx
|
|
1776
|
+
|
|
1777
|
+
```tsx
|
|
1778
|
+
import { Box, Typography } from '@mui/material'
|
|
1779
|
+
import type { ReactNode } from 'react'
|
|
1780
|
+
|
|
1781
|
+
import { EnergySplitBar, MonthlyEnergyChart, PaybackChart } from '../_charts/index.js'
|
|
1782
|
+
import { CARD_SX, SUNFOLD } from './tokens.js'
|
|
1783
|
+
|
|
1784
|
+
interface ChartCardProps {
|
|
1785
|
+
readonly title: string
|
|
1786
|
+
readonly subtitle: string
|
|
1787
|
+
readonly area?: string
|
|
1788
|
+
readonly children: ReactNode
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
const ChartCard = ({ title, subtitle, area, children }: ChartCardProps) => {
|
|
1792
|
+
return (
|
|
1793
|
+
<Box
|
|
1794
|
+
component="section"
|
|
1795
|
+
aria-label={title}
|
|
1796
|
+
sx={{ ...CARD_SX, display: 'grid', alignContent: 'start', gap: 2, gridArea: area }}
|
|
1797
|
+
>
|
|
1798
|
+
<Box component="header" sx={{ display: 'grid', gap: 0.25 }}>
|
|
1799
|
+
<Typography component="h2" sx={{ fontFamily: SUNFOLD.font, fontSize: 16, fontWeight: 600, color: SUNFOLD.ink }}>
|
|
1800
|
+
{title}
|
|
1801
|
+
</Typography>
|
|
1802
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>{subtitle}</Typography>
|
|
1803
|
+
</Box>
|
|
1804
|
+
{children}
|
|
1805
|
+
</Box>
|
|
1806
|
+
)
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
interface ChartCardsProps {
|
|
1810
|
+
readonly compact: boolean
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// The dashboard's charts: the year month by month across the full width, then the payback curve beside where the
|
|
1814
|
+
// power goes. On a phone they stack.
|
|
1815
|
+
export const ChartCards = ({ compact }: ChartCardsProps) => {
|
|
1816
|
+
return (
|
|
1817
|
+
<Box
|
|
1818
|
+
component="div"
|
|
1819
|
+
sx={{
|
|
1820
|
+
display: 'grid',
|
|
1821
|
+
gap: 2,
|
|
1822
|
+
gridTemplateColumns: compact ? 'minmax(0, 1fr)' : 'minmax(0, 3fr) minmax(0, 2fr)',
|
|
1823
|
+
gridTemplateAreas: compact ? '"monthly" "payback" "split"' : '"monthly monthly" "payback split"',
|
|
1824
|
+
}}
|
|
1825
|
+
>
|
|
1826
|
+
<ChartCard
|
|
1827
|
+
area="monthly"
|
|
1828
|
+
title="Your solar year"
|
|
1829
|
+
subtitle="Production by month, how much the home uses, and the home's own use"
|
|
1830
|
+
>
|
|
1831
|
+
<MonthlyEnergyChart height={compact ? 200 : 240} />
|
|
1832
|
+
</ChartCard>
|
|
1833
|
+
<ChartCard
|
|
1834
|
+
area="payback"
|
|
1835
|
+
title="When it pays back"
|
|
1836
|
+
subtitle="Money returned after the installed cost, with prices rising 2 % a year"
|
|
1837
|
+
>
|
|
1838
|
+
<PaybackChart height={compact ? 190 : 210} />
|
|
1839
|
+
</ChartCard>
|
|
1840
|
+
<ChartCard
|
|
1841
|
+
area="split"
|
|
1842
|
+
title="Where the power goes"
|
|
1843
|
+
subtitle="A year of production, split between the home and the grid"
|
|
1844
|
+
>
|
|
1845
|
+
<EnergySplitBar />
|
|
1846
|
+
</ChartCard>
|
|
1847
|
+
</Box>
|
|
1848
|
+
)
|
|
1849
|
+
}
|
|
1850
|
+
```
|
|
1851
|
+
|
|
1852
|
+
### ./src/\_shared/DesktopPage.tsx
|
|
1853
|
+
|
|
1854
|
+
```tsx
|
|
1855
|
+
import { Box, Typography } from '@mui/material'
|
|
1856
|
+
|
|
1857
|
+
import { LayoutSlots } from '@salsita-ai/sdk/configurator'
|
|
1858
|
+
|
|
1859
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
1860
|
+
import { ChartCards } from './ChartCards.js'
|
|
1861
|
+
import { KpiTiles } from './KpiTiles.js'
|
|
1862
|
+
import { PlannerInputs } from './PlannerInputs.js'
|
|
1863
|
+
import { SurveyPanel } from './SurveyPanel.js'
|
|
1864
|
+
import { SUNFOLD } from './tokens.js'
|
|
1865
|
+
|
|
1866
|
+
const SIDEBAR_WIDTH = 340
|
|
1867
|
+
|
|
1868
|
+
// The desktop page: the inputs in a sidebar with the cost and the booking at its foot, and the dashboard beside it,
|
|
1869
|
+
// four figures above three charts
|
|
1870
|
+
export const DesktopPage = () => {
|
|
1871
|
+
const { accessInfo } = phx.frontend.useAccessService()
|
|
1872
|
+
const navigationState = phx.frontend.useActiveNavigationState()
|
|
1873
|
+
// A booked plan keeps its inputs until the household returns to the plan
|
|
1874
|
+
const readOnly = accessInfo.viewMode || navigationState === 'booked'
|
|
1875
|
+
|
|
1876
|
+
return (
|
|
1877
|
+
<Box
|
|
1878
|
+
component="div"
|
|
1879
|
+
sx={{
|
|
1880
|
+
position: 'absolute',
|
|
1881
|
+
inset: 0,
|
|
1882
|
+
display: 'grid',
|
|
1883
|
+
gridTemplateColumns: `${SIDEBAR_WIDTH}px minmax(0, 1fr)`,
|
|
1884
|
+
bgcolor: SUNFOLD.linen,
|
|
1885
|
+
fontFamily: SUNFOLD.font,
|
|
1886
|
+
}}
|
|
1887
|
+
>
|
|
1888
|
+
<Box
|
|
1889
|
+
component="aside"
|
|
1890
|
+
aria-label="Your plan"
|
|
1891
|
+
sx={{
|
|
1892
|
+
display: 'flex',
|
|
1893
|
+
flexDirection: 'column',
|
|
1894
|
+
minBlockSize: 0,
|
|
1895
|
+
bgcolor: SUNFOLD.card,
|
|
1896
|
+
borderInlineEnd: `1px solid ${SUNFOLD.line}`,
|
|
1897
|
+
}}
|
|
1898
|
+
>
|
|
1899
|
+
<Box
|
|
1900
|
+
component="div"
|
|
1901
|
+
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, paddingInline: 3, paddingBlock: 2.5 }}
|
|
1902
|
+
>
|
|
1903
|
+
<Box
|
|
1904
|
+
component="span"
|
|
1905
|
+
aria-hidden
|
|
1906
|
+
sx={{
|
|
1907
|
+
inlineSize: 26,
|
|
1908
|
+
blockSize: 26,
|
|
1909
|
+
borderRadius: '50%',
|
|
1910
|
+
bgcolor: SUNFOLD.amber,
|
|
1911
|
+
boxShadow: `0 0 0 5px ${SUNFOLD.amberTint}`,
|
|
1912
|
+
}}
|
|
1913
|
+
/>
|
|
1914
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 20, fontWeight: 600, color: SUNFOLD.ink }}>
|
|
1915
|
+
sunfold
|
|
1916
|
+
</Typography>
|
|
1917
|
+
</Box>
|
|
1918
|
+
<Box component="div" sx={{ flex: 1, minBlockSize: 0, overflowY: 'auto', paddingInline: 3, paddingBlockEnd: 3 }}>
|
|
1919
|
+
<PlannerInputs readOnly={readOnly} />
|
|
1920
|
+
</Box>
|
|
1921
|
+
<Box component="div" sx={{ padding: 3, borderBlockStart: `1px solid ${SUNFOLD.line}` }}>
|
|
1922
|
+
<SurveyPanel compact={false} />
|
|
1923
|
+
</Box>
|
|
1924
|
+
</Box>
|
|
1925
|
+
|
|
1926
|
+
<Box component="main" sx={{ minBlockSize: 0, overflowY: 'auto' }}>
|
|
1927
|
+
<Box component="div" sx={{ display: 'grid', gap: 2.5, maxInlineSize: 1180, marginInline: 'auto', padding: 4 }}>
|
|
1928
|
+
<Box component="header" sx={{ display: 'grid', gap: 0.5 }}>
|
|
1929
|
+
<Typography
|
|
1930
|
+
component="h1"
|
|
1931
|
+
sx={{ fontFamily: SUNFOLD.font, fontSize: 28, fontWeight: 600, color: SUNFOLD.ink }}
|
|
1932
|
+
>
|
|
1933
|
+
Your solar plan
|
|
1934
|
+
</Typography>
|
|
1935
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 14, color: SUNFOLD.muted }}>
|
|
1936
|
+
Estimates for a home in central Europe. Every figure follows the plan on the left.
|
|
1937
|
+
</Typography>
|
|
1938
|
+
</Box>
|
|
1939
|
+
<KpiTiles columns={4} />
|
|
1940
|
+
<ChartCards compact={false} />
|
|
1941
|
+
</Box>
|
|
1942
|
+
</Box>
|
|
1943
|
+
|
|
1944
|
+
{/* Snackbars, such as a failed save, stack upwards from the dashboard's bottom right corner */}
|
|
1945
|
+
<Box component="div" sx={{ position: 'absolute', insetBlockEnd: 24, insetInlineEnd: 24, inlineSize: 320 }}>
|
|
1946
|
+
<LayoutSlots.DesktopSnackbarsRoot />
|
|
1947
|
+
</Box>
|
|
1948
|
+
</Box>
|
|
1949
|
+
)
|
|
1950
|
+
}
|
|
1951
|
+
```
|
|
1952
|
+
|
|
1953
|
+
### ./src/\_shared/KpiTiles.tsx
|
|
1954
|
+
|
|
1955
|
+
```tsx
|
|
1956
|
+
import { Box, Typography } from '@mui/material'
|
|
1957
|
+
|
|
1958
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
1959
|
+
import { CARD_SX, SUNFOLD } from './tokens.js'
|
|
1960
|
+
|
|
1961
|
+
const euro = (value: number) => {
|
|
1962
|
+
return value.toLocaleString('en-IE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
interface KpiTilesProps {
|
|
1966
|
+
readonly columns: number
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// The four figures a household asks first, each with the context that makes it mean something. Every value is an
|
|
1970
|
+
// expression of the spec, read from the root entity.
|
|
1971
|
+
export const KpiTiles = ({ columns }: KpiTilesProps) => {
|
|
1972
|
+
const plan = phx.frontend.useRootEntityState()
|
|
1973
|
+
const payback = plan.$paybackYears
|
|
1974
|
+
const tiles = [
|
|
1975
|
+
{
|
|
1976
|
+
label: 'System size',
|
|
1977
|
+
value: `${plan.$systemKwp.toFixed(1)} kWp`,
|
|
1978
|
+
context: `${plan.panels} panels, ${plan.$panelAreaM2} m² of roof`,
|
|
1979
|
+
},
|
|
1980
|
+
{
|
|
1981
|
+
label: 'Annual production',
|
|
1982
|
+
value: `${Math.round(plan.$annualProductionKwh).toLocaleString('en')} kWh`,
|
|
1983
|
+
context: `About ${plan.$co2Tonnes.toFixed(1)} t of CO₂ avoided a year`,
|
|
1984
|
+
},
|
|
1985
|
+
{
|
|
1986
|
+
label: 'Self-consumption',
|
|
1987
|
+
value: `${Math.round(plan.$selfConsumption * 100)} %`,
|
|
1988
|
+
context: 'of the solar power is used at home',
|
|
1989
|
+
},
|
|
1990
|
+
{
|
|
1991
|
+
label: 'Payback',
|
|
1992
|
+
value: Number.isFinite(payback) ? `${payback.toFixed(1)} years` : 'Over 25 years',
|
|
1993
|
+
context: `${euro(plan.$annualSavings)} saved in the first year`,
|
|
1994
|
+
},
|
|
1995
|
+
]
|
|
1996
|
+
|
|
1997
|
+
return (
|
|
1998
|
+
<Box component="div" sx={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gap: 2 }}>
|
|
1999
|
+
{tiles.map(tile => (
|
|
2000
|
+
<Box
|
|
2001
|
+
component="section"
|
|
2002
|
+
key={tile.label}
|
|
2003
|
+
aria-label={tile.label}
|
|
2004
|
+
sx={{ ...CARD_SX, display: 'grid', gap: 0.5, padding: 2 }}
|
|
2005
|
+
>
|
|
2006
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 13, color: SUNFOLD.muted }}>{tile.label}</Typography>
|
|
2007
|
+
<Typography
|
|
2008
|
+
sx={{
|
|
2009
|
+
fontFamily: SUNFOLD.font,
|
|
2010
|
+
fontSize: columns > 2 ? 30 : 24,
|
|
2011
|
+
fontWeight: 600,
|
|
2012
|
+
lineHeight: 1.15,
|
|
2013
|
+
color: SUNFOLD.ink,
|
|
2014
|
+
}}
|
|
2015
|
+
>
|
|
2016
|
+
{tile.value}
|
|
2017
|
+
</Typography>
|
|
2018
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>{tile.context}</Typography>
|
|
2019
|
+
</Box>
|
|
2020
|
+
))}
|
|
2021
|
+
</Box>
|
|
2022
|
+
)
|
|
2023
|
+
}
|
|
2024
|
+
```
|
|
2025
|
+
|
|
2026
|
+
### ./src/\_shared/PhonePage.tsx
|
|
2027
|
+
|
|
2028
|
+
```tsx
|
|
2029
|
+
import { Box, Typography } from '@mui/material'
|
|
2030
|
+
|
|
2031
|
+
import { LayoutSlots } from '@salsita-ai/sdk/configurator'
|
|
2032
|
+
|
|
2033
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
2034
|
+
import { ChartCards } from './ChartCards.js'
|
|
2035
|
+
import { KpiTiles } from './KpiTiles.js'
|
|
2036
|
+
import { PlannerInputs } from './PlannerInputs.js'
|
|
2037
|
+
import { SurveyPanel } from './SurveyPanel.js'
|
|
2038
|
+
import { CARD_SX, SUNFOLD } from './tokens.js'
|
|
2039
|
+
|
|
2040
|
+
// The phone page: one scrolling column with the figures first, the inputs below them and the charts last, and the
|
|
2041
|
+
// cost with the booking in a bar at the bottom
|
|
2042
|
+
export const PhonePage = () => {
|
|
2043
|
+
const { accessInfo } = phx.frontend.useAccessService()
|
|
2044
|
+
const navigationState = phx.frontend.useActiveNavigationState()
|
|
2045
|
+
const readOnly = accessInfo.viewMode || navigationState === 'booked'
|
|
2046
|
+
|
|
2047
|
+
return (
|
|
2048
|
+
<Box
|
|
2049
|
+
component="div"
|
|
2050
|
+
sx={{
|
|
2051
|
+
position: 'absolute',
|
|
2052
|
+
inset: 0,
|
|
2053
|
+
display: 'grid',
|
|
2054
|
+
gridTemplateRows: 'minmax(0, 1fr) auto',
|
|
2055
|
+
bgcolor: SUNFOLD.linen,
|
|
2056
|
+
fontFamily: SUNFOLD.font,
|
|
2057
|
+
}}
|
|
2058
|
+
>
|
|
2059
|
+
<Box component="main" sx={{ minBlockSize: 0, overflowY: 'auto' }}>
|
|
2060
|
+
<Box component="div" sx={{ display: 'grid', gap: 2, padding: 2 }}>
|
|
2061
|
+
<Box component="header" sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
|
2062
|
+
<Box
|
|
2063
|
+
component="span"
|
|
2064
|
+
aria-hidden
|
|
2065
|
+
sx={{
|
|
2066
|
+
inlineSize: 22,
|
|
2067
|
+
blockSize: 22,
|
|
2068
|
+
borderRadius: '50%',
|
|
2069
|
+
bgcolor: SUNFOLD.amber,
|
|
2070
|
+
boxShadow: `0 0 0 4px ${SUNFOLD.amberTint}`,
|
|
2071
|
+
}}
|
|
2072
|
+
/>
|
|
2073
|
+
<Typography
|
|
2074
|
+
component="h1"
|
|
2075
|
+
sx={{ fontFamily: SUNFOLD.font, fontSize: 20, fontWeight: 600, color: SUNFOLD.ink }}
|
|
2076
|
+
>
|
|
2077
|
+
Your solar plan
|
|
2078
|
+
</Typography>
|
|
2079
|
+
</Box>
|
|
2080
|
+
<KpiTiles columns={2} />
|
|
2081
|
+
<Box component="div" sx={CARD_SX}>
|
|
2082
|
+
<PlannerInputs readOnly={readOnly} />
|
|
2083
|
+
</Box>
|
|
2084
|
+
<ChartCards compact />
|
|
2085
|
+
</Box>
|
|
2086
|
+
</Box>
|
|
2087
|
+
<Box
|
|
2088
|
+
component="footer"
|
|
2089
|
+
sx={{
|
|
2090
|
+
padding: 2,
|
|
2091
|
+
bgcolor: SUNFOLD.card,
|
|
2092
|
+
borderBlockStart: `1px solid ${SUNFOLD.line}`,
|
|
2093
|
+
boxShadow: '0 -8px 24px rgba(28, 40, 38, 0.08)',
|
|
2094
|
+
}}
|
|
2095
|
+
>
|
|
2096
|
+
<SurveyPanel compact />
|
|
2097
|
+
</Box>
|
|
2098
|
+
<LayoutSlots.MobileSnackbarsRoot />
|
|
2099
|
+
</Box>
|
|
2100
|
+
)
|
|
2101
|
+
}
|
|
2102
|
+
```
|
|
2103
|
+
|
|
2104
|
+
### ./src/\_shared/PillField.tsx
|
|
2105
|
+
|
|
2106
|
+
```tsx
|
|
2107
|
+
import { Box, ButtonBase, Typography } from '@mui/material'
|
|
2108
|
+
import type { KeyboardEvent } from 'react'
|
|
2109
|
+
|
|
2110
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
2111
|
+
import { SUNFOLD } from './tokens.js'
|
|
2112
|
+
|
|
2113
|
+
// Arrow keys move the selection between the pills, as in a native radio group
|
|
2114
|
+
const ARROW_STEPS: Readonly<Record<string, number | undefined>> = {
|
|
2115
|
+
ArrowRight: 1,
|
|
2116
|
+
ArrowDown: 1,
|
|
2117
|
+
ArrowLeft: -1,
|
|
2118
|
+
ArrowUp: -1,
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
interface PillFieldProps {
|
|
2122
|
+
readonly instanceName: string
|
|
2123
|
+
readonly label: string
|
|
2124
|
+
readonly readOnly: boolean
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// One `attributeSelection` of the spec as a row of pills, with the chosen option's description below it
|
|
2128
|
+
export const PillField = ({ instanceName, label, readOnly }: PillFieldProps) => {
|
|
2129
|
+
const resolve = phx.frontend.useResolveLocalizable()
|
|
2130
|
+
const planId = phx.frontend.useRootEntityId()
|
|
2131
|
+
const { value, options } = phx.frontend.useAttributeSelection(planId, instanceName)
|
|
2132
|
+
const selected = options.find(option => option.value === value)
|
|
2133
|
+
const labelId = `sunfold-${instanceName}-label`
|
|
2134
|
+
|
|
2135
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLElement>, index: number) => {
|
|
2136
|
+
const step = ARROW_STEPS[event.key]
|
|
2137
|
+
|
|
2138
|
+
if (step === undefined) {
|
|
2139
|
+
return
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
event.preventDefault()
|
|
2143
|
+
const next = (index + step + options.length) % options.length
|
|
2144
|
+
options[next]?.onSelect()
|
|
2145
|
+
event.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus()
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
return (
|
|
2149
|
+
<Box component="div" sx={{ display: 'grid', gap: 0.75 }}>
|
|
2150
|
+
<Typography id={labelId} sx={{ fontFamily: SUNFOLD.font, fontSize: 14, color: SUNFOLD.ink }}>
|
|
2151
|
+
{label}
|
|
2152
|
+
</Typography>
|
|
2153
|
+
<Box
|
|
2154
|
+
component="div"
|
|
2155
|
+
role="radiogroup"
|
|
2156
|
+
aria-labelledby={labelId}
|
|
2157
|
+
sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}
|
|
2158
|
+
>
|
|
2159
|
+
{options.map((option, index) => {
|
|
2160
|
+
const isSelected = option.value === value
|
|
2161
|
+
|
|
2162
|
+
return (
|
|
2163
|
+
<ButtonBase
|
|
2164
|
+
key={String(option.value)}
|
|
2165
|
+
role="radio"
|
|
2166
|
+
aria-checked={isSelected}
|
|
2167
|
+
tabIndex={isSelected ? 0 : -1}
|
|
2168
|
+
disabled={readOnly && !isSelected}
|
|
2169
|
+
onClick={readOnly ? undefined : option.onSelect}
|
|
2170
|
+
onKeyDown={readOnly ? undefined : event => handleKeyDown(event, index)}
|
|
2171
|
+
sx={{
|
|
2172
|
+
paddingInline: 1.5,
|
|
2173
|
+
paddingBlock: 0.75,
|
|
2174
|
+
borderRadius: 999,
|
|
2175
|
+
border: `1px solid ${isSelected ? SUNFOLD.green : SUNFOLD.line}`,
|
|
2176
|
+
bgcolor: isSelected ? SUNFOLD.green : SUNFOLD.card,
|
|
2177
|
+
color: isSelected ? SUNFOLD.card : SUNFOLD.ink,
|
|
2178
|
+
fontFamily: SUNFOLD.font,
|
|
2179
|
+
fontSize: 13,
|
|
2180
|
+
fontWeight: isSelected ? 500 : 400,
|
|
2181
|
+
transition: 'background-color 120ms',
|
|
2182
|
+
'&:hover': { borderColor: SUNFOLD.green },
|
|
2183
|
+
'&.Mui-focusVisible': { outline: `2px solid ${SUNFOLD.amber}`, outlineOffset: 2 },
|
|
2184
|
+
}}
|
|
2185
|
+
>
|
|
2186
|
+
{resolve(option.title)}
|
|
2187
|
+
</ButtonBase>
|
|
2188
|
+
)
|
|
2189
|
+
})}
|
|
2190
|
+
</Box>
|
|
2191
|
+
{selected?.description && (
|
|
2192
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>
|
|
2193
|
+
{resolve(selected.description)}
|
|
2194
|
+
</Typography>
|
|
2195
|
+
)}
|
|
2196
|
+
</Box>
|
|
2197
|
+
)
|
|
2198
|
+
}
|
|
2199
|
+
```
|
|
2200
|
+
|
|
2201
|
+
### ./src/\_shared/PlannerInputs.tsx
|
|
2202
|
+
|
|
2203
|
+
```tsx
|
|
2204
|
+
import { Box, Typography } from '@mui/material'
|
|
2205
|
+
import type { ReactNode } from 'react'
|
|
2206
|
+
|
|
2207
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
2208
|
+
import { PillField } from './PillField.js'
|
|
2209
|
+
import { SliderField } from './SliderField.js'
|
|
2210
|
+
import { SUNFOLD } from './tokens.js'
|
|
2211
|
+
|
|
2212
|
+
interface GroupProps {
|
|
2213
|
+
readonly title: string
|
|
2214
|
+
// The invariant types whose message belongs under this group
|
|
2215
|
+
readonly messageTypes?: readonly string[]
|
|
2216
|
+
readonly children: ReactNode
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// A group of inputs with the message of an invariant the group can resolve: a warning, which blocks the booking, in
|
|
2220
|
+
// amber, a notice, which only informs, in blue. `useFinalizeViolations` lists both.
|
|
2221
|
+
const Group = ({ title, messageTypes = [], children }: GroupProps) => {
|
|
2222
|
+
const { violations } = phx.frontend.useFinalizeViolations()
|
|
2223
|
+
const resolve = phx.frontend.useResolveLocalizable()
|
|
2224
|
+
const messages = (violations ?? []).filter(({ violation }) => messageTypes.includes(violation.type))
|
|
2225
|
+
|
|
2226
|
+
return (
|
|
2227
|
+
<Box component="section" sx={{ display: 'grid', gap: 2 }}>
|
|
2228
|
+
<Typography component="h3" sx={{ fontFamily: SUNFOLD.font, fontSize: 13, fontWeight: 600, color: SUNFOLD.muted }}>
|
|
2229
|
+
{title}
|
|
2230
|
+
</Typography>
|
|
2231
|
+
{children}
|
|
2232
|
+
{messages.map(({ violation }) => {
|
|
2233
|
+
const isNotice = violation.severity === 'notice'
|
|
2234
|
+
|
|
2235
|
+
return (
|
|
2236
|
+
<Typography
|
|
2237
|
+
key={violation.type}
|
|
2238
|
+
role={isNotice ? 'status' : 'alert'}
|
|
2239
|
+
sx={{
|
|
2240
|
+
paddingInline: 1.5,
|
|
2241
|
+
paddingBlock: 1,
|
|
2242
|
+
borderRadius: 2,
|
|
2243
|
+
bgcolor: isNotice ? SUNFOLD.infoTint : SUNFOLD.warningTint,
|
|
2244
|
+
color: isNotice ? SUNFOLD.info : SUNFOLD.warning,
|
|
2245
|
+
fontFamily: SUNFOLD.font,
|
|
2246
|
+
fontSize: 13,
|
|
2247
|
+
lineHeight: 1.45,
|
|
2248
|
+
}}
|
|
2249
|
+
>
|
|
2250
|
+
{resolve(violation.message)}
|
|
2251
|
+
</Typography>
|
|
2252
|
+
)
|
|
2253
|
+
})}
|
|
2254
|
+
</Box>
|
|
2255
|
+
)
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
interface PlannerInputsProps {
|
|
2259
|
+
readonly readOnly: boolean
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// The household's inputs: the roof, the panels on it, the home's use and tariff, and the battery
|
|
2263
|
+
export const PlannerInputs = ({ readOnly }: PlannerInputsProps) => {
|
|
2264
|
+
const plan = phx.frontend.useRootEntityState()
|
|
2265
|
+
|
|
2266
|
+
return (
|
|
2267
|
+
<Box component="div" sx={{ display: 'grid', gap: 3.5 }}>
|
|
2268
|
+
<Group title="Your roof" messageTypes={['panelsExceedRoof', 'northFacing']}>
|
|
2269
|
+
<SliderField instanceName="roofArea" label="Usable roof area" step={1} readOnly={readOnly} />
|
|
2270
|
+
<PillField instanceName="orientation" label="Facing" readOnly={readOnly} />
|
|
2271
|
+
<SliderField instanceName="panels" label="Panels" step={1} readOnly={readOnly} />
|
|
2272
|
+
<Typography sx={{ marginBlockStart: -1.5, fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>
|
|
2273
|
+
430 W panels, 1.95 m² each. This roof holds up to {plan.$roofPanelLimit}.
|
|
2274
|
+
</Typography>
|
|
2275
|
+
</Group>
|
|
2276
|
+
<Group title="Your home">
|
|
2277
|
+
<SliderField instanceName="annualUse" label="Electricity used a year" step={100} readOnly={readOnly} />
|
|
2278
|
+
<PillField instanceName="tariff" label="Tariff" readOnly={readOnly} />
|
|
2279
|
+
</Group>
|
|
2280
|
+
<Group title="Storage">
|
|
2281
|
+
<PillField instanceName="battery" label="Home battery" readOnly={readOnly} />
|
|
2282
|
+
</Group>
|
|
2283
|
+
</Box>
|
|
2284
|
+
)
|
|
2285
|
+
}
|
|
2286
|
+
```
|
|
2287
|
+
|
|
2288
|
+
### ./src/\_shared/SliderField.tsx
|
|
2289
|
+
|
|
2290
|
+
```tsx
|
|
2291
|
+
import { Box, Slider, Typography } from '@mui/material'
|
|
2292
|
+
|
|
2293
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
2294
|
+
import { SUNFOLD } from './tokens.js'
|
|
2295
|
+
|
|
2296
|
+
interface SliderFieldProps {
|
|
2297
|
+
readonly instanceName: string
|
|
2298
|
+
readonly label: string
|
|
2299
|
+
readonly step: number
|
|
2300
|
+
readonly readOnly: boolean
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// One `genericDimension` of the spec as a slider with its value and unit above it. Dragging stages each value, so
|
|
2304
|
+
// every tile and chart follows the thumb, and letting go commits it.
|
|
2305
|
+
export const SliderField = ({ instanceName, label, step, readOnly }: SliderFieldProps) => {
|
|
2306
|
+
const planId = phx.frontend.useRootEntityId()
|
|
2307
|
+
const dimension = phx.frontend.useGenericDimension(planId, instanceName)
|
|
2308
|
+
const labelId = `sunfold-${instanceName}-label`
|
|
2309
|
+
|
|
2310
|
+
return (
|
|
2311
|
+
<Box component="div" sx={{ display: 'grid', gap: 0.25 }}>
|
|
2312
|
+
<Box component="div" sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 2 }}>
|
|
2313
|
+
<Typography id={labelId} sx={{ fontFamily: SUNFOLD.font, fontSize: 14, color: SUNFOLD.ink }}>
|
|
2314
|
+
{label}
|
|
2315
|
+
</Typography>
|
|
2316
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 15, fontWeight: 600, color: SUNFOLD.ink }}>
|
|
2317
|
+
{dimension.value.toLocaleString('en')} {dimension.unit}
|
|
2318
|
+
</Typography>
|
|
2319
|
+
</Box>
|
|
2320
|
+
<Slider
|
|
2321
|
+
value={dimension.value}
|
|
2322
|
+
min={dimension.min ?? 0}
|
|
2323
|
+
max={dimension.max ?? 100}
|
|
2324
|
+
step={step}
|
|
2325
|
+
disabled={readOnly}
|
|
2326
|
+
onChange={(_, value) => dimension.update(Array.isArray(value) ? (value[0] ?? 0) : value)}
|
|
2327
|
+
onChangeCommitted={() => dimension.resolve()}
|
|
2328
|
+
slotProps={{ input: { 'aria-labelledby': labelId } }}
|
|
2329
|
+
sx={{
|
|
2330
|
+
color: SUNFOLD.green,
|
|
2331
|
+
'& .MuiSlider-rail': { bgcolor: SUNFOLD.line, opacity: 1 },
|
|
2332
|
+
'& .MuiSlider-thumb': {
|
|
2333
|
+
inlineSize: 18,
|
|
2334
|
+
blockSize: 18,
|
|
2335
|
+
bgcolor: SUNFOLD.card,
|
|
2336
|
+
border: `3px solid ${SUNFOLD.green}`,
|
|
2337
|
+
},
|
|
2338
|
+
}}
|
|
2339
|
+
/>
|
|
2340
|
+
</Box>
|
|
2341
|
+
)
|
|
2342
|
+
}
|
|
2343
|
+
```
|
|
2344
|
+
|
|
2345
|
+
### ./src/\_shared/SurveyPanel.tsx
|
|
2346
|
+
|
|
2347
|
+
```tsx
|
|
2348
|
+
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material'
|
|
2349
|
+
|
|
2350
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
2351
|
+
import { SUNFOLD } from './tokens.js'
|
|
2352
|
+
|
|
2353
|
+
interface SurveyPanelProps {
|
|
2354
|
+
// On a phone the panel is a bar: the cost beside the button, without the hints
|
|
2355
|
+
readonly compact: boolean
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// The system's cost, the `price` component's value, and the spec's `survey` finalize action. A warning blocks the
|
|
2359
|
+
// booking and its `hints` say why; a notice does not. In the `booked` navigation state the panel confirms the
|
|
2360
|
+
// request instead.
|
|
2361
|
+
export const SurveyPanel = ({ compact }: SurveyPanelProps) => {
|
|
2362
|
+
const cost = phx.frontend.useRootEntityPrice()
|
|
2363
|
+
const { primary, hints } = phx.frontend.useFinalizeActions()
|
|
2364
|
+
const navigationState = phx.frontend.useActiveNavigationState()
|
|
2365
|
+
const navigate = phx.frontend.useNavigate()
|
|
2366
|
+
const { accessInfo } = phx.frontend.useAccessService()
|
|
2367
|
+
const isPending = primary?.isPending ?? false
|
|
2368
|
+
|
|
2369
|
+
if (navigationState === 'booked') {
|
|
2370
|
+
return (
|
|
2371
|
+
<Box component="div" role="status" sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
2372
|
+
<Box component="div" sx={{ display: 'grid', flex: 1 }}>
|
|
2373
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 16, fontWeight: 600, color: SUNFOLD.green }}>
|
|
2374
|
+
Survey requested
|
|
2375
|
+
</Typography>
|
|
2376
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 13, color: SUNFOLD.muted }}>
|
|
2377
|
+
An installer calls within two working days.
|
|
2378
|
+
</Typography>
|
|
2379
|
+
</Box>
|
|
2380
|
+
<Button
|
|
2381
|
+
onClick={() => navigate('plan')}
|
|
2382
|
+
sx={{
|
|
2383
|
+
fontFamily: SUNFOLD.font,
|
|
2384
|
+
fontSize: 13,
|
|
2385
|
+
color: SUNFOLD.ink,
|
|
2386
|
+
textTransform: 'none',
|
|
2387
|
+
textDecoration: 'underline',
|
|
2388
|
+
}}
|
|
2389
|
+
>
|
|
2390
|
+
Back to the plan
|
|
2391
|
+
</Button>
|
|
2392
|
+
</Box>
|
|
2393
|
+
)
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
const showButton = primary !== null && !accessInfo.viewMode
|
|
2397
|
+
|
|
2398
|
+
return (
|
|
2399
|
+
<Box component="div" sx={{ display: 'grid', gap: 1.25 }}>
|
|
2400
|
+
<Box
|
|
2401
|
+
component="div"
|
|
2402
|
+
sx={{
|
|
2403
|
+
display: 'flex',
|
|
2404
|
+
flexDirection: compact ? 'row' : 'column',
|
|
2405
|
+
alignItems: compact ? 'center' : 'stretch',
|
|
2406
|
+
justifyContent: 'space-between',
|
|
2407
|
+
gap: compact ? 2 : 1.5,
|
|
2408
|
+
}}
|
|
2409
|
+
>
|
|
2410
|
+
<Box component="div" sx={{ display: 'grid' }}>
|
|
2411
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.muted }}>Installed cost</Typography>
|
|
2412
|
+
<Typography
|
|
2413
|
+
sx={{ fontFamily: SUNFOLD.font, fontSize: compact ? 20 : 26, fontWeight: 600, color: SUNFOLD.ink }}
|
|
2414
|
+
>
|
|
2415
|
+
{cost}
|
|
2416
|
+
</Typography>
|
|
2417
|
+
</Box>
|
|
2418
|
+
{showButton && (
|
|
2419
|
+
<Button
|
|
2420
|
+
variant="contained"
|
|
2421
|
+
disableElevation
|
|
2422
|
+
disabled={primary.disabled || isPending}
|
|
2423
|
+
onClick={() => void primary.run()}
|
|
2424
|
+
title={primary.title}
|
|
2425
|
+
startIcon={isPending ? <CircularProgress size={16} color="inherit" /> : undefined}
|
|
2426
|
+
sx={{
|
|
2427
|
+
minBlockSize: 46,
|
|
2428
|
+
paddingInline: 2.5,
|
|
2429
|
+
borderRadius: 999,
|
|
2430
|
+
bgcolor: SUNFOLD.green,
|
|
2431
|
+
fontFamily: SUNFOLD.font,
|
|
2432
|
+
fontSize: 14,
|
|
2433
|
+
fontWeight: 500,
|
|
2434
|
+
textTransform: 'none',
|
|
2435
|
+
'&:hover': { bgcolor: SUNFOLD.greenDark },
|
|
2436
|
+
'&.Mui-disabled': { bgcolor: SUNFOLD.line, color: SUNFOLD.muted },
|
|
2437
|
+
}}
|
|
2438
|
+
>
|
|
2439
|
+
{isPending ? 'Booking…' : primary.label}
|
|
2440
|
+
</Button>
|
|
2441
|
+
)}
|
|
2442
|
+
</Box>
|
|
2443
|
+
{!compact && showButton && primary.disabled && hints && (
|
|
2444
|
+
<Typography sx={{ fontFamily: SUNFOLD.font, fontSize: 12, color: SUNFOLD.warning }}>{hints}</Typography>
|
|
2445
|
+
)}
|
|
2446
|
+
{primary?.error && <Alert severity="error">The survey could not be booked: {primary.error.message}</Alert>}
|
|
2447
|
+
</Box>
|
|
2448
|
+
)
|
|
2449
|
+
}
|
|
2450
|
+
```
|
|
2451
|
+
|
|
2452
|
+
### ./src/\_shared/tokens.ts
|
|
2453
|
+
|
|
2454
|
+
```ts
|
|
2455
|
+
// Sunfold's palette and type: warm linen, a deep green ink and the amber of the logo. Poppins is the brand's typeface;
|
|
2456
|
+
// the stack falls back to system fonts where it is not loaded.
|
|
2457
|
+
export const SUNFOLD = {
|
|
2458
|
+
linen: '#f6f3ec',
|
|
2459
|
+
card: '#ffffff',
|
|
2460
|
+
ink: '#1c2826',
|
|
2461
|
+
muted: '#5b6664',
|
|
2462
|
+
line: '#e7e2d8',
|
|
2463
|
+
green: '#1f6f54',
|
|
2464
|
+
greenDark: '#17583f',
|
|
2465
|
+
amber: '#f2a516',
|
|
2466
|
+
amberTint: '#fff4dc',
|
|
2467
|
+
warning: '#9a4b00',
|
|
2468
|
+
warningTint: '#fff3e0',
|
|
2469
|
+
info: '#2a5c8a',
|
|
2470
|
+
infoTint: '#e9f1f9',
|
|
2471
|
+
font: "'Poppins', 'Helvetica Neue', Arial, sans-serif",
|
|
2472
|
+
} as const
|
|
2473
|
+
|
|
2474
|
+
export const CARD_SX = {
|
|
2475
|
+
padding: 2.5,
|
|
2476
|
+
borderRadius: 3,
|
|
2477
|
+
bgcolor: SUNFOLD.card,
|
|
2478
|
+
boxShadow: '0 1px 2px rgba(28, 40, 38, 0.06)',
|
|
2479
|
+
border: `1px solid ${SUNFOLD.line}`,
|
|
2480
|
+
} as const
|
|
2481
|
+
```
|
|
2482
|
+
|
|
2483
|
+
### ./src/elements/ConfiguratorLayout.tsx
|
|
2484
|
+
|
|
2485
|
+
```tsx
|
|
2486
|
+
import { Box } from '@mui/material'
|
|
2487
|
+
|
|
2488
|
+
import { LayoutSlots } from '@salsita-ai/sdk/configurator'
|
|
2489
|
+
|
|
2490
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
2491
|
+
import { DesktopPage } from '../_shared/DesktopPage.js'
|
|
2492
|
+
import { PhonePage } from '../_shared/PhonePage.js'
|
|
2493
|
+
import { SUNFOLD } from '../_shared/tokens.js'
|
|
2494
|
+
|
|
2495
|
+
// A planning dashboard with no scene: the layout never mounts `LayoutSlots.SharedCanvas`, and every tile and chart is
|
|
2496
|
+
// React and SVG drawn from the spec's expressions
|
|
2497
|
+
const ConfiguratorLayout = () => {
|
|
2498
|
+
// `null` until the container has been measured
|
|
2499
|
+
const viewports = phx.frontend.useActiveViewports()
|
|
2500
|
+
const isDesktop = viewports?.includes('desktop') ?? false
|
|
2501
|
+
|
|
2502
|
+
return (
|
|
2503
|
+
<Box component="div" sx={{ position: 'absolute', inset: 0, overflow: 'clip', bgcolor: SUNFOLD.linen }}>
|
|
2504
|
+
<LayoutSlots.Services />
|
|
2505
|
+
{viewports !== null && (isDesktop ? <DesktopPage /> : <PhonePage />)}
|
|
2506
|
+
</Box>
|
|
2507
|
+
)
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
export default phx.frontend.createCustomPhoenixLayoutElement('ConfiguratorLayout', ConfiguratorLayout)
|
|
2511
|
+
```
|
|
2512
|
+
|
|
2513
|
+
### ./src/expressions/solar.ts
|
|
2514
|
+
|
|
2515
|
+
```ts
|
|
2516
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
2517
|
+
|
|
2518
|
+
const PANEL_KWP = 0.43
|
|
2519
|
+
const PANEL_AREA_M2 = 1.95
|
|
2520
|
+
// Vents, chimneys and the edge clearance leave about 85 % of a roof for panels
|
|
2521
|
+
const USABLE_ROOF = 0.85
|
|
2522
|
+
// A south-facing kWp in central Europe yields about 1,000 kWh a year
|
|
2523
|
+
const YIELD_KWH_PER_KWP = 1000
|
|
2524
|
+
const ORIENTATION_FACTOR = { South: 1, SouthEastWest: 0.95, EastWest: 0.82, North: 0.6 }
|
|
2525
|
+
|
|
2526
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
2527
|
+
// The share of the year's sun and of the household's use that falls in each month
|
|
2528
|
+
const SUN_SHARE = [0.04, 0.045, 0.08, 0.105, 0.125, 0.13, 0.13, 0.115, 0.09, 0.065, 0.04, 0.035]
|
|
2529
|
+
const USE_SHARE = [0.1, 0.09, 0.088, 0.08, 0.075, 0.07, 0.07, 0.072, 0.076, 0.085, 0.094, 0.1]
|
|
2530
|
+
const DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
2531
|
+
|
|
2532
|
+
const BATTERY_KWH = { None: 0, Kwh5: 5, Kwh10: 10, Kwh15: 15 }
|
|
2533
|
+
const BATTERY_PRICE = { None: 0, Kwh5: 3600, Kwh10: 6400, Kwh15: 8900 }
|
|
2534
|
+
const TARIFF = {
|
|
2535
|
+
Flat: { buy: 0.32, sell: 0.08, shiftBonus: 0 },
|
|
2536
|
+
TimeOfUse: { buy: 0.29, sell: 0.1, shiftBonus: 0.09 },
|
|
2537
|
+
ExportPlus: { buy: 0.32, sell: 0.15, shiftBonus: 0 },
|
|
2538
|
+
}
|
|
2539
|
+
// Panels lose half a percent a year; electricity gets 2 % dearer a year
|
|
2540
|
+
const DEGRADATION = 0.995
|
|
2541
|
+
const PRICE_GROWTH = 1.02
|
|
2542
|
+
const YEARS = 25
|
|
2543
|
+
|
|
2544
|
+
export default phx.domain.implementCustomExpressions({
|
|
2545
|
+
SolarPlan: {
|
|
2546
|
+
$systemKwp: s => {
|
|
2547
|
+
return s.panels * PANEL_KWP
|
|
2548
|
+
},
|
|
2549
|
+
$roofPanelLimit: s => {
|
|
2550
|
+
return Math.floor((s.roofArea * USABLE_ROOF) / PANEL_AREA_M2)
|
|
2551
|
+
},
|
|
2552
|
+
// The roof the panels cover, rounded up to whole square metres, as the tiles and the roof warning quote it
|
|
2553
|
+
$panelAreaM2: s => {
|
|
2554
|
+
return Math.ceil(s.panels * PANEL_AREA_M2)
|
|
2555
|
+
},
|
|
2556
|
+
$annualProductionKwh: s => {
|
|
2557
|
+
return s.$systemKwp * YIELD_KWH_PER_KWP * ORIENTATION_FACTOR[s.orientation]
|
|
2558
|
+
},
|
|
2559
|
+
// Each month's production and use, and how the production splits between the home and the grid. Without a
|
|
2560
|
+
// battery the home uses solar power only while the sun shines; a battery carries the surplus into the evening,
|
|
2561
|
+
// one charge a day, and covers the evening but not the early morning.
|
|
2562
|
+
$monthly: s => {
|
|
2563
|
+
const batteryKwh = BATTERY_KWH[s.battery] * 0.9
|
|
2564
|
+
|
|
2565
|
+
return MONTHS.map((month, index) => {
|
|
2566
|
+
const productionKwh = s.$annualProductionKwh * (SUN_SHARE[index] ?? 0)
|
|
2567
|
+
const consumptionKwh = s.annualUse * (USE_SHARE[index] ?? 0)
|
|
2568
|
+
const direct = Math.min(productionKwh * 0.55, consumptionKwh * 0.45)
|
|
2569
|
+
const stored = Math.min(
|
|
2570
|
+
productionKwh - direct,
|
|
2571
|
+
batteryKwh * (DAYS[index] ?? 30),
|
|
2572
|
+
(consumptionKwh - direct) * 0.6
|
|
2573
|
+
)
|
|
2574
|
+
const selfUseKwh = direct + stored
|
|
2575
|
+
|
|
2576
|
+
return { month, productionKwh, consumptionKwh, selfUseKwh, exportKwh: productionKwh - selfUseKwh }
|
|
2577
|
+
})
|
|
2578
|
+
},
|
|
2579
|
+
$selfConsumption: s => {
|
|
2580
|
+
const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
|
|
2581
|
+
|
|
2582
|
+
return s.$annualProductionKwh > 0 ? selfUse / s.$annualProductionKwh : 0
|
|
2583
|
+
},
|
|
2584
|
+
$selfSufficiency: s => {
|
|
2585
|
+
const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
|
|
2586
|
+
|
|
2587
|
+
return selfUse / s.annualUse
|
|
2588
|
+
},
|
|
2589
|
+
// A year's saving: power the home no longer buys, power sold to the grid, and on a time-of-use tariff the
|
|
2590
|
+
// evening power a battery shifts away from the dear hours
|
|
2591
|
+
$annualSavings: s => {
|
|
2592
|
+
const tariff = TARIFF[s.tariff]
|
|
2593
|
+
const selfUse = s.$monthly.reduce((sum, month) => sum + month.selfUseKwh, 0)
|
|
2594
|
+
const exported = s.$monthly.reduce((sum, month) => sum + month.exportKwh, 0)
|
|
2595
|
+
const shifted = s.battery === 'None' ? 0 : selfUse * 0.4
|
|
2596
|
+
|
|
2597
|
+
return selfUse * tariff.buy + exported * tariff.sell + shifted * tariff.shiftBonus
|
|
2598
|
+
},
|
|
2599
|
+
$systemCost: s => {
|
|
2600
|
+
return Math.round(1500 + s.$systemKwp * 1050 + BATTERY_PRICE[s.battery])
|
|
2601
|
+
},
|
|
2602
|
+
// The money the system has returned at the end of each year, starting at minus its cost in year 0
|
|
2603
|
+
$cumulativeSavings: s => {
|
|
2604
|
+
return Array.from({ length: YEARS + 1 }, (_, year) => {
|
|
2605
|
+
const earned = Array.from(
|
|
2606
|
+
{ length: year },
|
|
2607
|
+
(__, past) => s.$annualSavings * (PRICE_GROWTH * DEGRADATION) ** past
|
|
2608
|
+
).reduce((sum, value) => sum + value, 0)
|
|
2609
|
+
|
|
2610
|
+
return earned - s.$systemCost
|
|
2611
|
+
})
|
|
2612
|
+
},
|
|
2613
|
+
// The year the running total crosses zero, between whole years; beyond the panels' 25 years it is `Infinity`
|
|
2614
|
+
$paybackYears: s => {
|
|
2615
|
+
const totals = s.$cumulativeSavings
|
|
2616
|
+
const year = totals.findIndex(total => total >= 0)
|
|
2617
|
+
|
|
2618
|
+
if (year <= 0) {
|
|
2619
|
+
return year === 0 ? 0 : Infinity
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
const before = totals[year - 1] ?? 0
|
|
2623
|
+
const after = totals[year] ?? 0
|
|
2624
|
+
|
|
2625
|
+
return year - 1 + -before / (after - before)
|
|
2626
|
+
},
|
|
2627
|
+
$co2Tonnes: s => {
|
|
2628
|
+
return (s.$annualProductionKwh * 0.35) / 1000
|
|
2629
|
+
},
|
|
2630
|
+
},
|
|
2631
|
+
})
|
|
2632
|
+
```
|
|
2633
|
+
|
|
2634
|
+
### ./src/handlers/book-survey.ts
|
|
2635
|
+
|
|
2636
|
+
```ts
|
|
2637
|
+
import { useCallback } from 'react'
|
|
2638
|
+
|
|
2639
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
2640
|
+
|
|
2641
|
+
// Stands in for the installer's booking service: the request takes a moment, then the page shows the confirmation
|
|
2642
|
+
export default phx.frontend.createPhoenixHandler('bookSurvey', () => {
|
|
2643
|
+
const navigate = phx.frontend.useNavigate()
|
|
2644
|
+
|
|
2645
|
+
return {
|
|
2646
|
+
handler: useCallback(async () => {
|
|
2647
|
+
await new Promise(resolve => setTimeout(resolve, 1200))
|
|
2648
|
+
navigate('booked')
|
|
2649
|
+
}, [navigate]),
|
|
2650
|
+
}
|
|
2651
|
+
})
|
|
2652
|
+
```
|
|
2653
|
+
|
|
2654
|
+
### ./src/invariants/solar.ts
|
|
2655
|
+
|
|
2656
|
+
```ts
|
|
2657
|
+
import { phx } from '../../.phoenix/phx.js'
|
|
2658
|
+
|
|
2659
|
+
// More panels than the roof holds is a warning: the dashboard keeps showing the figures, and a survey cannot be
|
|
2660
|
+
// booked until the panel count fits. A north-facing roof is only a notice, so it never blocks the booking.
|
|
2661
|
+
export default phx.domain.implementCustomInvariants({
|
|
2662
|
+
SolarPlan: {
|
|
2663
|
+
panelsFitRoof: s => {
|
|
2664
|
+
if (s.panels <= s.$roofPanelLimit) {
|
|
2665
|
+
return []
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
return [
|
|
2669
|
+
{
|
|
2670
|
+
type: 'panelsExceedRoof',
|
|
2671
|
+
severity: 'warning',
|
|
2672
|
+
message: `${s.panels} panels need about ${s.$panelAreaM2} m² of roof; ${s.roofArea} m² holds at most ${s.$roofPanelLimit}. Lower the panel count or measure the roof again.`,
|
|
2673
|
+
},
|
|
2674
|
+
]
|
|
2675
|
+
},
|
|
2676
|
+
northFacing: s => {
|
|
2677
|
+
return s.orientation !== 'North'
|
|
2678
|
+
},
|
|
2679
|
+
},
|
|
2680
|
+
})
|
|
2681
|
+
```
|
|
2682
|
+
|
|
2683
|
+
</details>
|