Welcome animation when a workspace is set up (#22622)

## Desktop


https://github.com/user-attachments/assets/87999ff5-9257-4fed-90e0-781322b1db98

## Mobile



https://github.com/user-attachments/assets/0d115cb6-7bca-4229-8fde-0f188e862b00


Greets the user the moment they finish onboarding: a halftone Twenty
mark (brand purple) assembles from scattered particles, shimmers, then
bursts outward to reveal the freshly loaded workspace behind it. Plays
once, ~3s, with a reduced-motion fallback.

`PageChangeEffect` sets a transient `isWelcomeAnimationVisibleState`
atom at the onboarding to workspace redirect seam (gated by
`shouldShowWelcomeAnimationOnNavigate`); `WelcomeOverlay`, mounted in
`WorkspaceAppProviders`, plays it and clears it. Transient by design, so
it never replays on reload.

## Why a canvas rendered on another thread

The overlay plays at the exact moment the workspace mounts behind it,
which is one of the heaviest main-thread stretches in the app (metadata,
providers, first data fetch). Anything animating on the main thread
competes with that work:

- framer-motion and plain main-thread `requestAnimationFrame` visibly
stuttered and froze during the mount, because long tasks starve rAF.
- CSS keyframes stay smooth (they run on the compositor), but can't
drive a ~2900-particle halftone with per-particle physics (staggered
assemble, moving shimmer band, directional burst).

So the halftone runs as a Canvas 2D particle system inside a Web Worker,
drawing to an `OffscreenCanvas` transferred from the main thread. The
whole render loop lives off the main thread, so app-mount jank can't
reach it. A main-thread path is kept as a fallback for browsers without
`OffscreenCanvas` / `transferControlToOffscreen`, and Safari workers
(which lack rAF) fall back to a fixed-interval loop. The renderer is a
pure, DOM-free module so it bundles cleanly into the worker; dot colors
come from CSS vars read on the main thread and passed in.

Also fires for every onboarding completion path and skips the billing
`PlanRequired` detour.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22622?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-07-09 10:47:07 +02:00
committed by GitHub
parent e0bd4ab732
commit e38d587602
26 changed files with 4480 additions and 0 deletions
+1
View File
@@ -11,6 +11,7 @@
"src/generated-metadata",
"src/generated-admin",
"src/locales/generated",
"src/modules/onboarding/components/WelcomeOverlay/welcomeHalftoneDots.ts",
"src/testing/mock-data",
"**/__mocks__/**",
"**/*.stories.ts",
@@ -0,0 +1,6 @@
export default class WorkerMock {
postMessage() {}
terminate() {}
addEventListener() {}
removeEventListener() {}
}
+1
View File
@@ -56,6 +56,7 @@ const jestConfig = {
'\\.(jpg|jpeg|png|gif|webp|svg|svg\\?react)$':
'<rootDir>/__mocks__/imageMockFront.js',
'\\.css$': '<rootDir>/__mocks__/styleMock.js',
'\\?worker$': '<rootDir>/__mocks__/workerMock.js',
...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, {
prefix: '<rootDir>/',
}),
@@ -0,0 +1,365 @@
<svg preserveAspectRatio="xMidYMid meet" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 297.037 239.098" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group">
<path id="Vector" d="M128.678 0.176718H129.192M12.6453 90.5164H13.1586M276.943 167.951H277.456" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.353436" stroke-linecap="round"/>
<path id="Vector_2" d="M135.188 0.176718H135.576M251.221 19.5352H251.609M270.56 180.856H270.947" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.266774" stroke-linecap="round"/>
<path id="Vector_3" d="M141.649 0.176718H142.006" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.245369" stroke-linecap="round"/>
<path id="Vector_4" d="M148.088 0.176718H148.46M238.336 13.0823H238.708" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.256332" stroke-linecap="round"/>
<path id="Vector_5" d="M161.114 0.176718H161.22M225.577 0.176718H225.682M19.2957 71.1579H19.4014M290.039 167.951H290.145M167.56 238.932H167.666" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.0725666" stroke-linecap="round"/>
<path id="Vector_6" d="M167.458 0.176718H167.768M257.706 25.9882H258.015M296.384 116.328H296.693" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.213001" stroke-linecap="round"/>
<path id="Vector_7" d="M174.038 0.176718H174.081M238.501 6.62968H238.544M212.715 142.139H212.758M264.286 193.762H264.328M83.79 232.479H83.8329" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.0297575" stroke-linecap="round"/>
<path id="Vector_8" d="M186.868 0.176718H187.036M219.099 0.176718H219.267M25.7108 200.215H25.8792" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.115898" stroke-linecap="round"/>
<path id="Vector_9" d="M193.29 0.176718H193.505M277.092 174.403H277.307" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.148266" stroke-linecap="round"/>
<path id="Vector_10" d="M199.643 0.176718H200.046M51.3785 32.4408H51.7816M38.4858 38.8938H38.8892" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.277737" stroke-linecap="round"/>
<path id="Vector_11" d="M206.058 0.176718H206.523M186.719 96.9693H187.185M141.595 238.932H142.061" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.321068" stroke-linecap="round"/>
<path id="Vector_12" d="M212.567 0.176718H212.907M289.922 58.2523H290.263M289.922 155.045H290.263M25.6246 193.762H25.9653M90.0875 232.479H90.4278M122.319 238.932H122.659M128.765 238.932H129.105" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.234406" stroke-linecap="round"/>
<path id="Vector_13" d="M103.003 6.62945H103.297M180.358 84.0636H180.652M296.391 90.5162H296.685M186.805 232.479H187.098" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.202038" stroke-linecap="round"/>
<path id="Vector_14" d="M109.129 6.62945H110.064" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.644746" stroke-linecap="round"/>
<path id="Vector_15" d="M115.425 6.62945H116.659M218.566 135.686H219.8" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.849917" stroke-linecap="round"/>
<path id="Vector_16" d="M121.802 6.62945H123.177M25.1074 71.1576H26.4824" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.94702" stroke-linecap="round"/>
<path id="Vector_17" d="M127.817 6.62945H130.054M198.726 71.1576H200.963" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.5406" stroke-linecap="round"/>
<path id="Vector_18" d="M134.357 6.62945H136.406M37.663 51.7991H39.7119M282.621 103.422H284.67M11.8777 116.328H13.9267" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.41113" stroke-linecap="round"/>
<path id="Vector_19" d="M140.748 6.62945H142.907" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.48683" stroke-linecap="round"/>
<path id="Vector_20" d="M147.36 6.62945H149.189M224.715 19.535H226.544M18.4338 167.95H20.2634" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.26026" stroke-linecap="round"/>
<path id="Vector_21" d="M153.962 6.62945H155.478M31.4828 58.2521H32.999" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.04412" stroke-linecap="round"/>
<path id="Vector_22" d="M160.401 6.62945H161.933M44.3678 206.667H45.8996" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.05509" stroke-linecap="round"/>
<path id="Vector_23" d="M167.082 6.62945H168.143M70.3879 25.988H71.4493M192.867 96.9691H193.929M283.115 148.592H284.176" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.730887" stroke-linecap="round"/>
<path id="Vector_24" d="M173.387 6.62945H174.731M212.065 6.62945H213.409M25.1233 180.856H26.4668" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.925615" stroke-linecap="round"/>
<path id="Vector_25" d="M179.779 6.62945H181.232" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.00079" stroke-linecap="round"/>
<path id="Vector_26" d="M186.397 6.62945H187.506M231.521 19.535H232.63" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.763777" stroke-linecap="round"/>
<path id="Vector_27" d="M192.718 6.62945H194.078M250.735 32.4406H252.094M18.6686 84.0636H20.0281M231.396 206.667H232.755" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.936057" stroke-linecap="round"/>
<path id="Vector_28" d="M199.212 6.62945H200.477" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.871321" stroke-linecap="round"/>
<path id="Vector_29" d="M205.58 6.62945H207.002M12.1914 161.498H13.6134M83.1003 226.026H84.5227" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.979388" stroke-linecap="round"/>
<path id="Vector_30" d="M218.746 6.62945H219.62M263.87 45.3465H264.744M31.8042 51.7991H32.6781M186.515 77.6106H187.389M6.01935 135.686H6.89284M25.3582 187.309H26.2317M250.978 193.762H251.851M212.3 219.573H213.174" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.601415" stroke-linecap="round"/>
<path id="Vector_31" d="M225.42 6.62945H225.839M160.957 238.932H161.376" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.2887" stroke-linecap="round"/>
<path id="Vector_32" d="M231.952 6.62945H232.199M206.167 135.686H206.414M289.969 161.498H290.215" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.16967" stroke-linecap="round"/>
<path id="Vector_33" d="M244.931 6.62945H245.005M83.7742 13.0821H83.8485M186.915 109.875H186.989" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.0511621" stroke-linecap="round"/>
<path id="Vector_34" d="M89.9852 13.0822H90.5298M289.82 64.7052H290.364M193.126 116.328H193.67M6.1838 122.781H6.72813M6.1838 161.498H6.72813M218.911 219.573H219.455" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.37484" stroke-linecap="round"/>
<path id="Vector_35" d="M95.9063 13.0822H97.5009M263.51 51.7992H265.104" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.0979" stroke-linecap="round"/>
<path id="Vector_36" d="M101.929 13.0822H104.37M263.087 58.2522H265.527M198.624 84.0637H201.065M18.1281 161.498H20.569M243.748 187.309H246.189" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.68104" stroke-linecap="round"/>
<path id="Vector_37" d="M108.204 13.0822H110.989M198.451 77.6107H201.237" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.91858" stroke-linecap="round"/>
<path id="Vector_38" d="M114.274 13.0822H117.812M24.0258 161.498H27.5637" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.43646" stroke-linecap="round"/>
<path id="Vector_39" d="M120.383 13.0822H124.595M88.1516 219.573H92.3639" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.90058" stroke-linecap="round"/>
<path id="Vector_40" d="M126.461 13.0822H131.409" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.40802" stroke-linecap="round"/>
<path id="Vector_41" d="M133.025 13.0822H137.738M268.397 96.9693H273.11" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.24618" stroke-linecap="round"/>
<path id="Vector_42" d="M139.33 13.0822H144.326" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.44039" stroke-linecap="round"/>
<path id="Vector_43" d="M145.839 13.0822H150.709" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.35425" stroke-linecap="round"/>
<path id="Vector_44" d="M152.504 13.0822H156.936M158.951 13.0822H163.382M268.537 90.5163H272.969" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.05197" stroke-linecap="round"/>
<path id="Vector_45" d="M165.946 13.0822H169.28" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.29655" stroke-linecap="round"/>
<path id="Vector_46" d="M172.228 13.0822H175.891M17.517 103.422H21.1804" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.52313" stroke-linecap="round"/>
<path id="Vector_47" d="M178.783 13.0822H182.227M223.907 32.4407H227.351M275.478 103.422H278.922" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.37173" stroke-linecap="round"/>
<path id="Vector_48" d="M185.41 13.0822H188.493M269.212 71.1578H272.295" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.12375" stroke-linecap="round"/>
<path id="Vector_49" d="M191.911 13.0822H194.885M198.357 13.0822H201.331" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.04805" stroke-linecap="round"/>
<path id="Vector_50" d="M205.015 13.0822H207.566M198.569 96.9692H201.119M211.461 122.781H214.012" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.75674" stroke-linecap="round"/>
<path id="Vector_51" d="M211.642 13.0822H213.832M205.196 116.328H207.386M24.7 174.403H26.8902" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.50824" stroke-linecap="round"/>
<path id="Vector_52" d="M218.378 13.0822H219.988" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.10886" stroke-linecap="round"/>
<path id="Vector_53" d="M224.989 13.0822H226.27M192.757 71.1578H194.039M283.005 77.6107H284.286" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.882284" stroke-linecap="round"/>
<path id="Vector_54" d="M231.662 13.0822H232.489M199.431 122.781H200.257" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.569047" stroke-linecap="round"/>
<path id="Vector_55" d="M244.704 13.0822H245.233M57.7623 219.573H58.2909" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.363877" stroke-linecap="round"/>
<path id="Vector_56" d="M251.252 13.0822H251.577M193.236 64.7052H193.561" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.223965" stroke-linecap="round"/>
<path id="Vector_57" d="M77.2496 19.5353H77.4804M296.423 122.781H296.654M45.018 213.121H45.2488" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.159229" stroke-linecap="round"/>
<path id="Vector_58" d="M83.2176 19.5353H84.4045M63.8787 219.574H65.0657" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.817549" stroke-linecap="round"/>
<path id="Vector_59" d="M88.9586 19.5353H91.5564M275.901 84.0638H278.498M224.33 206.668H226.928M153.421 232.479H156.019" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.78911" stroke-linecap="round"/>
<path id="Vector_60" d="M94.927 19.5353H98.4806M268.976 77.6109H272.53" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.44743" stroke-linecap="round"/>
<path id="Vector_61" d="M100.645 19.5353H105.656M210.231 71.1579H215.242" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.45135" stroke-linecap="round"/>
<path id="Vector_62" d="M164.245 19.5353H170.981M254.493 84.0638H261.228" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.63852" stroke-linecap="round"/>
<path id="Vector_63" d="M170.903 19.5353H177.215" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.34721" stroke-linecap="round"/>
<path id="Vector_64" d="M177.498 19.5353H183.513M216.176 64.7053H222.19M229.069 180.856H235.083" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.14204" stroke-linecap="round"/>
<path id="Vector_65" d="M184.054 19.5353H189.849M254.963 129.234H260.758" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.99116" stroke-linecap="round"/>
<path id="Vector_66" d="M190.845 19.5353H195.951M29.6883 84.0638H34.7939" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.51609" stroke-linecap="round"/>
<path id="Vector_67" d="M197.691 19.5353H201.997M204.138 77.6109H208.443" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.96531" stroke-linecap="round"/>
<path id="Vector_68" d="M204.553 19.5353H208.028M30.5033 174.404H33.9785" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.39365" stroke-linecap="round"/>
<path id="Vector_69" d="M211.242 19.5353H214.232M237.027 38.8938H240.017M211.242 64.7053H214.232M17.8539 96.9694H20.8433" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.05901" stroke-linecap="round"/>
<path id="Vector_70" d="M217.986 19.5353H220.38M230.879 161.498H233.273M263.11 161.498H265.504M50.383 206.668H52.7772M179.309 226.026H181.703M147.077 232.479H149.471" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.64867" stroke-linecap="round"/>
<path id="Vector_71" d="M238.132 19.5353H238.912" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.53668" stroke-linecap="round"/>
<path id="Vector_72" d="M244.476 19.5353H245.46" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.677114" stroke-linecap="round"/>
<path id="Vector_73" d="M64.3727 25.988H64.5725M180.406 71.1577H180.605M193.298 122.781H193.498M283.546 167.95H283.746M199.744 226.026H199.944" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.137302" stroke-linecap="round"/>
<path id="Vector_74" d="M76.0504 25.988H78.6792M230.761 142.139H233.39" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.81051" stroke-linecap="round"/>
<path id="Vector_75" d="M82.0031 25.988H85.6195" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.49076" stroke-linecap="round"/>
<path id="Vector_76" d="M183.114 25.988H190.789M221.792 109.875H229.467" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.2864" stroke-linecap="round"/>
<path id="Vector_77" d="M190.117 25.988H196.679" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.52001" stroke-linecap="round"/>
<path id="Vector_78" d="M197.088 25.988H202.6" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.79643" stroke-linecap="round"/>
<path id="Vector_79" d="M204.083 25.988H208.499M255.653 148.592H260.069" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.04101" stroke-linecap="round"/>
<path id="Vector_80" d="M210.74 25.988H214.733M255.864 155.045H259.857" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.7497" stroke-linecap="round"/>
<path id="Vector_81" d="M217.5 25.988H220.866" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.31795" stroke-linecap="round"/>
<path id="Vector_82" d="M224.276 25.988H226.983" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.86428" stroke-linecap="round"/>
<path id="Vector_83" d="M231.231 25.988H232.92M257.016 45.3466H258.705M263.463 167.95H265.151M237.677 200.215H239.366" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.16315" stroke-linecap="round"/>
<path id="Vector_84" d="M237.827 25.988H239.217M199.149 116.328H200.54" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.957983" stroke-linecap="round"/>
<path id="Vector_85" d="M244.312 25.988H245.624M263.651 174.403H264.963" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.903689" stroke-linecap="round"/>
<path id="Vector_86" d="M251.04 25.988H251.788" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.515275" stroke-linecap="round"/>
<path id="Vector_87" d="M57.4486 32.4408H58.6042M205.713 51.7993H206.868M283.068 71.1578H284.224M192.82 90.5164H193.976" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.796144" stroke-linecap="round"/>
<path id="Vector_88" d="M63.5338 32.4408H65.4105" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.29263" stroke-linecap="round"/>
<path id="Vector_89" d="M69.2826 32.4408H72.5541" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.25322" stroke-linecap="round"/>
<path id="Vector_90" d="M74.7807 32.4408H79.9487" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.55942" stroke-linecap="round"/>
<path id="Vector_91" d="M190.257 32.4408H196.538M29.1004 142.139H35.3812" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.32581" stroke-linecap="round"/>
<path id="Vector_92" d="M196.32 32.4408H203.369" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.85465" stroke-linecap="round"/>
<path id="Vector_93" d="M203.385 32.4408H209.196M209.832 96.9693H215.642" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.0016" stroke-linecap="round"/>
<path id="Vector_94" d="M210.223 32.4408H215.25" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.46231" stroke-linecap="round"/>
<path id="Vector_95" d="M216.928 32.4408H221.438" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.10575" stroke-linecap="round"/>
<path id="Vector_96" d="M230.973 32.4408H233.179M237.419 32.4408H239.625M24.6916 77.6108H26.8975M276.096 122.781H278.302" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.5192" stroke-linecap="round"/>
<path id="Vector_97" d="M244.006 32.4408H245.93M192.436 77.6108H194.36" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.32499" stroke-linecap="round"/>
<path id="Vector_98" d="M257.494 32.4408H258.227M6.08966 109.875H6.82226M283.28 135.686H284.012M238.156 206.668H238.888" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.504312" stroke-linecap="round"/>
<path id="Vector_99" d="M264.262 32.4408H264.352M277.154 45.3467H277.244M206.246 142.139H206.335M109.551 238.932H109.641" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.0621254" stroke-linecap="round"/>
<path id="Vector_100" d="M44.5949 38.8939H45.6721M289.553 103.422H290.63" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.74185" stroke-linecap="round"/>
<path id="Vector_101" d="M50.5317 38.8939H52.628" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.4435" stroke-linecap="round"/>
<path id="Vector_102" d="M56.2022 38.8939H59.8499M217.359 122.781H221.007M262.483 142.139H266.131" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.51216" stroke-linecap="round"/>
<path id="Vector_103" d="M61.8807 38.8939H67.0644" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.56986" stroke-linecap="round"/>
<path id="Vector_104" d="M199.533 38.8939H200.156M283.334 142.139H283.957" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.429135" stroke-linecap="round"/>
<path id="Vector_105" d="M202.108 38.8939H210.473" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.76148" stroke-linecap="round"/>
<path id="Vector_106" d="M209.541 38.8939H215.932M22.5994 135.687H28.99" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.40098" stroke-linecap="round"/>
<path id="Vector_107" d="M216.356 38.8939H222.01" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.89406" stroke-linecap="round"/>
<path id="Vector_108" d="M223.406 38.8939H227.853" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.06294" stroke-linecap="round"/>
<path id="Vector_109" d="M230.597 38.8939H233.555" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.03709" stroke-linecap="round"/>
<path id="Vector_110" d="M243.591 38.8939H246.345M37.3102 58.2524H40.0645" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.89717" stroke-linecap="round"/>
<path id="Vector_111" d="M250.508 38.8939H252.322M244.061 193.762H245.875" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.24929" stroke-linecap="round"/>
<path id="Vector_112" d="M257.314 38.8939H258.407M276.653 58.2524H277.746" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.752813" stroke-linecap="round"/>
<path id="Vector_113" d="M264.082 38.8939H264.532M199.619 129.234H200.069M154.495 238.932H154.945" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.310105" stroke-linecap="round"/>
<path id="Vector_114" d="M32.2119 45.3466H32.2705M180.476 96.9693H180.535M186.923 103.422H186.981M290.063 142.139H290.122M244.939 206.667H244.998" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.0401988" stroke-linecap="round"/>
<path id="Vector_115" d="M38.2348 45.3466H39.1396M289.64 122.781H290.544" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.623342" stroke-linecap="round"/>
<path id="Vector_116" d="M44.1246 45.3466H46.1425M256.852 174.403H258.87" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.38973" stroke-linecap="round"/>
<path id="Vector_117" d="M49.8502 45.3466H53.31M82.0818 213.12H85.5412" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.38269" stroke-linecap="round"/>
<path id="Vector_118" d="M55.3402 45.3466H60.712M261.621 109.875H266.993" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.69933" stroke-linecap="round"/>
<path id="Vector_119" d="M205.321 45.3466H207.26M282.676 96.9693H284.615" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.33543" stroke-linecap="round"/>
<path id="Vector_120" d="M208.774 45.3466H216.7" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.4592" stroke-linecap="round"/>
<path id="Vector_121" d="M215.855 45.3466H222.512M67.5906 206.667H74.2473" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.58475" stroke-linecap="round"/>
<path id="Vector_122" d="M223.03 45.3466H228.229M48.9803 51.7992H54.1794M248.815 64.7052H254.014" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.58082" stroke-linecap="round"/>
<path id="Vector_123" d="M230.134 45.3466H234.017M236.581 187.309H240.463" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.674" stroke-linecap="round"/>
<path id="Vector_124" d="M236.549 45.3466H240.495M204.318 90.5163H208.263" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.71733" stroke-linecap="round"/>
<path id="Vector_125" d="M243.058 45.3466H246.878" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.63067" stroke-linecap="round"/>
<path id="Vector_126" d="M250.171 45.3466H252.658" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.71341" stroke-linecap="round"/>
<path id="Vector_127" d="M270.622 45.3466H270.884M25.6635 58.2522H25.9257M289.961 135.686H290.223M231.945 213.12H232.207" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.180633" stroke-linecap="round"/>
<path id="Vector_128" d="M43.4192 51.7994H46.8476" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.36129" stroke-linecap="round"/>
<path id="Vector_129" d="M210.803 51.7994H214.67" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.66356" stroke-linecap="round"/>
<path id="Vector_130" d="M215.196 51.7994H223.17" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.49157" stroke-linecap="round"/>
<path id="Vector_131" d="M222.324 51.7994H228.934M215.878 103.422H222.488" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.55238" stroke-linecap="round"/>
<path id="Vector_132" d="M229.703 51.7994H234.448" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.26759" stroke-linecap="round"/>
<path id="Vector_133" d="M235.844 51.7994H241.2" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.68889" stroke-linecap="round"/>
<path id="Vector_134" d="M242.674 51.7994H247.262M236.228 180.856H240.816" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.16004" stroke-linecap="round"/>
<path id="Vector_135" d="M249.755 51.7994H253.074" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.28559" stroke-linecap="round"/>
<path id="Vector_136" d="M256.703 51.7994H259.019" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.5949" stroke-linecap="round"/>
<path id="Vector_137" d="M270.238 51.7994H271.268M289.577 109.875H290.607M96.1883 232.479H97.2187M173.544 232.479H174.574" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.709482" stroke-linecap="round"/>
<path id="Vector_138" d="M276.872 51.7994H277.526M263.98 187.309H264.634" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.450539" stroke-linecap="round"/>
<path id="Vector_139" d="M283.507 51.7994H283.785M257.722 193.762H258" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.191597" stroke-linecap="round"/>
<path id="Vector_140" d="M42.7299 58.2525H47.5374M23.391 96.9696H28.1986M261.903 129.234H266.711" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.31092" stroke-linecap="round"/>
<path id="Vector_141" d="M205.783 58.2525H206.798M199.337 64.7055H200.352M192.891 103.423H193.906" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.698519" stroke-linecap="round"/>
<path id="Vector_142" d="M211.179 58.2525H214.294" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.14515" stroke-linecap="round"/>
<path id="Vector_143" d="M215.557 58.2525H222.809M215.557 96.9696H222.809" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.99509" stroke-linecap="round"/>
<path id="Vector_144" d="M221.752 58.2525H229.507" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.34017" stroke-linecap="round"/>
<path id="Vector_145" d="M228.826 58.2525H235.326M222.38 187.309H228.88" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.47668" stroke-linecap="round"/>
<path id="Vector_146" d="M235.303 58.2525H241.741" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.43335" stroke-linecap="round"/>
<path id="Vector_147" d="M242.118 58.2525H247.819" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.92643" stroke-linecap="round"/>
<path id="Vector_148" d="M249.301 58.2525H253.528" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.91154" stroke-linecap="round"/>
<path id="Vector_149" d="M256.24 58.2525H259.48M275.579 109.875H278.819M256.24 161.498H259.48" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.23129" stroke-linecap="round"/>
<path id="Vector_150" d="M269.862 58.2525H271.644M282.754 90.5166H284.537M224.738 142.14H226.52M108.705 232.479H110.487" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.22789" stroke-linecap="round"/>
<path id="Vector_151" d="M283.342 58.2525H283.95M231.772 155.045H232.379" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.418171" stroke-linecap="round"/>
<path id="Vector_152" d="M25.4441 64.7052H26.1454" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.482907" stroke-linecap="round"/>
<path id="Vector_153" d="M31.2559 64.7052H33.2267M276.215 71.1579H278.185" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.35736" stroke-linecap="round"/>
<path id="Vector_154" d="M36.7381 64.7052H40.6365M17.3992 109.875H21.2977M49.6305 200.215H53.529M68.9693 213.12H72.8678" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.68496" stroke-linecap="round"/>
<path id="Vector_155" d="M42.2594 64.7052H48.0073" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.95879" stroke-linecap="round"/>
<path id="Vector_156" d="M205.548 64.7052H207.033M205.548 122.781H207.033" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.02272" stroke-linecap="round"/>
<path id="Vector_157" d="M220.859 64.7052H230.4M233.752 84.0638H243.293M233.752 90.5164H243.293M233.752 96.9693H243.293M227.305 103.422H236.846M240.198 103.422H249.739M233.752 109.875H243.293" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.57067" stroke-linecap="round"/>
<path id="Vector_158" d="M227.987 64.7052H236.164" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.63148" stroke-linecap="round"/>
<path id="Vector_159" d="M234.637 64.7052H242.407" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.35113" stroke-linecap="round"/>
<path id="Vector_160" d="M241.553 64.7052H248.383M196.43 206.668H203.259" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.70326" stroke-linecap="round"/>
<path id="Vector_161" d="M255.723 64.7052H259.998M210.599 109.875H214.874M36.5498 180.856H40.8245" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.94391" stroke-linecap="round"/>
<path id="Vector_162" d="M262.726 64.7052H265.888" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.17752" stroke-linecap="round"/>
<path id="Vector_163" d="M269.604 64.7052H271.903" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.58394" stroke-linecap="round"/>
<path id="Vector_164" d="M276.41 64.7052H277.989M250.625 187.309H252.204" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.08745" stroke-linecap="round"/>
<path id="Vector_165" d="M283.201 64.7052H284.09" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.612379" stroke-linecap="round"/>
<path id="Vector_166" d="M30.8012 71.1576H33.6807" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.98331" stroke-linecap="round"/>
<path id="Vector_167" d="M36.2207 71.1576H41.1537M210.27 103.422H215.203" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.39706" stroke-linecap="round"/>
<path id="Vector_168" d="M186.633 71.1576H187.271M212.418 135.686H213.056M218.864 142.139H219.502M283.327 161.498H283.965M270.434 174.403H271.073M31.9221 200.215H32.5602" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.439576" stroke-linecap="round"/>
<path id="Vector_169" d="M204.529 71.1576H208.052" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.42602" stroke-linecap="round"/>
<path id="Vector_170" d="M215.674 71.1576H222.692" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.83273" stroke-linecap="round"/>
<path id="Vector_171" d="M220.882 71.1576H230.376M227.328 77.6105H236.822M220.882 84.0635H230.376M227.328 84.0635H236.822M227.328 90.5161H236.822M227.328 96.9691H236.822M233.775 103.422H243.269" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.5383" stroke-linecap="round"/>
<path id="Vector_172" d="M227.321 71.1576H236.83M233.767 77.6105H243.277M240.213 90.5161H249.723M240.213 96.9691H249.723M240.213 122.781H249.723" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.54927" stroke-linecap="round"/>
<path id="Vector_173" d="M233.939 71.1576H243.104" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.31173" stroke-linecap="round"/>
<path id="Vector_174" d="M240.989 71.1576H248.947M215.204 77.6105H223.162" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.48061" stroke-linecap="round"/>
<path id="Vector_175" d="M248.227 71.1576H254.602" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.39054" stroke-linecap="round"/>
<path id="Vector_176" d="M255.332 71.1576H260.39M177.976 219.573H183.035" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.48372" stroke-linecap="round"/>
<path id="Vector_177" d="M262.381 71.1576H266.233" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.6526" stroke-linecap="round"/>
<path id="Vector_178" d="M289.875 71.1576H290.309M296.321 103.422H296.756" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.299141" stroke-linecap="round"/>
<path id="Vector_179" d="M18.9508 77.6111H19.7462M193 109.875H193.796" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.547643" stroke-linecap="round"/>
<path id="Vector_180" d="M30.2838 77.6111H34.1983" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.69593" stroke-linecap="round"/>
<path id="Vector_181" d="M35.5233 77.6111H41.8511M22.6309 122.781H28.9587M22.6309 129.234H28.9587" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.35817" stroke-linecap="round"/>
<path id="Vector_182" d="M180.429 77.6111H180.582M12.826 84.064H12.9787M64.3964 226.026H64.5488" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.104934" stroke-linecap="round"/>
<path id="Vector_183" d="M209.761 77.6111H215.713" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.09871" stroke-linecap="round"/>
<path id="Vector_184" d="M220.867 77.6111H230.392M240.205 84.064H249.73M240.205 109.875H249.73M233.759 116.328H243.284M240.205 116.328H249.73" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.56023" stroke-linecap="round"/>
<path id="Vector_185" d="M240.48 77.6111H249.457" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.18226" stroke-linecap="round"/>
<path id="Vector_186" d="M247.725 77.6111H255.103M234.833 161.498H242.211" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.08123" stroke-linecap="round"/>
<path id="Vector_187" d="M254.916 77.6111H260.805M261.363 90.5167H267.251M261.363 103.423H267.251" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.0559" stroke-linecap="round"/>
<path id="Vector_188" d="M262.044 77.6111H266.57M197.581 213.121H202.107" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.11671" stroke-linecap="round"/>
<path id="Vector_189" d="M276.057 77.6111H278.342M218.041 129.234H220.325M269.611 155.045H271.895" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.57297" stroke-linecap="round"/>
<path id="Vector_190" d="M289.851 77.6111H290.333M70.6776 226.026H71.1595M148.033 238.932H148.515" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.331509" stroke-linecap="round"/>
<path id="Vector_191" d="M24.3787 84.0638H27.2112M262.891 155.045H265.723M30.8248 180.856H33.6576" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.95095" stroke-linecap="round"/>
<path id="Vector_192" d="M186.664 84.0638H187.24M19.0608 180.856H19.6365" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.396767" stroke-linecap="round"/>
<path id="Vector_193" d="M192.773 84.0638H194.023M231.451 148.592H232.7M276.574 161.498H277.824" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.86088" stroke-linecap="round"/>
<path id="Vector_194" d="M204.208 84.0638H208.373" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.86821" stroke-linecap="round"/>
<path id="Vector_195" d="M209.753 84.0638H215.72" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.10967" stroke-linecap="round"/>
<path id="Vector_196" d="M215.228 84.0638H223.139" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.44824" stroke-linecap="round"/>
<path id="Vector_197" d="M247.271 84.0638H255.558" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.70718" stroke-linecap="round"/>
<path id="Vector_198" d="M261.645 84.0638H266.97" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.66696" stroke-linecap="round"/>
<path id="Vector_199" d="M268.679 84.0638H272.828" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.85777" stroke-linecap="round"/>
<path id="Vector_200" d="M282.794 84.0638H284.498" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.17359" stroke-linecap="round"/>
<path id="Vector_201" d="M289.757 84.0638H290.427M6.1211 103.422H6.79096" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.461503" stroke-linecap="round"/>
<path id="Vector_202" d="M296.47 84.0638H296.607M180.437 90.5164H180.574" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.0944932" stroke-linecap="round"/>
<path id="Vector_203" d="M18.5123 90.5162H20.185" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.15219" stroke-linecap="round"/>
<path id="Vector_204" d="M23.908 90.5162H27.6813M101.263 226.026H105.037" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.5983" stroke-linecap="round"/>
<path id="Vector_205" d="M29.4613 90.5162H35.021" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.82932" stroke-linecap="round"/>
<path id="Vector_206" d="M186.672 90.5162H187.232" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.385804" stroke-linecap="round"/>
<path id="Vector_207" d="M198.663 90.5162H201.026M11.7209 148.592H14.0837M37.5061 193.762H39.8686M127.754 232.479H130.117" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.62727" stroke-linecap="round"/>
<path id="Vector_208" d="M209.722 90.5162H215.752" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.153" stroke-linecap="round"/>
<path id="Vector_209" d="M215.282 90.5162H223.083" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.37254" stroke-linecap="round"/>
<path id="Vector_210" d="M220.945 90.5162H230.313" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.45216" stroke-linecap="round"/>
<path id="Vector_211" d="M247.044 90.5162H255.785" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.02042" stroke-linecap="round"/>
<path id="Vector_212" d="M254.156 90.5162H261.566M228.371 174.403H235.78" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.10263" stroke-linecap="round"/>
<path id="Vector_213" d="M275.744 90.5162H278.655M63.0166 213.12H65.9279M75.909 219.573H78.8203" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.00472" stroke-linecap="round"/>
<path id="Vector_214" d="M289.616 90.5162H290.568M225.154 148.592H226.105M283.17 155.045H284.122M257.385 187.309H258.337" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.65571" stroke-linecap="round"/>
<path id="Vector_215" d="M12.2541 96.9693H13.5506M31.5929 193.762H32.8894" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.893248" stroke-linecap="round"/>
<path id="Vector_216" d="M28.8106 96.9693H35.6715" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.72518" stroke-linecap="round"/>
<path id="Vector_217" d="M204.192 96.9693H208.388" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.89013" stroke-linecap="round"/>
<path id="Vector_218" d="M221.133 96.9693H230.125" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.19322" stroke-linecap="round"/>
<path id="Vector_219" d="M246.949 96.9693H255.879" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.14989" stroke-linecap="round"/>
<path id="Vector_220" d="M254.195 96.9693H261.526" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.04886" stroke-linecap="round"/>
<path id="Vector_221" d="M261.261 96.9693H267.353" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.19633" stroke-linecap="round"/>
<path id="Vector_222" d="M275.595 96.9693H278.804" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.20989" stroke-linecap="round"/>
<path id="Vector_223" d="M289.53 96.9693H290.654" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.774218" stroke-linecap="round"/>
<path id="Vector_224" d="M296.29 96.9693H296.788M289.844 129.233H290.341" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.342473" stroke-linecap="round"/>
<path id="Vector_225" d="M11.9483 103.422H13.8563M11.9483 155.045H13.8563" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.31403" stroke-linecap="round"/>
<path id="Vector_226" d="M23.0539 103.422H28.5355M255.12 135.687H260.601" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.77503" stroke-linecap="round"/>
<path id="Vector_227" d="M198.671 103.422H201.018M159.993 232.479H162.34" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.6163" stroke-linecap="round"/>
<path id="Vector_228" d="M204.443 103.422H208.138M268.906 116.328H272.601M204.443 213.121H208.138" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.54453" stroke-linecap="round"/>
<path id="Vector_229" d="M221.454 103.422H229.804M227.901 167.951H236.25" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.75051" stroke-linecap="round"/>
<path id="Vector_230" d="M246.942 103.422H255.887" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.16085" stroke-linecap="round"/>
<path id="Vector_231" d="M254.133 103.422H261.589M228.348 122.781H235.804" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.135" stroke-linecap="round"/>
<path id="Vector_232" d="M268.436 103.422H273.071" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.19241" stroke-linecap="round"/>
<path id="Vector_233" d="M11.8695 109.875H13.9345" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.4221" stroke-linecap="round"/>
<path id="Vector_234" d="M22.9834 109.875H28.6062M248.603 148.592H254.226" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.87213" stroke-linecap="round"/>
<path id="Vector_235" d="M198.851 109.875H200.838M276.206 142.139H278.193" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.3678" stroke-linecap="round"/>
<path id="Vector_236" d="M204.678 109.875H207.903" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.22085" stroke-linecap="round"/>
<path id="Vector_237" d="M216.293 109.875H222.073M190.508 213.12H196.288" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.9802" stroke-linecap="round"/>
<path id="Vector_238" d="M227.524 109.875H236.627" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.2684" stroke-linecap="round"/>
<path id="Vector_239" d="M246.934 109.875H255.895" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.17129" stroke-linecap="round"/>
<path id="Vector_240" d="M254.305 109.875H261.417" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.89746" stroke-linecap="round"/>
<path id="Vector_241" d="M268.585 109.875H272.922" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.98724" stroke-linecap="round"/>
<path id="Vector_242" d="M282.558 109.875H284.733" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.49727" stroke-linecap="round"/>
<path id="Vector_243" d="M296.242 109.875H296.834M6.15997 116.328H6.75136M12.6064 174.403H13.1977" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.407208" stroke-linecap="round"/>
<path id="Vector_244" d="M17.2268 116.328H21.4701" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.9225" stroke-linecap="round"/>
<path id="Vector_245" d="M22.7248 116.328H28.8647" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.2287" stroke-linecap="round"/>
<path id="Vector_246" d="M211.038 116.328H214.435M269.055 129.233H272.452M243.27 180.856H246.667M43.435 193.762H46.832" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.33936" stroke-linecap="round"/>
<path id="Vector_247" d="M216.842 116.328H221.524" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.22478" stroke-linecap="round"/>
<path id="Vector_248" d="M222.528 116.328H228.731" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.27151" stroke-linecap="round"/>
<path id="Vector_249" d="M227.964 116.328H236.188" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.66385" stroke-linecap="round"/>
<path id="Vector_250" d="M247.098 116.328H255.73" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.94472" stroke-linecap="round"/>
<path id="Vector_251" d="M254.368 116.328H261.354" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.81132" stroke-linecap="round"/>
<path id="Vector_252" d="M261.825 116.328H266.789M261.825 122.781H266.789" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.41898" stroke-linecap="round"/>
<path id="Vector_253" d="M275.877 116.328H278.522" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.82147" stroke-linecap="round"/>
<path id="Vector_254" d="M282.895 116.328H284.396M211.987 129.233H213.487" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.03316" stroke-linecap="round"/>
<path id="Vector_255" d="M289.663 116.328H290.521" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.590974" stroke-linecap="round"/>
<path id="Vector_256" d="M11.7133 122.781H14.0914M11.7133 129.233H14.0914M89.0686 226.026H91.4467" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.63771" stroke-linecap="round"/>
<path id="Vector_257" d="M17.3211 122.781H21.3761" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.79303" stroke-linecap="round"/>
<path id="Vector_258" d="M223.053 122.781H228.206M36.1113 174.403H41.2637M229.5 187.309H234.652M223.053 193.762H228.206" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.54845" stroke-linecap="round"/>
<path id="Vector_259" d="M233.853 122.781H243.19" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.43024" stroke-linecap="round"/>
<path id="Vector_260" d="M247.42 122.781H255.409" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.50201" stroke-linecap="round"/>
<path id="Vector_261" d="M254.587 122.781H261.134" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.50905" stroke-linecap="round"/>
<path id="Vector_262" d="M269.204 122.781H272.303M269.204 142.139H272.303" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.13419" stroke-linecap="round"/>
<path id="Vector_263" d="M282.982 122.781H284.31" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.914652" stroke-linecap="round"/>
<path id="Vector_264" d="M6.07423 129.233H6.83824" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.526238" stroke-linecap="round"/>
<path id="Vector_265" d="M17.2193 129.233H21.4783M30.1118 167.95H34.3708" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.93294" stroke-linecap="round"/>
<path id="Vector_266" d="M193.392 129.233H193.404M0.00391546 142.139H0.0154082M19.3427 187.309H19.3542" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.00783093" stroke-linecap="round"/>
<path id="Vector_267" d="M205.721 129.233H206.861" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.785181" stroke-linecap="round"/>
<path id="Vector_268" d="M223.578 129.233H227.68" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.8254" stroke-linecap="round"/>
<path id="Vector_269" d="M229.515 129.233H234.636" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.52705" stroke-linecap="round"/>
<path id="Vector_270" d="M234.888 129.233H242.156" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.00553" stroke-linecap="round"/>
<path id="Vector_271" d="M240.738 129.233H249.198" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.82621" stroke-linecap="round"/>
<path id="Vector_272" d="M247.663 129.233H255.166" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.16737" stroke-linecap="round"/>
<path id="Vector_273" d="M276.3 129.233H278.099M57.1268 213.12H58.925" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.23833" stroke-linecap="round"/>
<path id="Vector_274" d="M283.123 129.233H284.169" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.720445" stroke-linecap="round"/>
<path id="Vector_275" d="M11.643 135.686H14.162M198.585 219.574H201.104" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.73481" stroke-linecap="round"/>
<path id="Vector_276" d="M17.258 135.686H21.4385M242.878 174.404H247.058" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.87917" stroke-linecap="round"/>
<path id="Vector_277" d="M224.323 135.686H226.936" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.79955" stroke-linecap="round"/>
<path id="Vector_278" d="M230.095 135.686H234.057M184.971 219.574H188.933" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.72829" stroke-linecap="round"/>
<path id="Vector_279" d="M234.919 135.686H242.124" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.96272" stroke-linecap="round"/>
<path id="Vector_280" d="M240.958 135.686H248.979" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.52394" stroke-linecap="round"/>
<path id="Vector_281" d="M248.039 135.686H254.79" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.64948" stroke-linecap="round"/>
<path id="Vector_282" d="M262.005 135.686H266.609" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.17048" stroke-linecap="round"/>
<path id="Vector_283" d="M269.18 135.686H272.326" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.16656" stroke-linecap="round"/>
<path id="Vector_284" d="M276.316 135.686H278.083" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.21693" stroke-linecap="round"/>
<path id="Vector_285" d="M5.87052 142.139H7.0418M270.168 167.95H271.339" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.806586" stroke-linecap="round"/>
<path id="Vector_286" d="M11.5172 142.139H14.2873M230.691 200.215H233.461" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.90761" stroke-linecap="round"/>
<path id="Vector_287" d="M17.1719 142.139H21.5247" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.99768" stroke-linecap="round"/>
<path id="Vector_288" d="M23.0072 142.139H28.5826" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.83976" stroke-linecap="round"/>
<path id="Vector_289" d="M233.979 142.139H243.065" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.25796" stroke-linecap="round"/>
<path id="Vector_290" d="M241.178 142.139H248.759" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.22166" stroke-linecap="round"/>
<path id="Vector_291" d="M248.243 142.139H254.586" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.36861" stroke-linecap="round"/>
<path id="Vector_292" d="M255.433 142.139H260.288" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.34328" stroke-linecap="round"/>
<path id="Vector_293" d="M6.05079 148.592H6.8615" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.558606" stroke-linecap="round"/>
<path id="Vector_294" d="M17.6578 148.592H21.0391" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.32892" stroke-linecap="round"/>
<path id="Vector_295" d="M23.4303 148.592H28.1597M158.802 226.026H163.531" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.25714" stroke-linecap="round"/>
<path id="Vector_296" d="M29.0065 148.592H35.4755" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.45528" stroke-linecap="round"/>
<path id="Vector_297" d="M219.123 148.592H219.244M251.354 200.215H251.475" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.0835299" stroke-linecap="round"/>
<path id="Vector_298" d="M233.807 148.592H243.238" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.49549" stroke-linecap="round"/>
<path id="Vector_299" d="M241.429 148.592H248.508" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.87606" stroke-linecap="round"/>
<path id="Vector_300" d="M262.663 148.592H265.951" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.26366" stroke-linecap="round"/>
<path id="Vector_301" d="M269.502 148.592H272.005M140.576 232.479H143.08" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.72437" stroke-linecap="round"/>
<path id="Vector_302" d="M276.269 148.592H278.13" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.28166" stroke-linecap="round"/>
<path id="Vector_303" d="M6.1129 155.045H6.79844" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.471944" stroke-linecap="round"/>
<path id="Vector_304" d="M17.8381 155.045H20.8589" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.08042" stroke-linecap="round"/>
<path id="Vector_305" d="M23.5557 155.045H28.034" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.08434" stroke-linecap="round"/>
<path id="Vector_306" d="M29.2731 155.045H35.2093" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.08827" stroke-linecap="round"/>
<path id="Vector_307" d="M225.537 155.045H225.721M6.36407 167.951H6.54783M115.951 238.932H116.134M135.289 238.932H135.473" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.126861" stroke-linecap="round"/>
<path id="Vector_308" d="M234.449 155.045H242.595" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.61008" stroke-linecap="round"/>
<path id="Vector_309" d="M241.789 155.045H248.148" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.37958" stroke-linecap="round"/>
<path id="Vector_310" d="M248.964 155.045H253.865" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.37565" stroke-linecap="round"/>
<path id="Vector_311" d="M276.496 155.045H277.903M37.984 200.215H39.3903M102.447 232.479H103.853" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.968425" stroke-linecap="round"/>
<path id="Vector_312" d="M29.6725 161.498H34.8095M61.9041 206.667H67.0408" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.53749" stroke-linecap="round"/>
<path id="Vector_313" d="M242.11 161.498H247.826" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.93687" stroke-linecap="round"/>
<path id="Vector_314" d="M249.348 161.498H253.481" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.8468" stroke-linecap="round"/>
<path id="Vector_315" d="M270.034 161.498H271.472M257.142 180.856H258.579M224.91 213.12H226.348" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.990351" stroke-linecap="round"/>
<path id="Vector_316" d="M12.4809 167.951H13.3233" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.580011" stroke-linecap="round"/>
<path id="Vector_317" d="M24.3863 167.951H27.2035M256.452 167.951H259.269" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.93998" stroke-linecap="round"/>
<path id="Vector_318" d="M35.6332 167.951H41.7417" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.20677" stroke-linecap="round"/>
<path id="Vector_319" d="M235.233 167.951H241.811M215.894 193.762H222.472" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.53045" stroke-linecap="round"/>
<path id="Vector_320" d="M242.525 167.951H247.411M49.1367 193.762H54.0227M216.74 200.215H221.626M210.294 206.668H215.18M152.277 226.026H157.163" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.36469" stroke-linecap="round"/>
<path id="Vector_321" d="M249.708 167.951H253.121" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.35032" stroke-linecap="round"/>
<path id="Vector_322" d="M18.7395 174.403H19.9578" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.838953" stroke-linecap="round"/>
<path id="Vector_323" d="M235.82 174.403H241.223" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.72126" stroke-linecap="round"/>
<path id="Vector_324" d="M250.123 174.403H252.706" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.77814" stroke-linecap="round"/>
<path id="Vector_325" d="M42.1971 180.856H48.0702" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.04494" stroke-linecap="round"/>
<path id="Vector_326" d="M221.846 180.856H229.412" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.2107" stroke-linecap="round"/>
<path id="Vector_327" d="M250.304 180.856H252.525" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.53016" stroke-linecap="round"/>
<path id="Vector_328" d="M263.824 180.856H264.791" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.666151" stroke-linecap="round"/>
<path id="Vector_329" d="M31.24 187.309H33.2423" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.37877" stroke-linecap="round"/>
<path id="Vector_330" d="M37.0908 187.309H40.2839M191.801 219.574H194.995" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.19892" stroke-linecap="round"/>
<path id="Vector_331" d="M42.8633 187.309H47.4044" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.12767" stroke-linecap="round"/>
<path id="Vector_332" d="M48.5412 187.309H54.6183" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.18537" stroke-linecap="round"/>
<path id="Vector_333" d="M215.149 187.309H223.217" stroke="var(--stroke-0, #4A38F5)" stroke-width="5.5563" stroke-linecap="round"/>
<path id="Vector_334" d="M270.74 187.309H270.767M32.2275 206.668H32.2547" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.0187942" stroke-linecap="round"/>
<path id="Vector_335" d="M54.7055 193.762H61.3468" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.57378" stroke-linecap="round"/>
<path id="Vector_336" d="M209.706 193.762H215.767M203.26 206.667H209.321" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.17441" stroke-linecap="round"/>
<path id="Vector_337" d="M230.071 193.762H234.08" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.76066" stroke-linecap="round"/>
<path id="Vector_338" d="M237.121 193.762H239.922" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.92954" stroke-linecap="round"/>
<path id="Vector_339" d="M43.9994 200.215H46.2677M114.908 232.479H117.177" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.56253" stroke-linecap="round"/>
<path id="Vector_340" d="M55.4106 200.215H60.6414" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.60223" stroke-linecap="round"/>
<path id="Vector_341" d="M61.0186 200.215H67.9263" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.75755" stroke-linecap="round"/>
<path id="Vector_342" d="M201.653 200.215H210.928" stroke="var(--stroke-0, #4A38F5)" stroke-width="6.38743" stroke-linecap="round"/>
<path id="Vector_343" d="M209.471 200.215H216.003" stroke="var(--stroke-0, #4A38F5)" stroke-width="4.49808" stroke-linecap="round"/>
<path id="Vector_344" d="M223.665 200.215H227.594" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.70637" stroke-linecap="round"/>
<path id="Vector_345" d="M244.508 200.215H245.428M51.1193 213.121H52.0399M180.045 232.479H180.965" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.633783" stroke-linecap="round"/>
<path id="Vector_346" d="M38.3289 206.667H39.0458M77.0066 226.026H77.7235M193.04 226.026H193.756" stroke="var(--stroke-0, #4A38F5)" stroke-width="0.49387" stroke-linecap="round"/>
<path id="Vector_347" d="M56.1318 206.667H59.9205" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.60927" stroke-linecap="round"/>
<path id="Vector_348" d="M217.382 206.667H220.983" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.47979" stroke-linecap="round"/>
<path id="Vector_349" d="M74.8748 213.12H79.8546" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.42995" stroke-linecap="round"/>
<path id="Vector_350" d="M211.407 213.12H214.067" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.83192" stroke-linecap="round"/>
<path id="Vector_351" d="M218.167 213.12H220.2" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.40017" stroke-linecap="round"/>
<path id="Vector_352" d="M69.9961 219.574H71.8414" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.2707" stroke-linecap="round"/>
<path id="Vector_353" d="M81.9561 219.574H85.6666M172.204 226.026H175.914" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.55549" stroke-linecap="round"/>
<path id="Vector_354" d="M205.431 219.574H207.151M166.753 232.479H168.473" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.18456" stroke-linecap="round"/>
<path id="Vector_355" d="M95.2795 226.026H98.128" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.96191" stroke-linecap="round"/>
<path id="Vector_356" d="M107.349 226.026H111.843" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.0953" stroke-linecap="round"/>
<path id="Vector_357" d="M113.756 226.026H118.329" stroke="var(--stroke-0, #4A38F5)" stroke-width="3.14908" stroke-linecap="round"/>
<path id="Vector_358" d="M165.593 226.026H169.632" stroke="var(--stroke-0, #4A38F5)" stroke-width="2.78207" stroke-linecap="round"/>
<path id="Vector_359" d="M186.178 226.026H187.726" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.06553" stroke-linecap="round"/>
<path id="Vector_360" d="M121.206 232.479H123.772" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.76718" stroke-linecap="round"/>
<path id="Vector_361" d="M134.216 232.479H136.547" stroke="var(--stroke-0, #4A38F5)" stroke-width="1.60534" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 54 KiB

@@ -20,6 +20,7 @@ import { IsMinimalMetadataReadyEffect } from '@/metadata-store/effect-components
import { MinimalMetadataLoadEffect } from '@/metadata-store/effect-components/MinimalMetadataLoadEffect';
import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-components/UserMetadataProviderInitialEffect';
import { ApolloCoreProvider } from '@/object-metadata/components/ApolloCoreProvider';
import { WelcomeOverlay } from '@/onboarding/components/WelcomeOverlay/WelcomeOverlay';
import { ApolloAdminProvider } from '@/settings/admin-panel/apollo/components/ApolloAdminProvider';
import { EndTrialAfterPaymentMethodGater } from '@/settings/billing/components/EndTrialAfterPaymentMethodGater';
import { SSEProvider } from '@/sse-db-event/components/SSEProvider';
@@ -80,6 +81,7 @@ export const WorkspaceAppProviders = () => {
<TrackPageViewEffect />
<RequestFreshCaptchaTokenEffect />
<PageChangeEffect />
<WelcomeOverlay />
<SignOutOnOtherTabSignOutEffect />
</SSEProvider>
</ApolloAdminProvider>
@@ -1,5 +1,7 @@
import { useExecuteTasksOnAnyLocationChange } from '@/app/hooks/useExecuteTasksOnAnyLocationChange';
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths';
import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
import { useReturnToPath } from '@/auth/hooks/useReturnToPath';
import { useIsOnAuthOrOnboardingPage } from '@/auth/hooks/useIsOnAuthOrOnboardingPage';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
@@ -19,6 +21,9 @@ import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useA
import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow';
import { useOpenNewRecordTitleCell } from '@/object-record/record-title-cell/hooks/useOpenNewRecordTitleCell';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
import { shouldShowWelcomeAnimationOnNavigate } from '@/onboarding/utils/shouldShowWelcomeAnimationOnNavigate';
import { PageFocusId } from '@/types/PageFocusId';
import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
@@ -39,6 +44,12 @@ import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffect
import { getPageLayoutIdForLocation } from '~/modules/app/utils/getPageLayoutIdForLocation';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
const ONBOARDING_OR_AUTH_NAVIGATE_PATHS = [
...ONBOARDING_PATHS,
...ONGOING_USER_CREATION_PATHS,
AppPath.ResetPassword,
];
// TODO: break down into smaller functions and / or hooks
// - moved usePageChangeEffectNavigateLocation into dedicated hook
export const PageChangeEffect = () => {
@@ -95,6 +106,12 @@ export const PageChangeEffect = () => {
const isOnAuthOrOnboardingPage = useIsOnAuthOrOnboardingPage();
const onboardingStatus = useOnboardingStatus();
const isOnOnboardingPage = ONBOARDING_PATHS.some((appPath) =>
isMatchingLocation(location, appPath),
);
const closeSidePanelUnlessNotRelevant = useCallback(() => {
const currentPage = store.get(sidePanelPageState.atom);
@@ -152,6 +169,21 @@ export const PageChangeEffect = () => {
const consumedReturnToPath =
getReturnToPath() === pageChangeEffectNavigateLocation;
const isNavigatingToOnboardingOrAuthPath =
ONBOARDING_OR_AUTH_NAVIGATE_PATHS.some(
(appPath) => pageChangeEffectNavigateLocation === appPath,
);
if (
shouldShowWelcomeAnimationOnNavigate({
onboardingStatus,
isOnOnboardingPage,
isNavigatingToOnboardingOrAuthPath,
})
) {
store.set(isWelcomeAnimationVisibleState.atom, true);
}
navigate(pageChangeEffectNavigateLocation);
if (consumedReturnToPath) {
@@ -163,9 +195,12 @@ export const PageChangeEffect = () => {
pageChangeEffectNavigateLocation,
isAppEffectRedirectEnabled,
isOnAuthOrOnboardingPage,
isOnOnboardingPage,
onboardingStatus,
saveReturnToPath,
getReturnToPath,
clearReturnToPath,
store,
]);
useEffect(() => {
@@ -0,0 +1,24 @@
import { useEffect } from 'react';
const WELCOME_HOLD_DURATION_MS = 2900;
type WelcomeAnimationAutoLeaveEffectProps = {
onAutoLeave: () => void;
};
export const WelcomeAnimationAutoLeaveEffect = ({
onAutoLeave,
}: WelcomeAnimationAutoLeaveEffectProps) => {
useEffect(() => {
const autoLeaveTimeoutId = setTimeout(
onAutoLeave,
WELCOME_HOLD_DURATION_MS,
);
return () => {
clearTimeout(autoLeaveTimeoutId);
};
}, [onAutoLeave]);
return null;
};
@@ -0,0 +1,40 @@
import { styled } from '@linaria/react';
import { useCallback, useRef, useState } from 'react';
import { WelcomeHalftoneCanvasEffect } from '@/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvasEffect';
import './welcomeHalftone.css';
const StyledCanvas = styled.canvas`
display: block;
height: 100%;
width: 100%;
`;
type WelcomeHalftoneCanvasProps = {
isLeaving: boolean;
};
export const WelcomeHalftoneCanvas = ({
isLeaving,
}: WelcomeHalftoneCanvasProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [hasWorkerFailed, setHasWorkerFailed] = useState(false);
const markWorkerAsFailed = useCallback(() => setHasWorkerFailed(true), []);
return (
<>
<StyledCanvas
key={hasWorkerFailed ? 'main-thread' : 'worker'}
ref={canvasRef}
aria-hidden="true"
/>
<WelcomeHalftoneCanvasEffect
canvasRef={canvasRef}
isLeaving={isLeaving}
hasWorkerFailed={hasWorkerFailed}
onWorkerFailed={markWorkerAsFailed}
/>
</>
);
};
@@ -0,0 +1,94 @@
import { type RefObject, useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { createWelcomeHalftoneMainThreadController } from '@/onboarding/components/WelcomeOverlay/createWelcomeHalftoneMainThreadController';
import { createWelcomeHalftoneWorkerController } from '@/onboarding/components/WelcomeOverlay/createWelcomeHalftoneWorkerController';
import { type WelcomeHalftoneController } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneController.type';
type WelcomeHalftoneCanvasEffectProps = {
canvasRef: RefObject<HTMLCanvasElement | null>;
isLeaving: boolean;
hasWorkerFailed: boolean;
onWorkerFailed: () => void;
};
export const WelcomeHalftoneCanvasEffect = ({
canvasRef,
isLeaving,
hasWorkerFailed,
onWorkerFailed,
}: WelcomeHalftoneCanvasEffectProps) => {
const [activeHalftoneController, setActiveHalftoneController] =
useState<WelcomeHalftoneController | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!isDefined(canvas)) {
return;
}
const prefersReducedMotion =
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const canvasStyles = getComputedStyle(canvas);
const dotColor = canvasStyles
.getPropertyValue('--welcome-dot-color')
.trim();
const dotHighlightColor =
canvasStyles.getPropertyValue('--welcome-dot-highlight').trim() ||
dotColor;
const readDevicePixelRatio = () =>
Math.min(window.devicePixelRatio || 1, 2);
const readCanvasClientSize = () => ({
width: canvas.clientWidth,
height: canvas.clientHeight,
});
const initialCanvasSize = readCanvasClientSize();
const sharedControllerOptions = {
canvas,
dotColor,
dotHighlightColor,
prefersReducedMotion,
initialCanvasWidth: initialCanvasSize.width,
initialCanvasHeight: initialCanvasSize.height,
devicePixelRatio: readDevicePixelRatio(),
};
const activeController =
(hasWorkerFailed
? null
: createWelcomeHalftoneWorkerController({
...sharedControllerOptions,
onWorkerUnavailable: onWorkerFailed,
})) ??
createWelcomeHalftoneMainThreadController(sharedControllerOptions);
if (!isDefined(activeController)) {
return;
}
setActiveHalftoneController(activeController);
const handleWindowResize = () => {
const nextCanvasSize = readCanvasClientSize();
activeController.resize(
nextCanvasSize.width,
nextCanvasSize.height,
readDevicePixelRatio(),
);
};
window.addEventListener('resize', handleWindowResize);
return () => {
window.removeEventListener('resize', handleWindowResize);
activeController.destroy();
};
}, [canvasRef, hasWorkerFailed, onWorkerFailed]);
useEffect(() => {
if (isLeaving) {
activeHalftoneController?.leave();
}
}, [isLeaving, activeHalftoneController]);
return null;
};
@@ -0,0 +1,211 @@
import { styled } from '@linaria/react';
import {
type AnimationEvent,
type CSSProperties,
useCallback,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { WelcomeAnimationAutoLeaveEffect } from '@/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect';
import { WelcomeHalftoneCanvas } from '@/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvas';
import { WelcomePersonChip } from '@/onboarding/components/WelcomeOverlay/WelcomePersonChip';
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
const WELCOME_TITLE_WORDS = ['Welcome', 'to', 'your', 'workspace'];
const StyledOverlay = styled.div`
align-items: center;
bottom: 0;
display: flex;
justify-content: center;
left: 0;
overflow: hidden;
position: fixed;
right: 0;
top: 0;
z-index: ${RootStackingContextZIndices.WelcomeOverlay};
`;
const StyledBackdrop = styled.div`
background: ${themeCssVariables.background.primary};
inset: 0;
position: absolute;
will-change: opacity;
z-index: 0;
&.is-leaving {
animation: welcomeBackdropOut 0.7s cubic-bezier(0.5, 0, 0.1, 1) 0.08s
forwards;
}
@keyframes welcomeBackdropOut {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
&.is-leaving {
animation-duration: 0.4s;
animation-delay: 0s;
}
}
`;
const StyledCanvasLayer = styled.div`
inset: 0;
position: absolute;
z-index: 1;
`;
const StyledTitle = styled.div`
align-items: center;
animation: welcomeTitleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) 0.8s both;
background: ${themeCssVariables.background.primary};
border-radius: ${themeCssVariables.border.radius.pill};
color: ${themeCssVariables.font.color.primary};
display: flex;
flex-wrap: nowrap;
font-size: 26px;
font-weight: ${themeCssVariables.font.weight.semiBold};
gap: ${themeCssVariables.spacing[2]};
justify-content: center;
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[8]};
position: relative;
white-space: nowrap;
will-change: transform, opacity;
z-index: 2;
@keyframes welcomeTitleIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (max-width: 600px) {
flex-wrap: wrap;
max-width: 90vw;
}
&.is-leaving {
animation: welcomeTitleOut 0.34s cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes welcomeTitleOut {
to {
opacity: 0;
transform: translateY(-10px);
}
}
@media (prefers-reduced-motion: reduce) {
animation: none;
&.is-leaving {
animation-name: welcomeTitleFadeOut;
}
@keyframes welcomeTitleFadeOut {
to {
opacity: 0;
}
}
}
`;
const StyledWord = styled.span`
animation: welcomeWordIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: calc(1.1s + var(--word-index) * 0.07s);
display: inline-flex;
@keyframes welcomeWordIn {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
animation-name: welcomeWordFadeIn;
animation-delay: 0s;
@keyframes welcomeWordFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
`;
export const WelcomeOverlay = () => {
const isWelcomeAnimationVisible = useAtomStateValue(
isWelcomeAnimationVisibleState,
);
const setIsWelcomeAnimationVisible = useSetAtomState(
isWelcomeAnimationVisibleState,
);
const [isLeaving, setIsLeaving] = useState(false);
const startLeaving = useCallback(() => setIsLeaving(true), []);
if (!isWelcomeAnimationVisible) {
return null;
}
const handleBackdropAnimationEnd = (
event: AnimationEvent<HTMLDivElement>,
) => {
if (event.target === event.currentTarget && isLeaving) {
setIsWelcomeAnimationVisible(false);
setIsLeaving(false);
}
};
const leavingClassName = isLeaving ? 'is-leaving' : undefined;
return createPortal(
<StyledOverlay>
<WelcomeAnimationAutoLeaveEffect onAutoLeave={startLeaving} />
<StyledBackdrop
className={leavingClassName}
onAnimationEnd={handleBackdropAnimationEnd}
/>
<StyledCanvasLayer>
<WelcomeHalftoneCanvas isLeaving={isLeaving} />
</StyledCanvasLayer>
<StyledTitle className={leavingClassName}>
{WELCOME_TITLE_WORDS.map((word, index) => (
<StyledWord
key={word}
style={{ '--word-index': index } as CSSProperties}
>
{word}
</StyledWord>
))}
<StyledWord
style={
{ '--word-index': WELCOME_TITLE_WORDS.length } as CSSProperties
}
>
<WelcomePersonChip />
</StyledWord>
</StyledTitle>
</StyledOverlay>,
document.body,
);
};
@@ -0,0 +1,45 @@
import { styled } from '@linaria/react';
import { Avatar } from 'twenty-ui/data-display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const StyledChip = styled.div`
align-items: center;
background: ${themeCssVariables.background.transparent.light};
border-radius: ${themeCssVariables.border.radius.md};
display: inline-flex;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
`;
const StyledPersonName = styled.span`
color: ${themeCssVariables.font.color.primary};
max-width: min(40vw, 360px);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const WelcomePersonChip = () => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const firstName = currentWorkspaceMember?.name?.firstName ?? '';
const lastName = currentWorkspaceMember?.name?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim();
return (
<StyledChip>
<Avatar
type="rounded"
size="lg"
placeholder={fullName}
placeholderColorSeed={currentWorkspaceMember?.id}
avatarUrl={getAbsoluteImageUrl(currentWorkspaceMember?.avatarUrl)}
/>
<StyledPersonName>{fullName}</StyledPersonName>
</StyledChip>
);
};
@@ -0,0 +1,42 @@
import { render, screen } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { createElement } from 'react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { WelcomeOverlay } from '@/onboarding/components/WelcomeOverlay/WelcomeOverlay';
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
import {
jotaiStore,
resetJotaiStore,
} from '@/ui/utilities/state/jotai/jotaiStore';
import { mockedWorkspaceMemberData } from '~/testing/mock-data/users';
const Wrapper = ({ children }: { children: React.ReactNode }) =>
createElement(JotaiProvider, { store: jotaiStore }, children);
describe('WelcomeOverlay', () => {
beforeEach(() => {
resetJotaiStore();
jotaiStore.set(currentWorkspaceMemberState.atom, {
...mockedWorkspaceMemberData,
name: { firstName: 'Marie', lastName: 'Curie' },
});
});
it('should render nothing when the welcome animation is not visible', () => {
render(<WelcomeOverlay />, { wrapper: Wrapper });
expect(screen.queryByText('Welcome')).not.toBeInTheDocument();
});
it('should render the welcome message and the member full name when visible', () => {
jotaiStore.set(isWelcomeAnimationVisibleState.atom, true);
render(<WelcomeOverlay />, { wrapper: Wrapper });
expect(screen.getByText('Welcome')).toBeInTheDocument();
expect(screen.getByText('workspace')).toBeInTheDocument();
expect(screen.getByText('Marie Curie')).toBeInTheDocument();
});
});
@@ -0,0 +1,80 @@
import { type WelcomeHalftoneParticle } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneParticle.type';
const TAU = Math.PI * 2;
const VIEWBOX_WIDTH = 297.037;
const VIEWBOX_CENTER_X = 148.5;
const VIEWBOX_CENTER_Y = 119.5;
const ASSEMBLE_STAGGER_SECONDS = 0.18;
const ASSEMBLE_JITTER_SECONDS = 0.12;
const MINIMUM_STROKE_WIDTH = 0.6;
const pseudoRandomFromSeed = (seed: number) => {
const noise = Math.sin(seed) * 43758.5453;
return noise - Math.floor(noise);
};
type WelcomeHalftoneParticleLayout = {
particles: WelcomeHalftoneParticle[];
halftoneSize: number;
maxDistanceToCenter: number;
};
export const buildWelcomeHalftoneParticles = (
dashes: readonly (readonly [number, number, number, number])[],
canvasWidth: number,
canvasHeight: number,
): WelcomeHalftoneParticleLayout => {
const halftoneSize = Math.max(canvasWidth * 1.05, canvasHeight * 1.3);
const viewboxToCanvasScale = halftoneSize / VIEWBOX_WIDTH;
const canvasCenterX = canvasWidth / 2;
const canvasCenterY = canvasHeight / 2;
const maxDistanceToCenter =
Math.hypot(VIEWBOX_CENTER_X, VIEWBOX_CENTER_Y) * viewboxToCanvasScale || 1;
const particles = dashes.map(
([dashStartX, dashEndX, dashY, dashStrokeWidth], dashIndex) => {
const targetX =
canvasCenterX +
((dashStartX + dashEndX) / 2 - VIEWBOX_CENTER_X) * viewboxToCanvasScale;
const targetY =
canvasCenterY + (dashY - VIEWBOX_CENTER_Y) * viewboxToCanvasScale;
const distanceToCenter = Math.hypot(
targetX - canvasCenterX,
targetY - canvasCenterY,
);
const scatterAngle = pseudoRandomFromSeed(dashIndex * 1.3) * TAU;
const scatterRadius =
halftoneSize * (0.35 + 0.5 * pseudoRandomFromSeed(dashIndex * 2.1));
return {
targetX,
targetY,
scatterStartX: canvasCenterX + Math.cos(scatterAngle) * scatterRadius,
scatterStartY: canvasCenterY + Math.sin(scatterAngle) * scatterRadius,
dashLength: Math.max(dashEndX - dashStartX, 0) * viewboxToCanvasScale,
strokeWidth: Math.max(
dashStrokeWidth * viewboxToCanvasScale,
MINIMUM_STROKE_WIDTH,
),
burstDirectionX:
distanceToCenter > 0
? (targetX - canvasCenterX) / distanceToCenter
: 0,
burstDirectionY:
distanceToCenter > 0
? (targetY - canvasCenterY) / distanceToCenter
: -1,
distanceToCenter,
assembleDelaySeconds:
ASSEMBLE_STAGGER_SECONDS * (distanceToCenter / maxDistanceToCenter) +
ASSEMBLE_JITTER_SECONDS * pseudoRandomFromSeed(dashIndex * 3.7),
driftPhase: pseudoRandomFromSeed(dashIndex * 5.1) * TAU,
positionAtLeaveStartX: 0,
positionAtLeaveStartY: 0,
opacityAtLeaveStart: 0,
};
},
);
return { particles, halftoneSize, maxDistanceToCenter };
};
@@ -0,0 +1,32 @@
const FALLBACK_FRAME_INTERVAL_MS = 16;
type WelcomeHalftoneAnimationFrameLoop = {
requestNextFrame: (onFrame: (timeMs: number) => void) => void;
cancelPendingFrame: () => void;
};
// requestAnimationFrame is unavailable inside a dedicated worker (the primary
// rendering path), so fall back to a fixed-interval timeout there.
export const createWelcomeHalftoneAnimationFrameLoop =
(): WelcomeHalftoneAnimationFrameLoop => {
const supportsAnimationFrame = typeof requestAnimationFrame === 'function';
let pendingFrameHandle = 0;
return {
requestNextFrame: (onFrame) => {
pendingFrameHandle = supportsAnimationFrame
? requestAnimationFrame(onFrame)
: (setTimeout(
() => onFrame(performance.now()),
FALLBACK_FRAME_INTERVAL_MS,
) as unknown as number);
},
cancelPendingFrame: () => {
if (supportsAnimationFrame) {
cancelAnimationFrame(pendingFrameHandle);
} else {
clearTimeout(pendingFrameHandle);
}
},
};
};
@@ -0,0 +1,73 @@
import { isDefined } from 'twenty-shared/utils';
import { WELCOME_HALFTONE_DASHES } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneDots';
import { type WelcomeHalftoneController } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneController.type';
import { createWelcomeHalftoneRenderer } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneRenderer';
type CreateWelcomeHalftoneMainThreadControllerOptions = {
canvas: HTMLCanvasElement;
dotColor: string;
dotHighlightColor: string;
prefersReducedMotion: boolean;
initialCanvasWidth: number;
initialCanvasHeight: number;
devicePixelRatio: number;
};
export const createWelcomeHalftoneMainThreadController = ({
canvas,
dotColor,
dotHighlightColor,
prefersReducedMotion,
initialCanvasWidth,
initialCanvasHeight,
devicePixelRatio,
}: CreateWelcomeHalftoneMainThreadControllerOptions): WelcomeHalftoneController | null => {
let renderingContext: CanvasRenderingContext2D | null = null;
try {
renderingContext = canvas.getContext('2d');
} catch {
renderingContext = null;
}
if (!isDefined(renderingContext)) {
return null;
}
const applyBackingStoreSize = (
canvasWidth: number,
canvasHeight: number,
activeDevicePixelRatio: number,
) => {
canvas.width = Math.round(canvasWidth * activeDevicePixelRatio);
canvas.height = Math.round(canvasHeight * activeDevicePixelRatio);
};
applyBackingStoreSize(
initialCanvasWidth,
initialCanvasHeight,
devicePixelRatio,
);
const renderer = createWelcomeHalftoneRenderer({
context: renderingContext,
dashes: WELCOME_HALFTONE_DASHES,
width: initialCanvasWidth,
height: initialCanvasHeight,
devicePixelRatio,
color: dotColor,
highlightColor: dotHighlightColor,
reducedMotion: prefersReducedMotion,
});
return {
leave: () => renderer.leave(),
resize: (nextCanvasWidth, nextCanvasHeight, nextDevicePixelRatio) => {
applyBackingStoreSize(
nextCanvasWidth,
nextCanvasHeight,
nextDevicePixelRatio,
);
renderer.resize(nextCanvasWidth, nextCanvasHeight, nextDevicePixelRatio);
},
destroy: () => renderer.destroy(),
};
};
@@ -0,0 +1,112 @@
import { isDefined } from 'twenty-shared/utils';
import { type WelcomeHalftoneController } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneController.type';
import WelcomeHalftoneWorker from './welcomeHalftone.worker?worker';
const WORKER_READY_TIMEOUT_MS = 1500;
type CreateWelcomeHalftoneWorkerControllerOptions = {
canvas: HTMLCanvasElement;
dotColor: string;
dotHighlightColor: string;
prefersReducedMotion: boolean;
initialCanvasWidth: number;
initialCanvasHeight: number;
devicePixelRatio: number;
onWorkerUnavailable: () => void;
};
export const createWelcomeHalftoneWorkerController = ({
canvas,
dotColor,
dotHighlightColor,
prefersReducedMotion,
initialCanvasWidth,
initialCanvasHeight,
devicePixelRatio,
onWorkerUnavailable,
}: CreateWelcomeHalftoneWorkerControllerOptions): WelcomeHalftoneController | null => {
const isOffscreenCanvasSupported =
typeof Worker !== 'undefined' &&
typeof OffscreenCanvas !== 'undefined' &&
typeof canvas.transferControlToOffscreen === 'function';
if (!isOffscreenCanvasSupported) {
return null;
}
let worker: Worker | null = null;
let workerReadyTimeoutId: ReturnType<typeof setTimeout> | null = null;
let hasWorkerSettled = false;
let hasCanvasBeenTransferred = false;
const settleWorker = () => {
hasWorkerSettled = true;
if (isDefined(workerReadyTimeoutId)) {
clearTimeout(workerReadyTimeoutId);
workerReadyTimeoutId = null;
}
};
const handleWorkerUnavailable = () => {
if (hasWorkerSettled) {
return;
}
settleWorker();
worker?.terminate();
onWorkerUnavailable();
};
try {
worker = new WelcomeHalftoneWorker();
workerReadyTimeoutId = setTimeout(
handleWorkerUnavailable,
WORKER_READY_TIMEOUT_MS,
);
worker.onerror = handleWorkerUnavailable;
worker.onmessage = (event: MessageEvent<{ type?: string }>) => {
if (event.data?.type === 'ready') {
settleWorker();
}
};
const offscreenCanvas = canvas.transferControlToOffscreen();
hasCanvasBeenTransferred = true;
worker.postMessage(
{
type: 'init',
canvas: offscreenCanvas,
width: initialCanvasWidth,
height: initialCanvasHeight,
devicePixelRatio,
color: dotColor,
highlightColor: dotHighlightColor,
reducedMotion: prefersReducedMotion,
},
[offscreenCanvas],
);
return {
leave: () => worker?.postMessage({ type: 'leave' }),
resize: (nextCanvasWidth, nextCanvasHeight, nextDevicePixelRatio) =>
worker?.postMessage({
type: 'resize',
width: nextCanvasWidth,
height: nextCanvasHeight,
devicePixelRatio: nextDevicePixelRatio,
}),
destroy: () => {
settleWorker();
worker?.postMessage({ type: 'stop' });
worker?.terminate();
},
};
} catch {
settleWorker();
worker?.terminate();
if (hasCanvasBeenTransferred) {
onWorkerUnavailable();
}
return null;
}
};
@@ -0,0 +1,4 @@
:root {
--welcome-dot-color: #4a38f5;
--welcome-dot-highlight: #9b91f9;
}
@@ -0,0 +1,80 @@
import { WELCOME_HALFTONE_DASHES } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneDots';
import { createWelcomeHalftoneRenderer } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneRenderer';
type InitMessage = {
type: 'init';
canvas: OffscreenCanvas;
width: number;
height: number;
devicePixelRatio: number;
color: string;
highlightColor: string;
reducedMotion: boolean;
};
type ResizeMessage = {
type: 'resize';
width: number;
height: number;
devicePixelRatio: number;
};
type WelcomeHalftoneWorkerMessage =
| InitMessage
| ResizeMessage
| { type: 'leave' }
| { type: 'stop' };
const workerSelf = self as unknown as {
postMessage: (message: unknown) => void;
};
let renderer: ReturnType<typeof createWelcomeHalftoneRenderer> | null = null;
let offscreenCanvas: OffscreenCanvas | null = null;
const applyBackingSize = (
width: number,
height: number,
devicePixelRatio: number,
) => {
if (offscreenCanvas === null) {
return;
}
offscreenCanvas.width = Math.round(width * devicePixelRatio);
offscreenCanvas.height = Math.round(height * devicePixelRatio);
};
addEventListener(
'message',
(event: MessageEvent<WelcomeHalftoneWorkerMessage>) => {
const message = event.data;
if (message.type === 'init') {
offscreenCanvas = message.canvas;
applyBackingSize(message.width, message.height, message.devicePixelRatio);
const context = offscreenCanvas.getContext('2d');
if (context === null) {
return;
}
renderer = createWelcomeHalftoneRenderer({
context,
dashes: WELCOME_HALFTONE_DASHES,
width: message.width,
height: message.height,
devicePixelRatio: message.devicePixelRatio,
color: message.color,
highlightColor: message.highlightColor,
reducedMotion: message.reducedMotion,
});
workerSelf.postMessage({ type: 'ready' });
} else if (message.type === 'resize') {
applyBackingSize(message.width, message.height, message.devicePixelRatio);
renderer?.resize(message.width, message.height, message.devicePixelRatio);
} else if (message.type === 'leave') {
renderer?.leave();
} else if (message.type === 'stop') {
renderer?.destroy();
renderer = null;
}
},
);
@@ -0,0 +1,9 @@
export type WelcomeHalftoneController = {
leave: () => void;
resize: (
canvasWidth: number,
canvasHeight: number,
devicePixelRatio: number,
) => void;
destroy: () => void;
};
@@ -0,0 +1,16 @@
export type WelcomeHalftoneParticle = {
targetX: number;
targetY: number;
scatterStartX: number;
scatterStartY: number;
dashLength: number;
strokeWidth: number;
burstDirectionX: number;
burstDirectionY: number;
distanceToCenter: number;
assembleDelaySeconds: number;
driftPhase: number;
positionAtLeaveStartX: number;
positionAtLeaveStartY: number;
opacityAtLeaveStart: number;
};
@@ -0,0 +1,227 @@
import { isDefined } from 'twenty-shared/utils';
import { buildWelcomeHalftoneParticles } from '@/onboarding/components/WelcomeOverlay/buildWelcomeHalftoneParticles';
import { createWelcomeHalftoneAnimationFrameLoop } from '@/onboarding/components/WelcomeOverlay/createWelcomeHalftoneAnimationFrameLoop';
import { type WelcomeHalftoneParticle } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneParticle.type';
const DASH_ASSEMBLE_DURATION_SECONDS = 0.62;
const BURST_DURATION_SECONDS = 0.75;
const SETTLE_DRIFT_DURATION_SECONDS = 0.6;
const SHIMMER_BAND_HALF_WIDTH = 0.028;
const MINIMUM_VISIBLE_OPACITY = 0.01;
const clampToUnitRange = (value: number) => Math.max(0, Math.min(1, value));
const interpolate = (from: number, to: number, ratio: number) =>
from + (to - from) * ratio;
const easeOutCubic = (ratio: number) => 1 - Math.pow(1 - ratio, 3);
const easeOutExpo = (ratio: number) =>
ratio >= 1 ? 1 : 1 - Math.pow(2, -10 * ratio);
const smootherStep = (ratio: number) => ratio * ratio * (3 - 2 * ratio);
type WelcomeHalftoneRendererOptions = {
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
dashes: readonly (readonly [number, number, number, number])[];
width: number;
height: number;
devicePixelRatio: number;
color: string;
highlightColor: string;
reducedMotion: boolean;
};
export const createWelcomeHalftoneRenderer = (
options: WelcomeHalftoneRendererOptions,
) => {
const { context, dashes, reducedMotion } = options;
const baseColor = options.color;
const highlightColor = options.highlightColor;
let canvasWidth = options.width;
let canvasHeight = options.height;
let devicePixelRatio = options.devicePixelRatio;
let particles: WelcomeHalftoneParticle[] = [];
let halftoneSize = 0;
let maxDistanceToCenter = 1;
let firstFrameTimeMs: number | null = null;
let leaveStartSeconds: number | null = null;
let hasLeaveBeenRequested = false;
const animationFrameLoop = createWelcomeHalftoneAnimationFrameLoop();
const rebuildParticlesForCurrentSize = () => {
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
context.lineCap = 'round';
const layout = buildWelcomeHalftoneParticles(
dashes,
canvasWidth,
canvasHeight,
);
particles = layout.particles;
halftoneSize = layout.halftoneSize;
maxDistanceToCenter = layout.maxDistanceToCenter;
};
const computeAssembleState = (
particle: WelcomeHalftoneParticle,
elapsedSeconds: number,
) => {
const assembleProgress = easeOutExpo(
clampToUnitRange(
(elapsedSeconds - particle.assembleDelaySeconds) /
DASH_ASSEMBLE_DURATION_SECONDS,
),
);
let particleX = interpolate(
particle.scatterStartX,
particle.targetX,
assembleProgress,
);
let particleY = interpolate(
particle.scatterStartY,
particle.targetY,
assembleProgress,
);
const settleDriftProgress = clampToUnitRange(
(elapsedSeconds -
(particle.assembleDelaySeconds + DASH_ASSEMBLE_DURATION_SECONDS)) /
SETTLE_DRIFT_DURATION_SECONDS,
);
particleX +=
Math.sin(elapsedSeconds * 1.6 + particle.driftPhase) *
settleDriftProgress *
1.2;
particleY +=
Math.cos(elapsedSeconds * 1.4 + particle.driftPhase) *
settleDriftProgress *
0.8;
return { particleX, particleY, opacity: assembleProgress };
};
const drawFrame = (timeMs: number) => {
if (!isDefined(firstFrameTimeMs)) {
firstFrameTimeMs = timeMs;
}
const elapsedSeconds = (timeMs - firstFrameTimeMs) / 1000;
if (hasLeaveBeenRequested && !isDefined(leaveStartSeconds)) {
leaveStartSeconds = elapsedSeconds;
for (const particle of particles) {
if (reducedMotion) {
particle.positionAtLeaveStartX = particle.targetX;
particle.positionAtLeaveStartY = particle.targetY;
particle.opacityAtLeaveStart = 1;
} else {
const assembleState = computeAssembleState(particle, elapsedSeconds);
particle.positionAtLeaveStartX = assembleState.particleX;
particle.positionAtLeaveStartY = assembleState.particleY;
particle.opacityAtLeaveStart = assembleState.opacity;
}
}
}
const isLeaving = isDefined(leaveStartSeconds);
const burstProgress = isDefined(leaveStartSeconds)
? clampToUnitRange(
(elapsedSeconds - leaveStartSeconds) / BURST_DURATION_SECONDS,
)
: 0;
context.clearRect(0, 0, canvasWidth, canvasHeight);
const shimmerSweepPosition = ((elapsedSeconds * 0.5) % 1.3) / 1.3;
const isShimmerActive = !reducedMotion && !isLeaving;
const inverseViewportSpan = 1 / (canvasWidth + canvasHeight);
let currentStrokeColor = '';
for (const particle of particles) {
let particleX: number;
let particleY: number;
let particleOpacity: number;
let capsuleLength = particle.dashLength;
let capsuleDirectionX = 1;
let capsuleDirectionY = 0;
if (reducedMotion) {
particleX = particle.targetX;
particleY = particle.targetY;
particleOpacity = isLeaving ? 1 - burstProgress : 1;
} else if (!isLeaving) {
const assembleState = computeAssembleState(particle, elapsedSeconds);
particleX = assembleState.particleX;
particleY = assembleState.particleY;
particleOpacity = assembleState.opacity;
} else {
const easedBurstProgress = smootherStep(burstProgress);
const outwardPushDistance =
easedBurstProgress *
halftoneSize *
1.1 *
(0.6 + 0.8 * (particle.distanceToCenter / maxDistanceToCenter));
particleX =
particle.positionAtLeaveStartX +
particle.burstDirectionX * outwardPushDistance;
particleY =
particle.positionAtLeaveStartY +
particle.burstDirectionY * outwardPushDistance;
particleOpacity =
particle.opacityAtLeaveStart *
(1 - easeOutCubic(clampToUnitRange(burstProgress / 0.85)));
capsuleLength =
particle.dashLength + outwardPushDistance * 0.12 * burstProgress;
capsuleDirectionX = particle.burstDirectionX;
capsuleDirectionY = particle.burstDirectionY;
}
if (particleOpacity <= MINIMUM_VISIBLE_OPACITY) {
continue;
}
const isParticleInShimmerBand =
isShimmerActive &&
Math.abs(
(particleX + particleY) * inverseViewportSpan - shimmerSweepPosition,
) < SHIMMER_BAND_HALF_WIDTH;
const strokeColor = isParticleInShimmerBand ? highlightColor : baseColor;
if (strokeColor !== currentStrokeColor) {
context.strokeStyle = strokeColor;
currentStrokeColor = strokeColor;
}
context.globalAlpha = particleOpacity;
context.lineWidth = particle.strokeWidth;
context.beginPath();
const halfCapsuleX = (capsuleDirectionX * capsuleLength) / 2;
const halfCapsuleY = (capsuleDirectionY * capsuleLength) / 2;
context.moveTo(particleX - halfCapsuleX, particleY - halfCapsuleY);
context.lineTo(particleX + halfCapsuleX, particleY + halfCapsuleY);
context.stroke();
}
context.globalAlpha = 1;
animationFrameLoop.requestNextFrame(drawFrame);
};
rebuildParticlesForCurrentSize();
animationFrameLoop.requestNextFrame(drawFrame);
return {
leave: () => {
hasLeaveBeenRequested = true;
},
resize: (
nextCanvasWidth: number,
nextCanvasHeight: number,
nextDevicePixelRatio: number,
) => {
canvasWidth = nextCanvasWidth;
canvasHeight = nextCanvasHeight;
devicePixelRatio = nextDevicePixelRatio;
rebuildParticlesForCurrentSize();
},
destroy: () => {
animationFrameLoop.cancelPendingFrame();
},
};
};
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isWelcomeAnimationVisibleState = createAtomState<boolean>({
key: 'isWelcomeAnimationVisibleState',
defaultValue: false,
});
@@ -0,0 +1,54 @@
import { shouldShowWelcomeAnimationOnNavigate } from '@/onboarding/utils/shouldShowWelcomeAnimationOnNavigate';
import { OnboardingStatus } from '~/generated-metadata/graphql';
describe('shouldShowWelcomeAnimationOnNavigate', () => {
it('should return true when leaving an onboarding page for the workspace after completion', () => {
expect(
shouldShowWelcomeAnimationOnNavigate({
onboardingStatus: OnboardingStatus.COMPLETED,
isOnOnboardingPage: true,
isNavigatingToOnboardingOrAuthPath: false,
}),
).toBe(true);
});
it('should return false when onboarding is not yet completed', () => {
expect(
shouldShowWelcomeAnimationOnNavigate({
onboardingStatus: OnboardingStatus.INVITE_TEAM,
isOnOnboardingPage: true,
isNavigatingToOnboardingOrAuthPath: false,
}),
).toBe(false);
});
it('should return false when navigating to another onboarding or auth path', () => {
expect(
shouldShowWelcomeAnimationOnNavigate({
onboardingStatus: OnboardingStatus.COMPLETED,
isOnOnboardingPage: true,
isNavigatingToOnboardingOrAuthPath: true,
}),
).toBe(false);
});
it('should return false when the current page is not an onboarding page', () => {
expect(
shouldShowWelcomeAnimationOnNavigate({
onboardingStatus: OnboardingStatus.COMPLETED,
isOnOnboardingPage: false,
isNavigatingToOnboardingOrAuthPath: false,
}),
).toBe(false);
});
it('should return false when the onboarding status is undefined', () => {
expect(
shouldShowWelcomeAnimationOnNavigate({
onboardingStatus: undefined,
isOnOnboardingPage: true,
isNavigatingToOnboardingOrAuthPath: false,
}),
).toBe(false);
});
});
@@ -0,0 +1,16 @@
import { OnboardingStatus } from '~/generated-metadata/graphql';
type ShouldShowWelcomeAnimationOnNavigateArgs = {
onboardingStatus: OnboardingStatus | null | undefined;
isOnOnboardingPage: boolean;
isNavigatingToOnboardingOrAuthPath: boolean;
};
export const shouldShowWelcomeAnimationOnNavigate = ({
onboardingStatus,
isOnOnboardingPage,
isNavigatingToOnboardingOrAuthPath,
}: ShouldShowWelcomeAnimationOnNavigateArgs): boolean =>
onboardingStatus === OnboardingStatus.COMPLETED &&
isOnOnboardingPage &&
!isNavigatingToOnboardingOrAuthPath;
@@ -20,6 +20,7 @@ export enum RootStackingContextZIndices {
RootModal = 40,
DropdownPortalAboveModal = 50,
Dialog = 9999,
WelcomeOverlay = 10000,
SnackBar = 10002,
NotFound = 10001,
}