merged master into the branch team/completing
continuous-integration/drone/push Build is passing Details

pull/84/head
Maël DAIM 1 year ago
commit 59e9cf6503

1
.gitignore vendored

@ -8,6 +8,7 @@ vendor
composer.lock composer.lock
*.phar *.phar
/dist /dist
.guard
# sqlite database files # sqlite database files
*.sqlite *.sqlite

@ -1,12 +0,0 @@
export function calculateRatio(
it: { x: number; y: number },
parent: DOMRect,
): { x: number; y: number } {
const relativeXPixels = it.x - parent.x
const relativeYPixels = it.y - parent.y
const xRatio = relativeXPixels / parent.width
const yRatio = relativeYPixels / parent.height
return { x: xRatio, y: yRatio }
}

@ -1,63 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
width="100"
height="50"
viewBox="7.5 18.5 85.5 56"
style="enable-background:new 7.5 18.5 85.5 56;"
xml:space="preserve">
<style type="text/css">
.st0{fill:none;stroke:#000000;stroke-miterlimit:10;}
.st1{fill:none;stroke:#000000;stroke-miterlimit:10;stroke-dasharray:1.4358,1.4358;}
.st2{fill:none;stroke:#000000;stroke-width:0.5;stroke-miterlimit:10;}
.st3{fill:none;stroke:#000000;stroke-miterlimit:10;stroke-dasharray:1.4407,1.4407;}
</style>
<polygon class="st0" points="92.1,72.1 50.1,72.1 8.1,72.1 8.1,21.2 50.1,21.2 92.1,21.2 "/>
<line class="st0" x1="50.1" y1="21.2" x2="50.1" y2="72.1"/>
<circle class="st0" cx="50.1" cy="46.6" r="6.4"/>
<path class="st0" d="M8.1,66h7.2c10.1,0,18.2-8.7,18.2-19.3s-8.2-19.3-18.2-19.3H8.1"/>
<path class="st0" d="M8.1,40.2h19c3.6,0,6.4,2.9,6.4,6.4s-2.9,6.4-6.4,6.4h-19"/>
<line class="st0" x1="27.1" y1="40.2" x2="27.1" y2="53.1"/>
<g>
<g><path class="st0" d="M27.4,40.3c-0.3,0-0.5,0-0.7,0"/>
<path class="st1"
d="M25.3,40.7c-2.5,0.9-4.3,3.3-4.3,6.1c0,3,2.2,5.6,5,6.2"/>
<path
class="st0" d="M26.7,53c0.2,0,0.5,0,0.7,0"/>
</g>
</g>
<line class="st0" x1="16.2" y1="53.1" x2="16.2" y2="54.1"/>
<line class="st2" x1="19.3" y1="53.1" x2="19.3" y2="54.1"/>
<line class="st2" x1="22.4" y1="53.1" x2="22.4" y2="54.1"/>
<line class="st2" x1="25.7" y1="53.1" x2="25.7" y2="54.1"/>
<line class="st0" x1="16.1" y1="39.2" x2="16.1" y2="40.2"/>
<line class="st2" x1="19.2" y1="39.2" x2="19.2" y2="40.2"/>
<line class="st2" x1="22.3" y1="39.2" x2="22.3" y2="40.2"/>
<line class="st2" x1="25.6" y1="39.2" x2="25.6" y2="40.2"/>
<line class="st0" x1="27.1" y1="40.2" x2="27.1" y2="53.1"/>
<path class="st0" d="M92.1,66.1h-7.2c-10.1,0-18.2-8.7-18.2-19.3s8.2-19.3,18.2-19.3h7.2"/>
<path class="st0" d="M92.1,40.3h-19c-3.6,0-6.4,2.9-6.4,6.4s2.9,6.4,6.4,6.4h19"/>
<line class="st0" x1="84" y1="53.2" x2="84" y2="54.1"/>
<line class="st2" x1="80.9" y1="53.2" x2="80.9" y2="54.1"/>
<line class="st2" x1="77.9" y1="53.2" x2="77.9" y2="54.1"/>
<line class="st2" x1="74.5" y1="53.2" x2="74.5" y2="54.1"/>
<line class="st0" x1="84.1" y1="39.3" x2="84.1" y2="40.3"/>
<line class="st2" x1="81" y1="39.3" x2="81" y2="40.3"/>
<line class="st2" x1="77.9" y1="39.3" x2="77.9" y2="40.3"/>
<line class="st2" x1="74.6" y1="39.3" x2="74.6" y2="40.3"/>
<line class="st0" x1="73.1" y1="40.3" x2="73.1" y2="53.2"/>
<line class="st2" x1="36.2" y1="70" x2="36.2" y2="74.1"/>
<line class="st2" x1="63.5" y1="70" x2="63.5" y2="74.1"/>
<line class="st2" x1="36.2" y1="19.1" x2="36.2" y2="23.2"/>
<line class="st2" x1="63.5" y1="19.1" x2="63.5" y2="23.2"/>
<g xmlns="http://www.w3.org/2000/svg">
<g>
<path class="st0" d="M72.9,40.3c0.3,0,0.5,0,0.7,0"/>
<path class="st3"
d="M75,40.7c2.5,0.9,4.3,3.3,4.3,6.1c0,3-2.2,5.6-5.1,6.2"/>
<path
class="st0" d="M73.5,53.1c-0.2,0-0.5,0-0.7,0"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

@ -0,0 +1,135 @@
<svg width="567" height="269" viewBox="0 0 567 269" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="73" y1="24" x2="495" y2="24" stroke="black" stroke-width="2"/>
<line x1="494" y1="23" x2="494" y2="247" stroke="black" stroke-width="2"/>
<line x1="495" y1="248" x2="73" y2="248" stroke="black" stroke-width="2"/>
<line x1="72" y1="249" x2="72" y2="23" stroke="black" stroke-width="2"/>
<line x1="283.5" y1="23" x2="283.5" y2="247" stroke="black"/>
<g filter="url(#filter0_i_3_2)">
<circle cx="283.5" cy="135.5" r="27" stroke="black"/>
</g>
<line x1="73" y1="99.5" x2="158" y2="99.5" stroke="black"/>
<line x1="73" y1="100.5" x2="158" y2="100.5" stroke="black"/>
<path d="M158.5 172V100" stroke="black"/>
<path d="M158 99.5H159" stroke="black"/>
<path d="M158 172.5H159" stroke="black"/>
<line x1="158" y1="172.5" x2="73" y2="172.5" stroke="black"/>
<line x1="158" y1="171.5" x2="73" y2="171.5" stroke="black"/>
<line x1="73" y1="37.5" x2="139" y2="37.5" stroke="black"/>
<line x1="73" y1="233.5" x2="139" y2="233.5" stroke="black"/>
<g filter="url(#filter1_i_3_2)">
<path d="M158.5 163C161.98 163 165.426 162.315 168.641 160.983C171.856 159.651 174.778 157.699 177.238 155.238C179.699 152.778 181.651 149.856 182.983 146.641C184.315 143.426 185 139.98 185 136.5C185 133.02 184.315 129.574 182.983 126.359C181.651 123.144 179.699 120.222 177.238 117.762C174.778 115.301 171.856 113.349 168.641 112.017C165.426 110.685 161.98 110 158.5 110L158.5 136.5L158.5 163Z" stroke="black"/>
</g>
<g filter="url(#filter2_i_3_2)">
<path d="M158.5 110C155.02 110 151.574 110.685 148.359 112.017C145.144 113.349 142.222 115.301 139.762 117.762C137.301 120.222 135.349 123.144 134.017 126.359C132.685 129.574 132 133.02 132 136.5C132 139.98 132.685 143.426 134.017 146.641C135.349 149.856 137.301 152.778 139.762 155.238C142.222 157.699 145.144 159.651 148.359 160.983C151.574 162.315 155.02 163 158.5 163" stroke="black" stroke-dasharray="4 4"/>
</g>
<line x1="135.5" y1="177" x2="135.5" y2="172" stroke="black"/>
<line x1="123.5" y1="177" x2="123.5" y2="172" stroke="black"/>
<line x1="111.5" y1="177" x2="111.5" y2="172" stroke="black"/>
<line x1="99.5" y1="177" x2="99.5" y2="172" stroke="black"/>
<line x1="135.5" y1="100" x2="135.5" y2="95" stroke="black"/>
<line x1="123.5" y1="100" x2="123.5" y2="95" stroke="black"/>
<line x1="111.5" y1="100" x2="111.5" y2="95" stroke="black"/>
<line x1="99.5" y1="100" x2="99.5" y2="95" stroke="black"/>
<path d="M140.212 233.607C159.054 225.612 175.149 211.967 186.427 194.431C197.705 176.895 203.649 156.271 203.497 135.213C203.345 114.155 197.104 93.6242 185.574 76.2645C174.045 58.9047 157.755 45.5096 138.799 37.8066" stroke="black"/>
<path d="M140 233.5H141" stroke="black"/>
<path d="M139 233.5H140" stroke="black"/>
<path d="M90.5 118.5C95.0041 118.5 99.3266 120.34 102.516 123.621C105.706 126.901 107.5 131.354 107.5 136C107.5 140.646 105.706 145.099 102.516 148.379C99.3266 151.66 95.0041 153.5 90.5 153.5" stroke="black"/>
<circle cx="87.5" cy="136.5" r="3" stroke="black"/>
<line x1="83.5" y1="149" x2="83.5" y2="123" stroke="black"/>
<line y1="-0.5" x2="85" y2="-0.5" transform="matrix(-1 0 0 1 494 100)" stroke="black"/>
<line y1="-0.5" x2="85" y2="-0.5" transform="matrix(-1 0 0 1 494 101)" stroke="black"/>
<path d="M408.5 172V100" stroke="black"/>
<path d="M409 99.5H408" stroke="black"/>
<path d="M409 172.5H408" stroke="black"/>
<line y1="-0.5" x2="85" y2="-0.5" transform="matrix(1 0 0 -1 409 172)" stroke="black"/>
<line y1="-0.5" x2="85" y2="-0.5" transform="matrix(1 0 0 -1 409 171)" stroke="black"/>
<line y1="-0.5" x2="66" y2="-0.5" transform="matrix(-1 0 0 1 494 38)" stroke="black"/>
<line y1="-0.5" x2="66" y2="-0.5" transform="matrix(-1 0 0 1 494 234)" stroke="black"/>
<g filter="url(#filter3_i_3_2)">
<path d="M408.5 163C405.02 163 401.574 162.315 398.359 160.983C395.144 159.651 392.222 157.699 389.762 155.238C387.301 152.778 385.349 149.856 384.017 146.641C382.685 143.426 382 139.98 382 136.5C382 133.02 382.685 129.574 384.017 126.359C385.349 123.144 387.301 120.222 389.762 117.762C392.222 115.301 395.144 113.349 398.359 112.017C401.574 110.685 405.02 110 408.5 110L408.5 136.5L408.5 163Z" stroke="black"/>
</g>
<g filter="url(#filter4_i_3_2)">
<path d="M408.5 110C411.98 110 415.426 110.685 418.641 112.017C421.856 113.349 424.778 115.301 427.238 117.762C429.699 120.222 431.651 123.144 432.983 126.359C434.315 129.574 435 133.02 435 136.5C435 139.98 434.315 143.426 432.983 146.641C431.651 149.856 429.699 152.778 427.238 155.238C424.778 157.699 421.856 159.651 418.641 160.983C415.426 162.315 411.98 163 408.5 163" stroke="black" stroke-dasharray="4 4"/>
</g>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 431 177)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 443 177)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 455 177)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 467 177)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 431 100)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 443 100)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 455 100)" stroke="black"/>
<line y1="-0.5" x2="5" y2="-0.5" transform="matrix(4.37114e-08 -1 -1 -4.37114e-08 467 100)" stroke="black"/>
<path d="M426.788 233.607C407.946 225.612 391.851 211.967 380.573 194.431C369.295 176.895 363.351 156.271 363.503 135.213C363.655 114.155 369.896 93.6242 381.426 76.2645C392.955 58.9047 409.245 45.5096 428.201 37.8066" stroke="black"/>
<path d="M427 233.5H426" stroke="black"/>
<path d="M428 233.5H427" stroke="black"/>
<g filter="url(#filter5_i_3_2)">
<path d="M476.5 118.5C471.996 118.5 467.673 120.34 464.484 123.621C461.294 126.901 459.5 131.354 459.5 136C459.5 140.646 461.294 145.099 464.484 148.379C467.673 151.66 471.996 153.5 476.5 153.5" stroke="black"/>
</g>
<circle cx="3.5" cy="3.5" r="3" transform="matrix(-1 0 0 1 483 133)" stroke="black"/>
<line y1="-0.5" x2="26" y2="-0.5" transform="matrix(0 -1 -1 0 483 149)" stroke="black"/>
<path d="M138 37.5H139" stroke="black"/>
<path d="M139.225 38.1519C139.221 38.1187 139.099 38.1364 139.073 38.135C138.98 38.1302 138.889 38.0674 138.794 38.0674C138.755 38.0674 138.672 38.0388 138.633 38.0252C138.581 38.0065 138.52 37.9719 138.465 37.9661C138.4 37.9593 138.366 37.9109 138.312 37.8854C138.275 37.8677 138.202 37.8366 138.177 37.8056C138.161 37.7847 138.063 37.7566 138.033 37.7371C137.962 37.6922 137.879 37.6309 137.806 37.5944" stroke="black" stroke-width="0.5"/>
<path d="M139.926 37.9883C139.906 37.9888 139.887 37.9922 139.867 37.9922C139.838 37.9922 139.812 37.978 139.787 37.9622C139.682 37.8946 139.599 37.7958 139.504 37.7151C139.388 37.6172 139.275 37.5101 139.135 37.4512C139.051 37.4159 138.965 37.3877 138.879 37.3594C138.85 37.3498 138.785 37.3113 138.754 37.332" stroke="black" stroke-width="0.5"/>
<defs>
<filter id="filter0_i_3_2" x="256" y="108" width="55" height="59" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
<filter id="filter1_i_3_2" x="158" y="109.5" width="27.5" height="58" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
<filter id="filter2_i_3_2" x="131.5" y="109.5" width="27" height="58" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
<filter id="filter3_i_3_2" x="381.5" y="109.5" width="27.5" height="58" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
<filter id="filter4_i_3_2" x="408.5" y="109.5" width="27" height="58" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
<filter id="filter5_i_3_2" x="459" y="118" width="17.5" height="40" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_2"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

@ -1,22 +1,74 @@
<svg width="100" height="50" viewBox="0 0 80 58" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="269" height="309" viewBox="0 0 269 309" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.74466 0.676824V28.6768V56.6768L76.7447 56.6768V28.6768V0.676824L2.74466 0.676824Z" stroke="black" stroke-miterlimit="10"/> <rect y="254" width="250" height="269" transform="rotate(-90 0 254)" fill="#D9D9D9"/>
<path d="M76.8393 0.876801L3.21608 0.876801" stroke="black" stroke-miterlimit="10"/> <line x1="24" y1="236" x2="24" y2="26" stroke="black" stroke-width="2"/>
<path d="M12.0393 56.8768V47.2768C12.0393 33.8101 24.6232 23.0101 39.9554 23.0101C55.2875 23.0101 67.8715 33.9435 67.8715 47.2768V56.8768" stroke="black" stroke-miterlimit="10"/> <line x1="248" y1="25" x2="248" y2="236" stroke="black" stroke-width="2"/>
<path d="M49.3571 56.8768V31.5435C49.3571 26.7435 45.1625 23.0101 40.1 23.0101C35.0375 23.0101 30.8429 26.8768 30.8429 31.5435V56.8768" stroke="black" stroke-miterlimit="10"/> <line x1="249" y1="237" x2="23" y2="237" stroke="black" stroke-width="2"/>
<path d="M49.3571 31.5435H30.6982" stroke="black" stroke-miterlimit="10"/> <line x1="23" y1="25.5" x2="247" y2="25.5" stroke="black"/>
<path d="M49.2125 31.1435C49.2125 31.5435 49.2125 31.8101 49.2125 32.0768" stroke="black" stroke-miterlimit="10"/> <g filter="url(#filter0_i_3_4)">
<path d="M48.6339 33.9435C47.3322 37.2768 43.8607 39.6768 39.8107 39.6768C35.4715 39.6768 31.7107 36.7435 30.8429 33.0102" stroke="black" stroke-miterlimit="10" stroke-dasharray="1.44 1.44"/> <path d="M108.5 26C108.5 33.0313 111.347 39.7727 116.411 44.7417C121.476 49.7103 128.342 52.5 135.5 52.5C142.658 52.5 149.524 49.7103 154.588 44.7417C159.653 39.7727 162.5 33.0313 162.5 26" stroke="black"/>
<path d="M30.8429 32.0768C30.8429 31.8101 30.8429 31.4101 30.8429 31.1435" stroke="black" stroke-miterlimit="10"/> </g>
<path d="M30.6982 46.0768H29.2518" stroke="black" stroke-miterlimit="10"/> <line x1="99.5" y1="236" x2="99.5" y2="151" stroke="black"/>
<path d="M30.6982 41.9435H29.2518" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/> <line x1="100.5" y1="236" x2="100.5" y2="151" stroke="black"/>
<path d="M30.6982 37.8102H29.2518" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/> <path d="M172 150.5H100" stroke="black"/>
<path d="M30.6982 33.4102H29.2518" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/> <path d="M99.5 151V150" stroke="black"/>
<path d="M50.8036 46.2101H49.3572" stroke="black" stroke-miterlimit="10"/> <path d="M172.5 151V150" stroke="black"/>
<path d="M50.8036 42.0768H49.3572" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/> <line x1="172.5" y1="151" x2="172.5" y2="236" stroke="black"/>
<path d="M50.8036 37.9435H49.3572" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/> <line x1="171.5" y1="151" x2="171.5" y2="236" stroke="black"/>
<path d="M50.8036 33.5435H49.3572" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/> <line x1="37.5" y1="236" x2="37.5" y2="170" stroke="black"/>
<path d="M49.3571 31.5435H30.6982" stroke="black" stroke-miterlimit="10"/> <line x1="233.5" y1="236" x2="233.5" y2="170" stroke="black"/>
<path d="M6.25357 19.4102H0.323216" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/> <g filter="url(#filter1_i_3_4)">
<path d="M79.8768 19.4102H73.9464" stroke="black" stroke-width="0.5" stroke-miterlimit="10"/> <path d="M163 150.5C163 147.02 162.315 143.574 160.983 140.359C159.651 137.144 157.699 134.222 155.238 131.762C152.778 129.301 149.856 127.349 146.641 126.017C143.426 124.685 139.98 124 136.5 124C133.02 124 129.574 124.685 126.359 126.017C123.144 127.349 120.222 129.301 117.762 131.762C115.301 134.222 113.349 137.144 112.017 140.359C110.685 143.574 110 147.02 110 150.5L136.5 150.5L163 150.5Z" stroke="black"/>
<path d="M30.7447 0.67682C30.7447 5.64738 34.998 9.67682 40.2447 9.67682C45.4914 9.67682 49.7447 5.64738 49.7447 0.67682" stroke="black"/> </g>
<g filter="url(#filter2_i_3_4)">
<path d="M110 150.5C110 153.98 110.685 157.426 112.017 160.641C113.349 163.856 115.301 166.778 117.762 169.238C120.222 171.699 123.144 173.651 126.359 174.983C129.574 176.315 133.02 177 136.5 177C139.98 177 143.426 176.315 146.641 174.983C149.856 173.651 152.778 171.699 155.238 169.238C157.699 166.778 159.651 163.856 160.983 160.641C162.315 157.426 163 153.98 163 150.5" stroke="black" stroke-dasharray="4 4"/>
</g>
<line x1="177" y1="173.5" x2="172" y2="173.5" stroke="black"/>
<line x1="177" y1="185.5" x2="172" y2="185.5" stroke="black"/>
<line x1="177" y1="197.5" x2="172" y2="197.5" stroke="black"/>
<line x1="177" y1="209.5" x2="172" y2="209.5" stroke="black"/>
<line x1="100" y1="173.5" x2="95" y2="173.5" stroke="black"/>
<line x1="100" y1="185.5" x2="95" y2="185.5" stroke="black"/>
<line x1="100" y1="197.5" x2="95" y2="197.5" stroke="black"/>
<line x1="100" y1="209.5" x2="95" y2="209.5" stroke="black"/>
<path d="M233.607 168.788C225.612 149.946 211.967 133.851 194.431 122.573C176.895 111.295 156.271 105.351 135.213 105.503C114.155 105.655 93.6242 111.896 76.2645 123.426C58.9047 134.955 45.5096 151.245 37.8066 170.201" stroke="black"/>
<path d="M233.5 169V168" stroke="black"/>
<path d="M233.5 170V169" stroke="black"/>
<path d="M118.5 218.5C118.5 213.996 120.34 209.673 123.621 206.484C126.901 203.294 131.354 201.5 136 201.5C140.646 201.5 145.099 203.294 148.379 206.484C151.66 209.673 153.5 213.996 153.5 218.5" stroke="black"/>
<circle cx="136.5" cy="221.5" r="3" transform="rotate(-90 136.5 221.5)" stroke="black"/>
<line x1="149" y1="225.5" x2="123" y2="225.5" stroke="black"/>
<path d="M37.5 171V170" stroke="black"/>
<path d="M38.1519 169.775C38.1187 169.779 38.1364 169.901 38.135 169.927C38.1302 170.02 38.0674 170.111 38.0674 170.206C38.0674 170.245 38.0388 170.328 38.0252 170.367C38.0065 170.419 37.9719 170.48 37.9661 170.535C37.9593 170.6 37.9109 170.634 37.8854 170.688C37.8677 170.725 37.8366 170.798 37.8056 170.823C37.7847 170.839 37.7566 170.937 37.7371 170.967C37.6922 171.038 37.6309 171.121 37.5944 171.194" stroke="black" stroke-width="0.5"/>
<path d="M37.9883 169.074C37.9888 169.094 37.9922 169.113 37.9922 169.133C37.9922 169.162 37.978 169.188 37.9622 169.213C37.8946 169.318 37.7958 169.401 37.7151 169.496C37.6172 169.612 37.5101 169.725 37.4512 169.865C37.4159 169.949 37.3877 170.035 37.3594 170.121C37.3498 170.15 37.3113 170.215 37.332 170.246" stroke="black" stroke-width="0.5"/>
<defs>
<filter id="filter0_i_3_4" x="108" y="26" width="55" height="31" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_4"/>
</filter>
<filter id="filter1_i_3_4" x="109.5" y="123.5" width="54" height="31.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_4"/>
</filter>
<filter id="filter2_i_3_4" x="109.5" y="150.5" width="54" height="31" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_3_4"/>
</filter>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""> <g transform="matrix(1.0899999999999992,0,0,1.0899999999999992,-23.040010986327673,-23.03998867034892)"> <path fill-rule="evenodd" d="M250.131 307.122 424.673 132.58v73.421c0 17.6 14.4 31.999 31.999 31.999 17.6 0 32-14.399 32-31.999V23.328H305.998c-17.6 0-31.999 14.4-31.999 31.999 0 17.6 14.399 32 31.999 32h73.421L204.935 261.81c-39.932 40.129-110.352 12.463-110.352-45.627 0-35.683 28.926-64.609 64.609-64.609 30.018 0 55.252 20.472 62.508 48.216l48.526-48.526c-22.324-38.099-63.688-63.689-111.034-63.689-71.028 0-128.608 57.58-128.608 128.608.001 115.408 139.655 170.832 219.547 90.939zm-149.25 58.743c25.733 10.037 53.881 13.203 81.205 9.303l-104.17 104.17c-12.445 12.445-32.809 12.445-45.254 0s-12.445-32.809 0-45.254z" clip-rule="evenodd" fill="#F00" opacity="1" data-original="#F00" class=""> </path> </g> </svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -1,4 +1,4 @@
<svg width="80" height="49" viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.5 4.5H55.5C66.5457 4.5 75.5 13.4543 75.5 24.5C75.5 35.5457 66.5457 44.5 55.5 44.5H24.5C13.4543 44.5 4.5 35.5457 4.5 24.5C4.5 13.4543 13.4543 4.5 24.5 4.5Z" <path d="M24.5 4.5H55.5C66.5457 4.5 75.5 13.4543 75.5 24.5C75.5 35.5457 66.5457 44.5 55.5 44.5H24.5C13.4543 44.5 4.5 35.5457 4.5 24.5C4.5 13.4543 13.4543 4.5 24.5 4.5Z"
stroke="black" stroke-width="9"/> stroke="black" stroke-width="9"/>
<line x1="24.5" y1="24.5" x2="55.5" y2="24.5" stroke="black" stroke-width="9" stroke-linecap="round"/> <line x1="24.5" y1="24.5" x2="55.5" y2="24.5" stroke="black" stroke-width="9" stroke-linecap="round"/>

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 405 B

@ -24,7 +24,7 @@ export default function TitleInput({
value={value} value={value}
onChange={(event) => setValue(event.target.value)} onChange={(event) => setValue(event.target.value)}
onBlur={(_) => on_validated(value)} onBlur={(_) => on_validated(value)}
onKeyDown={(event) => { onKeyUp={(event) => {
if (event.key == "Enter") ref.current?.blur() if (event.key == "Enter") ref.current?.blur()
}} }}
/> />

@ -0,0 +1,61 @@
import "../../style/actions/arrow_action.css"
import Draggable from "react-draggable"
import arrowPng from "../../assets/icon/arrow.svg"
import { useRef } from "react"
export interface ArrowActionProps {
onHeadDropped: (headBounds: DOMRect) => void
onHeadPicked: (headBounds: DOMRect) => void
onHeadMoved: (headBounds: DOMRect) => void
}
export default function ArrowAction({
onHeadDropped,
onHeadPicked,
onHeadMoved,
}: ArrowActionProps) {
const arrowHeadRef = useRef<HTMLDivElement>(null)
return (
<div className="arrow-action">
<img className="arrow-action-icon" src={arrowPng} alt="add arrow" />
<Draggable
nodeRef={arrowHeadRef}
onStart={() => {
const headBounds =
arrowHeadRef.current!.getBoundingClientRect()
onHeadPicked(headBounds)
}}
onStop={() => {
const headBounds =
arrowHeadRef.current!.getBoundingClientRect()
onHeadDropped(headBounds)
}}
onDrag={() => {
const headBounds =
arrowHeadRef.current!.getBoundingClientRect()
onHeadMoved(headBounds)
}}
position={{ x: 0, y: 0 }}>
<div ref={arrowHeadRef} className="arrow-head-pick" />
</Draggable>
</div>
)
}
export function ScreenHead() {
return (
<div
style={{ backgroundColor: "black", height: "5px", width: "25px" }}
/>
)
}
export function MoveToHead() {
return (
<svg viewBox={"0 0 50 50"} width={20} height={20}>
<polygon points={"50 0, 0 0, 25 40"} fill="#000" />
</svg>
)
}

@ -0,0 +1,18 @@
import { BallPiece } from "../editor/BallPiece"
import Draggable from "react-draggable"
import { useRef } from "react"
export interface BallActionProps {
onDrop: (el: HTMLElement) => void
}
export default function BallAction({ onDrop }: BallActionProps) {
const ref = useRef<HTMLDivElement>(null)
return (
<Draggable onStop={() => onDrop(ref.current!)} nodeRef={ref}>
<div ref={ref}>
<BallPiece />
</div>
</Draggable>
)
}

@ -0,0 +1,757 @@
import {
CSSProperties,
ReactElement,
RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
MouseEvent as ReactMouseEvent,
} from "react"
import {
add,
angle,
middle,
distance,
middlePos,
minus,
mul,
Pos,
posWithinBase,
ratioWithinBase,
relativeTo,
norm,
} from "./Pos"
import "../../style/bendable_arrows.css"
import Draggable from "react-draggable"
export interface BendableArrowProps {
area: RefObject<HTMLElement>
startPos: Pos
segments: Segment[]
onSegmentsChanges: (edges: Segment[]) => void
forceStraight: boolean
wavy: boolean
startRadius?: number
endRadius?: number
onDeleteRequested?: () => void
style?: ArrowStyle
}
export interface ArrowStyle {
width?: number
dashArray?: string
head?: () => ReactElement
tail?: () => ReactElement
}
const ArrowStyleDefaults: ArrowStyle = {
width: 3,
}
export interface Segment {
next: Pos
controlPoint?: Pos
}
/**
* Given a circle shaped by a central position, and a radius, return
* a position that is constrained on its perimeter, pointing to the direction
* between the circle's center and the reference position.
* @param center circle's center.
* @param reference a reference point used to create the angle where the returned position
* will point to on the circle's perimeter
* @param radius circle's radius.
*/
function constraintInCircle(center: Pos, reference: Pos, radius: number): Pos {
const theta = angle(center, reference)
return {
x: center.x - Math.sin(theta) * radius,
y: center.y - Math.cos(theta) * radius,
}
}
/**
* An arrow that follows a bézier curve built from given segments that can be edited, added or removed by the user
* The arrow only works with relative positions within a given area.
* All position handled by the arrow must be positions where x and y are a percentage within the area's surface
* (0.5, 0.5) is a position at the middle of the area
* (1, 0.75) means that the position is at 100percent to the right of given area, and 75 percent to the bottom
* @param area
* @param startPos
* @param segments
* @param onSegmentsChanges
* @param wavy
* @param forceStraight
* @param style
* @param startRadius
* @param endRadius
* @param onDeleteRequested
* @constructor
*/
export default function BendableArrow({
area,
startPos,
segments,
onSegmentsChanges,
forceStraight,
wavy,
style,
startRadius = 0,
endRadius = 0,
onDeleteRequested,
}: BendableArrowProps) {
const containerRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(null)
const styleWidth = style?.width ?? ArrowStyleDefaults.width
const computeInternalSegments = useCallback(
(segments: Segment[]) => {
return segments.map((segment, idx) => {
if (idx == 0) {
return {
start: startPos,
controlPoint: segment.controlPoint ?? null,
end: segment.next,
}
}
const start = segments[idx - 1].next
return {
start,
controlPoint: segment.controlPoint ?? null,
end: segment.next,
}
})
},
[segments, startPos],
)
// Cache the segments so that when the user is changing the segments (it moves an ArrowPoint),
// it does not unwind to this arrow's component parent until validated.
// The changes are validated (meaning that onSegmentsChanges is called) when the
// user releases an ArrowPoint.
const [internalSegments, setInternalSegments] = useState<FullSegment[]>(
() => computeInternalSegments(segments),
)
// If the (original) segments changes, overwrite the current ones.
useLayoutEffect(() => {
setInternalSegments(computeInternalSegments(segments))
}, [startPos, segments, computeInternalSegments])
const [isSelected, setIsSelected] = useState(false)
const headRef = useRef<HTMLDivElement>(null)
const tailRef = useRef<HTMLDivElement>(null)
/**
* Computes and return the segments edition points
* @param parentBase
*/
function computePoints(parentBase: DOMRect) {
return segments.flatMap(({ next, controlPoint }, i) => {
const prev = i == 0 ? startPos : segments[i - 1].next
const prevRelative = posWithinBase(prev, parentBase)
const nextRelative = posWithinBase(next, parentBase)
const cpPos =
controlPoint ||
ratioWithinBase(
add(middle(prevRelative, nextRelative), parentBase),
parentBase,
)
const setControlPointPos = (newPos: Pos | null) => {
const segment = segments[i]
const newSegments = segments.toSpliced(i, 1, {
...segment,
controlPoint: newPos ?? undefined,
})
onSegmentsChanges(newSegments)
}
return [
// curve control point
<ArrowPoint
key={i}
className={"arrow-point-control"}
posRatio={cpPos}
parentBase={parentBase}
onPosValidated={setControlPointPos}
onRemove={() => setControlPointPos(null)}
onMoves={(controlPoint) => {
setInternalSegments((is) => {
return is.toSpliced(i, 1, {
...is[i],
controlPoint,
})
})
}}
/>,
//next pos point (only if this is not the last segment)
i != segments.length - 1 && (
<ArrowPoint
key={i + "-2"}
className={"arrow-point-next"}
posRatio={next}
parentBase={parentBase}
onPosValidated={(next) => {
const currentSegment = segments[i]
const newSegments = segments.toSpliced(i, 1, {
...currentSegment,
next,
})
onSegmentsChanges(newSegments)
}}
onRemove={() => {
onSegmentsChanges(
segments.toSpliced(Math.max(i - 1, 0), 1),
)
}}
onMoves={(end) => {
setInternalSegments((is) => {
return is.toSpliced(
i,
2,
{
...is[i],
end,
},
{
...is[i + 1],
start: end,
},
)
})
}}
/>
),
]
})
}
/**
* Updates the states based on given parameters, which causes the arrow to re-render.
*/
const update = useCallback(() => {
const parentBase = area.current!.getBoundingClientRect()
const segment = internalSegments[0] ?? null
if (segment == null) throw new Error("segments might not be empty.")
const lastSegment = internalSegments[internalSegments.length - 1]
const startRelative = posWithinBase(startPos, parentBase)
const endRelative = posWithinBase(lastSegment.end, parentBase)
const startNext =
segment.controlPoint && !forceStraight
? posWithinBase(segment.controlPoint, parentBase)
: posWithinBase(segment.end, parentBase)
const endPrevious = forceStraight
? startRelative
: lastSegment.controlPoint
? posWithinBase(lastSegment.controlPoint, parentBase)
: posWithinBase(lastSegment.start, parentBase)
const tailPos = constraintInCircle(
startRelative,
startNext,
startRadius!,
)
const headPos = constraintInCircle(endRelative, endPrevious, endRadius!)
const left = Math.min(tailPos.x, headPos.x)
const top = Math.min(tailPos.y, headPos.y)
Object.assign(tailRef.current!.style, {
left: tailPos.x + "px",
top: tailPos.y + "px",
transformOrigin: "top center",
transform: `translateX(-50%) rotate(${
-angle(tailPos, startNext) * (180 / Math.PI)
}deg)`,
} as CSSProperties)
Object.assign(headRef.current!.style, {
left: headPos.x + "px",
top: headPos.y + "px",
transformOrigin: "top center",
transform: `translateX(-50%) rotate(${
-angle(headPos, endPrevious) * (180 / Math.PI)
}deg)`,
} as CSSProperties)
const svgStyle: CSSProperties = {
left: left + "px",
top: top + "px",
}
const segmentsRelatives = (
forceStraight
? [
{
start: startPos,
controlPoint: undefined,
end: lastSegment.end,
},
]
: internalSegments
).map(({ start, controlPoint, end }, idx) => {
const svgPosRelativeToBase = { x: left, y: top }
const nextRelative = relativeTo(
posWithinBase(end, parentBase),
svgPosRelativeToBase,
)
const startRelative = relativeTo(
posWithinBase(start, parentBase),
svgPosRelativeToBase,
)
const controlPointRelative =
controlPoint && !forceStraight
? relativeTo(
posWithinBase(controlPoint, parentBase),
svgPosRelativeToBase,
)
: middle(startRelative, nextRelative)
return {
start: startRelative,
end: nextRelative,
cp: controlPointRelative,
}
})
const computedSegments = segmentsRelatives
.map(({ start, cp, end: e }, idx) => {
let end = e
if (idx == segmentsRelatives.length - 1) {
//if it is the last element
end = constraintInCircle(end, cp, endRadius!)
}
const previousSegment =
idx != 0 ? segmentsRelatives[idx - 1] : undefined
const previousSegmentCpAndCurrentPosVector = minus(
start,
previousSegment?.cp ?? middle(start, end),
)
const smoothCp = previousSegment
? add(start, previousSegmentCpAndCurrentPosVector)
: cp
if (wavy) {
return wavyBezier(start, smoothCp, cp, end, 10, 10)
}
if (forceStraight) {
return `L${end.x} ${end.y}`
}
return `C${smoothCp.x} ${smoothCp.y}, ${cp.x} ${cp.y}, ${end.x} ${end.y}`
})
.join(" ")
const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments
pathRef.current!.setAttribute("d", d)
Object.assign(svgRef.current!.style, svgStyle)
}, [
startPos,
internalSegments,
forceStraight,
startRadius,
endRadius,
style,
])
// Will update the arrow when the props change
useEffect(update, [update])
// Adds a selection handler
// Also force an update when the window is resized
useEffect(() => {
const selectionHandler = (e: MouseEvent) => {
if (!(e.target instanceof Node)) return
// The arrow is selected if the mouse clicks on an element that belongs to the current arrow
const isSelected = containerRef.current!.contains(e.target)
setIsSelected(isSelected)
}
document.addEventListener("mousedown", selectionHandler)
window.addEventListener("resize", update)
return () => {
document.removeEventListener("mousedown", selectionHandler)
window.removeEventListener("resize", update)
}
}, [update, containerRef])
const addSegment = useCallback(
(e: ReactMouseEvent) => {
if (forceStraight) return
const parentBase = area.current!.getBoundingClientRect()
const clickAbsolutePos: Pos = { x: e.pageX, y: e.pageY }
const clickPosBaseRatio = ratioWithinBase(
clickAbsolutePos,
parentBase,
)
let segmentInsertionIndex = -1
let segmentInsertionIsOnRightOfCP = false
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
const beforeSegment = i != 0 ? segments[i - 1] : undefined
const beforeSegmentPos = i > 1 ? segments[i - 2].next : startPos
const currentPos = beforeSegment ? beforeSegment.next : startPos
const nextPos = segment.next
const segmentCp = segment.controlPoint
? segment.controlPoint
: middle(currentPos, nextPos)
const smoothCp = beforeSegment
? add(
currentPos,
minus(
currentPos,
beforeSegment.controlPoint ??
middle(beforeSegmentPos, currentPos),
),
)
: segmentCp
const result = searchOnSegment(
currentPos,
smoothCp,
segmentCp,
nextPos,
clickPosBaseRatio,
0.05,
)
if (result == PointSegmentSearchResult.NOT_FOUND) continue
segmentInsertionIndex = i
segmentInsertionIsOnRightOfCP =
result == PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT
break
}
if (segmentInsertionIndex == -1) return
const splicedSegment: Segment = segments[segmentInsertionIndex]
onSegmentsChanges(
segments.toSpliced(
segmentInsertionIndex,
1,
{
next: clickPosBaseRatio,
controlPoint: segmentInsertionIsOnRightOfCP
? splicedSegment.controlPoint
: undefined,
},
{
next: splicedSegment.next,
controlPoint: segmentInsertionIsOnRightOfCP
? undefined
: splicedSegment.controlPoint,
},
),
)
},
[area, forceStraight, onSegmentsChanges, segments, startPos],
)
return (
<div
ref={containerRef}
style={{ position: "absolute", top: 0, left: 0 }}>
<svg
ref={svgRef}
style={{
overflow: "visible",
position: "absolute",
pointerEvents: "none",
}}>
<path
className="arrow-path"
ref={pathRef}
stroke={"#000"}
strokeWidth={styleWidth}
strokeDasharray={
style?.dashArray ?? ArrowStyleDefaults.dashArray
}
fill="none"
tabIndex={0}
onDoubleClick={addSegment}
onKeyUp={(e) => {
if (onDeleteRequested && e.key == "Delete")
onDeleteRequested()
}}
/>
</svg>
<div
className={"arrow-head"}
style={{ position: "absolute", transformOrigin: "center" }}
ref={headRef}>
{style?.head?.call(style)}
</div>
<div
className={"arrow-tail"}
style={{ position: "absolute", transformOrigin: "center" }}
ref={tailRef}>
{style?.tail?.call(style)}
</div>
{!forceStraight &&
isSelected &&
computePoints(area.current!.getBoundingClientRect())}
</div>
)
}
interface ControlPointProps {
className: string
posRatio: Pos
parentBase: DOMRect
onMoves: (currentPos: Pos) => void
onPosValidated: (newPos: Pos) => void
onRemove: () => void
radius?: number
}
enum PointSegmentSearchResult {
LEFT_TO_CONTROL_POINT,
RIGHT_TO_CONTROL_POINT,
NOT_FOUND,
}
interface FullSegment {
start: Pos
controlPoint: Pos | null
end: Pos
}
/**
* returns a path delimiter that follows a given cubic béziers curve, but with additional waves on it, of the given
* density and amplitude.
* @param start
* @param cp1
* @param cp2
* @param end
* @param wavesPer100px
* @param amplitude
*/
function wavyBezier(
start: Pos,
cp1: Pos,
cp2: Pos,
end: Pos,
wavesPer100px: number,
amplitude: number,
): string {
function getVerticalAmplification(t: number): Pos {
const velocity = cubicBeziersDerivative(start, cp1, cp2, end, t)
const velocityLength = norm(velocity)
//rotate the velocity by 90 deg
const projection = { x: velocity.y, y: -velocity.x }
return {
x: (projection.x / velocityLength) * amplitude,
y: (projection.y / velocityLength) * amplitude,
}
}
let result: string = ""
const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end)
// we need two phases in order to complete a wave
const waveLength = (dist / 100) * wavesPer100px * 2
const step = 1 / waveLength
// 0 : middle to up
// 1 : up to middle
// 2 : middle to down
// 3 : down to middle
let phase = 0
for (let t = step; t <= 1; ) {
const pos = cubicBeziers(start, cp1, cp2, end, t)
const amplification = getVerticalAmplification(t)
let nextPos
if (phase == 1 || phase == 3) {
nextPos = pos
} else {
if (phase == 0) {
nextPos = add(pos, amplification)
} else {
nextPos = minus(pos, amplification)
}
}
const controlPointBase: Pos = cubicBeziers(
start,
cp1,
cp2,
end,
t - step / 2,
)
const controlPoint: Pos =
phase == 0 || phase == 1
? add(controlPointBase, amplification)
: minus(controlPointBase, amplification)
result += `Q${controlPoint.x} ${controlPoint.y} ${nextPos.x} ${nextPos.y}`
phase = (phase + 1) % 4
t += step
if (t < 1 && t > 1 - step) t = 1
}
return result
}
function cubicBeziersDerivative(
start: Pos,
cp1: Pos,
cp2: Pos,
end: Pos,
t: number,
): Pos {
return add(
add(
mul(minus(cp1, start), 3 * (1 - t) ** 2),
mul(minus(cp2, cp1), 6 * (1 - t) * t),
),
mul(minus(end, cp2), 3 * t ** 2),
)
}
function cubicBeziers(
start: Pos,
cp1: Pos,
cp2: Pos,
end: Pos,
t: number,
): Pos {
return add(
add(
add(mul(start, (1 - t) ** 3), mul(cp1, 3 * t * (1 - t) ** 2)),
mul(cp2, 3 * t ** 2 * (1 - t)),
),
mul(end, t ** 3),
)
}
/**
* Given a quadratic bézier curve (start position, end position and a middle control point position)
* search if the given `point` lies on the curve, within a minimum acceptance distance.
* @param start
* @param cp1
* @param cp2
* @param end
* @param point
* @param minDistance
*/
function searchOnSegment(
start: Pos,
cp1: Pos,
cp2: Pos,
end: Pos,
point: Pos,
minDistance: number,
): PointSegmentSearchResult {
const dist = distance(start, cp1) + distance(cp1, cp2) + distance(cp2, end)
const step = 1 / (dist / minDistance)
function getDistanceAt(t: number): number {
return distance(cubicBeziers(start, cp1, cp2, end, t), point)
}
for (let t = 0; t < 1; t += step) {
const distance = getDistanceAt(t)
if (distance <= minDistance)
return t >= 0.5
? PointSegmentSearchResult.RIGHT_TO_CONTROL_POINT
: PointSegmentSearchResult.LEFT_TO_CONTROL_POINT
}
return PointSegmentSearchResult.NOT_FOUND
}
/**
* An arrow point, that can be moved.
* @param className
* @param posRatio
* @param parentBase
* @param onMoves
* @param onPosValidated
* @param onRemove
* @param radius
* @constructor
*/
function ArrowPoint({
className,
posRatio,
parentBase,
onMoves,
onPosValidated,
onRemove,
radius = 7,
}: ControlPointProps) {
const ref = useRef<HTMLDivElement>(null)
const pos = posWithinBase(posRatio, parentBase)
return (
<Draggable
nodeRef={ref}
onStop={() => {
const pointPos = middlePos(ref.current!.getBoundingClientRect())
onPosValidated(ratioWithinBase(pointPos, parentBase))
}}
onDrag={() => {
const pointPos = middlePos(ref.current!.getBoundingClientRect())
onMoves(ratioWithinBase(pointPos, parentBase))
}}
position={{ x: pos.x - radius, y: pos.y - radius }}>
<div
ref={ref}
className={`arrow-point ${className}`}
style={{
position: "absolute",
width: radius * 2,
height: radius * 2,
}}
onKeyUp={(e) => {
if (e.key == "Delete") {
onRemove()
}
}}
tabIndex={0}
/>
</Draggable>
)
}

@ -0,0 +1,38 @@
import { Pos } from "./Pos"
export interface Box {
x: number
y: number
width: number
height: number
}
export function boundsOf(...positions: Pos[]): Box {
const allPosX = positions.map((p) => p.x)
const allPosY = positions.map((p) => p.y)
const x = Math.min(...allPosX)
const y = Math.min(...allPosY)
const width = Math.max(...allPosX) - x
const height = Math.max(...allPosY) - y
return { x, y, width, height }
}
export function surrounds(pos: Pos, width: number, height: number): Box {
return {
x: pos.x + width / 2,
y: pos.y + height / 2,
width,
height,
}
}
export function contains(box: Box, pos: Pos): boolean {
return (
pos.x >= box.x &&
pos.x <= box.x + box.width &&
pos.y >= box.y &&
pos.y <= box.y + box.height
)
}

@ -0,0 +1,81 @@
export interface Pos {
x: number
y: number
}
export const NULL_POS: Pos = { x: 0, y: 0 }
/**
* Returns position of a relative to b
* @param a
* @param b
*/
export function relativeTo(a: Pos, b: Pos): Pos {
return { x: a.x - b.x, y: a.y - b.y }
}
/**
* Returns the middle position of the given rectangle
* @param rect
*/
export function middlePos(rect: DOMRect): Pos {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
}
export function add(a: Pos, b: Pos): Pos {
return { x: a.x + b.x, y: a.y + b.y }
}
export function minus(a: Pos, b: Pos): Pos {
return { x: a.x - b.x, y: a.y - b.y }
}
export function mul(a: Pos, t: number): Pos {
return { x: a.x * t, y: a.y * t }
}
export function distance(a: Pos, b: Pos): number {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
}
export function norm(vector: Pos): number {
return distance(NULL_POS, vector)
}
/**
* Returns the angle in radian between the two points
* @param a
* @param b
*/
export function angle(a: Pos, b: Pos): number {
const r = relativeTo(a, b)
return Math.atan2(r.x, r.y)
}
export function ratioWithinBase(pos: Pos, base: DOMRect): Pos {
return {
x: (pos.x - base.x) / base.width,
y: (pos.y - base.y) / base.height,
}
}
export function posWithinBase(ratio: Pos, base: DOMRect): Pos {
return {
x: ratio.x * base.width,
y: ratio.y * base.height,
}
}
export function middle(a: Pos, b: Pos): Pos {
return {
x: a.x / 2 + b.x / 2,
y: a.y / 2 + b.y / 2,
}
}
export function rotate(vec: Pos, rad: number): Pos {
return {
x: Math.cos(rad) * vec.x - Math.sin(rad) * vec.y,
y: Math.sin(rad) * vec.x + Math.cos(rad) * vec.y,
}
}

@ -1,21 +1,7 @@
import React, { RefObject } from "react"
import "../../style/ball.css" import "../../style/ball.css"
import Ball from "../../assets/icon/ball.svg?react" import BallSvg from "../../assets/icon/ball.svg?react"
import Draggable from "react-draggable"
export interface BallPieceProps {
onDrop: () => void
pieceRef: RefObject<HTMLDivElement>
}
export function BallPiece({ onDrop, pieceRef }: BallPieceProps) { export function BallPiece() {
return ( return <BallSvg className={"ball"} />
<Draggable onStop={onDrop} nodeRef={pieceRef} position={{ x: 0, y: 0 }}>
<div className={`ball-div`} ref={pieceRef}>
<Ball className={"ball"} />
</div>
</Draggable>
)
} }

@ -1,44 +1,272 @@
import CourtSvg from "../../assets/basketball_court.svg?react" import { CourtBall } from "./CourtBall"
import "../../style/basket_court.css"
import { RefObject, useRef } from "react" import {
ReactElement,
RefObject,
useCallback,
useLayoutEffect,
useState,
} from "react"
import CourtPlayer from "./CourtPlayer" import CourtPlayer from "./CourtPlayer"
import { Player } from "../../model/tactic/Player" import { Player } from "../../model/tactic/Player"
import { Action, ActionKind } from "../../model/tactic/Action"
import ArrowAction from "../actions/ArrowAction"
import { middlePos, ratioWithinBase } from "../arrows/Pos"
import BallAction from "../actions/BallAction"
import { CourtObject } from "../../model/tactic/CourtObjects"
import { contains } from "../arrows/Box"
import { CourtAction } from "../../views/editor/CourtAction"
export interface BasketCourtProps { export interface BasketCourtProps {
players: Player[] players: Player[]
actions: Action[]
objects: CourtObject[]
renderAction: (a: Action, key: number) => ReactElement
setActions: (f: (a: Action[]) => Action[]) => void
onPlayerRemove: (p: Player) => void onPlayerRemove: (p: Player) => void
onBallDrop: (ref: HTMLDivElement) => void
onPlayerChange: (p: Player) => void onPlayerChange: (p: Player) => void
courtImage: string
onBallRemove: () => void
onBallMoved: (ball: DOMRect) => void
courtImage: ReactElement
courtRef: RefObject<HTMLDivElement> courtRef: RefObject<HTMLDivElement>
} }
export function BasketCourt({ export function BasketCourt({
players, players,
actions,
objects,
renderAction,
setActions,
onPlayerRemove, onPlayerRemove,
onBallDrop,
onPlayerChange, onPlayerChange,
onBallMoved,
onBallRemove,
courtImage, courtImage,
courtRef, courtRef,
}: BasketCourtProps) { }: BasketCourtProps) {
function placeArrow(origin: Player, arrowHead: DOMRect) {
const originRef = document.getElementById(origin.id)!
const courtBounds = courtRef.current!.getBoundingClientRect()
const start = ratioWithinBase(
middlePos(originRef.getBoundingClientRect()),
courtBounds,
)
for (const player of players) {
if (player.id == origin.id) {
continue
}
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
if (
!(
playerBounds.top > arrowHead.bottom ||
playerBounds.right < arrowHead.left ||
playerBounds.bottom < arrowHead.top ||
playerBounds.left > arrowHead.right
)
) {
const targetPos = document
.getElementById(player.id)!
.getBoundingClientRect()
const end = ratioWithinBase(middlePos(targetPos), courtBounds)
const action: Action = {
fromPlayerId: originRef.id,
toPlayerId: player.id,
type: origin.hasBall ? ActionKind.SHOOT : ActionKind.SCREEN,
moveFrom: start,
segments: [{ next: end }],
}
setActions((actions) => [...actions, action])
return
}
}
const action: Action = {
fromPlayerId: originRef.id,
type: origin.hasBall ? ActionKind.DRIBBLE : ActionKind.MOVE,
moveFrom: ratioWithinBase(
middlePos(originRef.getBoundingClientRect()),
courtBounds,
),
segments: [
{ next: ratioWithinBase(middlePos(arrowHead), courtBounds) },
],
}
setActions((actions) => [...actions, action])
}
const [previewAction, setPreviewAction] = useState<Action | null>(null)
const updateActionsRelatedTo = useCallback((player: Player) => {
const newPos = ratioWithinBase(
middlePos(
document.getElementById(player.id)!.getBoundingClientRect(),
),
courtRef.current!.getBoundingClientRect(),
)
setActions((actions) =>
actions.map((a) => {
if (a.fromPlayerId == player.id) {
return { ...a, moveFrom: newPos }
}
if (a.toPlayerId == player.id) {
const segments = a.segments.toSpliced(
a.segments.length - 1,
1,
{
...a.segments[a.segments.length - 1],
next: newPos,
},
)
return { ...a, segments }
}
return a
}),
)
}, [])
const [internActions, setInternActions] = useState<Action[]>([])
useLayoutEffect(() => setInternActions(actions), [actions])
return ( return (
<div <div
id="court-container" className="court-container"
ref={courtRef} ref={courtRef}
style={{ position: "relative" }}> style={{ position: "relative" }}>
<img src={courtImage} alt={"court"} id="court-svg" /> {courtImage}
{players.map((player) => {
return ( {players.map((player) => (
<CourtPlayer <CourtPlayer
key={player.team + player.role} key={player.id}
player={player} player={player}
onChange={onPlayerChange} onDrag={() => updateActionsRelatedTo(player)}
onRemove={() => onPlayerRemove(player)} onChange={onPlayerChange}
onBallDrop={onBallDrop} onRemove={() => onPlayerRemove(player)}
parentRef={courtRef} courtRef={courtRef}
/> availableActions={(pieceRef) => [
) <ArrowAction
key={1}
onHeadMoved={(headPos) => {
const baseBounds =
courtRef.current!.getBoundingClientRect()
const arrowHeadPos = middlePos(headPos)
const target = players.find(
(p) =>
p != player &&
contains(
document
.getElementById(p.id)!
.getBoundingClientRect(),
arrowHeadPos,
),
)
setPreviewAction((action) => ({
...action!,
segments: [
{
next: ratioWithinBase(
arrowHeadPos,
baseBounds,
),
},
],
type: player.hasBall
? target
? ActionKind.SHOOT
: ActionKind.DRIBBLE
: target
? ActionKind.SCREEN
: ActionKind.MOVE,
}))
}}
onHeadPicked={(headPos) => {
;(document.activeElement as HTMLElement).blur()
const baseBounds =
courtRef.current!.getBoundingClientRect()
setPreviewAction({
type: player.hasBall
? ActionKind.DRIBBLE
: ActionKind.MOVE,
fromPlayerId: player.id,
toPlayerId: undefined,
moveFrom: ratioWithinBase(
middlePos(
pieceRef.getBoundingClientRect(),
),
baseBounds,
),
segments: [
{
next: ratioWithinBase(
middlePos(headPos),
baseBounds,
),
},
],
})
}}
onHeadDropped={(headRect) => {
placeArrow(player, headRect)
setPreviewAction(null)
}}
/>,
player.hasBall && (
<BallAction
key={2}
onDrop={(ref) =>
onBallMoved(ref.getBoundingClientRect())
}
/>
),
]}
/>
))}
{internActions.map((action, idx) => renderAction(action, idx))}
{objects.map((object) => {
if (object.type == "ball") {
return (
<CourtBall
onMoved={onBallMoved}
ball={object}
onRemove={onBallRemove}
key="ball"
/>
)
}
throw new Error("unknown court object" + object.type)
})} })}
{previewAction && (
<CourtAction
courtRef={courtRef}
action={previewAction}
//do nothing on change, not really possible as it's a preview arrow
onActionDeleted={() => {}}
onActionChanges={() => {}}
/>
)}
</div> </div>
) )
} }

@ -0,0 +1,38 @@
import React, { useRef } from "react"
import Draggable from "react-draggable"
import { BallPiece } from "./BallPiece"
import { Ball } from "../../model/tactic/CourtObjects"
export interface CourtBallProps {
onMoved: (rect: DOMRect) => void
onRemove: () => void
ball: Ball
}
export function CourtBall({ onMoved, ball, onRemove }: CourtBallProps) {
const pieceRef = useRef<HTMLDivElement>(null)
const x = ball.rightRatio
const y = ball.bottomRatio
return (
<Draggable
onStop={() => onMoved(pieceRef.current!.getBoundingClientRect())}
nodeRef={pieceRef}>
<div
className={"ball-div"}
ref={pieceRef}
tabIndex={0}
onKeyUp={(e) => {
if (e.key == "Delete") onRemove()
}}
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<BallPiece />
</div>
</Draggable>
)
}

@ -1,18 +1,17 @@
import { RefObject, useRef, useState } from "react" import { ReactNode, RefObject, useRef } from "react"
import "../../style/player.css" import "../../style/player.css"
import RemoveIcon from "../../assets/icon/remove.svg?react"
import { BallPiece } from "./BallPiece"
import Draggable from "react-draggable" import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece" import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../model/tactic/Player" import { Player } from "../../model/tactic/Player"
import { calculateRatio } from "../../Utils" import { NULL_POS, ratioWithinBase } from "../arrows/Pos"
export interface PlayerProps { export interface PlayerProps {
player: Player player: Player
onDrag: () => void
onChange: (p: Player) => void onChange: (p: Player) => void
onRemove: () => void onRemove: () => void
onBallDrop: (ref: HTMLDivElement) => void courtRef: RefObject<HTMLElement>
parentRef: RefObject<HTMLDivElement> availableActions: (ro: HTMLElement) => ReactNode[]
} }
/** /**
@ -20,29 +19,29 @@ export interface PlayerProps {
* */ * */
export default function CourtPlayer({ export default function CourtPlayer({
player, player,
onDrag,
onChange, onChange,
onRemove, onRemove,
onBallDrop, courtRef,
parentRef, availableActions,
}: PlayerProps) { }: PlayerProps) {
const pieceRef = useRef<HTMLDivElement>(null) const hasBall = player.hasBall
const ballPiece = useRef<HTMLDivElement>(null)
const x = player.rightRatio const x = player.rightRatio
const y = player.bottomRatio const y = player.bottomRatio
const hasBall = player.hasBall const pieceRef = useRef<HTMLDivElement>(null)
return ( return (
<Draggable <Draggable
handle={".player-piece"} handle=".player-piece"
nodeRef={pieceRef} nodeRef={pieceRef}
bounds="parent" onDrag={onDrag}
position={{ x, y }} //The piece is positioned using top/bottom style attributes instead
position={NULL_POS}
onStop={() => { onStop={() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect() const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = parentRef.current!.getBoundingClientRect() const parentBounds = courtRef.current!.getBoundingClientRect()
const { x, y } = calculateRatio(pieceBounds, parentBounds) const { x, y } = ratioWithinBase(pieceBounds, parentBounds)
onChange({ onChange({
id: player.id, id: player.id,
@ -54,31 +53,22 @@ export default function CourtPlayer({
}) })
}}> }}>
<div <div
id={player.id}
ref={pieceRef} ref={pieceRef}
className={"player"} className="player"
style={{ style={{
position: "absolute", position: "absolute",
left: `${x * 100}%`, left: `${x * 100}%`,
top: `${y * 100}%`, top: `${y * 100}%`,
}}> }}>
<div <div
id={player.id}
tabIndex={0} tabIndex={0}
className="player-content" className="player-content"
onKeyUp={(e) => { onKeyUp={(e) => {
if (e.key == "Delete") onRemove() if (e.key == "Delete") onRemove()
}}> }}>
<div className="player-selection-tab"> <div className="player-actions">
<RemoveIcon {availableActions(pieceRef.current!)}
className="player-selection-tab-remove"
onClick={onRemove}
/>
{hasBall && (
<BallPiece
onDrop={() => onBallDrop(ballPiece.current!)}
pieceRef={ballPiece}
/>
)}
</div> </div>
<PlayerPiece <PlayerPiece
team={player.team} team={player.team}

@ -1,9 +1,8 @@
import React from "react"
import "../../style/player.css" import "../../style/player.css"
import { Team } from "../../model/tactic/Team" import { PlayerTeam } from "../../model/tactic/Player"
export interface PlayerPieceProps { export interface PlayerPieceProps {
team: Team team: PlayerTeam
text: string text: string
hasBall: boolean hasBall: boolean
} }

@ -0,0 +1,19 @@
import { Pos } from "../../components/arrows/Pos"
import { Segment } from "../../components/arrows/BendableArrow"
import { PlayerId } from "./Player"
export enum ActionKind {
SCREEN = "SCREEN",
DRIBBLE = "DRIBBLE",
MOVE = "MOVE",
SHOOT = "SHOOT",
}
export type Action = { type: ActionKind } & MovementAction
export interface MovementAction {
fromPlayerId: PlayerId
toPlayerId?: PlayerId
moveFrom: Pos
segments: Segment[]
}

@ -1,11 +1,17 @@
export type CourtObject = { type: "ball" } & Ball
export interface Ball { export interface Ball {
/** /**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) * The ball is a "ball" court object
*/ */
bottom_percentage: number readonly type: "ball"
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
readonly bottomRatio: number
/** /**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/ */
right_percentage: number readonly rightRatio: number
} }

@ -0,0 +1,17 @@
export type CourtObject = { type: "ball" } & Ball
export interface Ball {
/**
* The ball is a "ball" court object
*/
readonly type: "ball"
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
readonly bottomRatio: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
readonly rightRatio: number
}

@ -1,26 +1,35 @@
import { Team } from "./Team" export type PlayerId = string
export enum PlayerTeam {
Allies = "allies",
Opponents = "opponents",
}
export interface Player { export interface Player {
id: string readonly id: PlayerId
/** /**
* the player's team * the player's team
* */ * */
team: Team readonly team: PlayerTeam
/** /**
* player's role * player's role
* */ * */
role: string readonly role: string
/** /**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle) * Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/ */
bottomRatio: number readonly bottomRatio: number
/** /**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle) * Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/ */
rightRatio: number readonly rightRatio: number
hasBall: boolean /**
* True if the player has a basketball
*/
readonly hasBall: boolean
} }

@ -1,4 +1,6 @@
import { Player } from "./Player" import { Player } from "./Player"
import { CourtObject } from "./CourtObjects"
import { Action } from "./Action"
export interface Tactic { export interface Tactic {
id: number id: number
@ -8,4 +10,6 @@ export interface Tactic {
export interface TacticContent { export interface TacticContent {
players: Player[] players: Player[]
objects: CourtObject[]
actions: Action[]
} }

@ -1,4 +0,0 @@
export enum Team {
Allies = "allies",
Opponents = "opponents",
}

@ -0,0 +1,23 @@
.arrow-action {
height: 50%;
}
.arrow-action-icon {
user-select: none;
-moz-user-select: none;
max-width: 17px;
max-height: 17px;
}
.arrow-head-pick {
position: absolute;
cursor: grab;
top: 0;
left: 0;
min-width: 17px;
min-height: 17px;
}
.arrow-head-pick:active {
cursor: crosshair;
}

@ -0,0 +1,14 @@
.remove-action {
height: 100%;
}
.remove-action * {
stroke: red;
fill: white;
}
.remove-action:hover * {
fill: #f1dbdb;
stroke: #ff331a;
cursor: pointer;
}

@ -1,20 +0,0 @@
#court-container {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
height: 100%;
background-color: var(--main-color);
}
#court-svg {
margin: 35px 0 35px 0;
height: 87%;
user-select: none;
-webkit-user-drag: none;
}
#court-svg * {
stroke: var(--selected-team-secondarycolor);
}

@ -0,0 +1,22 @@
.arrow-point {
cursor: pointer;
border-radius: 100px;
background-color: black;
outline: none;
}
.arrow-point:hover {
background-color: var(--selection-color);
}
.arrow-path {
pointer-events: stroke;
cursor: pointer;
outline: none;
}
.arrow-path:hover,
.arrow-path:active {
stroke: var(--selection-color);
}

@ -0,0 +1,13 @@
:root {
--main-color: #ffffff;
--second-color: #ccde54;
--background-color: #d2cdd3;
--selected-team-primarycolor: #ffffff;
--selected-team-secondarycolor: #000000;
--selection-color: #3f7fc4;
--arrows-color: #676767;
}

@ -7,6 +7,8 @@
background-color: var(--background-color); background-color: var(--background-color);
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
#topbar-left { #topbar-left {
@ -32,6 +34,8 @@
#racks { #racks {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
height: 25px;
} }
.title-input { .title-input {
@ -43,8 +47,28 @@
height: 100%; height: 100%;
} }
#allies-rack .player-piece, #allies-rack,
#opponent-rack {
width: 125px;
min-width: 125px;
display: flex;
flex-direction: row;
align-items: flex-end;
}
#allies-rack {
justify-content: flex-start;
}
#opponent-rack {
justify-content: flex-end;
}
#opponent-rack .player-piece { #opponent-rack .player-piece {
margin-right: 5px;
}
#allies-rack .player-piece {
margin-left: 5px; margin-left: 5px;
} }
@ -58,7 +82,9 @@
#court-div { #court-div {
background-color: var(--background-color); background-color: var(--background-color);
height: 100%; height: 100%;
width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
@ -66,11 +92,33 @@
align-content: center; align-content: center;
} }
#court-div-bounds { #court-image-div {
padding: 20px 20px 20px 20px; position: relative;
background-color: white;
height: 100%;
width: 100%;
}
.court-container {
display: flex;
align-content: center;
align-items: center;
justify-content: center;
height: 75%; height: 75%;
} }
#court-image {
height: 100%;
width: 100%;
user-select: none;
-webkit-user-drag: none;
}
#court-image * {
stroke: var(--selected-team-secondarycolor);
}
.react-draggable { .react-draggable {
z-index: 2; z-index: 2;
} }

@ -1,9 +1,3 @@
/**
as the .player div content is translated,
the real .player div position is not were the user can expect.
Disable pointer events to this div as it may overlap on other components
on the court.
*/
.player { .player {
pointer-events: none; pointer-events: none;
} }
@ -42,38 +36,27 @@ on the court.
border-color: var(--player-piece-ball-border-color); border-color: var(--player-piece-ball-border-color);
} }
.player-selection-tab { .player-actions {
display: none; display: flex;
position: absolute; position: absolute;
margin-bottom: -20%; flex-direction: row;
justify-content: center; justify-content: space-evenly;
align-content: space-between;
width: fit-content; align-items: center;
transform: translateY(-20px);
}
.player-selection-tab-remove {
visibility: hidden; visibility: hidden;
pointer-events: all;
width: 25px;
height: 17px;
justify-content: center;
}
.player-selection-tab-remove * { transform: translateY(-25px);
stroke: red;
fill: white;
}
.player-selection-tab-remove:hover * { height: 20px;
fill: #f1dbdb; width: 150%;
stroke: #ff331a;
cursor: pointer; gap: 25%;
} }
.player:focus-within .player-selection-tab { .player:focus-within .player-actions {
display: flex; visibility: visible;
pointer-events: all;
} }
.player:focus-within .player-piece { .player:focus-within .player-piece {

@ -19,5 +19,6 @@
--editor-court-selection-background: #5f8fee; --editor-court-selection-background: #5f8fee;
--editor-court-selection-buttons: #acc4f3; --editor-court-selection-buttons: #acc4f3;
--player-piece-ball-border-color: #000000;
--text-main-font: "Roboto", sans-serif; --text-main-font: "Roboto", sans-serif;
} }

@ -3,30 +3,36 @@ import {
Dispatch, Dispatch,
SetStateAction, SetStateAction,
useCallback, useCallback,
useMemo,
useRef, useRef,
useState, useState,
} from "react" } from "react"
import "../style/editor.css" import "../style/editor.css"
import TitleInput from "../components/TitleInput" import TitleInput from "../components/TitleInput"
import { BasketCourt } from "../components/editor/BasketCourt" import PlainCourt from "../assets/court/full_court.svg?react"
import HalfCourt from "../assets/court/half_court.svg?react"
import plainCourt from "../assets/court/court.svg" import { BallPiece } from "../components/editor/BallPiece"
import halfCourt from "../assets/court/half_court.svg"
import { Rack } from "../components/Rack" import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece" import { PlayerPiece } from "../components/editor/PlayerPiece"
import { BallPiece } from "../components/editor/BallPiece"
import { Player } from "../model/tactic/Player" import { Player } from "../model/tactic/Player"
import { Tactic, TacticContent } from "../model/tactic/Tactic" import { Tactic, TacticContent } from "../model/tactic/Tactic"
import { fetchAPI } from "../Fetcher" import { fetchAPI } from "../Fetcher"
import { Team } from "../model/tactic/Team" import { PlayerTeam } from "../model/tactic/Player"
import { calculateRatio } from "../Utils"
import SavingState, { import SavingState, {
SaveState, SaveState,
SaveStates, SaveStates,
} from "../components/editor/SavingState" } from "../components/editor/SavingState"
import { CourtObject } from "../model/tactic/CourtObjects"
import { CourtAction } from "./editor/CourtAction"
import { BasketCourt } from "../components/editor/BasketCourt"
import { ratioWithinBase } from "../components/arrows/Pos"
import { Action, ActionKind } from "../model/tactic/Action"
const ERROR_STYLE: CSSProperties = { const ERROR_STYLE: CSSProperties = {
borderColor: "red", borderColor: "red",
} }
@ -52,10 +58,12 @@ export interface EditorProps {
* information about a player that is into a rack * information about a player that is into a rack
*/ */
interface RackedPlayer { interface RackedPlayer {
team: Team team: PlayerTeam
key: string key: string
} }
type RackedCourtObject = { key: "ball" }
export default function Editor({ id, name, courtType, content }: EditorProps) { export default function Editor({ id, name, courtType, content }: EditorProps) {
const isInGuestMode = id == -1 const isInGuestMode = id == -1
@ -112,34 +120,41 @@ function EditorView({
const [content, setContent, saveState] = useContentState( const [content, setContent, saveState] = useContentState(
initialContent, initialContent,
isInGuestMode ? SaveStates.Guest : SaveStates.Ok, isInGuestMode ? SaveStates.Guest : SaveStates.Ok,
onContentChange, useMemo(
() =>
debounceAsync(
(content) =>
onContentChange(content).then((success) =>
success ? SaveStates.Ok : SaveStates.Err,
),
250,
),
[onContentChange],
),
) )
const [allies, setAllies] = useState( const [allies, setAllies] = useState(
getRackPlayers(Team.Allies, content.players), getRackPlayers(PlayerTeam.Allies, content.players),
) )
const [opponents, setOpponents] = useState( const [opponents, setOpponents] = useState(
getRackPlayers(Team.Opponents, content.players), getRackPlayers(PlayerTeam.Opponents, content.players),
) )
const [showBall, setShowBall] = useState( const [objects, setObjects] = useState<RackedCourtObject[]>(
content.players.find((p) => p.hasBall) == undefined, isBallOnCourt(content) ? [] : [{ key: "ball" }],
) )
const ballPiece = useRef<HTMLDivElement>(null)
const courtDivContentRef = useRef<HTMLDivElement>(null) const courtDivContentRef = useRef<HTMLDivElement>(null)
const canDetach = (ref: HTMLDivElement) => { const isBoundsOnCourt = (bounds: DOMRect) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
// check if refBounds overlaps courtBounds // check if refBounds overlaps courtBounds
return !( return !(
refBounds.top > courtBounds.bottom || bounds.top > courtBounds.bottom ||
refBounds.right < courtBounds.left || bounds.right < courtBounds.left ||
refBounds.bottom < courtBounds.top || bounds.bottom < courtBounds.top ||
refBounds.left > courtBounds.right bounds.left > courtBounds.right
) )
} }
@ -147,14 +162,15 @@ function EditorView({
const refBounds = ref.getBoundingClientRect() const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = calculateRatio(refBounds, courtBounds) const { x, y } = ratioWithinBase(refBounds, courtBounds)
setContent((content) => { setContent((content) => {
return { return {
...content,
players: [ players: [
...content.players, ...content.players,
{ {
id: "player-" + content.players.length, id: "player-" + element.key + "-" + element.team,
team: element.team, team: element.team,
role: element.key, role: element.key,
rightRatio: x, rightRatio: x,
@ -162,36 +178,217 @@ function EditorView({
hasBall: false, hasBall: false,
}, },
], ],
actions: content.actions,
} }
}) })
} }
const onBallDrop = (ref: HTMLDivElement) => { const onObjectDetach = (
const ballBounds = ref.getBoundingClientRect() ref: HTMLDivElement,
let ballAssigned = false rackedObject: RackedCourtObject,
) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(refBounds, courtBounds)
setContent((content) => { let courtObject: CourtObject
const players = content.players.map((player) => {
if (ballAssigned) { switch (rackedObject.key) {
return { ...player, hasBall: false } case "ball":
} const ballObj = content.objects.findIndex(
const playerBounds = document (o) => o.type == "ball",
.getElementById(player.id)! )
.getBoundingClientRect() const playerCollidedIdx = getPlayerCollided(
const doesOverlap = !( refBounds,
ballBounds.top > playerBounds.bottom || content.players,
ballBounds.right < playerBounds.left ||
ballBounds.bottom < playerBounds.top ||
ballBounds.left > playerBounds.right
) )
if (doesOverlap) { if (playerCollidedIdx != -1) {
ballAssigned = true onBallDropOnPlayer(playerCollidedIdx)
setContent((content) => {
return {
...content,
objects: content.objects.toSpliced(ballObj, 1),
}
})
return
}
courtObject = {
type: "ball",
rightRatio: x,
bottomRatio: y,
}
break
default:
throw new Error("unknown court object " + rackedObject.key)
}
setContent((content) => {
return {
...content,
objects: [...content.objects, courtObject],
}
})
}
const getPlayerCollided = (
bounds: DOMRect,
players: Player[],
): number | -1 => {
for (let i = 0; i < players.length; i++) {
const player = players[i]
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
const doesOverlap = !(
bounds.top > playerBounds.bottom ||
bounds.right < playerBounds.left ||
bounds.bottom < playerBounds.top ||
bounds.left > playerBounds.right
)
if (doesOverlap) {
return i
}
}
return -1
}
function updateActions(actions: Action[], players: Player[]) {
return actions.map((action) => {
const originHasBall = players.find(
(p) => p.id == action.fromPlayerId,
)!.hasBall
let type = action.type
if (originHasBall && type == ActionKind.MOVE) {
type = ActionKind.DRIBBLE
} else if (originHasBall && type == ActionKind.SCREEN) {
type = ActionKind.SHOOT
} else if (type == ActionKind.DRIBBLE) {
type = ActionKind.MOVE
} else if (type == ActionKind.SHOOT) {
type = ActionKind.SCREEN
}
return {
...action,
type,
}
})
}
const onBallDropOnPlayer = (playerCollidedIdx: number) => {
setContent((content) => {
const ballObj = content.objects.findIndex((o) => o.type == "ball")
let player = content.players.at(playerCollidedIdx) as Player
const players = content.players.toSpliced(playerCollidedIdx, 1, {
...player,
hasBall: true,
})
return {
...content,
actions: updateActions(content.actions, players),
players,
objects: content.objects.toSpliced(ballObj, 1),
}
})
}
const onBallDrop = (refBounds: DOMRect) => {
if (!isBoundsOnCourt(refBounds)) {
removeCourtBall()
return
}
const playerCollidedIdx = getPlayerCollided(refBounds, content.players)
if (playerCollidedIdx != -1) {
setContent((content) => {
return {
...content,
players: content.players.map((player) => ({
...player,
hasBall: false,
})),
} }
return { ...player, hasBall: doesOverlap }
}) })
setShowBall(!ballAssigned) onBallDropOnPlayer(playerCollidedIdx)
return { players: players } return
}
if (content.objects.findIndex((o) => o.type == "ball") != -1) {
return
}
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const { x, y } = ratioWithinBase(refBounds, courtBounds)
let courtObject: CourtObject
courtObject = {
type: "ball",
rightRatio: x,
bottomRatio: y,
}
const players = content.players.map((player) => ({
...player,
hasBall: false,
}))
setContent((content) => {
return {
...content,
actions: updateActions(content.actions, players),
players,
objects: [...content.objects, courtObject],
}
})
}
const removePlayer = (player: Player) => {
setContent((content) => ({
...content,
players: toSplicedPlayers(content.players, player, false),
objects: [...content.objects],
actions: content.actions.filter(
(a) =>
a.toPlayerId !== player.id && a.fromPlayerId !== player.id,
),
}))
let setter
switch (player.team) {
case PlayerTeam.Opponents:
setter = setOpponents
break
case PlayerTeam.Allies:
setter = setAllies
}
if (player.hasBall) {
setObjects([{ key: "ball" }])
}
setter((players) => [
...players,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
}
const removeCourtBall = () => {
setContent((content) => {
const ballObj = content.objects.findIndex((o) => o.type == "ball")
return {
...content,
players: content.players.map((player) => ({
...player,
hasBall: false,
})),
objects: content.objects.toSpliced(ballObj, 1),
}
}) })
setObjects([{ key: "ball" }])
} }
return ( return (
@ -211,7 +408,7 @@ function EditorView({
}} }}
/> />
</div> </div>
<div id="topbar-right"></div> <div id="topbar-right" />
</div> </div>
<div id="edit-div"> <div id="edit-div">
<div id="racks"> <div id="racks">
@ -219,7 +416,9 @@ function EditorView({
id="allies-rack" id="allies-rack"
objects={allies} objects={allies}
onChange={setAllies} onChange={setAllies}
canDetach={canDetach} canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onPieceDetach} onElementDetached={onPieceDetach}
render={({ team, key }) => ( render={({ team, key }) => (
<PlayerPiece <PlayerPiece
@ -231,18 +430,24 @@ function EditorView({
)} )}
/> />
{showBall && ( <Rack
<BallPiece id={"objects"}
onDrop={() => onBallDrop(ballPiece.current!)} objects={objects}
pieceRef={ballPiece} onChange={setObjects}
/> canDetach={(div) =>
)} isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onObjectDetach}
render={renderCourtObject}
/>
<Rack <Rack
id="opponent-rack" id="opponent-rack"
objects={opponents} objects={opponents}
onChange={setOpponents} onChange={setOpponents}
canDetach={canDetach} canDetach={(div) =>
isBoundsOnCourt(div.getBoundingClientRect())
}
onElementDetached={onPieceDetach} onElementDetached={onPieceDetach}
render={({ team, key }) => ( render={({ team, key }) => (
<PlayerPiece <PlayerPiece
@ -258,48 +463,63 @@ function EditorView({
<div id="court-div-bounds"> <div id="court-div-bounds">
<BasketCourt <BasketCourt
players={content.players} players={content.players}
onBallDrop={onBallDrop} objects={content.objects}
courtImage={ actions={content.actions}
courtType == "PLAIN" ? plainCourt : halfCourt onBallMoved={onBallDrop}
} courtImage={<Court courtType={courtType} />}
courtRef={courtDivContentRef} courtRef={courtDivContentRef}
onPlayerChange={(player) => { setActions={(actions) =>
setContent((content) => ({ setContent((content) => ({
players: toSplicedPlayers( ...content,
content.players, players: content.players,
player, actions: actions(content.actions),
true,
),
})) }))
}} }
onPlayerRemove={(player) => { renderAction={(action, i) => (
<CourtAction
key={i}
action={action}
courtRef={courtDivContentRef}
onActionDeleted={() => {
setContent((content) => ({
...content,
actions: content.actions.toSpliced(
i,
1,
),
}))
}}
onActionChanges={(a) =>
setContent((content) => ({
...content,
actions: content.actions.toSpliced(
i,
1,
a,
),
}))
}
/>
)}
onPlayerChange={(player) => {
const playerBounds = document
.getElementById(player.id)!
.getBoundingClientRect()
if (!isBoundsOnCourt(playerBounds)) {
removePlayer(player)
return
}
setContent((content) => ({ setContent((content) => ({
...content,
players: toSplicedPlayers( players: toSplicedPlayers(
content.players, content.players,
player, player,
false, true,
), ),
})) }))
let setter
switch (player.team) {
case Team.Opponents:
setter = setOpponents
break
case Team.Allies:
setter = setAllies
}
if (player.hasBall) {
setShowBall(true)
}
setter((players) => [
...players,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
}} }}
onPlayerRemove={removePlayer}
onBallRemove={removeCourtBall}
/> />
</div> </div>
</div> </div>
@ -308,7 +528,33 @@ function EditorView({
) )
} }
function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] { function isBallOnCourt(content: TacticContent) {
if (content.players.findIndex((p) => p.hasBall) != -1) {
return true
}
return content.objects.findIndex((o) => o.type == "ball") != -1
}
function renderCourtObject(courtObject: RackedCourtObject) {
if (courtObject.key == "ball") {
return <BallPiece />
}
throw new Error("unknown racked court object " + courtObject.key)
}
function Court({ courtType }: { courtType: string }) {
return (
<div id="court-image-div">
{courtType == "PLAIN" ? (
<PlainCourt id="court-image" />
) : (
<HalfCourt id="court-image" />
)}
</div>
)
}
function getRackPlayers(team: PlayerTeam, players: Player[]): RackedPlayer[] {
return ["1", "2", "3", "4", "5"] return ["1", "2", "3", "4", "5"]
.filter( .filter(
(role) => (role) =>
@ -318,6 +564,19 @@ function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] {
.map((key) => ({ team, key })) .map((key) => ({ team, key }))
} }
function debounceAsync<A, B>(
f: (args: A) => Promise<B>,
delay = 1000,
): (args: A) => Promise<B> {
let task = 0
return (args: A) => {
clearTimeout(task)
return new Promise((resolve, reject) => {
task = setTimeout(() => f(args).then(resolve).catch(reject), delay)
})
}
}
function useContentState<S>( function useContentState<S>(
initialContent: S, initialContent: S,
initialSaveState: SaveState, initialSaveState: SaveState,

@ -1,7 +1,7 @@
import "../style/theme/default.css" import "../style/theme/default.css"
import "../style/new_tactic_panel.css" import "../style/new_tactic_panel.css"
import plainCourt from "../assets/court/court.svg" import plainCourt from "../assets/court/full_court.svg"
import halfCourt from "../assets/court/half_court.svg" import halfCourt from "../assets/court/half_court.svg"
import { BASE } from "../Constants" import { BASE } from "../Constants"

@ -1,6 +1,6 @@
import React, { CSSProperties, useState } from "react" import React, { CSSProperties, useState } from "react"
import "../style/visualizer.css" import "../style/visualizer.css"
import Court from "../assets/court/court.svg" import Court from "../assets/court/full_court.svg"
export default function Visualizer({ id, name }: { id: number; name: string }) { export default function Visualizer({ id, name }: { id: number; name: string }) {
const [style, setStyle] = useState<CSSProperties>({}) const [style, setStyle] = useState<CSSProperties>({})

@ -0,0 +1,58 @@
import { Action, ActionKind } from "../../model/tactic/Action"
import BendableArrow from "../../components/arrows/BendableArrow"
import { RefObject } from "react"
import { MoveToHead, ScreenHead } from "../../components/actions/ArrowAction"
export interface CourtActionProps {
action: Action
onActionChanges: (a: Action) => void
onActionDeleted: () => void
courtRef: RefObject<HTMLElement>
}
export function CourtAction({
action,
onActionChanges,
onActionDeleted,
courtRef,
}: CourtActionProps) {
let head
switch (action.type) {
case ActionKind.DRIBBLE:
case ActionKind.MOVE:
case ActionKind.SHOOT:
head = () => <MoveToHead />
break
case ActionKind.SCREEN:
head = () => <ScreenHead />
break
}
let dashArray
switch (action.type) {
case ActionKind.SHOOT:
dashArray = "10 5"
break
}
return (
<BendableArrow
forceStraight={action.type == ActionKind.SHOOT}
area={courtRef}
startPos={action.moveFrom}
segments={action.segments}
onSegmentsChanges={(edges) => {
onActionChanges({ ...action, segments: edges })
}}
wavy={action.type == ActionKind.DRIBBLE}
//TODO place those magic values in constants
endRadius={action.toPlayerId ? 26 : 17}
startRadius={0}
onDeleteRequested={onActionDeleted}
style={{
head,
dashArray,
}}
/>
)
}

@ -32,8 +32,8 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.1.0", "@vitejs/plugin-react": "^4.1.0",
"vite-plugin-svgr": "^4.1.0",
"prettier": "^3.1.0", "prettier": "^3.1.0",
"typescript": "^5.2.2" "typescript": "^5.2.2",
"vite-plugin-svgr": "^4.1.0"
} }
} }

@ -17,10 +17,10 @@ CREATE TABLE Account
CREATE TABLE Tactic CREATE TABLE Tactic
( (
id integer PRIMARY KEY AUTOINCREMENT, id integer PRIMARY KEY AUTOINCREMENT,
name varchar NOT NULL, name varchar NOT NULL,
creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
owner integer NOT NULL, owner integer NOT NULL,
content varchar DEFAULT '{"players": []}' NOT NULL, content varchar DEFAULT '{"players": [], "actions": [], "objects": []}' NOT NULL,
court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL, court_type varchar CHECK ( court_type IN ('HALF', 'PLAIN')) NOT NULL,
FOREIGN KEY (owner) REFERENCES Account FOREIGN KEY (owner) REFERENCES Account
); );

@ -42,7 +42,7 @@ class EditorController {
return ViewHttpResponse::react("views/Editor.tsx", [ return ViewHttpResponse::react("views/Editor.tsx", [
"id" => -1, //-1 id means that the editor will not support saves "id" => -1, //-1 id means that the editor will not support saves
"name" => TacticModel::TACTIC_DEFAULT_NAME, "name" => TacticModel::TACTIC_DEFAULT_NAME,
"content" => '{"players": []}', "content" => '{"players": [], "objects": [], "actions": []}',
"courtType" => $courtType->name(), "courtType" => $courtType->name(),
]); ]);
} }

@ -17,6 +17,7 @@
</script> </script>
<link rel="icon" href="<?= asset("assets/favicon.ico") ?>"> <link rel="icon" href="<?= asset("assets/favicon.ico") ?>">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" <meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
@ -30,6 +31,7 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
margin: 0; margin: 0;
overflow: hidden;
} }
</style> </style>

Loading…
Cancel
Save