This commit is contained in:
Nick Beckley 2026-06-03 22:22:36 +01:00
parent 2c48d7039a
commit 1410666a39
35 changed files with 1010 additions and 47 deletions

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUTW/cNhT8KwLPoswvUaJuxjpBF27qRbxIi96eyKeYsEwtSMqJYfi/F9Ru4fhU9EIQT5zhvHlDvZIAT0gGcn06fXrGkBOpSfZbSTChKdOUyaPgA2sHxRvRcqa0+JvUxN/iCxlIp6zl1iCVqJEqx1o6yslS59Bwxh0TrSqc8D2R4ZWAbxw+e4vNkr5hTH4JZCB/+uCWH+Vu8I2dl9U1cZlxH1KGYIuYP/a7W/r79eF4dzifSpgKuPGODISJzkwOJOW8R6q4QDoaI6kb+87YiWsm8QzzIWMMMDfJPb7f75YcMNtBNEI2jApD3mriIEORPELC48upqNgsuin1eitv2+GVPGMkg6j/NfNMd2Vnf/WUxtXP7uq8ZkiPaVsnsHmJL6Qmp7icMGaPmz/XKeHTOL8cIT1+Pp8p2/TpJ9o1o9sta8hkIFx2pCabQfMM2S+humjczR5D3j7m6EPy9r+4JKnJ3X11mCFPS3z6MI/7s837/+HyF7APPmC1vykYjsLJHukkuKaqd0ChlYZqLfWE3JpOcFKT3RKyD+uypmofMn6PW0tkIJ9hToX07r66jvbBZ7R5jaXPv3TJ1SEubrW5ep8lZw1rJGMFtObTmquv6HxEm7E0cYzrhe/bR4jQYgPt1hgx5OoA+aH6DdIDGYiB3nIwYAC0tRO2DCfmGGjWOdROo1PCdU4Z5bQyzGoFI1cOWj5OpUJqgiU61ZZWhXxkMGkq+xaogqml0AJQ1Jx1yB32ogTs3cfqbi44sD1T6CQ1ODmqDAdqlJTUyRY6oaeuE9MvllzywFkx2OHz+d1tCrRzPTrNqVC8pcoYTnvTdnTs+tGMusfWFs23GAPOvxj1xdu4pGXK1SUh1Qfrvq6h/DuqLS0/fKA/txndLPYRY1VmDD6Up3IZ69vb29s/AAAA//8DAHHCgySCBAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUW2/cNhCF/4rAZ1HmTbzozbBb1HDTLGKjLfo24oxiIVppQVGbBob/e0HtBqn71jdiwDM8/OaQr2yGI7GO3Z5OP51pziurWR73khLKcmG50M9KdsJ3om28Fa2R7i9Ws/GRvrGOOROjjIG4JkvcoGh5r4fIESlIIVGo1pSe8Hll3SuDsUE6j5GaZf2d0jouM+vYH+OMy9dyNoxNnJYNm7RM9DCvGeZYzPz2cPfIf709PH88XHattBZxMyLrGMlotGg1V0SGmx48D1FabgbyPrSWwOBFNs6Z0gxTs+KXH+fjkmfKsVON0o3gKrC3miFkKJZ7WOn526m42BHdl3q9l/dl98rOlFin6u8wL+1u4jTerFs/0ZmmE6SV0k1cjkeYi5dTWk6U8kg7ljOlflnHXIjG6MB7KdGDM61Dr1ulHWpB6IVrMWAAa+ygYtsO2PvgzGBQSYOWjOkdq9kObpogj8tcXb3fTSPNmdXs41N1mCAPSzq+Q/90IfrwP4AW26xjZad1jozWGoLtlSB0Nlqr+gjCDb0iGUm0wbggnZA6KBF0a513CCJEVrMPEF/GmaqHe9YxIUmh9sQHVc70CBxaHbi12g4kY3BKsprdLXMe523Z1uphzvQ57fdlHfsZppUuN71N8WXMFPOWCoQ/bQnjIS24xVz9CIAUjWi0EEW05dOWq0+EY6KYqeB4Ttu1338kyqpddLelRHOuDpBfql9gfWEdC+CjhAABwMY4UCtoECjACodk0RIahQ5NMGhNENEa6KVBaGU/lAqrGZW8VXvEfQShvYw8Bim4MWB5j0JzUL0IAw7Kq+Edx+rjVHQQvTCEmgcakJsggQejNUfdglN2cG7XfUdyDYsUBTDS+fJYdwcW0RNayZWRLTchSO5D63jvfB9666mNxfMjpZmmf4H6MMa0rMuQq2vWqnfoPm1z+XCqPXdfx5n/vc/ofolfKFVlxjDO5X1dx/pWsyPBuiU67v9V98oO5XFVz/u3pXxjhbE1e8qQ8na6lrVvtPX+7e3t7R8AAAD//wMAMdQZgPQEAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUwW7jNhCGX0XgWVQoiqJE3VKnixrZbIyNuy16G5Ejh4hMGhSVbRD43ReUHcTZRQ+9CaP5OTPf/OQrcbBH0pHrw+H3Z3RxIjmJdglxxiVlkrJqy8uOtR2ri1ZVbSP4PyQn9hZfSEcaoXWpFdIKJVJhWE37atDUGFQlKw3jtUhnwm4i3SsBWxh8thoLP33DMFnvSEf+ss7476k22EKPfjZF8COu3RTB6dTMl/Xqln6+3mzvN6esCackLqwhHcFSi4rVFeWIgooeWqp0KakYsG1VLRGEOcmsixgcjMVknt7rGx8dRt3xglcFo1yRY04MREgt9zDh9uWQulgQ3aR4voSXz+6VPGMgHc/fYJ6Ou9KjvdpP/WxHczX63c663cq7we7mADEVzskh+AOGaHGB85t1EF4++90unUc+wTghyckWw946GE8/bnCAeYwPfg4Lmck8/VcO6QjM0ZOcLCTHcSmbnYdZjRZd/EV7Ufj+IduMEAcf9h+W9HBiv/4f6O9AP1qH2fqGdISVyE3VIh14ymwNUKgrRaWs5IClVg0vSU5W3kXrZj9P2dpF3J2pfejvOuhHG1HHOaSZ/pbJbJvgzaxj9r7gkhWsqBhLojke5ph9RWMD6ohpiG2Yz+f9JOGSL6JPdsRf8KzmENDFbAPxMfsDpkfSEQWtLkGBApBaD1gzHJhhIFljUBqJRnDTGKGEkUIxLQX0pTBQl/2QIiQnmEyWLb4uoeW8NjVVyNPl4oz2gxK0FwDMVFpAKT7Aze7HpAPdMoGmogoHQ4UqgSpRVdRUNTRcDk3DhwtOZ0OULFG/NOGfE5qzl76crP02u8Hn0z1e+pTGtGhkSbkoayqUKmmr6ob2TdurXrZY6zTZyrvJv4H8hqH3k43pEbmzzu5h/DnlfTO3GByOF9u5szr4yQ8xO9sy+7Cvr7NLr1i2WPS7dfTfxRg3Xj9hyJKxwLqLZR6Px+MPAAAA//8DAJxlZlgMBQAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzS0NSN8NuUddNvYgXadEbRY5sIVpqQVGbBsb+94DaDRL31hsx4Hw98868kegOSDpyezz+csKYF1KTPG4mwQRQBpTJveAdMx1rG8OVYQr+ITUZH/Er6YhW3nNvkUoEpCqwlvZy8DQEtJzxwESrSkz3spDujbixCXgaPTbz8gnTMs6RdOSvMYb5S8ntxsZP8xqaNE/4EJfsoi/F/Plw90j/uN3tn3aXXwsuxbkZA+kIcq8kayUViIqq3hlqPQeqBjTGtoBOhYvbGDOm6KZmCZ9/5A9zjph9JxohG0aFJeeaBJddKbl3C+6/HksVG6L7Yq838/bs3sgJE+lE/R3mJdyNn8abPB8nPOF0dGnBdOPnw8HFUssxzUdMecQNy9bpNLk8zrG6JrubRoyZ1OTpudpNLg9zOrxj9XxB8PA/CJww9aQj5SdojUpK6Sz0gmHQ4AFE7x3TQy+Qe2StVdpyzbi0glnZgjY6OGY9qcnLNPdu+n3Z+FnBehOYFiBQoRUKwHqG0uDQamTBAfRgvA3SaKFMMNJZ57jtUXsLvgjkg/OvY8Tq4Z50hHEUQRqkgyg9mOCoa6WlABIG5N5qwUlN7uaYx7jO61I9xIwvaeNHOvKrmxa8kLtN/nXM6POaCtS/oSTbpTmsPlc/FMBZwxrJWHFa83HN1UcMY0KfseDdp/Ua7z8uAsTmdLemhDFXO5dfq9/c8lqoOOO5s6VV8H7AluHAAnPAdEAIgEGJoIOyKoCyzINyPVfBtbwfioXUBIvgqk3jxljuB9ZSQFPmKgZqADQV2hhlWyOgh3ccq6ep+DlvmMIgqcUhUGW5o1ZJSYNsnRYwaC2Gn5BcxcdZARzwdNnWrQIIwWAAToXiLVXWcmpsq2mvTW97MNj6UvMjpojTT6A+jD7Nyzzk6qrd6h26j2ssF6fadPxljPTfbUb3s/+MqSozdmMsC3Yd67kmB3TLmvCwHazujezKdlX77W4J0wBTUJPn7FJej1ezNI0EY87n8/kbAAAA//8DAKMP1wj1BAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUXW/lJhD9KxavNV7AGIPf0iSrXm2zifZetVXfBhgnNA6+wji7qyj/vcI32U9V6otlDcycw5kz80QiPCAZyNnxePmIMS+kJjlsIcGEokxR1h4EH5gemGoM00p3+m9Sk/AOP5OB9NI57gzSFhVS6VlHbTs66j0azrhnopOlJtwuZHgiEBqPj8FhMy9/YFrCHMlA/gzRzx8LNoTGTfPqmzRPuItLhugKmfe783f097Obw/XN6daCS0lugicDQe5ky7qWCkRJpQVNjeOKyhG1Np1CkP6UFmLGFGFqFn//Fd/POWJ2g2hE2zAqDHmuiYcMhbKFBQ+fj4XFJtFFiddbePsdnsgjJjKI+lXMU7k3bgpvHha7hsm/2b6kJsc0HzHlgJsa1/vqKwnOGtYIJRgjNbkCdxciVruL6noqTwSnmUTfUoOjp9JwoEa2LfVtB71QY9+LkdTkHaaI0zdVr4JL8zKPuXoRufoOZ392fhnBTlgw3sK0IKnJbtlniB6mOeLlJ3RrPtU6pLUcn88xh7jO61LtYsbbBC/nr/nna0oYc3UD+a76DZY7MhAD2nEwYACUcyN2DEfmGSjWe1ReoZfC914a6ZU0zCkJlksPHbdjiZCa/Fo03K/O4bJ8ZbOZZJo2DtVLn86ngDF/JyMZCOMofKuRjqJ4Q3ug0LWGKtWqEbkzveCvMAdIt5jJQEZtrWXOWSENdyAMtH5krVejEaLvQY5t10uhVMd6cL7vBZe+7dFBO7ZMC1KTmzT71eUfW91uDbhJ8z/ocpGKDESxrtMOrBVKcg1Go+RKamWwN3IEJltngXcarOUMOuaMFg64MiCt78YvKl3G2xDxIizHCT5/g6sb1bS/OADNR5A/XH+b4AE/zun+/cnGzfvLA6nJhzWWjVDtikU+hkg/qTLRV+uUw+EuIXj0V7PHn32EZVyqbUKtk8qjs9TwnlGplaZ69Ehtaz03thfeFKkuZnePqSoOgxDLVH0pdr2vzpK7CxldXlPh99fG43pf3UyQxzk9fLdJvnnYjwI0ouWswHl8PG2jjaPyXqNXnArJOyqN4VSbrqe219ZYpbFzRbHrNR/XXH1AHxK6vL34xYsb5vkduvufxXh1wYtFOSte25+22Cbt/1pizzV5QFjWhA/bth6eyC5GTBvyxXoaxV28CtMUFnRz9AsZOOOmMYzxF4b/fU+3jRCSPz8/P/8LAAD//wMANFneEhsGAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUTW/bOBD9KwLPpkJSFCUK2IPhtFgjTWMk7u5ibxQ5jInIokFRToMi/31BWa6dooe9CaP5eO/NG/5AvdoDatDycPh0hD4OaIGim0KMMIGJwKTYMtqQuiEiZ7Iuy0r8ixbI3cEbalDFtaZaAi5AAOaGlLgtrMbGgKSEGsJKnnqq5wE1P5ByuYGj05D74S8Ig/M9atDfrjf+Nc1WLtedH00efAfrfoiq1wnM1/XqDn9ZbrYPm1PWAEMqzp1BDQKqeUHKAjMAjnmraiw1FZhbqGtZClDcnMpcHyH0qssH83KZb3zsIeqG5azICWYSvS+QUVElyK0aYPt2SCgmiW5TfDGFp8/mBzpCQA1bnMU8tbvRnbvZD+3oOnMTVXiGaIPaw6sPL3BUHVqgQ/AHCNHBpM3DU3aBRElOciYYIWiB7pXeuR6y9W320CXCSteEgymwBGswl1RhyYsCm6JUFRO2qphFC3QHoYfuquu908EP3sZsljz7MGfl++j60Y9Dtu4jPAcVT3WfVTcAWqDN2HZu2D2CMm9b/zimf/3YJS6rMQToY7ZRcZf9qYYdapBUtaZKKqmU0NpCScASQ5QglQFhBBjOTGW45EZwSbTgqqXcqJK2NkXQAj2M8TDGs/7fr0Bsg9vvwVwQbCeNN52K1of92kAfnXVpNeeMyVBdN7HK5p6rzkEfP4iMGkQoMFPUgC1LPqqNwqosJBaiEBaolhWjJ8Gsex5/6kSsNbywZV2LVraUALNVzUllW2K0LbQ0ZQsVq0hVQy01IW3Jad1KTQzQcjLpJngz6virFYppQY9jn64zWyfar67H3wW/KLIJPnrtu7Qw18HT2xBh/7utrfz+4AcXE387r/bbAPe+T3/n+581m4uXPl5ikO4gm06vJXXLdalxS+syORFwqwuBCSeU1azlXCXgt16/QMiSwZTrp52cPfXwlC2D3rkIOo4hTf5nInVa5+fzyVz0yL9+2q58gOXhsJijfxyTSKdmZwN8eFe+DSl954d4RXmm9gSdPQO7spOB4+mpcikojKnBCIoZp4mnpLiWZYXbqm5lK2oo9cWu2SMYF0DHqd82jGnarOzvfLkM0Vml45CO54vXk51mf84pv4K8WtvP6tPwq9XNVpo7UZIs+3R6OCcH/a938/39/f0/AAAA//8DAKL+ZjEoBgAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTwW7cNhD9FWHOokxSFCXqZqxTdOEmXiSLtOiN4oxiwjK1oCgngbH/HlC7gZPeeiGIAd/Me28eXyHYZ4Iebk+ndy8U0gIlJL+VJJeacc14fZSi513PdWW4lnXT/Asl+Hv6Dj20yjnhDLGaNDGFvGFDPTqGSEZwgVw2Kve0XxboX8H6CunFO6rm5TPFxc8BevjbB5y/5tnWV26aV6ziPNE+LMkGl8l82O/u2V+3h+PD4fJqoSWDK4/QAwmnat7UTBIppgbbMeOEZmqkrjONJqvwAvMhUQx2qhZ8epuPcwqUXC8rWVecSQPnEtAmmykPdqHj91NmsVl0l+vlVt6u/Su8UIRelj/NvLS7cZO/eV6G1U94czmTXZ6yylOcTxSTp82TTeY02eTnUFwn7SZPIUEJD5+Kw2TTOMfn34z6dNG//x/yj3n6u2/k1kS4m9eQoAdRd1DCe+sefaBifwc9cEES647YKHOTDi2zTW2Y1rUeSTjTSgEl7OaQfFjndSn2IdGXuAmAHv6w00IX6rfRPfpELq0xq/pH5ywc4oyrS8Wb/4JXvKo5z6A1ndZUfCT0kVyirO8Y12u//0Cklhtot8ZIIRUHmx6LP+3yCD0Y2zlhjTXWaudGajiNHLnVvEXSqAmVxBaVUaiV4U4rOwiFthHDmCtQAuV1F1vCNFeOlOhYo3Bkamw7ZpREZtUw2m5ArXQOxZuPxcOUcdZ1XBHWzNCITBlhmVF1zbBubCv12LZy/MWS6/YFzwYjvVz+yoUBYkeoBZNKNEwZI1hnmpYNbTeYQXfUuMz5nmKg6Rej3nsX52UeU3ENT/GbdR/XkP97sQXpqw/s27aju9k9USzyjq0POd7XtZ7P5/MPAAAA//8DAEVhgyo2BAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTwW7UMBD9lcjnOLUd24lzq7YgVgW6ohUgbrP2mLVInZXjFFC1/46cXQTlxs169pt58974mUR4RDKQ6+Px1RPGPJOa5LBCgglNmaasfRB8YP3AdGNYr4xSX0hNwi3+JAPppLXcGqQtaqTSMUX3rbfUOTSccceEkqUmfJ3J8EwgNA6fgsVmmj9imsMUyUA+heim76U3hMaO0+KaNI24jXOGaIuY99vNLX17vXu4251fzTgXchMcGQhyK1umWioQJZV76KmxXFPpse+N0gjSnWkhZkwRxmZ23/70d1OOmO0gGtE2jApDTjVxkKFI3sOMDz+PRcVq0U3B6xVej8MzecJEBlH/NvNc7sqO4cpOj48Q3ZUPMcwHUpNjmo6YcsDVjnXCcYQcplhdmmzGgDGTmtzdV7sRsp/S4wuP7s+jb/9j8ndgDyFitb0hA2EchWt7pF6Ul70DCqo1VOtWe+TWdIKTmmymmENcpmWutjHj17SqJAN5DeOMZ33XyR5CRpuXVKR/1iVr/BHyZnIFYKQmuzS5xebqj92cNaxpWbm8W/JxydUHdCGhzVhmekjLpfw/FKHFStosKWHM1Q7yoXoD84EMxEBvORgwANpaj4qhZ46BZp1D7TQ6KVznpJFOS8OslrDn0oHie1+QorukW60LBd5DpxRSKxSjElugYHtLPbK+5b1R3r20tbobV57tmUTXUoPeUWk4UCPblrpWQSe07zrh/7LkkjhnxW+HT+evsSrQzvXoNKdCckWlMZz2RnV03/V7s9c9Kls032KKOP5l1Ltg0zRPPleXhaleWPdhieV7V+vyfA+R/lgju5nsN0xViRxCLNt8Sfl0Op1+AQAA//8DAKrhPt4lBAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUTW/cNhD9KwLPoswvUaJuxjpBF27qRbJIi94ozigmLFMLknJiGPvfC2q3SHwqeiGI4bzhmzePfCPBPiMZyO3p9OEFQ06kJtlvIcGEpkxTJo+CD6wfmG560zPJ5d+kJv4eX8lAOuUcdwapRI1UAWvpKCdHAdBwxoGJVpWa9lsiwxuxvgF88Q6bJX3FmPwSyED+9AGW7+Vu6xs3Lys0cZlxH1K2wRUyf+x39/T328Px4XDJSpgKuPFABoLcKclaSQWiomq0PTWOa6om7HvTarQKLjAfMsZg5ybB08/7YckBsxtEI2TDqDDkXBOw2RbKo014fD0VFptEdyVeb+FtO7yRF4xkEPW/Yl7K3bjZ3zyncfUz3FzWbNNT2tbJurzEV1KTU1xOGLPHTZ/blPB5nF+PNj19vOSUbfrwA92aEXbLGjIZCJctqckm0Dzb7JdQXTnuZo8hb4c5+pC8+69aktTk4Ut1mG2elvj8bh5fLjLv/4fKn6x79AGr/R0ZCOMoQPZIJ1Eye7DUttJQraWekDvTCU5qsltC9mFd1lTtQ8ZvcWuJDOSjnRNe+N1G9+gzurzG0udfuvjqEBdYXa5+zpKzhjWSsQJa82nN1WcEH9FlLE0c43qt9/U9RGixgXZrjBhydbD5sfrNpkcyEGN7x62xxlrt3IQtw4kBs5p1gBo0ghLQgTIKtDLMaWVHrsC2fJxKhNQEi3Wqza3cCeAcGVUcLFUTAzpaoWinuHE9cM2Zfqdj9TAXnHU9UwiSGpyAKsMtNUpKCrK1ndBT14npF0mufuCsCAz4cnl3GwMN0CNoToXiLVXGcNqbtqNj149m1D22rnC+xxhw/kWoT97FJS1Trq4Oqd5J93kN5e+oNrd894H+2GZ0t7gnjFWZsfWhPJXrWM/n8/kfAAAA//8DAANjiOSCBAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUXW/lJhD9KxavNQ5gwMZvaZJVr9J8aO9VW/VtgHFC4+ArjJNdRfnvlX2TzceqUl8sa4A5Z86cmScS4R5JR473+7MHjHkiJclhDQkmNGWasnoneMdNx1TVGN0Kpv4mJQnn+J10pJHOcWeQ1qiRSs8UtXXvqPdoOOOeCSWXnHAzke6JQKg8PgSH1Tj9gWkKYyQd+TNEPz4u2BAqN4yzr9I44CZOGaJbyFxuTs7p78fXu6vrw60Jp+VxFTzpCOuV4FIxKnrRU8kaQ61qkVqjnG0VMtDm8CzEjCnCUE3+7g3fjzlidp2oRF0xKgx5LomHDAtlCxPuvu8XFqtEp0u8XMPrb/dEHjCRTpSvYh7SHbkhHN1Pdg6DP1q/pCT7NO4x5YCrGlfb4o0EZxWrhBaMkZJcgLsNEYvNaXE1LCWCa5lEX1ODvafScKBG1jX1tYJG6L5pRE9Kco4p4vAu60VwaZzGPhcvIhcfcLbHJ2cR7IALxhcYJiQl2UzbDNHDMEY8+4ZuzodcuzQvxydjzCHO4zwVm5jxJsHL+ev7kzkljLm4hnxb/AbTLemIgdZxMGAAtHM9KoY98ww0azxqr9FL4RsvjfRaGua0BMulB8Vtv0RISX5dNNzOzuE0vbFZTTIMK4fipU8nQ8CYP8i4uISj8HWLtBdcU9l6oKBqQ7WudY/cmUbwV5gdpBvMpCN9a61lzlkhDXcgDNS+Z7XXvRGiaUD2tWqk0FqxBpxvGsGlrxt0UPc1awUpyXUa/ezy51bXawOu0/gPurxIRTqimVKtA2uFlrwF06LkWrbaYGNkD0zWzgJXLVjLGSjmTCsccG1AWq/6HyqdxZsQ8TRM+wG+v8NtK13VvziAlvcgP13/kuAeH8d0d3mwcXV5tiMl+TrHZSMUm8UijyHSb3qZ6It5yGF3mxA8+ovR488+wmVcinVCaxRQO2yp1FxQ2WigtteK+rZ1tgEvlG1ISU5Hd4epWBwGIS5T9SPZ1bY4Tu42ZHR5Tgu/v1YeV9vieoDcj+n+wyZ5V9hnASpRc7Z0xuPDYRutHLX3LXrNqZBcUWkMp61RDbVNa43VLSq3KHY15/2ci6/oQ0KX14pfvLhintyiu/tZjFcXvFiUs8Vr28MWW6X9X0vsuST3CNOc8H7d1t0T2cSIaUU+nQ+juIkXYRjChG6MfiKdMVpUtVAvBP/rGmdMqUpJ2Tw/Pz//CwAA//8DAIu0/5cbBgAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTy27cOBD8FYFnUeZbom7GeBcZOFkPEiNZ7I1itzJEZGpAUU4CY/59Qc0EiXPLjSiyuqurmi8kuickPbk9nf56xpgXUpMcNkgwYSgzlMlHwXtue6abjrFWm/Y/UpNwj99JT1rlPfcWqUSDVAHTdJCjpwBoOePAhFalpvu8kP6FuNAAPgePzbx8xLSEOZKefAoR5q+ltwuNn+YVmjRPuI9LdtEXMf/sd/f07e3h8eFwebXgUshNANITNmrBlWZUjGKkirWWDrpDOljth04jc8ZeaCFmTNFNzQJffvaHOUfMvheNkA2jwpJzTcBlVyQPbsHH76eiYrPoruD1Bm/H/oU8YyK9qH+YeSl346dw4+enJxfhZgwxLEdSk1OaT5hywM2ObcJpcjnMsbo22U0BYyY1efhQHSaXxzk9vfLow2X0/R9M/s75Y4hY7e8Kh6MA2SEdBTdUdeCo09JSY6QZkXvbCk5qsptjDnGd16Xax4yf06aS9ORvNy140Xeb/DFk9HlNRfq/pmSN30LezVAARmpySDOsPlc/7easYY1k5fJhzac1V+8RQkKfscz0mNZr+d8owoiNtFtTwpirg8vH6o1bjqQn1nWeO+usc8b7ETXDkQFzhrWABgyCEtCCsgqMsswb5QauwGk+jAUpuku61bZQoN0oPCiKrpNUDdzRzrSGcimUkGi1kfKVrdXDVHjOd0whSGpxBKosd9QqKSlI7VphxrYV4y+WXBPnrPgN+Hz5GpsCA9AhGE6F4poqazntrG7p0HaDHUyH2hfN95giTr8Y9S74NC/zmKvrwlSvrHu/xvK9q215voZIv22R3c3+C6aqRO5CLNt8Tfl8Pp//BwAA//8DAMbyj+ElBAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUT2/cNhDFv4rAsyjzP0XdDLtFDTfNIjbaorcRZxQL0UoLito0MPzdC2q3SNxbb8JAb/j4mzd8ZTMciXXs9nT66UxzXlnN8riXlFCOC8eFflayU6ITthFBBGnDX6xm4yN9Yx3zJkYZA3FNjrhBYXmvh8gRKUghUShrSk/4vLLulcHYIJ3HSM2y/k5pHZeZdeyPccblazkbxiZOy4ZNWiZ6mNcMcyxmfnu4e+S/3h6ePx4uf620FnEzIutYaD2FARTXPiA3AnoOg+h5T7aVUmrdGnuRjXOmNMPUrPjl+/m45Jly7FSjdCO4CuytZggZiuUeVnr+dioudkT3pV7v5f2ze2VnSqxT9b8wL+1u4jTerFs/0ZmmE6SV0k1cjkeYkdXslJYTpTzSjuVMqV/WMReiMXpoWymxBW+sx1ZbpT1qQdgKbzFgAGfcoKK1A/Zt8GYwqKRBR8b0ntVsBzdNkMdlrq7e76aR5sxq9vGpOkyQhyUd36F/uhB9+B9Ai23WMS2sdt6T0VpDcL0ShN5F51QfQfihVyQjCRuMD9ILqYMSQVvnW48gQmQ1+wDxZZyperhnHROSFOqW+KCk46ZF4GB14M5pN5CMwSvJana3zHmct2Vbq4c50+e035d17GeYVrrc9DbFlzFTzFsqEP50JYyHtOAWc/U9AFI0otFCFNGWT1uuPhGOiWKmguM5bdd+/5Eop3bR3ZYSzbk6QH6pfoH1pTCENkoIEABcjANZQYNAAU54JIeO0Cj0aIJBZ4KIzkAvDYKV/VAqrGZU8lbtEY+9i1b2yBVZww2R56GA0Tq6aJUhB+85Vh+nooPYCkOoeaABuQkSeDBac9QWvHKD92r4Ack1LFIUwEjny7LuDhxiS+gkV0ZabkKQvA3W8963fehdSzYWz4+UZpp+APVhjGlZlyFX16xV79B92uby4FR77r6OM/97n9H9Er9QqsqMYZzLfl3H+lazI8G6JTru71X3yg5luarn/dlSoXFWqJo9ZUh5O13Lum2UMPLt7e3tHwAAAP//AwDz0opy9AQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsygPP0SKuhl2i7pu6kW8SIveRuTIFqKVFhS1aWDsfw+o3SBxb70JA70zw2femTc24YFYy26Px19ONKWFlSwNW0iCNBwMB7WXopXQQl1BrYQA/Q8r2fBIX1nLrPZeeEdckSGuA9S8U73nIZATIALIWuec+LKw9o3hUAU6DZ6qeflEcRnmibXsr2EK85dcG4fKj/MaqjiP9DAtCSefm/nz4e6R/3G72z/tLn8ttGRxNQTWMtdYcj1KrqwLXAN2HHvoeEd1I4RQqtH1RTZMieKEY7WEzz/qhzlNlHwrK6kq4NKxc8kCJswtd7jQ/usxd7Ehus/xcgtvn+0bO1FkrSy/w7yku/HjcJPm40gnGo8YF4o3fj4ccAqsZMc4HymmgTYs20vHEdMwT8W12N040JRYyZ6ei92IqZ/j4R2r5wuCh/9B4ESxYy1TUCtjLWmlFDrTSaBgjTdGdh7B9p0k4Qlqp60TFoRyEpyqjW1sQHCelexlnDscf182fk5C1wSw0kjS5KQ2xnkg1VBfW4KAxnSm8S6oxkrdhEahQxSuI+ud8dkgH9C/DhMVD/esZSBIBtUQ76UwXDcBOdbKcWOU6Ul4Z6VgJbubpzRM67wuxcOU6CVu/FjLfsVxoQu52+hfh0Q+rTFD/dvkYrs4h9Wn4ocDBFRQKYAsWtNxTcVHCkMknyjj3cf1mu8/EmnkJrpbY6QpFTtMr8VvuLxmKth4gS4/1XjfUw3UQwA0YAOZYChoGWzQTgejHXijsRM6YC26PkdYySgbrtg8Do6sc0FzKWrHtfLAO+uBKxDBGiUNWvOOY/E0Zh36BjQFxR31gWsnkDutFA+qRitNb63sf0JyNZ+ADDjQ6bKtWwcmhIaCEVxqUXPtnOCNqy3vbNO5zjRU+9zzI8WJxp9AfRh8nJe5T8XVu8U7dB/XKV+cYvPxl2Hi/24zup/9Z4pFnjEOU16w61jPJTsQLmukw3aw2je2y9tV7Le7JV1lapAle04Y03q8hlVTSdDifD6fvwEAAP//AwCWgzK09QQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUy27jNhSGX0XgWlR4EyVqlzod1MhkYkzcadEdRR45RGTSoKhMg8DvPqDsIM4MuuhOOOJ/Lt/5yVfk9R5Qh64Ph9+fwacJlSi5JcQIk5hITPiW0Y6RjtQVZZyxRv2DSuRu4QV1qBHGUKMAc5CAhSU17vlgsLWgKKGWsFrknHo3oe4VaVdZeHYGqjB9gzi54FGH/nLehu+5tnaVGcNsqxhGWPspaW9yM1/Wq1v8+Xqzvd+cTk0wZXHlLOqQahtQg2aYN8piQXSP9UB63EPdUko5b0V9kjmfIHo9VpN9eq9vQ/KQTMcqxiuCmULHElmddG651xNsXw65iwXRTY6XS3j57F7RM0TUsfIN5indlRnd1X7qZzfaqzHsds7vVsEPbjdHnXLhEh1iOEBMDhY4vzmv48vnsNvlfOiTHidAJdpC3Duvx9OPGxj0PKaHMMeFzGSf/usM6pCeU0AlWkiO41K2OA+zGh349Iv2ovD9Q7EZdRpC3H9Y0sOJ/fp/oL/T5tF5KNY3qEOEArO8BTwwKrForca65gpLyeUA1KiGUVSiVfDJ+TnMU7H2CXZnah/6u47m0SUwaY55pr9lNtsmBjubVLwvmJKKVJyQLJrTYU7FV7AugkmQh9jG+ZzvJwmTbBF9ciP8gmc1xwg+FRudHos/9PSYcejWUK200loaM0BNYCCWaEkaC9JKsILZxgolrBSKGCl0T4XVNe2HHEElgmyyYvE1N40mQ8+xNUJiATXF7cAs1pTUDeNWkkF8gFvcj1mnTUsEWI4VDBYLRTVWgnNsea0bJoemYcMFp7MhKMnUL0345wT27KUvJ2u/zW7h+XSPlz6ltS1YSTETtMZCKYpbVTe4b9pe9bKF2uTJVsFP4Q3kN4h9mFzKj8id826vx5+PvG/mFqKH8WI7d87EMIUhFWdbFh/29XX2+RUrFot+dx7/uxjjJpgniEU2lnb+YpnH4/H4AwAA//8DAIOrApAMBQAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUS2/bOBD+KwLPokJKFCUa2IPhtFgjTWMk7u5ibxQ5jInIpEFRToMi/31BWa6dooe9CSPO43vM/EBO7gEt0PJw+HQEFweUo2inUElKjgnHpNqWdFGSBamLmnAi6vZflCN7B29ogRqmFFUCcAUcMNOkxl1lFNYaBCVUk7JmqaZ8HtDiB5K20HC0Cgo//AVhsN6hBfrbOu1fU29pC9X7URfB97B2Q5ROpWG+rld3+Mtys33YnF4NMKTkwmq0QKJtQBhZ4qoRGjMiOywN6XAHdUspraqW1ac06yIEJ/ti0C+X/tpHB1EtyqKsCoJLgd5zpGWUaeRODrB9O6QpJopuUzyfwtPn4gc6QkCLMj+TeSp3o3p7sx+60fb6JsrwDNEEuYdXH17gKHuUo0PwBwjRwsTNw1N2GYmSghQlLwlBObqXamcdZOvb7KFPgKVqCQNdYQFGYyaoxIJVFdZVLZuSm6YpDcrRHQQH/VXVe6uCH7yJ2Ux59qHPyrto3ejHIVu7CM9BxlPeZ9kPgHK0GbveDrtHkPpt6x/H9M+NfcKyGkMAF7ONjLvsTznski6yVVQKKaTkShmoCRiiieSk0cA1B81K3WgmmOZMEMWZ7CjTsqadSRGUo4cxHsZ45v/71RDbYPd70JcJthPHm15G48N+rcFFa2yS5vxiMlTfT6iyueaqt+DiB5LRAhEKpa5awKakHLNWSyzrSmDOK26AKtGU9ESYsc/jT56IMZpVpm5b3omOEihN0zLSmI5oZSoldN1BUzakaaEVipCuZrTthCIaaM10Qhe8HlX81QrVJNDj6NJ2ZusE+9U6/J2zCyOb4KNXvk+C2R6e3oYI+9+ptvL7gx9sTPjNLO23Ae69S3/n/Z85m5OXPl5ikPYgm1aPgeKmIhxL2jLMaNPiFuoKN3o6FUoZrVCObr16gZAlg0nrJk3Onnp4ypZB7WwEFceQOv8zgTrJ+fm8Mhc+iq+ftisfYHk45HP0j2Mi6VTsbIAPd+XbkJ7v/BCvIM/QnqA358Gu7KTheDpVNgW51i1oTnHJaI2ZEBS3om5w17Sd6HgLtbrYNXsEbQOoONXbhjF1m5n9nS+XIVojVRzS8nzxarLT7M/5ya9DXsn2M/vU/Eq62UpzJUqSZZ9Oh3Ny0P+6m+/v7+//AQAA//8DAKXy/XEoBgAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTW/UMBD9K5HPceqvOHFu1RbEqkBXZQWIm+OZUKups3Kcfqja/46cXVTgxsWyRn4z7715fiXBPiDpyOXh8O4RQ5pJSZJfS4IJTZmmTO4F7wTrmK44Z7JuxQ9SEn+NL6QjjXKOO4NUokaqgNW0l4OjAGg448BErXJP+3Mm3SuxvgJ89A6raf6KcfZTIB355gNMT3m29ZUbpwWqOI24DXOywWUyn7eba/rxcre/2Z1ezThncOWBdMS0DZrBCiobA1Qx21M7sJ72WLeccylbVZ9gPiSMwY7VDPdv82FKAZPrRCVkxagw5FgSsMlmyr2dcf9yyCxWi65yvVzL67V7JY8YSSfK32ae2l240V88zP3iR7g4ncnO91nlIU4HjMnj6skqcxxt8lMozpM2o8eQSEluvhS70aZhig9/GfXlpH/7H/L3efq7Z3RLQthMS0ikI1y2pCSfrLvzAYvtFekI4yhAtkgHwTVVLVhqa2mo1lIPyJ1pBCcl2Uwh+bBMy1xsQ8KfcRVAOvLejjOeqF9Gd+cTurTErOq7zlnYxQkWl4o3/zmrWCUZy6AlHZZU3CL4iC5h1rePy7nfPxChxQraLDFiSMXOprvig53vsim2ddwaa6zVzg1YMxwYMKtZA6hBIygBDSijQCvDnFa25wpszfshV0hJMK+7WBPGsHdON0AbxoAqaQZqmByo7pu2bxuhB5ND8eZjcTNmnHUtUwiSGhyAKsMtNUpKCrK2GdU0YvjDkvP2OcsGAz6e/srKQAO0CJpToXhNlTGctqZuaCZget1i7TLna4wBxz+M+uRdnOZpSMU5PMVf1t0uIf/3Yg3Skw/0ed3R1eTuMRZ5x9aHHO/zWo/H4/EXAAAA//8DAFmhW4E2BAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxU227cNhD9FYGvFRXeRJF6c20HXaSOjeyiLfo2JEe2aplaUJSTwPC/F9TauaJAXwRhSM45c+bMPJEID0h6cnY8Xj5izAupSR63kGBCU6YpkwfBe8F6phvOhTKi/ZvUZHyHn0lPOuU99xapRI1UBdZSJwdPQ0DLGQ9MtKrkhNuF9E8Exibg4+ixmZc/MC3jHElP/hxjmD8WbBgbP81raNI84S4uGaIvZN7vzt/R389uDtc3p1sLLuVxMwbSE2s6tAMIKjsbqGLgKAzMUYet4ZxLaVR7ejbGjCnC1Czh/it+mHPE7HvRCNkwKix5rkmADIWygwUPn4+FxSbRRYnXW3j77Z/IIybSi/pVzFO6N34a3zwsbh2n8Gb7kpoc03zElEfc1LjeV19JcNawRmjBGKnJFfi7MWK1u6iup1IieMMUBkktDoEqy4FaJSUNsoVO6KHrxEBq8g5TxOmbrFejT/MyD7l6Ebn6Dmd/dn4ZwU1YMN7CtCCpyW7ZZ4gBpjni5Sf0az7lOqS1HJ/PMY9xndel2sWMtwlezl/fn68pYczVDeS76jdY7kqLwHgOFiyA9n7AluHAAgPNuoA6aAxKhC4oq4JWlnmtwHEVoOVuKBFSk1+LhvvVe1yWr2w2k0zTxqF66dP5NGLM38lIesI4iiAN0kFwTZUJQKGVlmot9YDc207wV5gDpFvMpCeDcc4x751QlnsQFmQYmAx6sEJ0HahBtp0SWresAx+6TnAVZIce5CCZEaQmN2kOq88/tlpuDbhJ8z/oc5GK9ESztjUenBNacQPWoOJaGW2xs2oApqR3wFsDznEGLfPWCA9cW1AutMMXlS7j7RjxYlyOE3z+Btc0upG/eADDB1A/XH+b4AE/zun+/cnGzfvLA6nJhzWWjVDtikU+jpF+0mWir9Ypj4e7hBAwXM0Bf/YRlnGptgl1VvrWcKQOOkeVFS0tzKnyQoJ2ofO8SHUx+3tMVXEYjLFM1Zdk1/vqLPm7MaPPayr8/tp4XO+rmwnyMKeH7zbJN4X9KEAjJGcFLuDjaRttHHUIBoPmVCjeUmUtp8a2HXWdcdZpg60vil2v+bjm6gOGMaHPW8UvXtwwz+/Q3/8sxqsLXizKWfHa/rTFNmn/1xJ7rskDwrImfNi2df9EdjFi2pAv1tMo7uLVOE3jgn6OYSG9tbYx3JoXgv91jbOWNabt2ufn5+d/AQAA//8DAAb8XS4aBgAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTTW/cOAyG/4qhs+Xoy7LlWzDZRQdpN4M2aIveaInuCHWkgSynLYL57wt5pki7t70JlEi+fF7qhQR4QjKQ29Ppr2cMeSE1yX4LCSY0ZZoy+Sj4INjAdMO5aLtWfyE18ff4kwykU9Zya5BK1EiVYy0d5WSpc2g4446JVpWa8HUhwwsB3zh89habuHzEtPgYyEA++eDi99IbfGPnuLomxRn3YckQbBHzz353T9/eHh4fDpdXCy4lufGODMT0HZoJBJWdcVQxGClMbKQjtj3nXMpetZc0HzKmAHOzuG+v/V3MAbMdRCNkw6gw5FwTBxmK5BEWfPx5Kio2RHclXm/h7Ti8kGdMZBD1L5iXcjd29jc2Pj1BcDeTD345kpqcUjxhyh43HNuE8wzZx1Bdm+xmjyGTmjx8qA4z5Cmmpz8YfbiMvv8fk78De/QBq/0dGQjjKJzskU6Ca6p6BxRaaajWUk/IrekEJzXZxZB9WOO6VPuQ8WvaVJKB/A3zghd9t8kefUab11Skf9bFa/zh8y66EmCkJocU3Wpz9Yqbs4Y1kpXLhzWf1ly9R+cT2oxlpse0Xsv/J0VosSXt1pQw5OoA+Vi9geVYQEBvORgwANraCVuGE3MMNOscaqfRKeE6p4xyWhlmtYKRKwctH6cSKbqLu9W2UJ0w48gQqHFmpMpopCNnnBrGZNd1RqIuiF6xVg9zyQPbM4VOUoOTo8pwoEZJSZ1soRN66jox/Ybk6jhnpZjD58vX2BRo53p0mlOheEuVMZz2pu3o2PWjGXWPrS2a7zEFnH8D9c7bFJc45eq6MNUf6N6voXzvalue7z7QH5tld9F+w1QVy8GHss1Xl8/n8/lfAAAA//8DANrGmTolBAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUTW/cNhT8KwLPoswvUaJuxjpBF27qRbJIi94ovqeYsEwtSMqJYex/L6jdIvGp6IUgnjiPM/OGeiPBPiMZyO3p9OEFQ06kJtlvJcGEpkxTJo+CD4INTDec9Vwa8Tepib/HVzKQTjnHnUEqUSNVwFo6yslRADSccWCiVaWn/ZbI8EasbwBfvMNmSV8xJr8EMpA/fYDle7nb+sbNywpNXGbch5RtcIXMH/vdPf399nB8OFxOJUwF3HggAzF9h2aygsrOAFXMjtRObKQjtj3nXMpetReYDxljsHOT4Onn/bDkgNkNohGyYVQYcq4J2GwL5dEmPL6eCovNortSr7fyth3eyAtGMoj6XzMv7W7c7G+e07j6GW4ua7bpKW3rZF1e4iupySkuJ4zZ4+bPbUr4PM6vR5uePl7OlG368APdmhF2yxoyGQiXRdBm0Dzb7JdQXTnuZo8hbx9z9CF591+9JKnJw5fqMNs8LfH53Ty+XGze/w+XP1n36ANW+zsyEMZRgOyRToJrqnqw1LbSUK2lnpA70wlOarJbQvZhXdZU7UPGb3GTRAby0c4JL/xuo3v0GV1eY9H5ly65OsQFVpern7PkrGGNZKyA1nxac/UZwUd0GYuIY1yv/b6+hwgtNtBujRFDrg42P1a/2fRYlNvecWussVY7N2HLcGLArGYdoAaNoAR0oIwCrQxzWtmRK7AtH6dSITXBEp1qS+ukJi7aDqhk0lClmaCmBUNBQNejYEbo8Z2P1cNccNb1TCFIanACqgy31CgpKcjWdkJPXSemXyy55oGzYjDgy+XdbQw0QI+gORWKt1QZw2lv2o6OXT+aUffYusL5HmPA+RejPnkXl7RMubompHpn3ec1lH9HtaXluw/0xzaju8U9YazKjK0P5alcx3o+n8//AAAA//8DAGO5D/uCBAAA

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
namespace PlotLine.Controllers;
public sealed class RelationshipMapController(IRelationshipMapService relationshipMap) : Controller
{
public async Task<IActionResult> Index(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork = false)
{
var model = await relationshipMap.GetAsync(projectId, bookId, chapterId, sceneId, focusCharacterId, showFullNetwork);
return model is null ? NotFound() : View(model);
}
}

View File

@ -151,6 +151,7 @@ public interface ICharacterRepository
Task<int> SaveRelationshipEventAsync(RelationshipEvent relationshipEvent); Task<int> SaveRelationshipEventAsync(RelationshipEvent relationshipEvent);
Task DeleteRelationshipEventAsync(int relationshipEventId); Task DeleteRelationshipEventAsync(int relationshipEventId);
Task<(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> Appearances)> GetTimelineAsync(int projectId, int? bookId); Task<(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> Appearances)> GetTimelineAsync(int projectId, int? bookId);
Task<RelationshipMapData> GetRelationshipMapAsync(int projectId);
} }
public interface ILocationRepository public interface ILocationRepository
@ -2204,4 +2205,20 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
var appearances = (await result.ReadAsync<SceneCharacter>()).ToList(); var appearances = (await result.ReadAsync<SceneCharacter>()).ToList();
return (characters, appearances); return (characters, appearances);
} }
public async Task<RelationshipMapData> GetRelationshipMapAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync("dbo.RelationshipMap_GetByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure);
return new RelationshipMapData
{
Characters = (await result.ReadAsync<Character>()).ToList(),
Books = (await result.ReadAsync<Book>()).ToList(),
Chapters = (await result.ReadAsync<Chapter>()).ToList(),
Scenes = (await result.ReadAsync<Scene>()).ToList(),
Relationships = (await result.ReadAsync<CharacterRelationship>()).ToList(),
RelationshipEvents = (await result.ReadAsync<RelationshipEvent>()).ToList(),
RelationshipCategories = (await result.ReadAsync<RelationshipCategory>()).ToList()
};
}
} }

View File

@ -719,6 +719,17 @@ public sealed class CharacterLookupData
public IReadOnlyList<RelationshipCategory> RelationshipCategories { get; init; } = []; public IReadOnlyList<RelationshipCategory> RelationshipCategories { get; init; } = [];
} }
public sealed class RelationshipMapData
{
public IReadOnlyList<Character> Characters { get; init; } = [];
public IReadOnlyList<Book> Books { get; init; } = [];
public IReadOnlyList<Chapter> Chapters { get; init; } = [];
public IReadOnlyList<Scene> Scenes { get; init; } = [];
public IReadOnlyList<CharacterRelationship> Relationships { get; init; } = [];
public IReadOnlyList<RelationshipEvent> RelationshipEvents { get; init; } = [];
public IReadOnlyList<RelationshipCategory> RelationshipCategories { get; init; } = [];
}
public sealed class LocationType public sealed class LocationType
{ {
public int LocationTypeID { get; set; } public int LocationTypeID { get; set; }

View File

@ -55,6 +55,7 @@ public class Program
builder.Services.AddScoped<IContinuityValidationService, ContinuityValidationService>(); builder.Services.AddScoped<IContinuityValidationService, ContinuityValidationService>();
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>(); builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
builder.Services.AddScoped<IStoryBibleService, StoryBibleService>(); builder.Services.AddScoped<IStoryBibleService, StoryBibleService>();
builder.Services.AddScoped<IRelationshipMapService, RelationshipMapService>();
builder.Services.AddScoped<IWriterWorkspaceService, WriterWorkspaceService>(); builder.Services.AddScoped<IWriterWorkspaceService, WriterWorkspaceService>();
builder.Services.AddScoped<IExportService, ExportService>(); builder.Services.AddScoped<IExportService, ExportService>();
builder.Services.AddScoped<IDynamicsService, DynamicsService>(); builder.Services.AddScoped<IDynamicsService, DynamicsService>();

View File

@ -172,6 +172,11 @@ public interface IStoryBibleService
Task<StoryBibleViewModel?> GetAsync(int projectId, string? query); Task<StoryBibleViewModel?> GetAsync(int projectId, string? query);
} }
public interface IRelationshipMapService
{
Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork);
}
public interface IWriterWorkspaceService public interface IWriterWorkspaceService
{ {
Task<WriterDashboardViewModel> GetDashboardAsync(int? projectId); Task<WriterDashboardViewModel> GetDashboardAsync(int? projectId);
@ -2163,6 +2168,223 @@ public sealed class StoryBibleService(IProjectRepository projects, IStoryBibleRe
} }
} }
public sealed class RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService
{
public async Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork)
{
if (projectId <= 0)
{
projectId = (await projects.ListAsync()).FirstOrDefault()?.ProjectID ?? 0;
}
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
var data = await characters.GetRelationshipMapAsync(projectId);
var sceneOrder = BuildSceneOrder(data);
var point = ResolveStoryPoint(data, sceneOrder, bookId, chapterId, sceneId);
var eventsByRelationship = data.RelationshipEvents
.GroupBy(x => x.CharacterRelationshipID)
.ToDictionary(x => x.Key, x => x.OrderBy(e => SceneIndex(sceneOrder, e.SceneID)).ThenBy(e => e.CreatedDate).ThenBy(e => e.RelationshipEventID).ToList());
var relationships = data.Relationships
.Select(relationship => BuildRelationship(relationship, eventsByRelationship.TryGetValue(relationship.CharacterRelationshipID, out var events) ? events : [], sceneOrder, point.MaxSceneIndex))
.Where(x => x is not null)
.Select(x => x!)
.OrderBy(x => x.RelationshipCategoryName)
.ThenBy(x => x.CharacterAName)
.ThenBy(x => x.CharacterBName)
.ToList();
if (focusCharacterId.HasValue && !showFullNetwork)
{
relationships = relationships
.Where(x => x.CharacterAID == focusCharacterId || x.CharacterBID == focusCharacterId)
.ToList();
}
var activeCharacterIds = relationships
.SelectMany(x => new[] { x.CharacterAID, x.CharacterBID })
.ToHashSet();
if (focusCharacterId.HasValue)
{
activeCharacterIds.Add(focusCharacterId.Value);
}
var nodes = data.Characters
.Where(x => activeCharacterIds.Contains(x.CharacterID) || (!focusCharacterId.HasValue && !relationships.Any()))
.OrderByDescending(x => x.CharacterID == focusCharacterId)
.ThenBy(x => x.CharacterName)
.Select(x => new RelationshipMapNodeViewModel
{
CharacterID = x.CharacterID,
CharacterName = x.CharacterName,
IsFocus = x.CharacterID == focusCharacterId
})
.ToList();
return new RelationshipMapViewModel
{
Project = project,
BookID = point.BookID,
ChapterID = point.ChapterID,
SceneID = point.SceneID,
FocusCharacterID = focusCharacterId,
ShowFullNetwork = showFullNetwork,
StoryPointLabel = point.Label,
BookOptions = data.Books.OrderBy(x => x.SortOrder).ThenBy(x => x.BookNumber).Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == point.BookID)).ToList(),
ChapterOptions = data.Chapters.OrderBy(x => BookSort(data, x.BookID)).ThenBy(x => x.SortOrder).Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == point.ChapterID)).ToList(),
SceneOptions = data.Scenes.OrderBy(x => SceneIndex(sceneOrder, x.SceneID)).Select(x => new SelectListItem($"Ch {ChapterNumber(data, x.ChapterID)} / Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == point.SceneID)).ToList(),
CharacterOptions = data.Characters.OrderBy(x => x.CharacterName).Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), x.CharacterID == focusCharacterId)).ToList(),
Categories = data.RelationshipCategories.OrderBy(x => x.SortOrder).Select(x => new RelationshipMapCategoryViewModel
{
RelationshipCategoryID = x.RelationshipCategoryID,
CategoryName = x.CategoryName,
CssClass = RelationshipCategoryCss(x.CategoryName)
}).ToList(),
Nodes = nodes,
Links = relationships.Select(x => new RelationshipMapLinkViewModel
{
CharacterRelationshipID = x.CharacterRelationshipID,
SourceCharacterID = x.CharacterAID,
TargetCharacterID = x.CharacterBID,
RelationshipLabel = x.CurrentStateName,
CategoryName = x.RelationshipCategoryName,
CssClass = x.CssClass,
Intensity = x.CurrentIntensity
}).ToList(),
Relationships = relationships
};
}
private static RelationshipMapRelationshipViewModel? BuildRelationship(CharacterRelationship relationship, IReadOnlyList<RelationshipEvent> events, IReadOnlyDictionary<int, int> sceneOrder, int maxSceneIndex)
{
var visibleEvents = maxSceneIndex < 0
? []
: events.Where(x => SceneIndex(sceneOrder, x.SceneID) <= maxSceneIndex).ToList();
var hasInitialState = relationship.IsInitialRelationship || !events.Any();
if (!hasInitialState && visibleEvents.Count == 0)
{
return null;
}
var latestEvent = visibleEvents
.OrderBy(x => SceneIndex(sceneOrder, x.SceneID))
.ThenBy(x => x.CreatedDate)
.ThenBy(x => x.RelationshipEventID)
.LastOrDefault();
var initialState = relationship.InitialRelationshipStateName ?? relationship.RelationshipTypeName;
var currentState = latestEvent?.RelationshipStateName ?? initialState;
var history = new List<RelationshipMapHistoryItemViewModel>
{
new()
{
Label = "Initial State",
StateName = initialState,
Intensity = relationship.InitialIntensity,
Notes = relationship.Notes
}
};
history.AddRange(visibleEvents.Select(x => new RelationshipMapHistoryItemViewModel
{
Label = $"{x.BookTitle} / Ch {x.ChapterNumber} / Scene {x.SceneNumber}: {x.SceneTitle}",
StateName = x.RelationshipStateName,
Intensity = x.Intensity,
Notes = x.Description,
SceneID = x.SceneID
}));
return new RelationshipMapRelationshipViewModel
{
CharacterRelationshipID = relationship.CharacterRelationshipID,
CharacterAID = relationship.CharacterAID,
CharacterAName = relationship.CharacterAName,
CharacterBID = relationship.CharacterBID,
CharacterBName = relationship.CharacterBName,
RelationshipTypeName = relationship.RelationshipTypeName,
RelationshipCategoryName = relationship.RelationshipCategoryName,
CssClass = RelationshipCategoryCss(relationship.RelationshipCategoryName),
InitialStateName = initialState,
CurrentStateName = currentState,
InitialIntensity = relationship.InitialIntensity,
CurrentIntensity = latestEvent?.Intensity ?? relationship.InitialIntensity,
IsReciprocal = relationship.IsReciprocal,
Notes = latestEvent?.Description ?? relationship.Notes,
History = history
};
}
private static IReadOnlyDictionary<int, int> BuildSceneOrder(RelationshipMapData data)
{
var bookSort = data.Books.ToDictionary(x => x.BookID, x => x.SortOrder);
var chapterSort = data.Chapters.ToDictionary(x => x.ChapterID, x => x.SortOrder);
return data.Scenes
.OrderBy(x => bookSort.TryGetValue(data.Chapters.FirstOrDefault(c => c.ChapterID == x.ChapterID)?.BookID ?? 0, out var b) ? b : 0)
.ThenBy(x => chapterSort.TryGetValue(x.ChapterID, out var c) ? c : 0)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.SceneNumber)
.Select((scene, index) => new { scene.SceneID, Index = index })
.ToDictionary(x => x.SceneID, x => x.Index);
}
private static (int? BookID, int? ChapterID, int? SceneID, int MaxSceneIndex, string Label) ResolveStoryPoint(RelationshipMapData data, IReadOnlyDictionary<int, int> sceneOrder, int? bookId, int? chapterId, int? sceneId)
{
if (sceneId.HasValue && sceneOrder.ContainsKey(sceneId.Value))
{
var scene = data.Scenes.First(x => x.SceneID == sceneId.Value);
var chapter = data.Chapters.FirstOrDefault(x => x.ChapterID == scene.ChapterID);
var book = chapter is null ? null : data.Books.FirstOrDefault(x => x.BookID == chapter.BookID);
return (book?.BookID, chapter?.ChapterID, scene.SceneID, sceneOrder[scene.SceneID], $"{book?.BookTitle ?? "Book"} / Ch {chapter?.ChapterNumber} / Scene {scene.SceneNumber}");
}
if (chapterId.HasValue)
{
var chapterScenes = data.Scenes.Where(x => x.ChapterID == chapterId.Value && sceneOrder.ContainsKey(x.SceneID)).ToList();
if (chapterScenes.Count > 0)
{
var chapter = data.Chapters.FirstOrDefault(x => x.ChapterID == chapterId.Value);
var book = chapter is null ? null : data.Books.FirstOrDefault(x => x.BookID == chapter.BookID);
return (book?.BookID, chapter?.ChapterID, null, chapterScenes.Max(x => sceneOrder[x.SceneID]), $"End of {book?.BookTitle ?? "Book"} / Ch {chapter?.ChapterNumber}");
}
}
if (bookId.HasValue)
{
var chapterIds = data.Chapters.Where(x => x.BookID == bookId.Value).Select(x => x.ChapterID).ToHashSet();
var bookScenes = data.Scenes.Where(x => chapterIds.Contains(x.ChapterID) && sceneOrder.ContainsKey(x.SceneID)).ToList();
if (bookScenes.Count > 0)
{
var book = data.Books.FirstOrDefault(x => x.BookID == bookId.Value);
return (book?.BookID, null, null, bookScenes.Max(x => sceneOrder[x.SceneID]), $"End of {book?.BookTitle ?? "Book"}");
}
}
return (null, null, null, -1, "Project Start");
}
private static int SceneIndex(IReadOnlyDictionary<int, int> sceneOrder, int sceneId) => sceneOrder.TryGetValue(sceneId, out var index) ? index : int.MaxValue;
private static int BookSort(RelationshipMapData data, int bookId) => data.Books.FirstOrDefault(x => x.BookID == bookId)?.SortOrder ?? 0;
private static decimal ChapterNumber(RelationshipMapData data, int chapterId) => data.Chapters.FirstOrDefault(x => x.ChapterID == chapterId)?.ChapterNumber ?? 0;
private static string RelationshipCategoryCss(string categoryName) => categoryName switch
{
"Family" => "relationship-category-family",
"Friendship" => "relationship-category-friendship",
"Romantic / Sexual" => "relationship-category-romantic",
"Hostile / Conflict" => "relationship-category-hostile",
"Professional / Social" => "relationship-category-professional",
_ => "relationship-category-other"
};
}
public sealed class WriterWorkspaceService( public sealed class WriterWorkspaceService(
IProjectRepository projects, IProjectRepository projects,
IBookRepository books, IBookRepository books,

View File

@ -0,0 +1,98 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.RelationshipMap_GetByProject
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart,
Height, EyeColour, DefaultDescription, CharacterImportance, ShowInQuickAddBar,
CreatedDate, UpdatedDate, IsArchived
FROM dbo.Characters
WHERE ProjectID = @ProjectID
AND IsArchived = 0
ORDER BY CharacterName;
SELECT b.BookID, b.ProjectID, b.BookTitle, b.BookNumber, b.Description, b.SortOrder,
b.CreatedDate, b.UpdatedDate, b.IsArchived
FROM dbo.Books b
WHERE b.ProjectID = @ProjectID
AND b.IsArchived = 0
ORDER BY b.SortOrder, b.BookNumber, b.BookTitle;
SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary,
c.SortOrder, c.RevisionStatusID, c.CreatedDate, c.UpdatedDate, c.IsArchived
FROM dbo.Chapters c
INNER JOIN dbo.Books b ON b.BookID = c.BookID
WHERE b.ProjectID = @ProjectID
AND b.IsArchived = 0
AND c.IsArchived = 0
ORDER BY b.SortOrder, c.SortOrder, c.ChapterNumber, c.ChapterTitle;
SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.Summary, s.POVCharacterID,
s.PrimaryLocationID, s.SortOrder, s.TimeModeID, s.StartDateTime, s.EndDateTime,
s.DurationAmount, s.DurationUnitID, s.RelativeTimeText, s.TimeConfidenceID,
s.ScenePurposeNotes, s.SceneOutcomeNotes, s.RevisionStatusID, s.CreatedDate,
s.UpdatedDate, s.IsArchived
FROM dbo.Scenes s
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
INNER JOIN dbo.Books b ON b.BookID = c.BookID
WHERE b.ProjectID = @ProjectID
AND b.IsArchived = 0
AND c.IsArchived = 0
AND s.IsArchived = 0
ORDER BY b.SortOrder, c.SortOrder, s.SortOrder, s.SceneNumber, s.SceneTitle;
SELECT cr.CharacterRelationshipID, cr.ProjectID, cr.CharacterAID, ca.CharacterName AS CharacterAName,
cr.CharacterBID, cb.CharacterName AS CharacterBName, cr.RelationshipTypeID, rt.TypeName AS RelationshipTypeName,
rt.RelationshipCategoryID, rc.CategoryName AS RelationshipCategoryName, COALESCE(rc.SortOrder, 900) AS CategorySortOrder,
rt.SortOrder AS RelationshipTypeSortOrder,
cr.IsPermanent, cr.StartSceneID, cr.EndSceneID, cr.IsKnownToReader, cr.Notes,
cr.IsInitialRelationship, cr.InitialBookID, b.BookTitle AS InitialBookTitle, cr.ReaderInitiallyKnows,
cr.InitialRelationshipStateID, rs.StateName AS InitialRelationshipStateName, cr.InitialIntensity, cr.IsReciprocal,
cr.CreatedDate, cr.UpdatedDate, cr.IsArchived
FROM dbo.CharacterRelationships cr
INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID
INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID
LEFT JOIN dbo.RelationshipCategories rc ON rc.RelationshipCategoryID = rt.RelationshipCategoryID
LEFT JOIN dbo.Books b ON b.BookID = cr.InitialBookID
LEFT JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = cr.InitialRelationshipStateID
WHERE cr.ProjectID = @ProjectID
AND cr.IsArchived = 0
ORDER BY COALESCE(rc.SortOrder, 900), ca.CharacterName, cb.CharacterName, rt.SortOrder;
SELECT re.RelationshipEventID, re.CharacterRelationshipID, cr.ProjectID, cr.CharacterAID, ca.CharacterName AS CharacterAName,
cr.CharacterBID, cb.CharacterName AS CharacterBName, rt.TypeName AS RelationshipTypeName,
rt.RelationshipCategoryID, rc.CategoryName AS RelationshipCategoryName, COALESCE(rc.SortOrder, 900) AS CategorySortOrder,
rt.SortOrder AS RelationshipTypeSortOrder,
cr.IsReciprocal, re.SceneID, s.SceneNumber, s.SceneTitle, ch.ChapterNumber, b.BookTitle,
re.RelationshipStateID, rs.StateName AS RelationshipStateName, re.Intensity, re.IsKnownToOtherCharacter,
re.Description, re.CreatedDate, re.UpdatedDate
FROM dbo.RelationshipEvents re
INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID
INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID
INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID
LEFT JOIN dbo.RelationshipCategories rc ON rc.RelationshipCategoryID = rt.RelationshipCategoryID
INNER JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = re.RelationshipStateID
INNER JOIN dbo.Scenes s ON s.SceneID = re.SceneID
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID
INNER JOIN dbo.Books b ON b.BookID = ch.BookID
WHERE cr.ProjectID = @ProjectID
AND cr.IsArchived = 0
AND b.IsArchived = 0
AND ch.IsArchived = 0
AND s.IsArchived = 0
ORDER BY b.SortOrder, ch.SortOrder, s.SortOrder, re.CreatedDate, re.RelationshipEventID;
SELECT RelationshipCategoryID, CategoryName, SortOrder, IsActive
FROM dbo.RelationshipCategories
WHERE IsActive = 1
ORDER BY SortOrder, CategoryName;
END;
GO

View File

@ -960,6 +960,78 @@ public sealed class CurrentRelationshipViewModel
public string? CurrentNotes => LatestEvent?.Description ?? Relationship.Notes; public string? CurrentNotes => LatestEvent?.Description ?? Relationship.Notes;
} }
public sealed class RelationshipMapViewModel
{
public Project Project { get; set; } = new();
public int? BookID { get; set; }
public int? ChapterID { get; set; }
public int? SceneID { get; set; }
public int? FocusCharacterID { get; set; }
public bool ShowFullNetwork { get; set; }
public string StoryPointLabel { get; set; } = "Project Start";
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ChapterOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
public IReadOnlyList<RelationshipMapCategoryViewModel> Categories { get; set; } = [];
public IReadOnlyList<RelationshipMapNodeViewModel> Nodes { get; set; } = [];
public IReadOnlyList<RelationshipMapLinkViewModel> Links { get; set; } = [];
public IReadOnlyList<RelationshipMapRelationshipViewModel> Relationships { get; set; } = [];
}
public sealed class RelationshipMapCategoryViewModel
{
public int RelationshipCategoryID { get; set; }
public string CategoryName { get; set; } = string.Empty;
public string CssClass { get; set; } = "relationship-category-other";
}
public sealed class RelationshipMapNodeViewModel
{
public int CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public bool IsFocus { get; set; }
}
public sealed class RelationshipMapLinkViewModel
{
public int CharacterRelationshipID { get; set; }
public int SourceCharacterID { get; set; }
public int TargetCharacterID { get; set; }
public string RelationshipLabel { get; set; } = string.Empty;
public string CategoryName { get; set; } = "Other";
public string CssClass { get; set; } = "relationship-category-other";
public int? Intensity { get; set; }
}
public sealed class RelationshipMapRelationshipViewModel
{
public int CharacterRelationshipID { get; set; }
public int CharacterAID { get; set; }
public string CharacterAName { get; set; } = string.Empty;
public int CharacterBID { get; set; }
public string CharacterBName { get; set; } = string.Empty;
public string RelationshipTypeName { get; set; } = string.Empty;
public string RelationshipCategoryName { get; set; } = "Other";
public string CssClass { get; set; } = "relationship-category-other";
public string InitialStateName { get; set; } = string.Empty;
public string CurrentStateName { get; set; } = string.Empty;
public int? InitialIntensity { get; set; }
public int? CurrentIntensity { get; set; }
public bool IsReciprocal { get; set; }
public string? Notes { get; set; }
public IReadOnlyList<RelationshipMapHistoryItemViewModel> History { get; set; } = [];
}
public sealed class RelationshipMapHistoryItemViewModel
{
public string Label { get; set; } = string.Empty;
public string StateName { get; set; } = string.Empty;
public int? Intensity { get; set; }
public string? Notes { get; set; }
public int? SceneID { get; set; }
}
public sealed class InitialRelationshipEditViewModel public sealed class InitialRelationshipEditViewModel
{ {
public int CharacterRelationshipID { get; set; } public int CharacterRelationshipID { get; set; }

View File

@ -25,6 +25,7 @@
</div> </div>
<nav class="context-nav" aria-label="Project navigation"> <nav class="context-nav" aria-label="Project navigation">
<a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstAppearance?.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Show on timeline</a> <a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstAppearance?.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Show on timeline</a>
<a asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-focusCharacterId="@Model.Character.CharacterID">Relationship Map</a>
<a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a> <a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a>
<a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Warnings</a> <a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Warnings</a>
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a> <a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
@ -40,6 +41,7 @@
<div class="button-row"> <div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a> <a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstAppearance?.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Back to timeline</a> <a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstAppearance?.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Back to timeline</a>
<a class="btn btn-outline-primary" asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-focusCharacterId="@Model.Character.CharacterID">View Relationship Map</a>
<a class="btn btn-outline-primary" asp-controller="Dynamics" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Character arc</a> <a class="btn btn-outline-primary" asp-controller="Dynamics" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Character arc</a>
<a class="btn btn-outline-primary" asp-controller="Dynamics" asp-action="Knowledge" asp-route-id="@Model.Character.CharacterID">Knowledge timeline</a> <a class="btn btn-outline-primary" asp-controller="Dynamics" asp-action="Knowledge" asp-route-id="@Model.Character.CharacterID">Knowledge timeline</a>
<a class="btn btn-outline-primary" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Export character</a> <a class="btn btn-outline-primary" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Export character</a>

View File

@ -20,6 +20,7 @@
<a asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Scenarios</a> <a asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Scenarios</a>
<a asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Writer Workspace</a> <a asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Writer Workspace</a>
<a asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a> <a asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a>
<a asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Relationship Map</a>
<a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a> <a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a>
<a asp-controller="Dynamics" asp-action="Story" asp-route-projectId="@Model.Project.ProjectID">Story dynamics</a> <a asp-controller="Dynamics" asp-action="Story" asp-route-projectId="@Model.Project.ProjectID">Story dynamics</a>
<a asp-controller="Acceptance" asp-action="Phase1" asp-route-projectId="@Model.Project.ProjectID">Phase 1 Checklist</a> <a asp-controller="Acceptance" asp-action="Phase1" asp-route-projectId="@Model.Project.ProjectID">Phase 1 Checklist</a>
@ -49,6 +50,7 @@
<a class="btn btn-outline-primary" asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Scenarios</a> <a class="btn btn-outline-primary" asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Scenarios</a>
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Writer Workspace</a> <a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Writer Workspace</a>
<a class="btn btn-outline-primary" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a> <a class="btn btn-outline-primary" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a>
<a class="btn btn-outline-primary" asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Relationship Map</a>
<a class="btn btn-outline-primary" asp-controller="PlotLines" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Plot lines</a> <a class="btn btn-outline-primary" asp-controller="PlotLines" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Plot lines</a>
<a class="btn btn-outline-primary" asp-controller="PlotThreads" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Plot threads</a> <a class="btn btn-outline-primary" asp-controller="PlotThreads" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Plot threads</a>
<a class="btn btn-outline-primary" asp-controller="StoryAssets" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story assets</a> <a class="btn btn-outline-primary" asp-controller="StoryAssets" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story assets</a>

View File

@ -0,0 +1,270 @@
@using System.Text.Json
@model RelationshipMapViewModel
@{
ViewData["Title"] = $"Relationship Map - {Model.Project.ProjectName}";
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>Relationship Map</span>
</nav>
<section class="context-header">
<div class="context-title">
<span class="eyebrow">Project workspace</span>
<strong>@Model.Project.ProjectName</strong>
</div>
<nav class="context-nav" aria-label="Project navigation">
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Timeline</a>
<a asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a>
<a class="active" asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Relationship Map</a>
<a asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
</nav>
</section>
<div class="page-heading">
<div>
<p class="eyebrow">Relationships</p>
<h1>Relationship Map</h1>
<p class="lead-text">A read-only network of character relationships as of @Model.StoryPointLabel.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a>
<a class="btn btn-outline-secondary" asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
</div>
</div>
<form class="relationship-map-controls plain-card" method="get" asp-action="Index">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<div>
<label class="form-label" for="bookId">Book</label>
<select class="form-select form-select-sm" id="bookId" name="bookId" asp-items="Model.BookOptions">
<option value="">Project Start</option>
</select>
</div>
<div>
<label class="form-label" for="chapterId">Chapter</label>
<select class="form-select form-select-sm" id="chapterId" name="chapterId" asp-items="Model.ChapterOptions">
<option value="">Any chapter</option>
</select>
</div>
<div>
<label class="form-label" for="sceneId">Scene</label>
<select class="form-select form-select-sm" id="sceneId" name="sceneId" asp-items="Model.SceneOptions">
<option value="">Any scene</option>
</select>
</div>
<div>
<label class="form-label" for="focusCharacterId">Character focus</label>
<select class="form-select form-select-sm" id="focusCharacterId" name="focusCharacterId" asp-items="Model.CharacterOptions">
<option value="">Full cast</option>
</select>
</div>
<label class="form-check relationship-map-check">
<input class="form-check-input" type="checkbox" name="showFullNetwork" value="true" checked="@Model.ShowFullNetwork" />
<span class="form-check-label">Show full network</span>
</label>
<button class="btn btn-primary btn-sm" type="submit">Update Map</button>
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Project Start</a>
</form>
<section class="relationship-map-legend" aria-label="Relationship categories">
@foreach (var category in Model.Categories)
{
<span class="relationship-map-legend-item @category.CssClass">@category.CategoryName</span>
}
</section>
<section class="relationship-map-workspace">
<div class="relationship-map-canvas plain-card">
@if (!Model.Nodes.Any() || !Model.Links.Any())
{
<div class="empty-state">
<h2>No visible relationships at this point</h2>
<p class="muted">Try choosing a later book, chapter, or scene, or remove the character focus.</p>
</div>
}
else
{
<svg id="relationshipMapSvg" class="relationship-map-svg" role="img" aria-label="Relationship network"></svg>
}
</div>
<aside class="relationship-map-details plain-card">
<p class="eyebrow">Relationship detail</p>
<div id="relationshipMapEmptyDetail">
<h2>Select a relationship</h2>
<p class="muted">Click a connecting line or choose a relationship below to inspect its current state and history.</p>
</div>
<div id="relationshipMapDetail" class="d-none">
<h2 data-map-detail-title></h2>
<div class="relationship-map-detail-badges">
<span class="status-pill" data-map-detail-category></span>
<span class="status-pill" data-map-detail-reciprocal></span>
</div>
<dl class="relationship-map-detail-list">
<dt>Relationship type</dt>
<dd data-map-detail-type></dd>
<dt>Initial state</dt>
<dd data-map-detail-initial></dd>
<dt>Current state</dt>
<dd data-map-detail-current></dd>
</dl>
<h3>History</h3>
<ol class="relationship-map-history" data-map-detail-history></ol>
</div>
</aside>
</section>
<section class="list-section">
<h2>Visible relationships</h2>
@if (!Model.Relationships.Any())
{
<p class="muted">No relationships are visible for this map state.</p>
}
else
{
<div class="relationship-map-list">
@foreach (var relationship in Model.Relationships)
{
<button class="relationship-map-list-item" type="button" data-map-select="@relationship.CharacterRelationshipID">
<strong>@relationship.CharacterAName - @relationship.CharacterBName</strong>
<span>@relationship.CurrentStateName</span>
<span class="status-pill @relationship.CssClass">@relationship.RelationshipCategoryName</span>
</button>
}
</div>
}
</section>
@section Scripts {
<script>
(() => {
const nodes = @Html.Raw(JsonSerializer.Serialize(Model.Nodes, jsonOptions));
const links = @Html.Raw(JsonSerializer.Serialize(Model.Links, jsonOptions));
const relationships = @Html.Raw(JsonSerializer.Serialize(Model.Relationships, jsonOptions));
const svg = document.getElementById("relationshipMapSvg");
if (!svg || !nodes.length) return;
const width = 960;
const height = 620;
const centreX = width / 2;
const centreY = height / 2;
const radius = Math.max(180, Math.min(width, height) / 2 - 90);
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
const positions = new Map();
nodes.forEach((node, index) => {
const angle = nodes.length === 1 ? -Math.PI / 2 : (-Math.PI / 2) + (index * 2 * Math.PI / nodes.length);
const nodeRadius = node.isFocus ? radius * 0.15 : radius;
positions.set(node.characterID, {
x: centreX + Math.cos(angle) * nodeRadius,
y: centreY + Math.sin(angle) * nodeRadius
});
});
const relationshipById = new Map(relationships.map(item => [item.characterRelationshipID, item]));
const makeSvg = name => document.createElementNS("http://www.w3.org/2000/svg", name);
const linkLayer = makeSvg("g");
const nodeLayer = makeSvg("g");
svg.append(linkLayer, nodeLayer);
links.forEach(link => {
const from = positions.get(link.sourceCharacterID);
const to = positions.get(link.targetCharacterID);
if (!from || !to) return;
const group = makeSvg("g");
group.classList.add("relationship-map-link-group", link.cssClass);
group.dataset.relationshipId = link.characterRelationshipID;
group.setAttribute("tabindex", "0");
group.setAttribute("role", "button");
group.setAttribute("aria-label", `${link.relationshipLabel} relationship`);
const line = makeSvg("line");
line.setAttribute("x1", from.x);
line.setAttribute("y1", from.y);
line.setAttribute("x2", to.x);
line.setAttribute("y2", to.y);
line.setAttribute("stroke-width", Math.max(3, Math.min(9, link.intensity || 4)));
const label = makeSvg("text");
label.setAttribute("x", (from.x + to.x) / 2);
label.setAttribute("y", (from.y + to.y) / 2 - 8);
label.setAttribute("text-anchor", "middle");
label.textContent = link.relationshipLabel;
group.append(line, label);
group.addEventListener("click", () => selectRelationship(link.characterRelationshipID));
group.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectRelationship(link.characterRelationshipID);
}
});
linkLayer.append(group);
});
nodes.forEach(node => {
const position = positions.get(node.characterID);
const group = makeSvg("g");
group.classList.add("relationship-map-node");
if (node.isFocus) group.classList.add("is-focus");
const circle = makeSvg("circle");
circle.setAttribute("cx", position.x);
circle.setAttribute("cy", position.y);
circle.setAttribute("r", node.isFocus ? 34 : 27);
const label = makeSvg("text");
label.setAttribute("x", position.x);
label.setAttribute("y", position.y + 50);
label.setAttribute("text-anchor", "middle");
label.textContent = node.characterName;
group.append(circle, label);
nodeLayer.append(group);
});
document.querySelectorAll("[data-map-select]").forEach(button => {
button.addEventListener("click", () => selectRelationship(Number(button.dataset.mapSelect)));
});
if (relationships.length) {
selectRelationship(relationships[0].characterRelationshipID);
}
function selectRelationship(id) {
const relationship = relationshipById.get(id);
if (!relationship) return;
document.querySelectorAll(".relationship-map-link-group, .relationship-map-list-item").forEach(item => item.classList.remove("is-selected"));
document.querySelectorAll(`[data-relationship-id="${id}"], [data-map-select="${id}"]`).forEach(item => item.classList.add("is-selected"));
document.getElementById("relationshipMapEmptyDetail")?.classList.add("d-none");
document.getElementById("relationshipMapDetail")?.classList.remove("d-none");
document.querySelector("[data-map-detail-title]").textContent = `${relationship.characterAName} - ${relationship.characterBName}`;
document.querySelector("[data-map-detail-category]").textContent = relationship.relationshipCategoryName;
document.querySelector("[data-map-detail-reciprocal]").textContent = relationship.isReciprocal ? "Reciprocal" : "Directional";
document.querySelector("[data-map-detail-type]").textContent = relationship.relationshipTypeName;
document.querySelector("[data-map-detail-initial]").textContent = formatState(relationship.initialStateName, relationship.initialIntensity);
document.querySelector("[data-map-detail-current]").textContent = formatState(relationship.currentStateName, relationship.currentIntensity);
const history = document.querySelector("[data-map-detail-history]");
history.innerHTML = "";
relationship.history.forEach(item => {
const li = document.createElement("li");
const notes = item.notes ? ` - ${item.notes}` : "";
li.textContent = `${item.label}: ${formatState(item.stateName, item.intensity)}${notes}`;
history.append(li);
});
}
function formatState(name, intensity) {
return intensity ? `${name} / Intensity ${intensity}` : name;
}
})();
</script>
}

View File

@ -43,6 +43,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Writer" asp-action="Index">Writer Workspace</a> <a class="nav-link text-dark" asp-area="" asp-controller="Writer" asp-action="Index">Writer Workspace</a>
</li> </li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="RelationshipMap" asp-action="Index">Relationship Map</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">About</a> <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">About</a>
</li> </li>

View File

@ -20,6 +20,7 @@
<nav class="context-nav" aria-label="Project navigation"> <nav class="context-nav" aria-label="Project navigation">
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Timeline</a> <a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Timeline</a>
<a class="active" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a> <a class="active" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story Bible</a>
<a asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Relationship Map</a>
<a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a> <a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a>
<a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Warnings</a> <a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Warnings</a>
<a asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a> <a asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
@ -48,6 +49,7 @@
<a class="btn btn-outline-secondary btn-sm" asp-controller="Exports" asp-action="StoryBible" asp-route-projectId="@Model.Project.ProjectID" asp-route-format="text">Text</a> <a class="btn btn-outline-secondary btn-sm" asp-controller="Exports" asp-action="StoryBible" asp-route-projectId="@Model.Project.ProjectID" asp-route-format="text">Text</a>
<a class="btn btn-outline-primary btn-sm" asp-controller="Exports" asp-action="NarrativeTimeline" asp-route-projectId="@Model.Project.ProjectID">Timeline export</a> <a class="btn btn-outline-primary btn-sm" asp-controller="Exports" asp-action="NarrativeTimeline" asp-route-projectId="@Model.Project.ProjectID">Timeline export</a>
<a class="btn btn-outline-primary btn-sm" asp-controller="Exports" asp-action="ResearchPack" asp-route-projectId="@Model.Project.ProjectID">Research pack</a> <a class="btn btn-outline-primary btn-sm" asp-controller="Exports" asp-action="ResearchPack" asp-route-projectId="@Model.Project.ProjectID">Research pack</a>
<a class="btn btn-outline-primary btn-sm" asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Open Relationship Map</a>
</div> </div>
<nav class="story-bible-nav" aria-label="Story Bible sections"> <nav class="story-bible-nav" aria-label="Story Bible sections">

View File

@ -1453,6 +1453,238 @@ a:focus-visible {
--bs-alert-border-color: #705229; --bs-alert-border-color: #705229;
} }
.relationship-map-controls {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr)) auto auto auto;
gap: 0.75rem;
align-items: end;
margin-bottom: 1rem;
}
.relationship-map-check {
display: flex;
gap: 0.45rem;
align-items: center;
min-height: 2rem;
margin: 0;
white-space: nowrap;
}
.relationship-map-legend {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin: 0 0 1rem;
}
.relationship-map-legend-item,
.relationship-map-detail-badges .status-pill,
.relationship-map-list-item .status-pill {
border: 1px solid transparent;
}
.relationship-map-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 1rem;
align-items: stretch;
}
.relationship-map-canvas {
min-height: 660px;
overflow: hidden;
}
.relationship-map-svg {
display: block;
width: 100%;
min-height: 620px;
}
.relationship-map-link-group {
cursor: pointer;
}
.relationship-map-link-group line {
stroke: #7a8796;
stroke-linecap: round;
opacity: 0.72;
transition: opacity 0.16s ease, stroke-width 0.16s ease;
}
.relationship-map-link-group text {
fill: #2f3742;
font-size: 0.78rem;
font-weight: 700;
paint-order: stroke;
stroke: rgba(255, 255, 255, 0.86);
stroke-width: 4px;
stroke-linejoin: round;
}
.relationship-map-link-group:hover line,
.relationship-map-link-group:focus line,
.relationship-map-link-group.is-selected line {
opacity: 1;
}
.relationship-map-link-group.is-selected line {
stroke-width: 8;
}
.relationship-map-node circle {
fill: #fffdf8;
stroke: #6b7280;
stroke-width: 2;
filter: drop-shadow(0 6px 12px rgba(31, 41, 55, 0.14));
}
.relationship-map-node.is-focus circle {
fill: #fff3d8;
stroke: #c47c2c;
stroke-width: 3;
}
.relationship-map-node text {
fill: #1f2937;
font-size: 0.82rem;
font-weight: 700;
}
.relationship-map-details {
position: sticky;
top: 1rem;
align-self: start;
max-height: calc(100vh - 2rem);
overflow: auto;
}
.relationship-map-detail-badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.4rem 0 0.8rem;
}
.relationship-map-detail-list {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 0.25rem 0.75rem;
margin: 1rem 0;
}
.relationship-map-detail-list dt {
color: var(--muted-text, #667085);
font-weight: 600;
}
.relationship-map-detail-list dd {
margin: 0;
}
.relationship-map-history {
display: grid;
gap: 0.45rem;
padding-left: 1.1rem;
}
.relationship-map-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.6rem;
}
.relationship-map-list-item {
display: grid;
gap: 0.25rem;
padding: 0.8rem;
text-align: left;
color: inherit;
background: var(--surface, #fff);
border: 1px solid rgba(107, 114, 128, 0.22);
border-radius: 0.6rem;
box-shadow: 0 8px 20px rgba(31, 41, 55, 0.06);
}
.relationship-map-list-item:hover,
.relationship-map-list-item:focus,
.relationship-map-list-item.is-selected {
border-color: #c47c2c;
outline: none;
}
.relationship-category-family,
.relationship-category-family line {
color: #7c3f12;
border-color: #d89a5e;
background: #fff1df;
stroke: #b86522;
}
.relationship-category-friendship,
.relationship-category-friendship line {
color: #14532d;
border-color: #7cc69a;
background: #e7f8ed;
stroke: #2f9e5f;
}
.relationship-category-romantic,
.relationship-category-romantic line {
color: #831843;
border-color: #efa3c1;
background: #fde7f0;
stroke: #d9467f;
}
.relationship-category-hostile,
.relationship-category-hostile line {
color: #7f1d1d;
border-color: #ee8f8f;
background: #fee6e6;
stroke: #dc3f3f;
}
.relationship-category-professional,
.relationship-category-professional line {
color: #1e3a8a;
border-color: #93b4ef;
background: #e8f0ff;
stroke: #4f7fd8;
}
.relationship-category-other,
.relationship-category-other line {
color: #374151;
border-color: #bac2cf;
background: #f2f4f7;
stroke: #7a8796;
}
[data-theme="dark"] .relationship-map-link-group text {
fill: #f7efe4;
stroke: rgba(20, 20, 22, 0.92);
}
[data-theme="dark"] .relationship-map-node circle {
fill: #222126;
stroke: #9ca3af;
}
[data-theme="dark"] .relationship-map-node.is-focus circle {
fill: #3b2a16;
stroke: #f1c38f;
}
[data-theme="dark"] .relationship-map-node text {
fill: #f7efe4;
}
[data-theme="dark"] .relationship-map-list-item {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.12);
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
.timeline-filter-grid { .timeline-filter-grid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
@ -1461,6 +1693,19 @@ a:focus-visible {
.timeline-control-panel { .timeline-control-panel {
align-items: stretch; align-items: stretch;
} }
.relationship-map-controls {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
.relationship-map-workspace {
grid-template-columns: 1fr;
}
.relationship-map-details {
position: static;
max-height: none;
}
} }
@media (max-width: 760px) { @media (max-width: 760px) {