diff --git a/app/assets/scripts/components/project/prime-panel/tabs/predict/checkpoint-selector.js b/app/assets/scripts/components/project/prime-panel/tabs/predict/checkpoint-selector.js index 03b3c660..6af3ad5b 100644 --- a/app/assets/scripts/components/project/prime-panel/tabs/predict/checkpoint-selector.js +++ b/app/assets/scripts/components/project/prime-panel/tabs/predict/checkpoint-selector.js @@ -123,7 +123,6 @@ function CheckpointSelector() { .map((c) => ( { actorRef.send({ type: 'Apply checkpoint', diff --git a/app/assets/scripts/components/project/prime-panel/tabs/predict/mosaic-selector/index.js b/app/assets/scripts/components/project/prime-panel/tabs/predict/mosaic-selector/index.js index 06357b23..424482e0 100644 --- a/app/assets/scripts/components/project/prime-panel/tabs/predict/mosaic-selector/index.js +++ b/app/assets/scripts/components/project/prime-panel/tabs/predict/mosaic-selector/index.js @@ -1,22 +1,78 @@ import React, { useState } from 'react'; -import { ProjectMachineContext } from '../../../../../../fsm/project'; -import { EditButton } from '../../../../../../styles/button'; + +import styled, { css } from 'styled-components'; + +import { Heading } from '@devseed-ui/typography'; +import { themeVal, glsp } from '@devseed-ui/theme-provider'; + +import { ActionButton } from '../../../../../../styles/button'; +import ShadowScrollbar from '../../../../../common/shadow-scrollbar'; import { - HeadOption, HeadOptionHeadline, HeadOptionToolbar, } from '../../../../../../styles/panel'; -import { - Subheading, - SubheadingStrong, -} from '../../../../../../styles/type/heading'; +import { Subheading } from '../../../../../../styles/type/heading'; import { MosaicSelectorModal } from './modal'; + +import { ProjectMachineContext } from '../../../../../../fsm/project'; import { SESSION_MODES } from '../../../../../../fsm/project/constants'; import selectors from '../../../../../../fsm/project/selectors'; import * as guards from '../../../../../../fsm/project/guards'; + import { formatMosaicDateRange } from '../../../../../../utils/dates'; +import { SelectorHeadOption } from '../../../selection-styles'; + +const Option = styled.div` + display: grid; + cursor: pointer; + background: ${themeVal('color.baseDark')}; + padding: ${glsp(0.25)} 0; + + h1 { + margin: 0; + padding-left: ${glsp(1.5)}; + } + + ${({ hasSubtitle }) => + hasSubtitle && + css` + .subtitle { + margin: 0; + } + `} + ${({ selected }) => + selected && + css` + border-left: ${glsp(0.25)} solid ${themeVal('color.primary')}; + h1 { + color: ${themeVal('color.primary')}; + padding-left: ${glsp(1.25)}; + } + background: ${themeVal('color.primaryAlphaA')}; + `} + + ${({ selected }) => + !selected && + css` + &:hover { + background: ${themeVal('color.baseAlphaC')}; + } + `} +`; + +const MosaicOption = styled(Option)` + ${({ disabled }) => + disabled && + css` + &:hover { + background: ${themeVal('color.baseDark')}; + cursor: default; + } + `} +`; export function MosaicSelector() { + const actorRef = ProjectMachineContext.useActorRef(); const [showModal, setShowModal] = useState(false); const sessionMode = ProjectMachineContext.useSelector(selectors.sessionMode); @@ -30,6 +86,15 @@ export function MosaicSelector() { const currentMosaic = ProjectMachineContext.useSelector( selectors.currentMosaic ); + const currentTimeframe = ProjectMachineContext.useSelector( + selectors.currentTimeframe + ); + const timeframesList = ProjectMachineContext.useSelector( + ({ context }) => context.timeframesList + ); + const mosaicsList = ProjectMachineContext.useSelector( + ({ context }) => context.mosaicsList + ); let label; let disabled = true; @@ -49,6 +114,18 @@ export function MosaicSelector() { disabled = false; } + const optionsList = timeframesList?.map((t) => { + const mosaic = mosaicsList.find((m) => m.id === t.mosaic); + return { + id: t.id, + label: formatMosaicDateRange( + mosaic.mosaic_ts_start, + mosaic.mosaic_ts_end + ), + timeframe: t, + }; + }); + return ( <> - + + - Imagery Mosaic Date Range + Mosaics - !disabled && setShowModal(true)} - title={label} - disabled={disabled} + + - {label} - - {!disabled && ( - - { - !disabled && setShowModal(true); - }} - title='Select Imagery Mosaic' - > - Edit Mosaic Selection - - - )} - + + {label} + + {!!optionsList?.length && + optionsList + .filter((t) => t.id != currentTimeframe?.id) + .map((t) => ( + { + actorRef.send({ + type: 'Apply existing timeframe', + data: { timeframe: { ...t.timeframe } }, + }); + }} + > + {t.label} + + ))} + + + !disabled && setShowModal(true)} + title={label} + disabled={disabled} + useIcon='plus' + > + {label} + + { + actorRef.send({ + type: 'Delete timeframe', + }); + }} + > + Delete Current Mosaic + + + ); } diff --git a/app/assets/scripts/components/project/prime-panel/tabs/predict/mosaic-selector/modal/content.js b/app/assets/scripts/components/project/prime-panel/tabs/predict/mosaic-selector/modal/content.js index a8b172a5..66686ee4 100644 --- a/app/assets/scripts/components/project/prime-panel/tabs/predict/mosaic-selector/modal/content.js +++ b/app/assets/scripts/components/project/prime-panel/tabs/predict/mosaic-selector/modal/content.js @@ -36,6 +36,9 @@ export const MosaicContentInner = ({ const currentImagerySource = ProjectMachineContext.useSelector( selectors.currentImagerySource ); + const timeframesList = ProjectMachineContext.useSelector( + ({ context }) => context.timeframesList + ); const { data: collection, @@ -70,6 +73,20 @@ export const MosaicContentInner = ({ } } + // Check if mosaic is already used by a timeframe + const existingTimeframe = timeframesList.find( + (timeframe) => timeframe.mosaic === mosaic.id + ); + + if (existingTimeframe) { + actorRef.send({ + type: 'Apply existing timeframe', + data: { timeframe: existingTimeframe }, + }); + onMosaicCreated(); + return; + } + try { const { mosaics: mosaicsList } = await apiClient.get('mosaic'); onMosaicCreated(); diff --git a/app/assets/scripts/fsm/project/actions.js b/app/assets/scripts/fsm/project/actions.js index 231cf90c..67c28e9c 100644 --- a/app/assets/scripts/fsm/project/actions.js +++ b/app/assets/scripts/fsm/project/actions.js @@ -234,15 +234,33 @@ export const actions = { currentCheckpoint: event.data.checkpoint, })), setCurrentTimeframe: assign((context, event) => { - const { currentCheckpoint } = context; + const checkpoint = event.data?.checkpoint || context.currentCheckpoint; const newTimeframe = event.data.timeframe; - const retrainClasses = - newTimeframe?.classes || currentCheckpoint?.classes || []; + // Clear current prediction if timeframe is changed + if (!newTimeframe) { + return { + currentTimeframe: null, + currentMosaic: null, + retrainClasses: [], + }; + } + + const retrainClasses = newTimeframe?.classes || checkpoint?.classes || []; + + // A timeframe is always associated with a mosaic, but currently the API + // doesn't populate it in the response + const mosaicId = + typeof newTimeframe?.mosaic === 'string' + ? newTimeframe?.mosaic + : newTimeframe?.mosaic?.id; + + const currentMosaic = context.mosaicsList.find((m) => m.id === mosaicId); // Apply new timeframe and (re-)initialize retrain classes return { currentTimeframe: { ...newTimeframe }, + currentMosaic, retrainClasses, }; }), @@ -322,6 +340,9 @@ export const actions = { clearCurrentPrediction: assign(() => ({ currentPrediction: null, })), + clearCurrentTimeframe: assign(() => ({ + currentTimeframe: null, + })), setMapRef: assign((context, event) => ({ mapRef: event.data.mapRef, })), @@ -454,6 +475,12 @@ export const actions = { disabled: false, }, })), + enterApplyTimeframe: assign(() => ({ + sessionStatusMessage: 'Applying timeframe', + globalLoading: { + disabled: false, + }, + })), exitRetrainMode: assign((context) => { const { freehandDraw, polygonDraw } = context.mapRef; diff --git a/app/assets/scripts/fsm/project/guards.js b/app/assets/scripts/fsm/project/guards.js index 6d47369c..11c17e9b 100644 --- a/app/assets/scripts/fsm/project/guards.js +++ b/app/assets/scripts/fsm/project/guards.js @@ -22,9 +22,17 @@ export const isPredictionReady = (context) => { currentModel, currentAoi, } = context; - return ( - !!currentAoi && !!currentImagerySource && !!currentMosaic && !!currentModel - ); + + if (context.project.id === 'new') { + return ( + !!currentAoi && + !!currentImagerySource && + !!currentMosaic && + !!currentModel + ); + } else { + return !!currentAoi && !!currentMosaic; + } }; export const isProjectNew = (c) => c.project.id === 'new'; @@ -38,3 +46,5 @@ export const isLivePredictionAreaSize = ({ currentAoi, apiLimits }) => currentAoi.area < apiLimits.live_inference; export const hasAois = (c) => c.aoisList?.length > 0; + +export const hasTimeframe = (c) => c.currentTimeframe; diff --git a/app/assets/scripts/fsm/project/machine.js b/app/assets/scripts/fsm/project/machine.js index eb3a21fd..838cfa1f 100644 --- a/app/assets/scripts/fsm/project/machine.js +++ b/app/assets/scripts/fsm/project/machine.js @@ -6,7 +6,7 @@ import { SESSION_MODES } from './constants'; export const projectMachine = createMachine( { - /** @xstate-layout N4IgpgJg5mDOIC5QAcBOB7AVmAxgFwFoBbAQxwAsBLAOzADoAFEmAAkthaPQFdq9IAxACU46ADYA3MCxLc85MH0o4SeSumoBtAAwBdRCnSxKajQZAAPRAQCMNgMwB2OnYCcAVkcAOLwDYnvl7uADQgAJ6INv50jo6uAEwevq7JTu4ALAC+maFoWLiEpBQ09Eys7CyoYCQQYQI6+kggyEYm6tTmVgi2dvF06Tbu2jZ+2unu7l6hEQgOrl50vgPa9qtj2omu2bkY2PjEZFS0jMzSFVU1dZo2jYbGph1NXQSO2r794-O+vo7x6Yn2aaRdLadz9SbxXzxOLxeITdzbZq7AoHYrHMpnDgXWr1eK3ZqtB6dazJd4JcbaDZDH7aVxA2buRKLdJedLpezuXwOeL2RF5PaFQ4lOgAYQUOAA1jQoGwAGYsbiwMCoNgcWTyRRqFT8CD1PTmFr3drE7q9bR0DIgt7aIK+Cb09lg-yM62+UEg3k5JH5fZFI70MW4KXUGWUeWK5WqmRyBRKbWCa74w1tMxPaxeRxgmzpO1xV7Q9yrB0cxYc+Ku90rPnI31C44AUT4yulLFoAHcWPyCq2SEQwAIAMpgPCdmsj6i9sANA2E41p012MHuOx2ynfbSOGz0myOcb9G3L4Y2Vz2KJZL1d2to+gAETAspKLAfqFgI4AggB5ACSAgYVVgSoQAqyBiOgNQsJ+X4sAARnIeAaNOTTJkS862F48Q2HQ9hjPY6SOPYXgbIEUzhJErhxBamYYbEJ7uK4DjVj6grXnQd4PrQT6UC+77fr+-6ASwECoCQHbPq+EHfjBcEIfqSGzqmoDPNhUR0MMQxeK4bI2GMW6kQgqw7qp9hwqe2YaZmjECqi-qihcaghpwJDIAIACyTlRjgdmQIhdwpo8inWHYG4uAMyTLqsBaAnpJ7mmMbppJyEz+JZKJ+sKADqJBtA5sroCqQnMCweUsCo1A4GAYiue5XARhA6Btlosm+ShAXdKedEuAkNqZgRDicvSuGcnQPicr8fhaU4KVXjZmXZTKuX5cJMrFaV5WVX+cACatFUSVBBUdrBeDwY1Sbyf5ljWOWfilh4rgrFC9jfAN-yYXdrLzG86GeOeOxMdZGVZfZ83FQVy0qtt638ZAJUkGVO2QYJwkHdJWg3DORoKRd3RXa4ql-CsOZsgkj0DRM9gxPh6nrg4bxTcxNk3kjLYI9BYSI8wUDSlVyCcDwSpcFIPkEhj51KRmzj-Cyp6UqFbIDXCfQ7vEGl3YygQZHT-3HIzInM5JrPs1AnMhtzvMRtwyBC8hc6tQQATk-RbrExhdrQvSkKvHQjtfDmV1spraXa0zDks2zoPG1AfGbdDEO7ezyNHTJp0iyaduFgs8y4UshOQr4A0OO8nzHoksRBI4vgB3W9AAGI0Ow5CCfedch-rPDUBAsACHVxw0BI6ASvQl708KtfUPXjfsXrUHQW3HcIL36Dau0DRW2dqc8pCMQ8myX3eIu7s+LjGT0YWIJun8nq-VZgc18OFBsGPagkGIgmqCQXcaPQC8D3QQ9a7feB751yfi-CAb957UD7kvJOq8U6oV6OTTk6Q7qUj+IMSE9J8wxEXA9csyRWSVxYtXO+DdgGUGfq-PA79lQYFQL-MQqgFpEF-mOf+dBiGANIY-choDwEL2gSdWBflU4aXSHQcs9EEg8nsF1EiMxJiYQwo9WIKxyLly8IQmyG0IDKAeJUaoOI-yUD7FJRO1BRzRwgEIlqWNBoLA2MrcK5FCJuz0jI2k4jWTlhkd8PwhZNHCm0bo9o+jLhRwAtDC2oFwIsxRtYm2tidxiM8MZP4-gNK0gGhmcmtJ5iOmzK8fxF5WE30YFUHR+AQnYjqCIAAjtwOAPE9oVWHFOJqwthHzicJha03guTIKGG8B0KRzSDEmHmcuiQAnonKcEjQoTDFQyAu2OOh1jrxMxl0ZWBFKJ0Szsgj2Dpsx9EhHFLwBdszTNKLMyp8zqnCDAPUxp0MEawDbCYCgGzRaIFWAMLC7I2TtSWARI5KQvZvBkfhfwCQK7FL+qUoJtzzH3LqQ018LzJKwHICQKoLAACqQgAAyXyTQgkzC4H4cJ5i3X6npQZTp5hjShPRDRcLr5VzKZAOZyKDF1AHO8zhJVuCoCqHwB+r5YblRYHgMIyA2nJ06a1M+mEJgnliMrAuGRizQn6GkV4-xXC3SuZyipej7kACFVD3zQFypFJV0BEBAq0qx7TrabMQGfMErxyIJA0iXEIekibmm2Rhc5YVPDGsRWa3lg4BX33gvovAwkaC8wgPK9GiqsbLjZBaSF3S7ARQdNaYayxDWrkmmy1KHKo1VJjW+ZAIE2YUCDC0GgeASVdKCH0Skmkcy-AGIaqKMwnBOCMqeQ1x4nCZ0jTc6NYSXJGCyjgFgbYSAcCVGIAo3lXVry6RMc0xMvDYWohpZ6lJ-mfENfuxkxrGz8BVDa01VTeB6gVTYroe8XCZiPMsfpQ7EBwjsIsDcwxvAbFPEe29TYH2zufdQeoaM5JwNap+ncQw7C-uzP+hAkIMhYVwuhf4lJXgZig-eixT67kvs0HiDN77ECoe-Rh7Sf73afq+OXb4dFvC4WNSKOyU8P490gf3QeJSOX8eqEDXaECoGqGXnoDtrVsyFlUiyOInJBrYWw9CJYWEeqMjwlyBElbprCjfJUiQ8mHI0AlXDJ8xVH3coEF+agdmpXnF4GPEMSmsbHjtBaF2PqPo5jpHpaEJ4vYUg3MZF6PhjUWbUFZ6TtmqH2YWhR5zrn3OYgWWEXzWyFbiOVtI340IojuxSM4WIDhfjDHZLuBLlnrOhjc2lqVGWnNIpc21yV0gyBJfk-M7FHBZRZU3S6t9CSug7juvuKEGRtLKTzuF1V4jvhRAIkEFYjhjVCC8y2LrDwBCSdUNDVLfWV1gGgrAReA9207uQ1jKluN0LYSWEeuiEL3YEfEZMEEOZOSaRsHtg7DkjvtAEFwNNlUJV4EVPo8qlApCTbo9NgDUJzSmWhH4P4GkdwHx5GpwYwwOPxFB9QbzMoIcaAeUjlHJVxQSlbXwArGOdVUUCCeDCsIVhsbhIscK0JYSJD8Kyq+VaWL7cp4d2DtORD05jkzlnI4xDsAe1N91OHjOqWzGuQsYUizhe3hTG0UQYSaS2KZ4exxpdU8y91hXYBkfQzUH2WUwk+xs+16eFwy4bBwjZJ4Cr4X-BiL+IkDcrsA9wgp-bmn8Gncu6Agn0c6AoD8W91S5wvVuNoKGI4d26cXBoeQT8dkSi4+y9tcdt8M9UAjjWfMm1ETUdIczVshbzJYgEQio9Pw7s2TmlPoEKEbpQRjCr+DuX8Ga3DbXU+cb27NffJw-RZJvzgf3WUaTQYw083UW0hMH63p2VS7B9TmfUdKPIt4CuhfJB686izxk-oPgNzlyIyO-OHUOQ7y5KCD4CDtbmwnbtXjftftypUHfquhwDgA6k6s-o9h3gBu-pRKCD8EEDuBsOkANMZM4D8IaoRDaMkKCJfKfpLjZKdtJkPEJl-CJj-H-KUtQbLkxLJovENoIsgfRrMOWJhMCsRJpE7CCuFiyPYnaLhLdP2gRMaoSmBEBAwvwOJJBK+mjlrnEGIiCP9oWB4MeHhNuOyJhGyJsMMJsB4OTiAaUnIeBIoY0rtAhiviaByOaIRldLaMuLnNuFCGIvgvhN1JuDIZYRytYQoWdsobxDRo4V0uTNkskIauSARLCNuN+lhP9sZAkHEJpOQUwRygAGrPyUBgL8DxzmIqHe52xOALCsi-C7jaaJAE56SDD-DDSnL0QrDGTm7Gr5Fq5FHSD7SlERGIbNTo5tSPQnKQgjDET97YZRBE7eDLiSIsjLji4UFmbazsAgQkBhAthRLyFxzQ7PwCB4ogTyHQxWY9FxwPibrlHHjzClgsjDAyIFJHqD7ZjiKDQsZ9p+LGo3gbEMLbEOS7ExKSQHGVQiigRKjATRJAQIygk3EeFewyIyLoS0i9S4GiHJCqSZhDBLEeBsi7ZBEsS-GwCbEAkyhAkwkgnoBgJgkQnSAUn7HUnPyJhqGr62BiH4YTBRBUg8ZF7RBEzsiESlz4l7b3j-gNwIyErq6qHt48EEA67eDoR9SaSZhFo5oHLzAjArCMhFIS5rH0AiAe5wASmSRSmvgOGsmpxcjOA9p0QDChp4TonDonzAbl6Zy7jnIn45EsT1gWBzSI5pZQCbpxz7Spr9jlFoLvDfBQgao7i-Luyj5qZ-CanZjHLZHiYsQigaAPhQDCotgrIqELqwBLr37rotL4DL6WmoQuxgggh+LJk5hLCF6BrHjOBfa4RRD+CPTAF6k24BjZmUC5moD5lgAdgqFfikAwCoBsy3bCoeZlmboVlt7DFa4ECbCYTYQeHc4aRchHKTAxC0irgngEYrHelUEDlDkjljm8QLow5RgbpbrLkdJykmH6Y5gFw+CbgbgOieC1l7zDCsjjDIJ8YXl5kOQFm8SorPKUlQRvIfLkARmFixTYQOJPHnLfktkZgl71EeF-BxAgXUA5lgUygQU-hQXhHNKbr8ARklaLDKz4Trn-ZFq-ClhlZjBgaeCnkZnnmEWDnEWtijn2FGImJN7mIt6AQ0VE7Qixbj6gh-BHIlgB6BBUjImQgEVEXDngWCUqEbSt5Ql7GxJmI3FHouHpETDoUqlOk-KsjkyrC1FLDHwMSEk8UaVXn2H8rwVCoiqajirtbSAypyoRlEFYRxmza7jjD+AOjoR9B4SnjqaxYVq9lsL1qNothgB+mviCbdz0F9yMHcXmYNpiBkksDpXq5TzsECIrzcEjFrnLH9BQitk8gaFLA-mjrWg2gYS0gFImZJWlJ3hUVpUZXSYqHZV0Dfxibwocr9XDiDVlUtxfgVWcFVVRG2zaTIIhWTCrBHomRwgOiwiFyR5fT-5cWTVS6PJoo6ghktL8AykrlslRD0QhQfmFipmghqlZisgbBjIZJ+CilPLoowWNxUXhlDHPk1VRDXTnKAWAXkS1Z7U6o7gFzeD4QYZ8YCbzUsBYo4rSAEqEp0FjUMETVn5UHo0yivLYq4q42LUPDLVVlKpLZYTbUPW47zAOi-BHy3FHqTEeBo1SZTyY0U041EoCA0J5T0KMJ5TMJnnCgsEY1Y2U1ErU0Ka6De4giGrDRHpAFXQcgrYzDvlRkfbkSQrkSwq9UcoiBKgjj9H2HlHGSrDDRBSSJRDZhhYzB-B-KUyY4EZ4XpmnUMx-FbFxzY0kCpoUI6LPzp4PISoN7xwtiiUWKt623-A56aaESLgrBBDbisiYS7gYGe12BRA-EB1swIzB2h2gI8KR1-iLxgDQzx3iWVmyk1XvZOjsVbUeAkbJF4SqTfCVFuiqrGTGoDhUIN7gHObe4jDXSDB2jnJSKBD5ysj7jSUlZDCwgElm0sTD04o0FX4slN1a7HgeLl4ZBIJ4SFh4GPWMgBBi6vAbgnXE3CjkWN5WoNwJ743jUsJ+2P3nV2HQQv0O4PBK0wLVVa6xYLB4Ral4JqzjB7W-5SGaRwipCilJpZQ8phIeWCoJpHZhmq0xkWiG64TQj5gDRSKLBEEArHgRrOXf0oMpr3JDgjhVC0PmKkA8zQ7pr72r7LiqYAE+CEQERjDxDFjYQhROAelJDHjIPJpoM4hvgQBARMPSOY29hOre7LhQj76AqTArAkEOhcg51KV8MQinhSOoN5aDjDiJpKMDYu4lQMIARqNmTgrlzcmB5OAOgQPDQxTdJbbcOmN0MxrHG9FWNmM4D2ProqObqwCOObzYTILoSemxABp63lxggdnaSUiPRGrUO27DhKP0NxoNw4DCqiojgXb2YBUcN3UmgeFkiUoFJakJCgrmg1F+Cw2CH+MyM1J5NmNVD-VIErVZopBpMGrIIpDILZhZIOAUzlj4RxNH6m2rF9l0AiDMPmNP0YqwWC34pEqq13Th6r0yIRUeEkP7nLhxA+DIL1a+0P25PMNcxJ4M6KOoMCB5AZ6bTe74QshfoaRxN93NnyI7ZYSolX0mU3o5MGk9PNyRyPPQzPM0ACDwGOrA2fNZy6r4T9q1EpBRWbgWimGcifT4KdNczsOw5ULw5Yi4DO4o6otch4wbhrU-B0RCOBqvDkwsglYjAjLjDEsmywtATNqSgq60vgOTB3QekZCJA-mqbcsF0eCfm8swtUvJ6M4troBtosBq6viosTBbw-pEz9L0jLFghQ0sjZjjCnix4QsrNQtU507Uuu7GJimTiou4tBDcM-BOwnh7U5hkN0SEQnjaQELWurPSMPPKsM6p5vOZ4gOr5zP2LixcaLgkyBqghgjlibDl4ETAqKsCB155SMO8CfNq2BaxHKx3QODFiaScndQjRmu5uhtmMjYlUip5SN3VPzgOWKw7i-Bc5YbYZ4TeCeKZiUrA52gNu2vQHmKwH2rIvOre5LAqTeBpKET92MgeMrBexOxYF4XnK5v5tyCmLHQJ0SWxsmjAqaGMhzNvB456PNFESUiWiw1BBNaDYpa9Zww9Y5ZRioAX7lE5jkxujLC47uHJORCcZ-abiqqzMB5en5XHCJbI4ta+V9YnbK7qtioztOQgSUDttg2rlmuLB+BgaSIZgDDbiTH1X0SPSfDZv32UHmbNbvs5Zft+U-u8oRnOAFosoYSalrAAuRACPiIQ3zCnw7ikbWuIfJYtjlPlSseXY2PSfz6jZL5PlupslDC656FniFicYeDbgSLrZlw-DaRqpW4b02QpVFUtiCvM4Yd4D2squ2cq6vMYDvMONnvzj-BE42iGrqKeCZN4HDDiL-DOFHn7MJaFXFXOf2dodqsatYeFW4dqe7pKqzNRZcj953QchyI-IYtjpPFToDA9lLNsIhEthcDFnKDv2E2f23P0DlcOSVdLpANcGDNdBFdezQihRHpuiAVnqvRjCieZgZIWSIjUDUlwAGjwdgB01Yx2wJC1l47Sw6TZz0gVHgg2jIJshdkTCRqnBRg1RNgQBzfPAF19C4S0gMvDD65GvkxQpAWvC-CI09WlcIoHfnC8qneBSDAOzkQSLeGu2RA2iAdcgd3Kyyz0f6mihM4yfhhKgqgVDqixhahnYnecOpx2C4veGJPZj6NBdYQXxX2SEg9kbNhaUdhDw9h9jfcLjfPnLoZUgsjM-bhDAOw4G6cwrlzmdvfm1cpVD4DSroBp4Chp5XHSDICnC0-ynGRYTqxCl+AjAjDCP6ZQj5iMp9Q-FNwcRiRNLS8Fr3cdnA7jMwONHDNdfu1CFIIlfS3HCy0yisP68ZNv79s0QxksszCfXDQ4mcj+AyzxbWuzTSYZagxFTgx9ZiDS-SXOAIPT08gfa60-KJDvAG6Cn4z4Q8+2+3jBxk36xhxLQRzS8CMLAETsjxQHLSX5z08MV4TrAJXGqjzjxpqTwY0zy8AdxR862dRHPfqfmD4gjraLYKxaEN8kIPxtBh1vxO9tXHm-NkqzFGtDRKLFc1b7M28zcmpQHVK08jp9AwrSLdLnK7WpsdTxG7hvR+fr9f0NjQYANwa0+Fhgg7acVSLjQCf6TfC4zOEjRXrkjr28+ZlSau0Wnuaz6DKVVY0ZaegmTeLDBKUQwFIHaHHaScmOMnD9h1kcwz4QB2SFwARgHTMpE+a+QiOtjkqJFVYU+S-DXgSTqcTQ9Fc0FCAzAHgmW8xSrEQOTod1aQweTPhv3t4i8CgtPD6KpC5IkZnEd9QfNdBo5fQyYNIV7lnzoAhFNWYRPXhjy6Q2g-s6CQwn8GWLbgZEfQV4EBx0jUcrWFnYUN0UKJnYSiwA5QbbCcB7gmqtRXTl9RmIZBh8n-PMOpCPQ3MGO6xEkv8R2InFgSUEUEvry+jDR+0TZDFtSB+yPQvYnpN4MIXQiikjSWKOOGaTwDS8MwS4DIF2gP6Owi0AWJUsR0cROAeQt6P0tJgF6BlgyCMUMuwyj6506K4wKFIRH+BQgEy3zd0LxxNoFx1KfFTSiRW0rfgo+v5fcPWW27fB9CbiRbsCyCDbUysdgKHssys7FVSqmVeakMIFx3QeQhYJEksWLCqZA8AXJWLRC14DUHIKw4aoMKsHzcA8RAu6FyC8S30IQe1YLgakGAqJKQCw0Aj-QBpXVgac3eCMgA9QbgwBaCMDAy08ChBN0soNIdjHYpYlOK2pciOMlCAzxE4RAD1AHjoGgjiCUHZssOSgDkAYRtgDIvCLAwcgkRGYUIO8ggDyBIg5YUIAoEHKEiPUJEZ4OWG7obgER5Ikbu-zJQgiBgYI3EbzWQ7k1saOzQlLTwaxiIRgWQ7Ia0MDQpBcYszc+AHmdpwdr+kLS2hYMghF8AUcvN4cfTxIEDJ0PhUkOMHMr+si6PgwOqXQuDl1BIldKALqKZAZEy0bRDCFqkaK-BEE6vEuCQUHrWst6o9afJQMxjUD5woAmIKIm3jKxWQBA9qGIiyJnxYghMVYH9QuowR-6CeLAbq08CbguWjIaGqTGujlwP82w4gv7BDaTtt+VwroBkBLC6EJgpyWem9TcTnJyYi4SYMfBMqLNZBjbaFrT3VjBo8eHgj4bEAGi-AGUK4RIIDg+GvskOzHPykX1UEqwuQsIZoWhm0EURNwPITssVyRKRdUqDkGLm2klGwDFgzKHbk7ADzv8OQe+c0ZmAGTRkqw1rRrg70XTKB-h6AQEfpESAxUDkeEQ1Fd3HEgAoRMIrJgQVwgAcWhonKKKiPgjoj9Il3D4H-yAnis8RTI8Ceckgk7dCG+Cc+iAGpG0jeC2gBkc7gJEwiWQoQDrskBwnQTIQsE+WAkBQl4U0JH+bINkCAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QAcBOB7AVmAxgFwFoBbAQxwAsBLAOzADoAFEmAAkthaPQFdq9IAxACU46ADYA3MCxLc85MH0o4SeSumoBtAAwBdRCnSxKajQZAAPRAQCMNgMwB2OnYCcAVkcAOLwDYnvl7uADQgAJ6INv50jo6uAEwevq7JTu4ALAC+maFoWLiEpBQ09Eys7CyoYCQQYQI6+kggyEYm6tTmVgi2dvF06Tbu2jZ+2unu7l6hEQgOrl50vgPa9qtj2omu2bkY2PjEZFS0jMzSFVU1dZo2jYbGph1NXQSO2r794-O+vo7x6Yn2aaRdLadz9SbxXzxOLxeITdzbZq7AoHYrHMpnDgXWr1eK3ZqtB6dazJd4JcbaDZDH7aVxA2buRKLdJedLpezuXwOeL2RF5PaFQ4lOgAYQUOAA1jQoGwAGYsbiwMCoNgcWTyRRqFT8CD1PTmFr3drE7q9bR0DIgt7aIK+Cb09lg-yM62+UEg3k5JH5fZFI70MW4KXUGWUeWK5WqmRyBRKbWCa74w1tMxPaxeRxgmzpO1xV7Q9yrB0cxYc+Ku90rPnI31C44AUT4yulLFoAHcWPyCq2SEQwAIAMpgPCdmsj6i9sANA2E41p012MHuOx2ynfbSOGz0myOcb9G3L4Y2Vz2KJZL1d2to+gAETAspKLAfqFgI4AggB5ACSAgYVVgSoQAqyBiOgNQsJ+X4sAARnIeAaNOTTJkS862F48Q2HQ9hjPY6SOPYXgbIEUzhJErhxBamYYbEJ7uK4DjVj6grXnQd4PrQT6UC+77fr+-6ASwECoCQHbPq+EHfjBcEIfqSGzqmoDPNhUR0MMQxeK4bI2GMW6kQgqw7qp9hwqe2YaZmjECqi-qihcaghpwJDIAIACyTlRjgdmQIhdwpo8inWHYG4uAMyTLqsBaAnpJ7mmMbppJyEz+JZKJ+sKADqJBtA5sroCqQnMCweUsCo1A4GAYiue5XARhA6Btlosm+ShAXdKedEuAkNqZgRDicvSuGcnQPicr8fhaU4KVXjZmXZTKuX5cJMrFaV5WVX+cACatFUSVBBUdrBeDwY1Sbyf5ljWOWfilh4rgrFC9jfAN-yYXdrLzG86GeOeOxMdZGVZfZ83FQVy0qtt638ZAJUkGVO2QYJwkHdJWg3DORoKRd3RXa4ql-CsOZsgkj0DRM9gxPh6nrg4bxTcxNk3kjLYI9BYSI8wUDSlVyCcDwSpcFIPkEhj51KRmzj-Cyp6UqFbIDXCfQ7vEGl3YygQZHT-3HIzInM5JrPs1AnMhtzvMRtwyBC8hc6tQQATk-RbrExhdrQvSkKvHQjtfDmV1spraXa0zDks2zoPG1AfGbdDEO7ezyNHTJp0iyaduFgs8y4UshOQr4A0OO8nzHoksRBI4vgB3W9AAGI0Ow5CCfedch-rPDUBAsACHVxw0BI6ASvQl708KtfUPXjfsXrUHQW3HcIL36Dau0DRW2dqc8pCMQ8myX3eIu7s+LjGT0YWIJun8nq-VZgc18OFBsGPagkGIgmqCQXcaPQC8D3QQ9a7feB751yfi-CAb957UD7kvJOq8U6oV6OTTk6Q7qUj+IMSE9J8wxEXA9csyRWSVxYtXO+DdgGUGfq-PA79lQYFQL-MQqgFpEF-mOf+dBiGANIY-choDwEL2gSdWBflU4aXSHQcs9EEg8nsF1EiMxJiYQwo9WIKxyLly8IQmyG0IDKAeJUaoOI-yUD7FJRO1BRzRwgEIlqWNBoLA2MrcK5FCJuz0jI2k4jWTlhkd8PwhZNHCm0bo9o+jLhRwAtDC2oFwIsxRtYm2tidxiM8MZP4-gNK0gGhmcmtJ5iOmzK8fxF5WE30YFUHR+AQnYjqCIAAjtwOAPE9oVWHFOJqwthHzicJha03guTIKGG8B0KRzSDEmHmcuiQAnonKcEjQoTDFQyAu2OOh1jrxMxl0ZWBFKJ0Szsgj2Dpsx9EhHFLwBdszTNKLMyp8zqnCDAPUxp0MEawDbCYCgGzRaIFWAMLC7I2TtSWARI5KQvZvBkfhfwCQK7FL+qUoJtzzH3LqQ018LzJKwHICQKoLAACqQgAAyXyTQgkzC4H4cJ5i3X6npQZTp5hjShPRDRcLr5VzKZAOZyKDF1AHO8zhJVuCoCqHwB+r5YblRYHgMIyA2nJ06a1M+mEJgnliMrAuGRizQn6GkV4-xXC3SuZyipej7kACFVD3zQFypFJV0BEBAq0qx7TrabMQGfMErxyIJA0iXEIekibmm2Rhc5YVPDGsRWa3lg4BX33gvovAwkaC8wgPK9GiqsZ7MLhvQisRxhy0DVEXGmYhjFoGMgn63p2UsSjVUmNb5kAgTZhQIMLQaB4BJfOHMwUUhxHdLCU8ec3GZlGcXFk5c9mOEjTc6NYTG3NpYGACw7AgbSuMfeYSfYu1KtpOTZWQRlwJELJCdIA16JiI8L8N0m4oQ2HiDO21c6cR3jEK09dfZZRbvTXJOBrUCLDAtAM49nhy66RmARb4WFaToUPpTadbLUocrrXcmNLkjBZRwCwNsJAOBKjffgbyrq15dOMgsWEx5dy-HLMc0mFFNx3TPuWA1xrGz8BVDa01VTeB6gVTYroe8XCjrsMsfpUUZhwjsIsDcwxvAbFPF4S+1akMsTY5GTj3LKg8cTBm-jiBBM7jLdmbSYn3Z2jEb89C-xKSvAzKxpsHHZ3ceoLiPjCSBPnKE0Z0T2ZxOIGhJ5r45dvh0W8LhY1Io7JTw-j3SB-dB4lI5ZF6oa7IIQKgaoZeegd1Y2zIWVSE6kiDWwn5hA0IlhYR6oyPCXIESIemsKN8lSJCZYcjQCVcMnzFQ00igQX5qAdalecXgY8Qw5a6MeO0FoXY+o+jmOkeloQni9hSDcxkXo+GNU1tQLW13taoZ1haFiuMaD6wNg7Q2sS8vG-5hW4jlbSOo7e92faKYOF+MMdku4tvNda6Gc7krpBHZ6w8M7g3pBkB25l+Z2KOCyiym+l1bn3WzH7fuKEGRtLKWHRJ1V4jvhRAIkEFYCGr4qZskIEbLYQftAEMl1Q0N9uA+w2AaCsBF4D07cRv9WMqW43QthJYim6IQvdrhcjkwQQ5k5JpGwxrKfUFGzKGnp2uBpsqhKvAip9HlUoFIJHun3P+ahOaUyAWoQsnoo4A+PICuDGGEFh99Xh7HAV0r473KHm6-1yVcUEp218Bu2VqEzgqKBBPBhWEKx3a-CdOFaEsJEh+FZWThrruqcORVy5kQ3uY5+4DyOMQq6g+QkGKpbMa5CxhSLIt7eFMbRRBhJpLYzu2Fu+p0507OewB6+hmoT936S-+EwoMXolpPBRDM7hcRBqNyu3vXCeXGfled+z7gHvPus+jnQFAfiJfQtYXOaFtBQxreLfTi4QzyCfjsiUUvxXHen207fDPVAI41nzJtREg3v7M1bIx8yLEARBFI9H4O7GyOaKfIEFCG6KCGMPfu7lnlHCduYrDk+AjkRsjt8mVhehaL8rLvdMoqTGXopuRMZDuNpBMFWn-KUu3pnqvsgZpqgLwNhrhjIK-jqCXhkv0D4BuOXNZk4KVu1LjByDvFyKCD4HLq3rQcvh7r1ihsiiwThhwDgA6k6pwdzn-v5rwZRKCD8EEDuBsGem4sZM4D8IaoRDaMkKCEpjQUllFvQUxDFl-HFj-HYSxPTmukPOlovNDoIpoXprMDRsyKAaIk7CCotiyPYnaLhLdL8OyCnspmnvQISmBEBAwvwOJJBLxobijnEGIiCJLoWB4MeHhNuOyJhGyJsMMJsB4E7qni7ikWkSwBkY0rtPUGjL-oERyOaFZldLaMuLnNuBbosIythOLA4Ike4TZKkeBK0VkbxJoHiLkdgasMNFCoaoahfOhPENuKOlhJLsZAkHEJpLYYlixAAGrPyUBgL8DxzmLZFB52xOALCsi-C7glaJA7jbgZB9DoROxY7kE-DGpXFF63HSD7QPGLGdHNRG5tSPQnKl7J6KaBClZRC27eBHqlEGFTHnEMzsAgQkBhAthRLNEIxq7PwCB4ogRpHQwtZglxwPhvpPHHjzClgsjDAyIFKKbgHZjiKDQmY5h9J1YNFsI3gEkMLEkOSkkxKSQUmVQiigRKjATRJATknoBgJiAsmDFewyIyLoR7rnLGESbzaqSlpjBBAeBsik5JGNGsQSlEkkk0mylQTyl05KnSAylqlykanPw6ZdFwm2BRFYQcjLjnyeA8nn7RBEwJF8Gsh4Ty6bpwANwIyEqro5EBko4EC1YxA+BKJ0R4QBozBS4FEJDzAjArCMhFKim0FJlYpxxpmvgdFYGpxcjOCUi3QDChp4TGk-InzSY36Zy7hGmsYrprpVD4CwxQBvpxz7Spr9hPFoLvDfBQgao7i-JmbXRjB-DlnGb-BnHwpJYaAPhQDCotgrLZHoawCYasF4YtKEY-6wlZkuxgggh+I7k5hLBn7FmUYWh3S4RRD+CPRSE1lHnUAnlnkOQXm8RfikAwCoBszs7CqXYsD4YFCYErHryGqYTYSDER4aRchHKTAxC0irgnji64mHkeHHmUCnmoDnlgAdiXkak7QVBoUPmLmGqm67hcihGbgbgOieCvl7zDDxnHwRY0V0UMVMW8SorPLelQRvIfLkCLmFixTYQOJcnnICVFoZiX5fGDF-BxASXgW0WQUyjQU-hyULHNJvr8CLkPaLDKz4SbCDCsgOgbh9D+DUZjByYRkmUQX0VQWMXtFGImIf7mJf6AQOW27QjrawGgh-BHIlj3qBBUj6mQgBVmVBUWUhXZEbTf4qlkn6xxIBGBkjDYTDRHETDaWaS7hZLsghkfFLDHwMTSFgWBXSXtH8rKVCoiqajioXbSAypyqcVsmnj4Q7jkRsicilYsiwj9CTUTrraTTtUsQLpiBSkyjLqrrRbdwuF9xuF4mNZNqbUtg7WvhTw+ECIrxlXPnLgLC+yUY8j5FLCCVOCqSwHoTaQXoWRrUMwtJroXWpa8T7V0DfwJZUUA12XnUrqXUtxfjXV+G3UtnwJjAiFuWrCKYmRwgOiwg5pvRwhiGUU1oU6PJoo6izmA0Ll3XYG2DJAj4DCgGFjGaggeV7gjAggYQ1VvSwqgUsTWWU0Izq6tLNmYXwLQHDSc0shEybhOB406oUHKLZIiYRYOEyivLYq4oEqErOHg2uGQ2k3CieFTyoVa3SA61I0PAo3i1KpY6H5AlfBJ4Oi-BHysmKYjBhRq0pam1Yo4oW1EoCA0J5T0KMJ5TMLTHG3q1xx+3a1EpW1Za6BB4giGrDSKaSFXQcg44epcjLlC5kE+rlyJlKgjiQntFPHGRrHnI-XHhRDZgLYml-KUwm7i5GUHlG3awOlswIz+0kCpoUI6LPw74PISpv7xwtgRUWLf4V3-DOBZ0AaLgrBBDbisiYS7h6HN12BRDGrimwCEnd2SS9392gI8LD1-iLxgDQyT1RUYWZl02C5Oi+VY1Xo2h7F4SfXeWjCqrGTGoDhUJv6P4oEZlPnYEjDXSDB2jnJSKBD5ysj7hxUPZDCwg2mR3HB-04peEMH+kgMmjHgeI34ZBIKFlCFcnTYBDJ6vAbgk3k7CiC0wRWoNxIFg0Q0sJQ20Pk1tHQQMNyEPAJ0wK00mjraPWbguJvBqzjB40dQyKhaaRwipCJlJpZQ8phI9WCoJog7zlB6Y4FFqUqJ3T0RzUrCYS8XlwbgmaaTUHHWu7DjJrKM4hDgjhVCKMpqkA8xq4-o4PzgFmvkWMTU1GFgOjsi4ygjlwJCe0fEKO2MLJ1BvgQBARONRPXmOrMkCNeNWnYJ5iaTfDO2BofZOXrj9K0Qim2lt42NKPRODjDiJpROQ694lQMIARaOaTmhxCLgpA+BLAN0eqTC9FvBWkGrkR80lO1nON2N1DUngnVPlM4ANN4a9hOqwBNMjAuAjSno0QZgOgZhiJwhlouWESKaRPlP3KqP3w4DCqiojhM6dYjUeMdKBF0TLPE6Zjp00xzWbDMg7gZiUiERtX81k2jMVMiAAtVBPLoqPl3Nwk-CeD3adMInnK41uJSKLCWmqx1Ht00PWPAsxqC0YqKXm34pEpaOkguCrBhZuC-CGM-C6p4MRmpWxCHPNyRzd697xNlM0ACB5C76bRB5GmxQW67jaTaRxD0jLg5j3bGSkgCv0v-XsOjNczMs+4JNKMCCqHJOtI8uTFYQSJ10BCv2BofT9Cj74QcjzA8gMtK4CDuMa5UJa5Xa57gturYFH59CMZE00yES9kIAsj3pYR9paW3ot5-Oyu2Pyvr4su+5troAdoatuj-J-DLhmRLDgY51QjrG36shfP3rmuhv2sRuSgF4tHF6pOtRGlmH3QKL3pfRFmICMiaQxCV1jHAooNWP0BAshsmwKt94bpfqTgxvkz4RfPbnWbVteuhnSbUo7FR7UPJF0BttKM5sb7Qxb6ct77FtYzwu4zkQOCCtUaFlHIVYrgXoEzzDTt2lzuMsCAv55SOO8A8vBFdlLZ1GwgjsUj7rIKUjLjBagjZsdtsuoFsHB3lJB7xFiKUj3qFIZAtWbN6U0Yy2TCMgaQ-tMt-tabmLKH2pqsaGo2tQfYLArlHgUvlxzU6lbN3Qn6qxIeXszxyCmLHRT3RVrsCb40hkh6Uoy7Jtev7EnhbHQhvnsg-ZQ57YA5wxg5DVRjMEP5jaMfWA5jkxujLB+COJ2gjs7hQZwibiqrlibh-ACd65-aDWA50755RtirodOQgSUC32eO2zevIv6G0jHhbMcd2DXTzYFyfCQantsLbZ6dCfg6ifM7nDXbScICxR5a1YnuMgfE+Cet5ZOhWjGRDCV3fC6e7YthXPlQBeda1Npcw5sHw6UCI5PFDDl6lFniFjBYeDbgSL45lw-A-VOCBvDMcobVbV5v+4md4Be6LtAStr5udccsYBcuNMhfjBUuwZ4SZgJE14QamTQaOJpBqKL4yvHCtcth9cdcdpGeRsdq3kyCnWWcOskZKp+rggWMaQ+JxAjtpBjrkgZh2hqpbanVtf96bqTgCAAAq3b36e35nReVnELKOBq9ioURjRHiQA0XICwCbOYhEkw5yzbbD2sgNLYr3PbfYetLDqDt4KPDkaP36fD-h2HticQcnFG2NiQNI2d+kSUFMHJ4hG4yCO9uPMo+P73zDBtrDHdOPMNeP33k4hPK8MJgPqxQByL6EpcxcuEz0+EiwYWjIRNPzCGXo1ALF8ASELbttWMdsx6-QfwHtMsZ4nrzx4ItINRTg16GEkapwUYNUTYEAWvzwW9fQuEZv-F2kYjIr-bj0BarwvwFBxT2PJw5QV2lwjvgUgwDs5EEiFuXTswNocnXIV6ysssXnpSgYko6X4YSoKoFQ6osYWoDODvd9qcdgm4Tl3reE2YXIHHawWrjo0i77BzK39AamOVrYIVQ8PYfY4fC4LIUtZaVIMt7lek9uDsRhFXMKoTiZOiE5I46jY42+TJ0gyApwvf2ZxkWE6seafgIwIwxY5MAQ0IGwjKfUzP7EQOXENl6-dgax+EAwsuKQM124KQYIUiMZdoSb3t+nrjN-HvPBvmC3ikETz0hWQvRIYGN38AyxNsLfOgLNDXRHZQYRUcGIDjEDr84qzgWRhAx5BC5qeRxd4FXgSL4x8ITXIPjrHeQI0YIYcJaBHHX4AYFgBEdkPFAORxV84-fbJEZXWArVjUo8ceGmkniUDqO7cdXtZ2148ghox4GRIWRtAbNIiIIfHJjgViFEeBJCB+G0AHpvw-+H1PdARBPBkp0SIrIaEogGA7gGMOkR9CgWia99BCLrSENIm6TwsX2oITdoal3BvRXBIFZrqpgcw8NnMvfQsGCBJwRkpE40b8j8m+AiF1IKLOiEZW-4g0vwvfbMP8GRZm9OQBOWlBJn6Tl42OpFT-oHxbZ0AfOaXNrMJylTA5V8iQ7JCSxlqTZKeL2QiPjkSoEREg37WAXQRXxP5MYjrE0M5XNBQgvmtoTMJiXqHmhZ6L9K9MeDiEd4mIvfA1qCE-Y2hnEVDcAtdB95Vsq8hSY1LMXSIM5r+JfLpDaHETLhqsp4eNvv1H4yI+grweTjpAMbLcg2xwUEjcQZz3FdodA3cGCBeofEKuGwBwD8XkFpDC6UQ1YDvS7pOlVScceUjfy+jrE0kk3G7tT2kRkheW64E8OhETJfpkyDZVdOvwzBLgMgQQQdKsEdgeUps31PwGNAq5mtYB9YMci2Dn5TkZywtJGPOXQHr0K+KSNKv8ChBmZ++A6VkmBkehZUpKwVGSgkP2G2x1Or5KwtViyZflz0cIaDEEGxrUYXOT3ZtLDV2oI10BVZcFOIOJHi4X2HIfdIQ1eBKxaIzPXnttThrxCb+ysc0HdCh6z0P26EPGoBgNSDAVE3zRMqCyFqSQRa-ALXvBGQAepPKFfT5jZk9GhA30soPAJdF8pmkIylZciOMlCAzxE4RAD1Pej6FoI5MZjTwKEHopQByAcY00McUTFyYTWzzM-O8ggDyBIg5YUIAoFooliPUJEZ4MxnbJDDCIVY1MYGjDEW4Ix+YxHtz1sg+1KBsdAOoSl75fYxEIwAkYSLsHDJkg4iOfL8JdiXI2hcAKpmXUgh0CAUW-T0QQytLU9JBYiCIS1Rqp0Q0+HKXevvTjhH15SgkU+lAH3FMhjihqBmr8K1Sj8KWuhRICXCsI-1YB6DABvQU6HnRuh84JIVcNETbwD0OYfOBkBWyu8fgPFdkOixnZ0MuGgqLPJUImCURNwIwOovGTkQ-JJg7wUxrEELAAYxgljJHq2xQ7VJe+q5ZwMLlIrQgK8uxRFgtRP5INvApxeAluLlYhhe+-BPoDBnxpP9FhWSafG6HUjyZJk1ZLwTZCKH6cMuYAOgYcJVhchYQHrQzNuEa7YIeQgFEwXqXVFnUHIG3AvDONAz1s80WxH3rXxZCUSS4j0eYeckwl2k1ufPAfJOBnEaphoboCIRIjVjPRiKE1D2GaPEaWjhwqPfnj3wlEk8wU96fcmfAIj4Qwh+kV2P8gAp0SoWnobIEAA */ predictableActionArguments: true, id: 'project-machine', initial: 'Page is mounted', @@ -218,9 +218,21 @@ export const projectMachine = createMachine( actions: 'setCurrentCheckpoint', }, + 'Apply existing timeframe': { + target: 'Applying timeframe', + actions: 'setCurrentTimeframe', + }, + + 'Delete timeframe': 'Deleting timeframe', + 'Mosaic was selected': { - target: 'Loading mosaic', - actions: ['setCurrentMosaic', 'clearCurrentPrediction'], + target: 'Prediction ready', + actions: [ + 'setCurrentMosaic', + 'clearCurrentPrediction', + 'clearCurrentTimeframe', + ], + internal: true, }, }, }, @@ -252,7 +264,7 @@ export const projectMachine = createMachine( 'Activating instance for prediction': { invoke: { - src: 'activateInstance', + src: 'activatePredictionRunInstance', }, on: { @@ -687,7 +699,8 @@ export const projectMachine = createMachine( actions: [ 'hideGlobalLoading', 'setCurrentInstance', - 'setCurrentInstanceWebsocket', + 'setCurrentCheckpoint', + 'setCurrentTimeframe', ], }, 'Instance activation has failed': { @@ -719,14 +732,35 @@ export const projectMachine = createMachine( }, }, - 'Loading mosaic': { - invoke: { - src: 'fetchLatestMosaicTimeframe', - onDone: { + 'Applying timeframe': { + entry: 'enterApplyTimeframe', + + on: { + 'Timeframe was applied': { target: 'Prediction ready', - actions: ['setCurrentTimeframe', 'setCurrentShare'], + actions: 'hideGlobalLoading', }, }, + + invoke: { + src: 'applyTimeframe', + }, + }, + + 'Deleting timeframe': { + invoke: { + src: 'deleteCurrentTimeframe', + + onDone: [ + { + target: 'Applying timeframe', + cond: 'hasTimeframe', + }, + 'Configuring new AOI', + ], + }, + + exit: ['setCurrentTimeframe', 'setTimeframesList'], }, }, }, diff --git a/app/assets/scripts/fsm/project/services.js b/app/assets/scripts/fsm/project/services.js index c991b97a..11eb3217 100644 --- a/app/assets/scripts/fsm/project/services.js +++ b/app/assets/scripts/fsm/project/services.js @@ -94,11 +94,18 @@ export const services = { } catch (error) { logger('Error fetching tilejson'); - currentTimeframe = undefined; - currentMosaic = undefined; + // Timeframe tilejson is not available, which means there was an + // error during prediction. To recover from this, delete this + // timeframe and force the user to enter the project explore page + // again. + await apiClient.delete( + `project/${projectId}/aoi/${currentAoi.id}/timeframe/${currentTimeframe.id}` + ); + toasts.error( 'There was an error loading the prediction for the latest AOI timeframe, please run a prediction again.' ); + throw new Error('Error loading prediction'); } } } @@ -251,36 +258,7 @@ export const services = { timeframesList, }; }, - fetchLatestMosaicTimeframe: async (context) => { - const { - currentMosaic, - timeframesList, - sharesList, - apiClient, - project, - currentAoi, - } = context; - - let currentTimeframe = timeframesList - .filter((timeframe) => timeframe.mosaic === currentMosaic.id) - .sort((a, b) => b.created_at - a.created_at)[0]; - let currentShare; - - if (currentTimeframe) { - currentTimeframe.tilejson = await apiClient.get( - `project/${project.id}/aoi/${currentAoi.id}/timeframe/${currentTimeframe.id}/tiles` - ); - currentShare = sharesList.find( - (share) => share.id === currentTimeframe.share - ); - } - - return { - timeframe: currentTimeframe, - share: currentShare, - }; - }, activateInstance: (context) => async (callback) => { try { const { @@ -293,27 +271,51 @@ export const services = { let instance; - const instanceConfig = { - type: currentInstanceType, - ...(currentTimeframe?.id && { timeframe_id: currentTimeframe.id }), - ...(currentTimeframe?.checkpoint_id && { - checkpoint_id: - currentCheckpoint?.id || currentTimeframe.checkpoint_id, - }), - }; - // Fetch active instances for this project const activeInstances = await apiClient.get( `project/${projectId}/instance?status=active&type=${currentInstanceType}` ); + let instanceConfig; + let instanceCheckpoint = currentCheckpoint; + let instanceTimeframe = currentTimeframe; + // Reuse existing instance if available if (activeInstances.total > 0) { const { id: instanceId } = activeInstances.instances[0]; instance = await apiClient.get( `project/${projectId}/instance/${instanceId}` ); + const instanceCheckpointId = instance.checkpoint_id; + const instanceTimeframeId = instance.timeframe_id; + + if (instanceCheckpointId) { + instanceCheckpoint = await apiClient.get( + `project/${projectId}/checkpoint/${instanceCheckpointId}` + ); + } + if (instanceTimeframeId) { + instanceTimeframe = await apiClient.get( + `project/${projectId}/aoi/${context.currentAoi.id}/timeframe/${instanceTimeframeId}` + ); + instanceTimeframe.tilejson = await apiClient.get( + `project/${projectId}/aoi/${context.currentAoi.id}/timeframe/${instanceTimeframeId}/tiles` + ); + } } else { + // There are no instance running. Check if there is a timeframe or + // checkpoint to use and create a new instance + const instanceTimeframeId = currentTimeframe?.id; + const instanceCheckpointId = + currentTimeframe?.checkpoint_id || currentCheckpoint?.id; + + instanceConfig = { + type: currentInstanceType, + ...(instanceTimeframeId && { timeframe_id: instanceTimeframeId }), + ...(instanceCheckpointId && { + checkpoint_id: instanceCheckpointId, + }), + }; instance = await apiClient.post( `project/${projectId}/instance`, instanceConfig @@ -408,20 +410,11 @@ export const services = { }); break; case 'model#status': - if (data.processing) { - // Instance is processing a different timeframe, abort it - if (instanceConfig.timeframe_id !== data.timeframe) { - websocket.sendMessage({ - action: 'instance#terminate', - }); - callback({ - type: 'Instance activation has failed', - }); - } - } else { + if (!data.processing) { // If a timeframe is specified and it is different from the // current timeframe, request it if ( + instanceConfig && instanceConfig.timeframe_id && instanceConfig.timeframe_id !== data.timeframe ) { @@ -432,6 +425,7 @@ export const services = { }, }); } else if ( + instanceConfig && instanceConfig.checkpoint_id && instanceConfig.checkpoint_id !== data.checkpoint ) { @@ -442,12 +436,17 @@ export const services = { }, }); } else { - // Instance has the same timeframe and is not processing, it should be ready + // Instance has the timeframe and is not processing, it should be ready callback({ type: 'Instance is ready', - data: { instance }, + data: { + instance, + timeframe: instanceTimeframe, + checkpoint: instanceCheckpoint, + }, }); websocket.close(); + return; } } break; @@ -463,6 +462,32 @@ export const services = { }); } }, + activatePredictionRunInstance: (context) => async (callback) => { + try { + const { currentInstance, apiClient, project } = context; + + if (!currentInstance) { + const instance = await apiClient.post(`project/${project.id}/instance`); + + callback({ + type: 'Instance is running', + data: { instance }, + }); + } else { + callback({ + type: 'Instance is ready', + data: { instance: context.currentInstance }, + }); + } + + // If project is not new, an instance is already running, use it + } catch (error) { + callback({ + type: 'Instance activation has failed', + data: { error }, + }); + } + }, runPrediction: (context) => (callback, onReceive) => { const { apiClient, project, currentAoi } = context; const { token } = context.currentInstance; @@ -1055,4 +1080,211 @@ export const services = { }); return () => websocket.close(); }, + applyTimeframe: (context) => async (callback, onReceive) => { + const { + currentTimeframe, + apiClient, + currentAoi, + project, + mosaicsList, + sharesList, + } = context; + + let currentShare; + + const currentMosaic = mosaicsList.find( + (mosaic) => mosaic.id === currentTimeframe.mosaic + ); + currentMosaic.tileUrl = getMosaicTileUrl(currentMosaic); + currentTimeframe.tilejson = await apiClient.get( + `project/${project.id}/aoi/${currentAoi.id}/timeframe/${currentTimeframe.id}/tiles` + ); + + currentShare = sharesList.find( + (share) => share.timeframe?.id === currentTimeframe?.id + ); + + const { token } = context.currentInstance; + const websocket = new WebsocketClient(token); + + /** + * Ping pong logic + */ + let pingCount = 0; + let lastPintCount; + let pingPongInterval; + websocket.addEventListener('open', () => { + // Send first ping + websocket.send(`ping#${pingCount}`); + + // Check for pong messages every interval + pingPongInterval = setInterval(() => { + if (lastPintCount === pingCount) { + pingCount = pingCount + 1; + websocket.send(`ping#${pingCount}`); + } else { + // Pong didn't happened, reconnect + websocket.reconnect(); + } + }, config.websocketPingPongInterval); + }); + websocket.addEventListener('close', () => { + if (pingPongInterval) { + clearInterval(pingPongInterval); + } + }); + + /** + * Handle events received from the machine + */ + onReceive((event) => { + if (event.type === 'Abort retrain') { + websocket.sendMessage({ + action: 'model#abort', + }); + // Ideally we should thrown an error here to make the service + // execute the 'onError' event, but XState doesn't support errors + // thrown inside onReceive. A fix is planned for XState v5, more + // here: https://github.com/statelyai/xstate/issues/3279 + callback({ type: 'Retrain was aborted' }); + websocket.close(); + return; + } + }); + + /** + * Handle events received from the websocket + */ + let isStarted = false; + websocket.addEventListener('message', (e) => { + // Update ping count on pong + if (e.data.startsWith('pong#')) { + lastPintCount = parseInt(e.data.split('#')[1], 10); + return; + } + + const { message, data } = JSON.parse(e.data); + + switch (message) { + case 'error': + // Send abort message to errored instance + websocket.sendMessage({ + action: 'model#abort', + }); + + // Send error message to the machine + callback({ + type: 'Unexpected Instance Error', + data: { error: data.error }, + }); + websocket.close(); + return; + + case 'info#connected': + case 'info#disconnected': + case 'model#timeframe#progress': + // After connection, send a message to the server to request + // model status + websocket.sendMessage({ + action: 'model#status', + }); + break; + case 'model#status': + if (!isStarted && data.processing) { + // Send abort message to stop previous process + websocket.sendMessage({ + action: 'instance#abort', + }); + callback({ + type: 'Unexpected Instance Error', + data: { error: data.error }, + }); + websocket.close(); + return; + } else if (!isStarted && !data.processing) { + isStarted = true; + if (data.aoi !== currentTimeframe.id) { + websocket.sendMessage({ + action: 'model#timeframe', + data: { + id: context.currentTimeframe.id, + }, + }); + } + } + break; + case 'model#timeframe#complete': + callback({ + type: 'Timeframe was applied', + data: { + ...data, + currentShare, + currentMosaic, + }, + }); + websocket.close(); + return; + + default: + if (data?.error) { + callback({ + type: 'Unexpected Instance Error', + data: { error: data.error }, + }); + websocket.close(); + return; + } else { + logger('Unhandled websocket message', message, data); + } + break; + } + }); + }, + deleteCurrentTimeframe: async (context) => { + const { + apiClient, + project, + currentAoi, + currentTimeframe, + timeframesList, + } = context; + + const nextTimeframesList = timeframesList.filter( + (t) => t.id !== currentTimeframe.id + ); + + const nextTimeframe = + nextTimeframesList.length > 0 ? nextTimeframesList[0] : null; + + let nextInstance = context.currentInstance; + + // If there is a next timeframe, update the instance + // or terminate it if there is no next timeframe + const { token } = context.currentInstance; + const websocket = new WebsocketClient(token); + if (nextTimeframe) { + websocket.sendMessage({ + action: 'model#timeframe', + data: { + id: nextTimeframe.id, + }, + }); + } else { + websocket.sendMessage({ + action: 'instance#terminate', + }); + nextInstance = null; + } + + websocket.close(); + + await apiClient.delete( + `project/${project.id}/aoi/${currentAoi.id}/timeframe/${currentTimeframe.id}` + ); + return { + timeframe: nextTimeframe, + timeframesList: nextTimeframesList, + currentInstance: nextInstance, + }; + }, };