Compare commits
711
Commits
cc6555e8a9
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca5485b208 | ||
|
|
480a93ce1c | ||
|
|
7f33ffe2bd | ||
|
|
d9cc66b5e9 | ||
|
|
933c4c5d71 | ||
|
|
746c70fcce | ||
|
|
15a270939e | ||
|
|
0063e1866f | ||
|
|
2a9418f50b | ||
|
|
4ff989c125 | ||
|
|
1d5cdff0a5 | ||
|
|
26b6d3ae0d | ||
|
|
d35bdfa69d | ||
|
|
7ab4c3263f | ||
|
|
3cebb37ed0 | ||
|
|
c5f16fd4fa | ||
|
|
703c89c617 | ||
|
|
c15be5116a | ||
|
|
eff5fa2505 | ||
|
|
00a379595b | ||
|
|
4a7ad0f159 | ||
|
|
66279024d8 | ||
|
|
990ce104d9 | ||
|
|
b384fa0660 | ||
|
|
49b6ab871c | ||
|
|
e81ec1c1e2 | ||
|
|
5762bee9f1 | ||
|
|
8661501dae | ||
|
|
b82ea6edcf | ||
|
|
8ef7bf0e5c | ||
|
|
7c35341002 | ||
|
|
e6cdcb2952 | ||
|
|
6e346cfb5e | ||
|
|
83e246deb4 | ||
|
|
ffaf66d4d6 | ||
|
|
908923c23e | ||
|
|
fbfc49c2fa | ||
|
|
db8462c6d4 | ||
|
|
638b5422d2 | ||
|
|
01d3b0d2f2 | ||
|
|
146831fdba | ||
|
|
e9e949112c | ||
|
|
b647be18e5 | ||
|
|
75b8815dac | ||
|
|
6261529017 | ||
|
|
193e03927b | ||
|
|
33207a450c | ||
|
|
ef76443fab | ||
|
|
bf5d130e32 | ||
|
|
3f2c0583a2 | ||
|
|
ea21006352 | ||
|
|
2286031266 | ||
|
|
7a8f334520 | ||
|
|
8a4598c051 | ||
|
|
43f3fa923b | ||
|
|
3656ebcdf0 | ||
|
|
9c0ef7f827 | ||
|
|
8e0bd24853 | ||
|
|
eba998b585 | ||
|
|
c267196139 | ||
|
|
546db1ab11 | ||
|
|
42e4aa70ca | ||
|
|
b7e7278630 | ||
|
|
5bf5e0c46e | ||
|
|
04021d1f73 | ||
|
|
26fcebeb46 | ||
|
|
c583dec29b | ||
|
|
422c449eb9 | ||
|
|
31e6adf7de | ||
|
|
1a029141f8 | ||
|
|
46ab9d5b11 | ||
|
|
a8d7832c8b | ||
|
|
c4a09c1ca2 | ||
|
|
67e3162a57 | ||
|
|
510965f51b | ||
|
|
20b2deeddd | ||
|
|
510b36eea2 | ||
|
|
16c363fb9b | ||
|
|
34659db663 | ||
|
|
23425e5e15 | ||
|
|
aaecfa3438 | ||
|
|
958f50ee6e | ||
|
|
309be22ce0 | ||
|
|
825ae3dcc5 | ||
|
|
4d64317563 | ||
|
|
ca11ee1456 | ||
|
|
f044b8d0b4 | ||
|
|
16aea41631 | ||
|
|
27c62ce220 | ||
|
|
be86e3ee2c | ||
|
|
4d84b54f2d | ||
|
|
32ddaa8064 | ||
|
|
b1e8d6cb7f | ||
|
|
b55bad1686 | ||
|
|
f127c0179e | ||
|
|
2a5d9a4254 | ||
|
|
e36fc5091d | ||
|
|
8e96880880 | ||
|
|
394fb7ba60 | ||
|
|
6ae8bb4f6a | ||
|
|
4c6658cf1c | ||
|
|
d33cf220f3 | ||
|
|
2d239f780e | ||
|
|
7bae5ebb7d | ||
|
|
6d5e5f911e | ||
|
|
b5b6264016 | ||
|
|
837fe3d64a | ||
|
|
6461b41293 | ||
|
|
9edaec82e3 | ||
|
|
c7e9747c72 | ||
|
|
56f606f331 | ||
|
|
736aa405cf | ||
|
|
5e33dda958 | ||
|
|
63ba44a717 | ||
|
|
c637ff14ea | ||
|
|
13646d2a8c | ||
|
|
ecd4a95ab5 | ||
|
|
4baf49ba48 | ||
|
|
0d1c4f2615 | ||
|
|
dd35589079 | ||
|
|
e067a22439 | ||
|
|
13608c6be5 | ||
|
|
7cb82c6112 | ||
|
|
35f861a517 | ||
|
|
56bc88bc47 | ||
|
|
b342e33411 | ||
|
|
81f7d98e6f | ||
|
|
6744524669 | ||
|
|
529baca3ff | ||
|
|
cb4bab7bf2 | ||
|
|
c45814f910 | ||
|
|
3d8c161efa | ||
|
|
0da2fa0767 | ||
|
|
6c87207f76 | ||
|
|
3abd2bdfd4 | ||
|
|
bbd6254892 | ||
|
|
863abc7766 | ||
|
|
6327fd99c3 | ||
|
|
8dac0bf3f0 | ||
|
|
f8d5e0cbe1 | ||
|
|
74a6cd9134 | ||
|
|
d5c322ffa0 | ||
|
|
2641c15e29 | ||
|
|
38e09559c4 | ||
|
|
397096a9d9 | ||
|
|
6d914b7647 | ||
|
|
6329ea2a3a | ||
|
|
6e981b1c90 | ||
|
|
08e72ab323 | ||
|
|
a8efd834b7 | ||
|
|
53d502d9a4 | ||
|
|
27ec7da7be | ||
|
|
fab776eb31 | ||
|
|
3428e0abbd | ||
|
|
5180d001fc | ||
|
|
972baf989d | ||
|
|
5f54a09267 | ||
|
|
74a318a459 | ||
|
|
d7845a6a21 | ||
|
|
0bf2c6fa8c | ||
|
|
fbaa5b4838 | ||
|
|
b7eb44cd5c | ||
|
|
bcca4163df | ||
|
|
1dc815eae3 | ||
|
|
b72ab3a3ed | ||
|
|
64a64068e0 | ||
|
|
d38a680bf8 | ||
|
|
8500d4724f | ||
|
|
c278acb35a | ||
|
|
1ab59256e3 | ||
|
|
4bf8bb0e82 | ||
|
|
b60eef65d1 | ||
|
|
f3c8436551 | ||
|
|
ec6689ecfd | ||
|
|
7e056d182d | ||
|
|
e40cd1f687 | ||
|
|
5284402ce9 | ||
|
|
3de9ca043a | ||
|
|
8227972eec | ||
|
|
e12c4d6c28 | ||
|
|
8cf68188b7 | ||
|
|
b93a9ee24d | ||
|
|
63dfc9a357 | ||
|
|
cf2201723f | ||
|
|
75730c43f3 | ||
|
|
04b645fd90 | ||
|
|
d3d038349b | ||
|
|
e657067bd3 | ||
|
|
608b6c80d9 | ||
|
|
0d3bb1154a | ||
|
|
e275bf353b | ||
|
|
0a8008ce09 | ||
|
|
5076c44425 | ||
|
|
17c785596a | ||
|
|
4ecf51c223 | ||
|
|
864114ee2b | ||
|
|
c06ad0835f | ||
|
|
bf61251ff8 | ||
|
|
dd0650d8f2 | ||
|
|
4821e3b060 | ||
|
|
9e49b9c4db | ||
|
|
3cde4dcd32 | ||
|
|
5cdf4e840a | ||
|
|
fbe0f982d3 | ||
|
|
3c4fc991c4 | ||
|
|
b4d2b86da4 | ||
|
|
1c635f3e02 | ||
|
|
3fc50044e1 | ||
|
|
0f9807699e | ||
|
|
68e82f9de9 | ||
|
|
e27753df82 | ||
|
|
c02602dcc0 | ||
|
|
a7cc8a304e | ||
|
|
f5e7c839d0 | ||
|
|
4e0d7d2a4a | ||
|
|
be3ae14b1b | ||
|
|
68a96e253f | ||
|
|
32e3a6723a | ||
|
|
f5c4887056 | ||
|
|
9423bd4202 | ||
|
|
aabd50a5c4 | ||
|
|
c672111d18 | ||
|
|
54cc2a4ef4 | ||
|
|
8883e7cee7 | ||
|
|
9c7449cf67 | ||
|
|
5bc017b07e | ||
|
|
d4ad0d6685 | ||
|
|
13ba1fe298 | ||
|
|
2303798186 | ||
|
|
cd4eabbf0e | ||
|
|
61e9adf369 | ||
|
|
a53b499878 | ||
|
|
5ea602d3a8 | ||
|
|
9832374487 | ||
|
|
c7a438138d | ||
|
|
5e25f418cc | ||
|
|
feea843595 | ||
|
|
a4f55c3748 | ||
|
|
f4889352f9 | ||
|
|
8230788e96 | ||
|
|
55e565f3bf | ||
|
|
deab5a348f | ||
|
|
952ce4142a | ||
|
|
ed47306209 | ||
|
|
6c3831ce43 | ||
|
|
7993cdcd99 | ||
|
|
bc92038071 | ||
|
|
5034fa4ea8 | ||
|
|
d7ba07832c | ||
|
|
9a035bc188 | ||
|
|
b0f354a7ff | ||
|
|
ed4df9e2c3 | ||
|
|
a44b816773 | ||
|
|
2d356fac91 | ||
|
|
a0a089a3d0 | ||
|
|
db5b7656db | ||
|
|
e2e71ad3ca | ||
|
|
be5ef91f72 | ||
|
|
8ce01dfa35 | ||
|
|
d03eb1ccda | ||
|
|
8b87186a26 | ||
|
|
4eccca40d0 | ||
|
|
1ca7e61b08 | ||
|
|
e24e3900f8 | ||
|
|
209383ed17 | ||
|
|
d8413cf29b | ||
|
|
5c1c5c32b7 | ||
|
|
f3586ef8e1 | ||
|
|
7ac250a23a | ||
|
|
cf396d93ff | ||
|
|
df05b1a1f3 | ||
|
|
6330d1ca52 | ||
|
|
df574665b1 | ||
|
|
7b3bb72b54 | ||
|
|
bef62e411e | ||
|
|
e9084ff9a4 | ||
|
|
704e193c1b | ||
|
|
f570729f7c | ||
|
|
b87c55c846 | ||
|
|
557dbf5b8f | ||
|
|
6e00e2eebd | ||
|
|
6c86612f65 | ||
|
|
67b8f1d568 | ||
|
|
74d8a071d6 | ||
|
|
990343edc1 | ||
|
|
fcf7daecb6 | ||
|
|
0f05b642ee | ||
|
|
75050815fa | ||
|
|
1edf718523 | ||
|
|
2b9440a44e | ||
|
|
a5a9a7cec0 | ||
|
|
e534e65091 | ||
|
|
17b095942d | ||
|
|
f211ff42a5 | ||
|
|
62008429b0 | ||
|
|
54aa635f0e | ||
|
|
cae69d1aa2 | ||
|
|
e7c9fe3a05 | ||
|
|
743718fe01 | ||
|
|
3610206fa9 | ||
|
|
0bf50a1837 | ||
|
|
0c7f71d103 | ||
|
|
71f8ee7248 | ||
|
|
93400e3e5c | ||
|
|
6d25379469 | ||
|
|
7e1b5bd27e | ||
|
|
0c0b169d9e | ||
|
|
04d16e0e2f | ||
|
|
0c7d584ef9 | ||
|
|
c5c21a7fa1 | ||
|
|
54cc970746 | ||
|
|
31a3b74d97 | ||
|
|
282e9fcfc8 | ||
|
|
019c79f9cc | ||
|
|
688472612b | ||
|
|
2fa68b95d8 | ||
|
|
0daebace79 | ||
|
|
c2778280fe | ||
|
|
50c6056e95 | ||
|
|
219e7fc223 | ||
|
|
190066ca86 | ||
|
|
eaaa5ab71f | ||
|
|
9208de6435 | ||
|
|
a12f28a7e5 | ||
|
|
bb6ae4faf5 | ||
|
|
fa5fa3bff9 | ||
|
|
04659a028f | ||
|
|
76959c9d88 | ||
|
|
f43a36aa39 | ||
|
|
9413b6c69a | ||
|
|
be9250b7e7 | ||
|
|
71f87ea19e | ||
|
|
cf704c8459 | ||
|
|
e147242906 | ||
|
|
7c6735613e | ||
|
|
132935b395 | ||
|
|
192d4feebe | ||
|
|
7b5ee62a42 | ||
|
|
dfd7427c46 | ||
|
|
887c89bbf8 | ||
|
|
93e86f87c6 | ||
|
|
06390f7a0d | ||
|
|
26437ef19c | ||
|
|
c0626e3ccb | ||
|
|
2399c533c6 | ||
|
|
c61f9a9e9a | ||
|
|
d85529c782 | ||
|
|
af9d4195aa | ||
|
|
07862ff178 | ||
|
|
e09c6863f5 | ||
|
|
f0dd4a8d75 | ||
|
|
8e0c3bfc03 | ||
|
|
f1ed24d17b | ||
|
|
af8b667055 | ||
|
|
06afa29d88 | ||
|
|
8c351bfbf5 | ||
|
|
9e3c87edd5 | ||
|
|
2c8c715cc2 | ||
|
|
9fbaf22565 | ||
|
|
23e97a8765 | ||
|
|
a1d63ebfd6 | ||
|
|
d496517ce4 | ||
|
|
c3e56f2e68 | ||
|
|
3939cd11f9 | ||
|
|
a2b67626bb | ||
|
|
0db53a9032 | ||
|
|
fe2ed66268 | ||
|
|
ff738b7a3c | ||
|
|
d909d6aabe | ||
|
|
6279ac547c | ||
|
|
3a08e4df9d | ||
|
|
f453a9ce43 | ||
|
|
c47e9f8615 | ||
|
|
bd6f6d8836 | ||
|
|
5fdfc64d10 | ||
|
|
b2ad4f7e40 | ||
|
|
81950486c7 | ||
|
|
e2ce3b5f22 | ||
|
|
a3be7da0af | ||
|
|
3136eb32c6 | ||
|
|
976bd868b9 | ||
|
|
254e3c0c6d | ||
|
|
2bf429ec98 | ||
|
|
c55f614d50 | ||
|
|
b751f29b01 | ||
|
|
4737756502 | ||
|
|
f0f35c5749 | ||
|
|
c8e861e90a | ||
|
|
6e53acfcac | ||
|
|
53e49063d4 | ||
|
|
9ae5ee1439 | ||
|
|
2f93c84bb7 | ||
|
|
96c9eaed37 | ||
|
|
83735340ab | ||
|
|
75bfe784cb | ||
|
|
41f68b8178 | ||
|
|
2c79ff9046 | ||
|
|
6c7fc62e08 | ||
|
|
0e411abdf3 | ||
|
|
8052142cf8 | ||
|
|
cb2db4ff49 | ||
|
|
6dd6ced454 | ||
|
|
a00bf2786a | ||
|
|
8ced94e044 | ||
|
|
3474e55658 | ||
|
|
b3325e8e24 | ||
|
|
16854e831e | ||
|
|
53a7b14f94 | ||
|
|
f0febb0d0f | ||
|
|
29803ada06 | ||
|
|
5f31458967 | ||
|
|
2584325b16 | ||
|
|
2f718bc8df | ||
|
|
4dec149964 | ||
|
|
b6b1201800 | ||
|
|
5c1eeb759b | ||
|
|
f468a3a3fa | ||
|
|
560e71cc71 | ||
|
|
8c6b0401a4 | ||
|
|
4c7cb49aa7 | ||
|
|
01bca00e3c | ||
|
|
3290b736e5 | ||
|
|
29ce206496 | ||
|
|
eaf9337638 | ||
|
|
f4e2fc0ba4 | ||
|
|
7c7888235e | ||
|
|
910dc8d13b | ||
|
|
0b5b54c168 | ||
|
|
8752bb0d1a | ||
|
|
769ff14f20 | ||
|
|
7444d0c882 | ||
|
|
b342f8f784 | ||
|
|
bd7fe3c679 | ||
|
|
a1c033c3cb | ||
|
|
c2d3b8ccd9 | ||
|
|
7fdbcc4794 | ||
|
|
24be69f325 | ||
|
|
c172c4a462 | ||
|
|
df2145e853 | ||
|
|
f87831533c | ||
|
|
f598a9a530 | ||
|
|
8eb389eb39 | ||
|
|
433d6d204b | ||
|
|
8e7a795abc | ||
|
|
0e6da95c95 | ||
|
|
5770cb4ce7 | ||
|
|
6fa684e0fe | ||
|
|
1cd614da14 | ||
|
|
252a49e916 | ||
|
|
35aaed1506 | ||
|
|
bde0b0098b | ||
|
|
e6be11cdd9 | ||
|
|
eaadf27782 | ||
|
|
edbce254ec | ||
|
|
2d5d357bf3 | ||
|
|
088bb1289c | ||
|
|
e012a09cf2 | ||
|
|
46db8e56c3 | ||
|
|
5c6c8a85b4 | ||
|
|
5a249afb21 | ||
|
|
d70b66f57e | ||
|
|
0a450d47f1 | ||
|
|
a870e9a3a4 | ||
|
|
f57c18ee10 | ||
|
|
a518afaba2 | ||
|
|
0740592b7b | ||
|
|
21bd62b18a | ||
|
|
84bb7a1b72 | ||
|
|
91aa771878 | ||
|
|
b5cbe2c589 | ||
|
|
9ac0e395af | ||
|
|
3d45038658 | ||
|
|
9ac7a3fd12 | ||
|
|
6ff34a92f2 | ||
|
|
078e8356d0 | ||
|
|
2afb1af1ca | ||
|
|
1cd8668d8a | ||
|
|
1173d237dd | ||
|
|
d9578fedd6 | ||
|
|
d449c38598 | ||
|
|
06b676f914 | ||
|
|
0b698cb1b6 | ||
|
|
6df12153c5 | ||
|
|
a137c177f8 | ||
|
|
916d610ec8 | ||
|
|
baa6d10c08 | ||
|
|
767bfe8e00 | ||
|
|
21048c65fc | ||
|
|
877ea70aca | ||
|
|
8f96b16380 | ||
|
|
c91d5aa6c9 | ||
|
|
c2184625fa | ||
|
|
a48c1a3b9b | ||
|
|
2ce1e66095 | ||
|
|
50cc45731c | ||
|
|
0a6f4f5ec1 | ||
|
|
a7daf042b5 | ||
|
|
86f9a831f7 | ||
|
|
0816595e2e | ||
|
|
24f39bcb54 | ||
|
|
2a969a4f1d | ||
|
|
09b57398b7 | ||
|
|
e299e1cdb8 | ||
|
|
59b17f3a7d | ||
|
|
4b7402270d | ||
|
|
f103a3ad57 | ||
|
|
19857d9ee1 | ||
|
|
4b70627682 | ||
|
|
dba853b87e | ||
|
|
7964fbcf31 | ||
|
|
d9bf515d47 | ||
|
|
2c174f0b3d | ||
|
|
ecf99aefde | ||
|
|
1b84907500 | ||
|
|
7be4feb1cf | ||
|
|
5f2e6e18b6 | ||
|
|
d027304804 | ||
|
|
8d50aa0582 | ||
|
|
08dd166da3 | ||
|
|
0dce57b0aa | ||
|
|
2402497284 | ||
|
|
72e2c10824 | ||
|
|
c048788909 | ||
|
|
7f0417a010 | ||
|
|
4d298a0d23 | ||
|
|
d7007591fc | ||
|
|
d63912f101 | ||
|
|
924b1f61eb | ||
|
|
492ad3a48a | ||
|
|
959fab65ba | ||
|
|
a52fe78751 | ||
|
|
eb3b5b4918 | ||
|
|
4f13829326 | ||
|
|
21a95f8e5a | ||
|
|
efe0eb5417 | ||
|
|
973ec7cb5b | ||
|
|
ffa969e311 | ||
|
|
77afadf8c7 | ||
|
|
ffd94423c6 | ||
|
|
86d86db25c | ||
|
|
e3266a4eaa | ||
|
|
f1f988b9f9 | ||
|
|
5c1035a5e8 | ||
|
|
4689576d7d | ||
|
|
4027005193 | ||
|
|
5102f775ae | ||
|
|
8576123f6d | ||
|
|
e8c6e35443 | ||
|
|
1268179cc8 | ||
|
|
d416ac5922 | ||
|
|
fe34f561a0 | ||
|
|
58b35b3ab9 | ||
|
|
dd360094b3 | ||
|
|
b099add036 | ||
|
|
f262007f56 | ||
|
|
a764cd75cc | ||
|
|
4e5bbac50d | ||
|
|
be1c707da0 | ||
|
|
4dd8dcf30f | ||
|
|
493f861e06 | ||
|
|
07db7e2023 | ||
|
|
78bab9eeda | ||
|
|
f98480e2f0 | ||
|
|
68e3408068 | ||
|
|
0c0d7f7f7f | ||
|
|
c2afe5f640 | ||
|
|
3cb67d1762 | ||
|
|
b3c1762229 | ||
|
|
7643c2d548 | ||
|
|
2df5054fd3 | ||
|
|
9bf96b4442 | ||
|
|
b6d8983dd7 | ||
|
|
e6315f8581 | ||
|
|
5a079fdbf4 | ||
|
|
88dd941d73 | ||
|
|
3e70362b14 | ||
|
|
785307dcd0 | ||
|
|
710272f6a2 | ||
|
|
994b91636c | ||
|
|
a2b3217d80 | ||
|
|
760972bb9a | ||
|
|
6cc3edc153 | ||
|
|
21c1428772 | ||
|
|
aa2c2a870f | ||
|
|
b521328bf9 | ||
|
|
2b9e2476a9 | ||
|
|
6a86bd0a2b | ||
|
|
d34f6b85ca | ||
|
|
2a272e40c4 | ||
|
|
4f1803b733 | ||
|
|
99b93585ad | ||
|
|
595542f8b2 | ||
|
|
a22deacf6f | ||
|
|
49f9dc73f8 | ||
|
|
63d4b3da19 | ||
|
|
95a4d29bd9 | ||
|
|
4db7931175 | ||
|
|
7e9563a2c9 | ||
|
|
a8dc1bd7e1 | ||
|
|
d35743870d | ||
|
|
3ec5779169 | ||
|
|
64c9470f95 | ||
|
|
42673505e7 | ||
|
|
057cbaa5b5 | ||
|
|
edc98bad57 | ||
|
|
33a74ef46e | ||
|
|
dd483a8fbd | ||
|
|
315accf9ca | ||
|
|
dc027cd738 | ||
|
|
1357c859c8 | ||
|
|
ba58637ca2 | ||
|
|
833e4146a9 | ||
|
|
ac8dc77c00 | ||
|
|
ef59f49896 | ||
|
|
a23ccc2b04 | ||
|
|
350853997d | ||
|
|
78ffc8743c | ||
|
|
4e7704998a | ||
|
|
aa8f846446 | ||
|
|
0e85be7e06 | ||
|
|
efb5f246d9 | ||
|
|
150260c55e | ||
|
|
3f5bbe509e | ||
|
|
983638473b | ||
|
|
fa23d807c2 | ||
|
|
67aae7e7d6 | ||
|
|
48cff44a0c | ||
|
|
265c23d5f2 | ||
|
|
9e5d60fc78 | ||
|
|
abda7002fa | ||
|
|
c98706be06 | ||
|
|
61419af563 | ||
|
|
1f2ecf42b1 | ||
|
|
9941b976dc | ||
|
|
9a850212d5 | ||
|
|
b7a434cf1d | ||
|
|
e54d5bbb94 | ||
|
|
2202b6f28c | ||
|
|
a49454325c | ||
|
|
ab71a2e06a | ||
|
|
cc02d1da0d | ||
|
|
66e84b56bf | ||
|
|
8c7a3379cc | ||
|
|
233606048b | ||
|
|
b384715d91 | ||
|
|
91e85cfd95 | ||
|
|
fcf4720066 | ||
|
|
db03913b90 | ||
|
|
02e0727170 | ||
|
|
8379836748 | ||
|
|
a03174b1bf | ||
|
|
74c7815475 | ||
|
|
a5f5cac739 | ||
|
|
9085a51056 | ||
|
|
3af0c45306 | ||
|
|
3b976113c3 | ||
|
|
ff19a0bc55 | ||
|
|
2c874a1066 | ||
|
|
50b3e15e54 | ||
|
|
eceda4ee35 | ||
|
|
64d5921b94 | ||
|
|
1de0e62949 | ||
|
|
d027e5b617 | ||
|
|
536dacefac | ||
|
|
1cbeea6bfd | ||
|
|
add01ef5de | ||
|
|
e95487967b | ||
|
|
e0136d4db6 | ||
|
|
81db8ee970 | ||
|
|
0038012872 | ||
|
|
98b9d1b459 | ||
|
|
9c747ed13d | ||
|
|
08b34194af | ||
|
|
d540395938 | ||
|
|
d1999ace14 | ||
|
|
e0ad372e70 | ||
|
|
dc8f201877 | ||
|
|
ffa7f9d236 | ||
|
|
37b653eb8a | ||
|
|
6f1e0c8202 | ||
|
|
b9f9795a83 | ||
|
|
cfde9dc0bd | ||
|
|
ca37861159 | ||
|
|
9749e3ec68 | ||
|
|
0d560350bd | ||
|
|
b6c4e98d38 | ||
|
|
f1ddb82c41 | ||
|
|
1fb027093c | ||
|
|
dcedf35506 | ||
|
|
8212da9c19 | ||
|
|
bac6ff1853 | ||
|
|
ce7ed33843 | ||
|
|
3fce063617 | ||
|
|
2307854911 | ||
|
|
b2282af8c5 | ||
|
|
f8fff70d56 | ||
|
|
b628df3e93 | ||
|
|
c06661093c | ||
|
|
473e3532e8 | ||
|
|
303342a56e | ||
|
|
db22c14c16 | ||
|
|
8fc456657b | ||
|
|
a4f5997c91 | ||
|
|
a1c5be2361 | ||
|
|
818d7ec757 | ||
|
|
9fd5594656 | ||
|
|
15a4087bf2 | ||
|
|
828e5f1a13 | ||
|
|
42a17a5fb4 | ||
|
|
ac768dab94 | ||
|
|
9370233fbd |
@@ -46,7 +46,9 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000
|
|||||||
|
|
||||||
# Public URL for email links (프론트엔드 주소)
|
# Public URL for email links (프론트엔드 주소)
|
||||||
APP_PUBLIC_BASE_URL=http://localhost:5173
|
APP_PUBLIC_BASE_URL=http://localhost:5173
|
||||||
|
KAKAO_REST_API_KEY=ea05e4c86be146d9e28586d3f25d5a08
|
||||||
|
KAKAO_JS_KEY=98ac6edef14ffbca300d90e540644b68
|
||||||
|
# kakao app ID 1603330
|
||||||
# Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f
|
# Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f
|
||||||
# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA
|
# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA
|
||||||
# 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B
|
# 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ config/formula_node/
|
|||||||
config/spreadsheet_node/
|
config/spreadsheet_node/
|
||||||
# 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물.
|
# 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물.
|
||||||
config/b07_extent_node/
|
config/b07_extent_node/
|
||||||
|
# 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1).
|
||||||
|
config/b07_note_node/
|
||||||
|
|
||||||
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
|
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
|
||||||
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
|
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/* ☰ [설정] 창(PLAN 332-2) — 탭 줄 · 휠 방향 고름 · 표 */
|
||||||
|
.app-settings__tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
padding-bottom: var(--spacing-8);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-settings__radios {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-settings__radios > label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
min-height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-settings__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-settings__table th,
|
||||||
|
.app-settings__table td {
|
||||||
|
padding: var(--spacing-4) var(--spacing-8);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-settings__table thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-settings__table input[aria-invalid="true"] {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-settings__error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-settings__error:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* app_settings.ts
|
||||||
|
* ☰ [설정] 창(PLAN 332-2 · 요구 224-8) — 탭 셋: ① 마우스 ② CAD 단축키 ③ 다른 곳 단축키(읽기 전용).
|
||||||
|
*
|
||||||
|
* - 고침은 창 안 사본에만 · [저장] 때만 계정 pref(`input-prefs`)로 한 번 씀 — 읽기는 `input_prefs.ts`
|
||||||
|
* - 저장 안 한 고침이 있으면 닫을 때 물음(askLeave) · 잘못 있는 별칭은 저장 안 함
|
||||||
|
* - 로그인 전엔 이 브라우저에만 남음(계정 올리기는 `b_page_state` 가 로그인 뒤에)
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||||
|
import { askLeave } from "@ui/ui_template_leave_dialog";
|
||||||
|
import { t as L } from "@ui/ui_template_locale";
|
||||||
|
import { openModal } from "@ui/ui_template_modal";
|
||||||
|
import { writeState } from "./b_page_state";
|
||||||
|
import { inputPrefs, type InputPrefs, type WheelZoom } from "./input_prefs";
|
||||||
|
import { buildCadAliasTab, checkCadAliases } from "./app_settings_cad";
|
||||||
|
import "./app_settings.css";
|
||||||
|
|
||||||
|
type LocaleKey = Parameters<typeof L>[0];
|
||||||
|
type TabId = "mouse" | "cad" | "keys";
|
||||||
|
|
||||||
|
const TABS: [TabId, LocaleKey][] = [
|
||||||
|
["mouse", "App_Settings_Tab_Mouse"],
|
||||||
|
["cad", "App_Settings_Tab_Cad"],
|
||||||
|
["keys", "App_Settings_Tab_Keys"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const WHEEL: [WheelZoom, LocaleKey][] = [
|
||||||
|
["pull", "App_Settings_Wheel_Pull"],
|
||||||
|
["push", "App_Settings_Wheel_Push"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const MOUSE_RULES: LocaleKey[] = [
|
||||||
|
"App_Settings_Rule_Pan",
|
||||||
|
"App_Settings_Rule_Pick",
|
||||||
|
"App_Settings_Rule_Right",
|
||||||
|
"App_Settings_Rule_Pinch",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** ③ 안내표 줄 — [자리, 키] */
|
||||||
|
const KEY_ROWS: [LocaleKey, LocaleKey][] = [
|
||||||
|
["App_Settings_Keys_All", "App_Settings_Keys_All_Keys"],
|
||||||
|
["App_Settings_Keys_Profile", "App_Settings_Keys_Profile_Keys"],
|
||||||
|
["App_Settings_Keys_RouteEdit", "App_Settings_Keys_RouteEdit_Keys"],
|
||||||
|
["App_Settings_Keys_Section", "App_Settings_Keys_Section_Keys"],
|
||||||
|
["App_Settings_Keys_Cad", "App_Settings_Keys_Cad_Keys"],
|
||||||
|
["App_Settings_Keys_Table", "App_Settings_Keys_Table_Keys"],
|
||||||
|
["App_Settings_Keys_M02", "App_Settings_Keys_M02_Keys"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 비교 · 저장 꼴 — 별칭 명령 차례를 맞춤 */
|
||||||
|
const normalize = (p: InputPrefs): InputPrefs => ({
|
||||||
|
wheelZoom: p.wheelZoom,
|
||||||
|
cadAliases: Object.fromEntries(Object.entries(p.cadAliases).sort(([a], [b]) => (a < b ? -1 : 1))),
|
||||||
|
});
|
||||||
|
const snapshot = (p: InputPrefs): string => JSON.stringify(normalize(p));
|
||||||
|
|
||||||
|
function mouseTab(draft: InputPrefs): HTMLElement {
|
||||||
|
const group = el("div", {
|
||||||
|
className: "app-settings__radios",
|
||||||
|
attrs: { role: "radiogroup", "aria-label": L("App_Settings_Wheel") },
|
||||||
|
});
|
||||||
|
for (const [mode, key] of WHEEL) {
|
||||||
|
const input = el("input", {
|
||||||
|
attrs: { type: "radio", name: "app-settings-wheel", value: mode },
|
||||||
|
});
|
||||||
|
input.checked = draft.wheelZoom === mode;
|
||||||
|
input.addEventListener("change", () => (draft.wheelZoom = mode));
|
||||||
|
group.append(el("label", { children: [input, L(key)] }));
|
||||||
|
}
|
||||||
|
return el("div", {
|
||||||
|
children: [
|
||||||
|
el("h4", { text: L("App_Settings_Wheel") }),
|
||||||
|
group,
|
||||||
|
el("h4", { text: L("App_Settings_Rules") }),
|
||||||
|
el("ul", { children: MOUSE_RULES.map((key) => el("li", { text: L(key) })) }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function keysTab(): HTMLElement {
|
||||||
|
const rows = KEY_ROWS.map(([place, keys]) =>
|
||||||
|
el("tr", { children: [el("th", { text: L(place) }), el("td", { text: L(keys) })] }),
|
||||||
|
);
|
||||||
|
return el("table", {
|
||||||
|
className: "app-settings__table",
|
||||||
|
children: [el("tbody", { children: rows })],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ☰ [설정] — 창을 열고 지금 설정을 사본으로 고침 */
|
||||||
|
export function openAppSettings(): void {
|
||||||
|
const draft = normalize(inputPrefs());
|
||||||
|
let saved = snapshot(draft);
|
||||||
|
const cad = buildCadAliasTab(draft.cadAliases);
|
||||||
|
const panels: Record<TabId, HTMLElement> = {
|
||||||
|
mouse: mouseTab(draft),
|
||||||
|
cad: cad.root,
|
||||||
|
keys: keysTab(),
|
||||||
|
};
|
||||||
|
const tabs = el("div", {
|
||||||
|
className: "app-settings__tabs ui-subtabs",
|
||||||
|
attrs: { role: "tablist" },
|
||||||
|
});
|
||||||
|
const show = (id: TabId): void => {
|
||||||
|
tabs.replaceChildren(
|
||||||
|
...TABS.map(([tab, key]) => {
|
||||||
|
const button = createButton({
|
||||||
|
label: L(key),
|
||||||
|
variant: tab === id ? "filled" : "ghost",
|
||||||
|
size: "sm",
|
||||||
|
onClick: () => show(tab),
|
||||||
|
});
|
||||||
|
button.setAttribute("role", "tab");
|
||||||
|
button.setAttribute("aria-selected", String(tab === id));
|
||||||
|
button.dataset.settingsTab = tab;
|
||||||
|
return button;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const [tab, panel] of Object.entries(panels)) panel.hidden = tab !== id;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** [저장] — 잘못 있는 별칭이면 CAD 탭을 보이고 안 씀 */
|
||||||
|
const save = (): boolean => {
|
||||||
|
const errors = checkCadAliases(draft.cadAliases);
|
||||||
|
cad.mark(errors);
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
show("cad");
|
||||||
|
showToast(L("App_Settings_Cad_Invalid"), "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (snapshot(draft) !== saved) writeState("input-prefs", normalize(draft));
|
||||||
|
saved = snapshot(draft);
|
||||||
|
showToast(L("App_Settings_Saved"), "success");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveButton = createButton({
|
||||||
|
label: L("Common_Btn_Save"),
|
||||||
|
variant: "filled",
|
||||||
|
size: "sm",
|
||||||
|
onClick: () => void save(),
|
||||||
|
});
|
||||||
|
openModal({
|
||||||
|
title: L("App_Settings"),
|
||||||
|
closeLabel: L("Common_Btn_Close"),
|
||||||
|
dialogClass: "app-settings",
|
||||||
|
actions: [saveButton],
|
||||||
|
beforeClose: async () => {
|
||||||
|
if (snapshot(draft) === saved) return true;
|
||||||
|
const choice = await askLeave(L("App_Settings_Leave"));
|
||||||
|
return choice === "discard" || (choice === "save" && save());
|
||||||
|
},
|
||||||
|
mount: (body) => {
|
||||||
|
show("mouse");
|
||||||
|
body.append(tabs, ...Object.values(panels));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* app_settings_cad.ts
|
||||||
|
* ☰ [설정] ② CAD 단축키 탭(PLAN 332-2) — 명령 표(명령 · 한글 이름 · 기본 별칭 · 내 별칭) · 검사.
|
||||||
|
*
|
||||||
|
* - 명령 목록 = `openwebcad/src/commands/command-catalog.json`(registry 거울 · commands.*.ts 는 xstate 가 딸려 와서 JSON 한 벌)
|
||||||
|
* - acad.pgp 식 덮어쓰기 — CAD 는 명령 이름 > 내 별칭 > 기본 별칭 순서로 찾음(배선은 332-3)
|
||||||
|
* - 검사 = 영문 · 숫자만 · 내 별칭끼리 겹침 금지 · 명령 이름과 같은 별칭 금지
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import catalog from "../B07_DesignDetail/openwebcad/src/commands/command-catalog.json";
|
||||||
|
import { el } from "@ui/ui_template_elements";
|
||||||
|
import { t as L } from "@ui/ui_template_locale";
|
||||||
|
|
||||||
|
export interface CadCommandRow {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
aliases: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CAD_COMMANDS = catalog as CadCommandRow[];
|
||||||
|
|
||||||
|
export type AliasError = "chars" | "dup" | "name";
|
||||||
|
|
||||||
|
const ERROR_TEXT: Record<AliasError, Parameters<typeof L>[0]> = {
|
||||||
|
chars: "App_Settings_Cad_ErrChars",
|
||||||
|
dup: "App_Settings_Cad_ErrDup",
|
||||||
|
name: "App_Settings_Cad_ErrName",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 칸 글 → 별칭 목록(쉼표 · 빈칸으로 가름 · 대문자) */
|
||||||
|
export const parseAliases = (text: string): string[] =>
|
||||||
|
text
|
||||||
|
.toUpperCase()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
/** 내 별칭 검사 — `{명령: 첫 잘못}` · 비면 통과 */
|
||||||
|
export function checkCadAliases(
|
||||||
|
aliases: Record<string, string[]>,
|
||||||
|
commands: CadCommandRow[] = CAD_COMMANDS,
|
||||||
|
): Record<string, AliasError> {
|
||||||
|
const ids = new Set(commands.map((c) => c.id));
|
||||||
|
const owner = new Map<string, string>();
|
||||||
|
const errors: Record<string, AliasError> = {};
|
||||||
|
for (const [id, list] of Object.entries(aliases)) {
|
||||||
|
for (const alias of list) {
|
||||||
|
const first = owner.get(alias);
|
||||||
|
const error: AliasError | null = !/^[A-Z0-9]+$/.test(alias)
|
||||||
|
? "chars"
|
||||||
|
: ids.has(alias)
|
||||||
|
? "name"
|
||||||
|
: first !== undefined
|
||||||
|
? "dup"
|
||||||
|
: null;
|
||||||
|
if (!error) owner.set(alias, id);
|
||||||
|
else {
|
||||||
|
errors[id] ??= error;
|
||||||
|
if (first !== undefined) errors[first] ??= "dup";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 탭 몸 — `aliases` 를 칸 입력대로 고쳐 씀 · `mark` 로 잘못 칸 표시 */
|
||||||
|
export function buildCadAliasTab(aliases: Record<string, string[]>): {
|
||||||
|
root: HTMLElement;
|
||||||
|
mark: (errors: Record<string, AliasError>) => void;
|
||||||
|
} {
|
||||||
|
const message = el("p", { className: "app-settings__error", attrs: { role: "alert" } });
|
||||||
|
const inputs = new Map<string, HTMLInputElement>();
|
||||||
|
const rows = CAD_COMMANDS.map((command) => {
|
||||||
|
const input = el("input", {
|
||||||
|
className: "ui-input ui-input--sm",
|
||||||
|
attrs: { type: "text", "aria-label": `${command.id} ${L("App_Settings_Cad_Mine")}` },
|
||||||
|
});
|
||||||
|
input.value = (aliases[command.id] ?? []).join(", ");
|
||||||
|
input.dataset.cadCommand = command.id;
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
const list = parseAliases(input.value);
|
||||||
|
if (list.length) aliases[command.id] = list;
|
||||||
|
else delete aliases[command.id];
|
||||||
|
});
|
||||||
|
inputs.set(command.id, input);
|
||||||
|
const cells = [command.id, command.label, command.aliases.join(", ")].map((text) =>
|
||||||
|
el("td", { text }),
|
||||||
|
);
|
||||||
|
return el("tr", { children: [...cells, el("td", { children: [input] })] });
|
||||||
|
});
|
||||||
|
const head = el("tr", {
|
||||||
|
children: (
|
||||||
|
[
|
||||||
|
"App_Settings_Cad_Command",
|
||||||
|
"App_Settings_Cad_Label",
|
||||||
|
"App_Settings_Cad_Default",
|
||||||
|
"App_Settings_Cad_Mine",
|
||||||
|
] as const
|
||||||
|
).map((key) => el("th", { text: L(key) })),
|
||||||
|
});
|
||||||
|
const table = el("table", {
|
||||||
|
className: "app-settings__table",
|
||||||
|
children: [el("thead", { children: [head] }), el("tbody", { children: rows })],
|
||||||
|
});
|
||||||
|
const mark = (errors: Record<string, AliasError>): void => {
|
||||||
|
for (const [id, input] of inputs) {
|
||||||
|
const error = errors[id];
|
||||||
|
if (error) input.setAttribute("aria-invalid", "true");
|
||||||
|
else input.removeAttribute("aria-invalid");
|
||||||
|
input.title = error ? L(ERROR_TEXT[error]) : "";
|
||||||
|
}
|
||||||
|
const kinds = [...new Set(Object.values(errors))];
|
||||||
|
message.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · ");
|
||||||
|
};
|
||||||
|
const root = el("div", {
|
||||||
|
children: [el("p", { text: L("App_Settings_Cad_Help") }), message, table],
|
||||||
|
});
|
||||||
|
return { root, mark };
|
||||||
|
}
|
||||||
+281
-31
@@ -11,6 +11,7 @@ import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_l
|
|||||||
import { createButton } from "@ui/ui_template_elements";
|
import { createButton } from "@ui/ui_template_elements";
|
||||||
import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
|
import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
|
||||||
import { navigateTo, isAuthenticated } from "./router";
|
import { navigateTo, isAuthenticated } from "./router";
|
||||||
|
import { readTouchMode, setTouchMode, type TouchMode } from "./device_context";
|
||||||
|
|
||||||
const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href;
|
const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href;
|
||||||
|
|
||||||
@@ -65,38 +66,12 @@ function buildHeader(): HTMLElement {
|
|||||||
const header = document.createElement("header");
|
const header = document.createElement("header");
|
||||||
header.className = "app-header";
|
header.className = "app-header";
|
||||||
|
|
||||||
// 로고 (클릭 시 홈)
|
const brand = buildBrand();
|
||||||
const brand = document.createElement("button");
|
|
||||||
brand.className = "app-brand";
|
|
||||||
brand.type = "button";
|
|
||||||
const brandIcon = document.createElement("img");
|
|
||||||
brandIcon.className = "app-brand__icon";
|
|
||||||
brandIcon.src = BRAND_ICON_URL;
|
|
||||||
brandIcon.alt = "";
|
|
||||||
const brandName = document.createElement("span");
|
|
||||||
brandName.className = "app-brand__name";
|
|
||||||
brandName.textContent = L("App_BrandName");
|
|
||||||
brand.append(brandIcon, brandName);
|
|
||||||
brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME));
|
|
||||||
|
|
||||||
// 네비게이션 링크
|
// 네비게이션 링크
|
||||||
const nav = document.createElement("nav");
|
const nav = document.createElement("nav");
|
||||||
nav.className = "app-nav";
|
nav.className = "app-nav";
|
||||||
const navItems: [keyof typeof ui_locales, string][] = [
|
nav.append(...buildNavButtons());
|
||||||
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
|
|
||||||
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
|
|
||||||
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
|
|
||||||
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
|
|
||||||
["App_Nav_Support", ROUTES.A08_SUPPORT],
|
|
||||||
];
|
|
||||||
for (const [labelKey, route] of navItems) {
|
|
||||||
const link = createButton({
|
|
||||||
label: L(labelKey),
|
|
||||||
variant: "pill",
|
|
||||||
onClick: () => navigateTo(route as never),
|
|
||||||
});
|
|
||||||
nav.append(link);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 우측 액션 (언어/테마/로그인)
|
// 우측 액션 (언어/테마/로그인)
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
@@ -120,10 +95,171 @@ function buildHeader(): HTMLElement {
|
|||||||
actions.append(authSlot);
|
actions.append(authSlot);
|
||||||
void fillAuthActions(authSlot);
|
void fillAuthActions(authSlot);
|
||||||
|
|
||||||
header.append(brand, nav, actions);
|
header.append(brand, nav, actions, ...buildDrawer(header));
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 로고 (클릭 시 홈) — 위 줄 · ☰ 서랍 첫 묶음이 같이 씀 */
|
||||||
|
function buildBrand(): HTMLButtonElement {
|
||||||
|
const brand = document.createElement("button");
|
||||||
|
brand.className = "app-brand";
|
||||||
|
brand.type = "button";
|
||||||
|
const brandIcon = document.createElement("img");
|
||||||
|
brandIcon.className = "app-brand__icon";
|
||||||
|
brandIcon.src = BRAND_ICON_URL;
|
||||||
|
brandIcon.alt = "";
|
||||||
|
const brandName = document.createElement("span");
|
||||||
|
brandName.className = "app-brand__name";
|
||||||
|
brandName.textContent = L("App_BrandName");
|
||||||
|
brand.append(brandIcon, brandName);
|
||||||
|
brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME));
|
||||||
|
return brand;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠.
|
||||||
|
* 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */
|
||||||
|
let drawerSteps: { node: HTMLElement; home: HTMLElement | null } | null = null;
|
||||||
|
|
||||||
|
/** 집(진행 판) 없는 요소 = 서랍이 늘 가짐 · 닫을 때 안 옮김(B01 · M01 · M02 바로가기) */
|
||||||
|
export function setDrawerSteps(node: HTMLElement | null): void {
|
||||||
|
drawerSteps = node ? { node, home: node.parentElement } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ITEMS: [keyof typeof ui_locales, string][] = [
|
||||||
|
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
|
||||||
|
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
|
||||||
|
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
|
||||||
|
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
|
||||||
|
["App_Nav_Support", ROUTES.A08_SUPPORT],
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildNavButtons(): HTMLElement[] {
|
||||||
|
return NAV_ITEMS.map(([labelKey, route]) =>
|
||||||
|
createButton({
|
||||||
|
label: L(labelKey),
|
||||||
|
variant: "pill",
|
||||||
|
onClick: () => navigateTo(route as never),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
* ☰ 서랍(PLAN_기기대응 2-2 · 7-1) — 공개 쪽은 폭 900 이하 · 터치 모드에서 · 로그인 뒤 쪽(B · M)은 늘 보임.
|
||||||
|
* 위 메뉴 · 언어 · 테마 · 로그인 단추와 같은 항목 + 터치 모드 스위치.
|
||||||
|
* -------------------------------------------------------------------------- */
|
||||||
|
function buildDrawer(header: HTMLElement): HTMLElement[] {
|
||||||
|
const burger = document.createElement("button");
|
||||||
|
burger.type = "button";
|
||||||
|
burger.className = "app-burger";
|
||||||
|
burger.textContent = "☰";
|
||||||
|
burger.setAttribute("aria-label", L("App_Menu"));
|
||||||
|
burger.setAttribute("aria-expanded", "false");
|
||||||
|
const drawer = document.createElement("div");
|
||||||
|
drawer.className = "app-drawer";
|
||||||
|
drawer.setAttribute("role", "dialog");
|
||||||
|
drawer.setAttribute("aria-label", L("App_Menu"));
|
||||||
|
const scrim = document.createElement("div");
|
||||||
|
scrim.className = "app-drawer__scrim";
|
||||||
|
const steps = document.createElement("div");
|
||||||
|
steps.className = "app-drawer__group app-drawer__steps";
|
||||||
|
const setOpen = (open: boolean): void => {
|
||||||
|
header.classList.toggle("is-drawer-open", open);
|
||||||
|
burger.setAttribute("aria-expanded", String(open));
|
||||||
|
if (!drawerSteps) return;
|
||||||
|
if (open) steps.append(drawerSteps.node);
|
||||||
|
else if (drawerSteps.home && steps.contains(drawerSteps.node))
|
||||||
|
drawerSteps.home.append(drawerSteps.node);
|
||||||
|
};
|
||||||
|
burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open")));
|
||||||
|
scrim.addEventListener("click", () => setOpen(false));
|
||||||
|
header.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); // ☰ 에 포커스가 있어도 닫힘
|
||||||
|
// 로고 · 진행 단계 · 메뉴 · 로그인 · [설정] 을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠)
|
||||||
|
drawer.addEventListener("click", (e) => {
|
||||||
|
const hit = (e.target as Element).closest("button");
|
||||||
|
if (
|
||||||
|
hit?.closest(
|
||||||
|
".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth, .app-drawer__settings",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 묶음 차례 = 로고 · 홈 ─ 진행 단계(프로젝트 안) ─ 메뉴 ─ 언어 · 테마 · 로그인 · 터치 모드
|
||||||
|
const brandRow = document.createElement("div");
|
||||||
|
brandRow.className = "app-drawer__group app-drawer__brand";
|
||||||
|
brandRow.append(buildBrand());
|
||||||
|
const navList = document.createElement("nav");
|
||||||
|
navList.className = "app-drawer__group app-drawer__nav";
|
||||||
|
navList.append(...buildNavButtons());
|
||||||
|
const prefs = document.createElement("div");
|
||||||
|
prefs.className = "app-drawer__row";
|
||||||
|
prefs.append(
|
||||||
|
createButton({
|
||||||
|
label: currentLanguageIndex === 0 ? "EN" : "한",
|
||||||
|
variant: "pill",
|
||||||
|
onClick: onApp_Lang_Toggle_Click,
|
||||||
|
}),
|
||||||
|
createButton({
|
||||||
|
label: document.documentElement.getAttribute("data-theme") === "dark" ? "☀" : "☾",
|
||||||
|
variant: "pill",
|
||||||
|
onClick: onApp_Theme_Toggle_Click,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// ☰ [설정](332) — 마우스 · 단축키 창 · 처음 누를 때 불러옴
|
||||||
|
const settingsButton = createButton({
|
||||||
|
label: L("App_Settings"),
|
||||||
|
variant: "pill",
|
||||||
|
onClick: () => void import("./app_settings").then((m) => m.openAppSettings()),
|
||||||
|
});
|
||||||
|
settingsButton.classList.add("app-drawer__settings");
|
||||||
|
prefs.append(settingsButton);
|
||||||
|
const auth = document.createElement("div");
|
||||||
|
auth.className = "app-drawer__row app-drawer__auth";
|
||||||
|
void fillAuthActions(auth);
|
||||||
|
const settings = document.createElement("div");
|
||||||
|
settings.className = "app-drawer__group";
|
||||||
|
settings.append(prefs, auth, buildTouchModeSwitch());
|
||||||
|
drawer.append(brandRow, steps, navList, settings);
|
||||||
|
return [burger, scrim, drawer];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTouchModeSwitch(): HTMLElement {
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.className = "app-drawer__touch";
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = L("App_TouchMode");
|
||||||
|
const group = document.createElement("div");
|
||||||
|
group.className = "app-drawer__row";
|
||||||
|
group.setAttribute("role", "radiogroup");
|
||||||
|
group.setAttribute("aria-label", L("App_TouchMode"));
|
||||||
|
const modes: [TouchMode, keyof typeof ui_locales][] = [
|
||||||
|
["auto", "App_TouchMode_Auto"],
|
||||||
|
["on", "App_TouchMode_On"],
|
||||||
|
["off", "App_TouchMode_Off"],
|
||||||
|
];
|
||||||
|
const buttons = modes.map(([mode, key]) => {
|
||||||
|
const b = createButton({
|
||||||
|
label: L(key),
|
||||||
|
variant: "pill",
|
||||||
|
onClick: () => {
|
||||||
|
setTouchMode(mode);
|
||||||
|
mark();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
b.setAttribute("role", "radio");
|
||||||
|
b.dataset.touchMode = mode;
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
const mark = (): void => {
|
||||||
|
const now = readTouchMode();
|
||||||
|
for (const b of buttons) b.setAttribute("aria-checked", String(b.dataset.touchMode === now));
|
||||||
|
};
|
||||||
|
mark();
|
||||||
|
group.append(...buttons);
|
||||||
|
box.append(label, group);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
/** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */
|
/** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */
|
||||||
async function fillAuthActions(slot: HTMLElement): Promise<void> {
|
async function fillAuthActions(slot: HTMLElement): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -188,6 +324,7 @@ function buildFooter(): HTMLElement {
|
|||||||
* -------------------------------------------------------------------------- */
|
* -------------------------------------------------------------------------- */
|
||||||
export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement {
|
export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement {
|
||||||
appRoot.innerHTML = "";
|
appRoot.innerHTML = "";
|
||||||
|
drawerSteps = null; // 새 쪽이 진행 판을 세우면 다시 맡김
|
||||||
const header = buildHeader();
|
const header = buildHeader();
|
||||||
const outlet = document.createElement("main");
|
const outlet = document.createElement("main");
|
||||||
outlet.className = "app-outlet";
|
outlet.className = "app-outlet";
|
||||||
@@ -259,10 +396,10 @@ const SHELL_CSS = `
|
|||||||
gap: var(--spacing-8);
|
gap: var(--spacing-8);
|
||||||
}
|
}
|
||||||
.app-outlet {
|
.app-outlet {
|
||||||
min-height: calc(100vh - 64px - 56px);
|
min-height: calc(100dvh - var(--app-header-h) - 56px);
|
||||||
}
|
}
|
||||||
.app-outlet--without-footer {
|
.app-outlet--without-footer {
|
||||||
min-height: calc(100vh - 64px);
|
min-height: calc(100dvh - var(--app-header-h));
|
||||||
}
|
}
|
||||||
.app-footer {
|
.app-footer {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@@ -285,7 +422,120 @@ const SHELL_CSS = `
|
|||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.app-nav { display: none; }
|
.app-nav { display: none; }
|
||||||
|
.app-actions { margin-left: auto; }
|
||||||
}
|
}
|
||||||
|
/* ☰ 서랍 — 공개 쪽은 폭 900 이하 · 터치 모드에서만 · 로그인 뒤 쪽(B · M)은 늘 */
|
||||||
|
.app-burger,
|
||||||
|
.app-drawer,
|
||||||
|
.app-drawer__scrim {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.app-burger {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-buttons);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; }
|
||||||
|
.app-header.is-drawer-open { z-index: var(--z-overlay); }
|
||||||
|
.app-header.is-drawer-open .app-drawer__scrim {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: var(--app-header-h) 0 0 0;
|
||||||
|
background: rgba(38, 17, 74, 0.35);
|
||||||
|
}
|
||||||
|
.app-header.is-drawer-open .app-drawer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-16);
|
||||||
|
position: fixed;
|
||||||
|
top: var(--app-header-h);
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: min(320px, 100vw);
|
||||||
|
padding: var(--spacing-16);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.app-drawer__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
}
|
||||||
|
.app-drawer__nav > * { justify-content: flex-start; min-height: 44px; }
|
||||||
|
.app-drawer__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
}
|
||||||
|
.app-drawer__group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
}
|
||||||
|
.app-drawer__group + .app-drawer__group {
|
||||||
|
padding-top: var(--spacing-16);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.app-drawer__steps:empty { display: none; }
|
||||||
|
.app-drawer__touch {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
}
|
||||||
|
.app-drawer__touch [aria-checked="true"] {
|
||||||
|
background: var(--color-mist-violet);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
/* 휴대폰 폭 — 오른쪽 단추(언어 · 테마 · 로그인)는 서랍 안으로 */
|
||||||
|
html[data-size="compact"] .app-header { padding: 0 var(--spacing-16); gap: var(--spacing-8); }
|
||||||
|
html[data-size="compact"] .app-actions { display: none; }
|
||||||
|
html[data-size="compact"] .app-burger { margin-left: auto; }
|
||||||
|
/* 로그인 뒤 쪽(B · M) — 폭 · 입력과 상관없이 위 줄을 걷고 ☰ 만 화면 왼쪽 위 모서리에 띄움(7-1)
|
||||||
|
본문이 화면 높이를 다 씀 · 서랍은 왼쪽에서 열림 · 위 줄 항목은 모두 서랍에 있음 */
|
||||||
|
html.is-bm-route .app-header {
|
||||||
|
height: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
z-index: calc(var(--z-dropdown) + 1);
|
||||||
|
}
|
||||||
|
html.is-bm-route .app-header > :is(.app-brand, .app-nav, .app-actions) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
/* ☰ 폭 36(7-4) · 높이는 손가락 44 · 마우스 36 */
|
||||||
|
html.is-bm-route .app-burger {
|
||||||
|
min-width: var(--app-burger-w);
|
||||||
|
min-height: var(--app-burger-w);
|
||||||
|
position: fixed;
|
||||||
|
top: var(--spacing-8);
|
||||||
|
left: var(--spacing-8);
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
html.is-bm-route[data-touch="on"] .app-burger { min-height: 44px; }
|
||||||
|
html.is-bm-route .app-header.is-drawer-open .app-drawer {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
html.is-bm-route .app-drawer__brand {
|
||||||
|
min-height: 44px;
|
||||||
|
padding-left: calc(var(--app-burger-inset) - var(--spacing-16));
|
||||||
|
}
|
||||||
|
html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-start; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function injectShellStyles(): void {
|
export function injectShellStyles(): void {
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ const SCAFFOLD_CSS = `
|
|||||||
font-size: var(--text-body-sm);
|
font-size: var(--text-body-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.b-scaffold-wf { height: calc(100vh - 64px - 56px); }
|
.b-scaffold-wf { height: calc(100dvh - var(--app-header-h) - 56px); }
|
||||||
|
|
||||||
.b-pending {
|
.b-pending {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+37
-16
@@ -57,8 +57,6 @@ export const STATE_REGISTRY = {
|
|||||||
legacy: () => "b05-route-drainage-collapsed",
|
legacy: () => "b05-route-drainage-collapsed",
|
||||||
},
|
},
|
||||||
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
||||||
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
|
|
||||||
"drainage-visible": { bucket: "pref", scope: "global" },
|
|
||||||
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
|
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
|
||||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||||
@@ -66,9 +64,11 @@ export const STATE_REGISTRY = {
|
|||||||
"m02-card-pills": { bucket: "pref", scope: "global" },
|
"m02-card-pills": { bucket: "pref", scope: "global" },
|
||||||
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
|
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
|
||||||
"m01-logic-panel": { bucket: "pref", scope: "global" },
|
"m01-logic-panel": { bucket: "pref", scope: "global" },
|
||||||
/** B09 묶음 탭(171-3) 보기 `{탭: cards · table}` · 가로 열 수 `{탭: 1~4}` */
|
/** B09 묶음 탭(171-3) 보기 `cards · table`(304-1 셋 탭 한 값) · 가로 열 수 `{탭: 1~4}` */
|
||||||
"b09-group-view": { bucket: "pref", scope: "global" },
|
"b09-group-view": { bucket: "pref", scope: "global" },
|
||||||
"b09-group-cols": { bucket: "pref", scope: "global" },
|
"b09-group-cols": { bucket: "pref", scope: "global" },
|
||||||
|
/** B09 행 누름(303-1) `popup · link` */
|
||||||
|
"b09-link-mode": { bucket: "pref", scope: "global" },
|
||||||
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
|
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
|
||||||
"routeedit-view": { bucket: "pref", scope: "project" },
|
"routeedit-view": { bucket: "pref", scope: "project" },
|
||||||
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
|
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
|
||||||
@@ -101,6 +101,12 @@ export const STATE_REGISTRY = {
|
|||||||
},
|
},
|
||||||
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
|
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
|
||||||
"cross-columns": { bucket: "pref", scope: "global" },
|
"cross-columns": { bucket: "pref", scope: "global" },
|
||||||
|
/** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */
|
||||||
|
"mass-haul-badge-pos": { bucket: "pref", scope: "global" },
|
||||||
|
/** ☰ [설정] 마우스 · 단축키(332) — 읽기는 `input_prefs.ts`(같은 키 글자) · [저장] 때만 씀. */
|
||||||
|
"input-prefs": { bucket: "pref", scope: "global" },
|
||||||
|
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
|
||||||
|
"b05-3d-projection": { bucket: "pref", scope: "global" },
|
||||||
"section-panel-height": {
|
"section-panel-height": {
|
||||||
bucket: "pref",
|
bucket: "pref",
|
||||||
scope: "global",
|
scope: "global",
|
||||||
@@ -110,6 +116,8 @@ export const STATE_REGISTRY = {
|
|||||||
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
|
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
|
||||||
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
|
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
|
||||||
"structure-notes": { bucket: "draft", scope: "route" },
|
"structure-notes": { bucket: "draft", scope: "route" },
|
||||||
|
/** B06 횡단 [상세] 모달 표 위 CAD 칸(260-1) — 측점키(0.01 m) → CAD 엔티티 묶음. [저장]이 내보냄. */
|
||||||
|
"structure-note-cads": { bucket: "draft", scope: "route" },
|
||||||
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
|
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
|
||||||
"detail-table": { bucket: "draft", scope: "route" },
|
"detail-table": { bucket: "draft", scope: "route" },
|
||||||
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
|
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
|
||||||
@@ -127,10 +135,14 @@ export const STATE_REGISTRY = {
|
|||||||
scope: "project",
|
scope: "project",
|
||||||
legacy: (p) => `aislo:structure-pick:${p}`,
|
legacy: (p) => `aislo:structure-pick:${p}`,
|
||||||
},
|
},
|
||||||
|
/** 관 세션 id 장부(323-5 · `design_pipe_ids`) — `{next, ids: [{id, at}]}` · 저장 안 함. */
|
||||||
|
"pipe-ids": { bucket: "draft", scope: "project" },
|
||||||
/** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */
|
/** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */
|
||||||
selection: { bucket: "draft", scope: "project" },
|
selection: { bucket: "draft", scope: "project" },
|
||||||
/** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */
|
/** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */
|
||||||
"structure-type-cache": { bucket: "draft", scope: "project" },
|
"structure-type-cache": { bucket: "draft", scope: "project" },
|
||||||
|
/** 종류를 바꾼 관 자리 `{누가거리: 처음 종류}`(318-4) — [저장] 때 그 측점 옛 조정값을 정본에서도 비움. */
|
||||||
|
kindreset: { bucket: "draft", scope: "project" },
|
||||||
/** 배수관·암거 조정창에서 예약한 옵션 값. */
|
/** 배수관·암거 조정창에서 예약한 옵션 값. */
|
||||||
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
|
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
|
||||||
/** 배수관 이동(측점 옮김) 예약. */
|
/** 배수관 이동(측점 옮김) 예약. */
|
||||||
@@ -185,6 +197,8 @@ export const STATE_REGISTRY = {
|
|||||||
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
|
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
|
||||||
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
|
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
|
||||||
"b08-options": { bucket: "draft", scope: "project" },
|
"b08-options": { bucket: "draft", scope: "project" },
|
||||||
|
/** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */
|
||||||
|
"b08-milestone": { bucket: "draft", scope: "project" },
|
||||||
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
|
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
|
||||||
"b09-estimation": { bucket: "draft", scope: "project" },
|
"b09-estimation": { bucket: "draft", scope: "project" },
|
||||||
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
||||||
@@ -477,29 +491,36 @@ export function clearState(
|
|||||||
routeId?: number | string | null,
|
routeId?: number | string | null,
|
||||||
): void {
|
): void {
|
||||||
const entry = entryOf(name);
|
const entry = entryOf(name);
|
||||||
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다.
|
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다(`routeStateKeys`).
|
||||||
// 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`),
|
// 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`),
|
||||||
// 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169
|
// 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169
|
||||||
// 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 —
|
// 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 —
|
||||||
// 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다.
|
// 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다.
|
||||||
if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) {
|
if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) {
|
||||||
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
|
routeStateKeys(name, projectId).forEach((key) => writeRaw(key, null));
|
||||||
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
|
|
||||||
try {
|
|
||||||
const doomed: string[] = [];
|
|
||||||
for (let i = 0; i < window.sessionStorage.length; i += 1) {
|
|
||||||
const key = window.sessionStorage.key(i);
|
|
||||||
if (key && key.startsWith(prefix)) doomed.push(key);
|
|
||||||
}
|
|
||||||
doomed.forEach((key) => writeRaw(key, null));
|
|
||||||
} catch {
|
|
||||||
/* 무시 — 저장소가 막힌 브라우저 */
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writeStateRaw(name, null, projectId, routeId);
|
writeStateRaw(name, null, projectId, routeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 노선 범위 이름의 그 프로젝트 키 전부(모든 노선 · 세션에 있는 것만). */
|
||||||
|
export function routeStateKeys(name: StateName, projectId: string): string[] {
|
||||||
|
const entry = entryOf(name);
|
||||||
|
if (entry?.scope !== "route") return [];
|
||||||
|
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
|
||||||
|
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
|
||||||
|
const keys: string[] = [];
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < window.sessionStorage.length; i += 1) {
|
||||||
|
const key = window.sessionStorage.key(i);
|
||||||
|
if (key && key.startsWith(prefix)) keys.push(key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 무시 — 저장소가 막힌 브라우저 */
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
/** 등록표에서 한 통에 속한 이름만 고른다. */
|
/** 등록표에서 한 통에 속한 이름만 고른다. */
|
||||||
export function namesInBucket(bucket: StateBucket): StateName[] {
|
export function namesInBucket(bucket: StateBucket): StateName[] {
|
||||||
return (Object.keys(STATE_REGISTRY) as StateName[]).filter(
|
return (Object.keys(STATE_REGISTRY) as StateName[]).filter(
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/b_project_switch.ts
|
||||||
|
* 다른 프로젝트에 들어갈 때 **그 밖 프로젝트의 캐시**를 지운다(계획서 290-1 · 요구 183-1).
|
||||||
|
*
|
||||||
|
* 지우는 것 — 이 탭 세션의 다른 프로젝트 칸(초안 · 결과 · 고름 · 옛 키 · 페이지별 칸)과
|
||||||
|
* 3D 지형 보관함(IndexedDB). 남기는 것 — 지금 프로젝트 것 · 계정 취향(pref, localStorage).
|
||||||
|
* 다른 프로젝트에 저장 안 한 고침(초안)이 있으면 확인창 — 「남기기」면 그 초안만 둔다.
|
||||||
|
* 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { showConfirmDialog } from "@ui/ui_template_elements";
|
||||||
|
import { purgeOtherProjects } from "./b_asset_cache";
|
||||||
|
import { STATE_REGISTRY, type StateEntry } from "./b_page_state";
|
||||||
|
|
||||||
|
/** 이 탭이 마지막으로 들어온 프로젝트. */
|
||||||
|
const ENTERED_KEY = "aislo:entered-project";
|
||||||
|
|
||||||
|
const MARK_P = "\u0001";
|
||||||
|
const MARK_R = "\u0002";
|
||||||
|
|
||||||
|
/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */
|
||||||
|
function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
|
||||||
|
const out: Array<{ prefix: string; draft: boolean }> = [];
|
||||||
|
Object.entries(STATE_REGISTRY as Record<string, StateEntry>).forEach(([name, entry]) => {
|
||||||
|
if (entry.bucket === "pref" || entry.scope === "global") return;
|
||||||
|
const draft = entry.bucket === "draft";
|
||||||
|
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
|
||||||
|
out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft });
|
||||||
|
const legacy = entry.legacy?.(MARK_P, MARK_R);
|
||||||
|
const at = legacy?.indexOf(MARK_P) ?? -1;
|
||||||
|
if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filled = (value: string | null): boolean =>
|
||||||
|
value !== null && value !== "" && value !== "{}" && value !== "[]";
|
||||||
|
|
||||||
|
export interface ProjectPurgePlan {
|
||||||
|
/** 저장 안 한 고침이 남은 다른 프로젝트. */
|
||||||
|
draftProjects: string[];
|
||||||
|
/** 지울 키 — `keep` 프로젝트의 초안은 뺀다. */
|
||||||
|
doomed: (keep: ReadonlySet<string>) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 세션 칸(키 → 값)에서 다른 프로젝트 몫을 가른다 — 저장소를 만지지 않는다. */
|
||||||
|
export function planProjectPurge(
|
||||||
|
session: Record<string, string>,
|
||||||
|
projectId: string,
|
||||||
|
): ProjectPurgePlan {
|
||||||
|
const prefixes = projectPrefixes();
|
||||||
|
const owners = new Map<string, { project: string; draft: boolean }>();
|
||||||
|
Object.keys(session).forEach((key) => {
|
||||||
|
const hit = prefixes.find((item) => key.startsWith(item.prefix));
|
||||||
|
if (!hit) return;
|
||||||
|
const project = key.slice(hit.prefix.length).split(":")[0];
|
||||||
|
if (project) owners.set(key, { project, draft: hit.draft });
|
||||||
|
});
|
||||||
|
const others = new Set(
|
||||||
|
[...owners.values()].map((owner) => owner.project).filter((id) => id !== projectId),
|
||||||
|
);
|
||||||
|
const draftProjects = [
|
||||||
|
...new Set(
|
||||||
|
[...owners.entries()]
|
||||||
|
.filter(([key, owner]) => owner.draft && others.has(owner.project) && filled(session[key]))
|
||||||
|
.map(([, owner]) => owner.project),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
draftProjects,
|
||||||
|
// 등록표 밖 칸도 키 마디에 다른 프로젝트 번호가 있으면 그 프로젝트 몫으로 본다.
|
||||||
|
doomed: (keep) =>
|
||||||
|
Object.keys(session).filter((key) => {
|
||||||
|
const owner = owners.get(key);
|
||||||
|
if (owner) return others.has(owner.project) && !(owner.draft && keep.has(owner.project));
|
||||||
|
return key.split(":").some((part) => others.has(part) && !keep.has(part));
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionEntries(): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < window.sessionStorage.length; i += 1) {
|
||||||
|
const key = window.sessionStorage.key(i);
|
||||||
|
const value = key === null ? null : window.sessionStorage.getItem(key);
|
||||||
|
if (key !== null && value !== null) out[key] = value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 저장소가 막힌 브라우저 — 지울 것이 없다. */
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프로젝트에 들어감 — 들어온 표식이 다르면 그 밖 프로젝트 캐시를 지운다. 실패해도 이동은 막지 않는다.
|
||||||
|
* `nameOf` 는 확인창에 적을 프로젝트 이름.
|
||||||
|
*/
|
||||||
|
export async function enterProject(
|
||||||
|
projectId: string,
|
||||||
|
nameOf: (projectId: string) => Promise<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (window.sessionStorage.getItem(ENTERED_KEY) === projectId) return;
|
||||||
|
const plan = planProjectPurge(sessionEntries(), projectId);
|
||||||
|
const keep = new Set<string>();
|
||||||
|
for (const other of plan.draftProjects) {
|
||||||
|
const name = await nameOf(other).catch(() => other);
|
||||||
|
const drop = await showConfirmDialog(
|
||||||
|
`「${name}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기`,
|
||||||
|
"지우고 열기",
|
||||||
|
"남기기",
|
||||||
|
);
|
||||||
|
if (!drop) keep.add(other);
|
||||||
|
}
|
||||||
|
plan.doomed(keep).forEach((key) => window.sessionStorage.removeItem(key));
|
||||||
|
window.sessionStorage.setItem(ENTERED_KEY, projectId);
|
||||||
|
await purgeOtherProjects(projectId);
|
||||||
|
} catch {
|
||||||
|
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
|
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { wheelEventIn } from "./input_prefs";
|
||||||
|
|
||||||
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
|
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
|
||||||
export interface ZoomPanState {
|
export interface ZoomPanState {
|
||||||
scale: number;
|
scale: number;
|
||||||
@@ -61,12 +63,18 @@ export interface SvgZoomPanOptions {
|
|||||||
maxScale?: number;
|
maxScale?: number;
|
||||||
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
|
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
|
||||||
initial?: ZoomPanState;
|
initial?: ZoomPanState;
|
||||||
|
/** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */
|
||||||
|
homeScale?: number;
|
||||||
|
/** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */
|
||||||
|
home?: ZoomPanState;
|
||||||
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
|
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
|
||||||
onChange?: (state: ZoomPanState) => void;
|
onChange?: (state: ZoomPanState) => void;
|
||||||
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||||
content?: ContentBounds;
|
content?: ContentBounds;
|
||||||
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
|
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
|
||||||
wheelZoom?: boolean;
|
wheelZoom?: boolean;
|
||||||
|
/** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */
|
||||||
|
panStart?: (event: PointerEvent) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
|
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
|
||||||
@@ -83,9 +91,17 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
|||||||
height: Math.max(heightPx - pad.top - pad.bottom, 1),
|
height: Math.max(heightPx - pad.top - pad.bottom, 1),
|
||||||
};
|
};
|
||||||
const maxScale = options.maxScale ?? 8;
|
const maxScale = options.maxScale ?? 8;
|
||||||
let scale = initial?.scale ?? 1;
|
const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale);
|
||||||
let tx = initial?.tx ?? 0;
|
const home: ZoomPanState = options.home ?? {
|
||||||
let ty = initial?.ty ?? 0;
|
scale: homeScale,
|
||||||
|
tx: (plot.x + plot.width / 2) * (1 - homeScale),
|
||||||
|
ty: (plot.y + plot.height / 2) * (1 - homeScale),
|
||||||
|
};
|
||||||
|
/** 축소 한계 — 1배 · 처음 보기가 더 작으면 그만큼. */
|
||||||
|
const minScale = Math.min(1, home.scale);
|
||||||
|
let scale = initial?.scale ?? home.scale;
|
||||||
|
let tx = initial?.tx ?? home.tx;
|
||||||
|
let ty = initial?.ty ?? home.ty;
|
||||||
const applyTransform = (): void => {
|
const applyTransform = (): void => {
|
||||||
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||||||
onChange?.({ scale, tx, ty });
|
onChange?.({ scale, tx, ty });
|
||||||
@@ -122,7 +138,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
|||||||
|
|
||||||
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
|
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
|
||||||
const zoomAt = (factor: number, cx: number, cy: number): void => {
|
const zoomAt = (factor: number, cx: number, cy: number): void => {
|
||||||
const next = Math.min(maxScale, Math.max(1, scale * factor));
|
const next = Math.min(maxScale, Math.max(minScale, scale * factor));
|
||||||
tx = cx - ((cx - tx) / scale) * next;
|
tx = cx - ((cx - tx) / scale) * next;
|
||||||
ty = cy - ((cy - ty) / scale) * next;
|
ty = cy - ((cy - ty) / scale) * next;
|
||||||
scale = next;
|
scale = next;
|
||||||
@@ -146,8 +162,10 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
|||||||
if (event.button === 1) event.preventDefault();
|
if (event.button === 1) event.preventDefault();
|
||||||
});
|
});
|
||||||
svg.addEventListener("pointerdown", (event) => {
|
svg.addEventListener("pointerdown", (event) => {
|
||||||
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다.
|
// 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1)
|
||||||
if (event.button !== 1) return;
|
moved = false;
|
||||||
|
// 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다.
|
||||||
|
if (!(options.panStart ? options.panStart(event) : event.button === 1)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
panning = true;
|
panning = true;
|
||||||
moved = false;
|
moved = false;
|
||||||
@@ -178,7 +196,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
|||||||
/* 이미 해제됨 */
|
/* 이미 해제됨 */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
|
if (scale !== 1 || tx !== 0 || ty !== 0) {
|
||||||
clampPan();
|
clampPan();
|
||||||
applyTransform();
|
applyTransform();
|
||||||
}
|
}
|
||||||
@@ -197,17 +215,47 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
|||||||
const rect = svg.getBoundingClientRect();
|
const rect = svg.getBoundingClientRect();
|
||||||
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
|
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
|
||||||
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
|
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
|
||||||
// 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`).
|
// ☰ [설정] 휠 방향(332 · 기본 당기면 확대) — 그림 면이 손가락을 받으므로 핀치는 트랙패드뿐.
|
||||||
zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy);
|
zoomAt(wheelEventIn(event) ? WHEEL_IN : WHEEL_OUT, cx, cy);
|
||||||
},
|
},
|
||||||
{ passive: false },
|
{ passive: false },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 손가락 둘 = 벌려 확대 · 같이 끌어 이동(기기대응 3-2) — 한 손가락은 그 화면의 고르기 · 목록 밀기 몫.
|
||||||
|
// 휠 줌 화면은 그림 면이 손가락을 다 받음 · 아니면(B06 카드 목록) 한 손가락 밀기는 브라우저에 둠.
|
||||||
|
svg.setAttribute?.("data-touch-own", "pinch"); // ?. = 노드 시험의 흉내 SVG
|
||||||
|
svg.style?.setProperty("touch-action", options.wheelZoom ? "none" : "pan-x pan-y");
|
||||||
|
const touches = new Map<number, { x: number; y: number }>();
|
||||||
|
const touchPair = (): { x: number; y: number; dist: number } => {
|
||||||
|
const [a, b] = [...touches.values()];
|
||||||
|
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, dist: Math.hypot(a.x - b.x, a.y - b.y) };
|
||||||
|
};
|
||||||
|
svg.addEventListener("pointerdown", (event) => {
|
||||||
|
if (event.pointerType === "touch")
|
||||||
|
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
});
|
||||||
|
svg.addEventListener("pointermove", (event) => {
|
||||||
|
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
|
||||||
|
const before = touches.size === 2 ? touchPair() : null;
|
||||||
|
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
if (!before) return;
|
||||||
|
const after = touchPair();
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const w = Math.max(rect.width, 1);
|
||||||
|
const h = Math.max(rect.height, 1);
|
||||||
|
tx += ((after.x - before.x) / w) * widthPx;
|
||||||
|
ty += ((after.y - before.y) / h) * heightPx;
|
||||||
|
moved = true;
|
||||||
|
const cx = ((after.x - rect.left) / w) * widthPx;
|
||||||
|
const cy = ((after.y - rect.top) / h) * heightPx;
|
||||||
|
zoomAt(before.dist > 0 && after.dist > 0 ? after.dist / before.dist : 1, cx, cy);
|
||||||
|
});
|
||||||
|
const liftTouch = (event: PointerEvent): void => void touches.delete(event.pointerId);
|
||||||
|
svg.addEventListener("pointerup", liftTouch);
|
||||||
|
svg.addEventListener("pointercancel", liftTouch);
|
||||||
const currentScale = (): number => scale;
|
const currentScale = (): number => scale;
|
||||||
const reset = (): void => {
|
const reset = (): void => {
|
||||||
scale = 1;
|
({ scale, tx, ty } = home);
|
||||||
tx = 0;
|
|
||||||
ty = 0;
|
|
||||||
applyTransform();
|
applyTransform();
|
||||||
};
|
};
|
||||||
// 더블클릭 원복.
|
// 더블클릭 원복.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
fetchSurfaceStatus,
|
fetchSurfaceStatus,
|
||||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
|
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
|
||||||
|
import { enterProject } from "./b_project_switch";
|
||||||
import { navigateTo } from "./router";
|
import { navigateTo } from "./router";
|
||||||
|
|
||||||
export interface WorkflowState {
|
export interface WorkflowState {
|
||||||
@@ -93,14 +94,20 @@ export async function dropGoneProject(): Promise<boolean> {
|
|||||||
const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE];
|
const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE];
|
||||||
|
|
||||||
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
|
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
|
||||||
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
// 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
|
||||||
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
|
void enterProject(
|
||||||
clearWorkflowStateCache();
|
projectId,
|
||||||
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
|
async (id) => (await fetchWorkflowState(id)).project_name || id,
|
||||||
navigateTo(route);
|
).then(() => {
|
||||||
return;
|
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
||||||
}
|
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
|
||||||
void routeAfterPreloadCheck(projectId, route);
|
clearWorkflowStateCache();
|
||||||
|
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
|
||||||
|
navigateTo(route);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void routeAfterPreloadCheck(projectId, route);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,11 +50,40 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b05-route-profile__trailing > .b05-route-profile__popout {
|
.b05-route-profile__tools > .b05-route-profile__popout {
|
||||||
padding: var(--btn-bar-padding);
|
padding: var(--btn-bar-padding);
|
||||||
font-size: var(--btn-bar-font-size);
|
font-size: var(--btn-bar-font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */
|
||||||
|
.b05-subpanel__popout {
|
||||||
|
flex: none;
|
||||||
|
padding: var(--btn-bar-padding);
|
||||||
|
font-size: var(--btn-bar-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b05-profile__masshaul-overlay > .b05-subpanel__popout {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: var(--spacing-8);
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */
|
||||||
|
.b05-profile__masshaul-overlay.is-popout,
|
||||||
|
.b05-drainage.is-popout {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b05-profile__masshaul-overlay.is-popout > .ui-resizer,
|
||||||
|
.b05-drainage.is-popout > .ui-resizer,
|
||||||
|
.b05-drainage.is-popout > .ui-workflow-overlay__handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼
|
/* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼
|
||||||
안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */
|
안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */
|
||||||
.b05-route__panel .b06-adjust-dock,
|
.b05-route__panel .b06-adjust-dock,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { currentRoute, navigateTo, type MountedPage } from "../router";
|
import { currentRoute, navigateTo, type MountedPage } from "../router";
|
||||||
import type { DesignView } from "./design_dock";
|
import type { DesignView } from "./design_dock";
|
||||||
import type { DesignPageShared } from "./design_shared";
|
import type { DesignPageShared } from "./design_shared";
|
||||||
|
import { usePipeIds } from "./design_pipe_ids";
|
||||||
import "./design_page.css";
|
import "./design_page.css";
|
||||||
|
|
||||||
export type { DesignPageShared } from "./design_shared";
|
export type { DesignPageShared } from "./design_shared";
|
||||||
@@ -105,6 +106,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
|||||||
page.className = "design-page";
|
page.className = "design-page";
|
||||||
root.replaceChildren(page);
|
root.replaceChildren(page);
|
||||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||||
|
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
|
||||||
|
|
||||||
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
|
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
|
||||||
const left = document.createElement("div");
|
const left = document.createElement("div");
|
||||||
|
|||||||
@@ -82,15 +82,16 @@ export function createSectionFrame(
|
|||||||
heading.textContent = title;
|
heading.textContent = title;
|
||||||
if (view === "profile") {
|
if (view === "profile") {
|
||||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
|
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
|
||||||
|
// ui-panel-form: 좌측 패널 컨테이너 공용 양식(269 · 「구조물 배치」 기준 칸 · 라벨 · 단추) — 두 보기 모두.
|
||||||
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
|
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
|
||||||
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
|
||||||
heading.className = "ui-collapsible__title";
|
heading.className = "ui-collapsible__title";
|
||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
body.className = "b05-route__panel-body";
|
body.className = "b05-route__panel-body";
|
||||||
root.append(heading, body);
|
root.append(heading, body);
|
||||||
return { root, body };
|
return { root, body };
|
||||||
}
|
}
|
||||||
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
root.className = `b06-profile__group ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
|
||||||
heading.className = "b06-profile__group-legend ui-collapsible__title";
|
heading.className = "b06-profile__group-legend ui-collapsible__title";
|
||||||
root.append(heading);
|
root.append(heading);
|
||||||
return { root, body: root };
|
return { root, body: root };
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/design/design_pick_escape.ts
|
||||||
|
* 풀기 한 길 — Esc = 고름 한 칸 비움 (233-3 · 조사 AF (나)).
|
||||||
|
*
|
||||||
|
* 값을 비우면 구독한 화면이 모두 따라 풂 — 종단(측점선 · 알약) · 3D(띠 · 핀 · 부재) · 배수유역도 ·
|
||||||
|
* 좌측 폼 · 횡단 카드 · 범위 테두리 · 포장층 · 형상. 입력 칸 · 창(모달) · 메뉴 · 종단 도구가 먼저
|
||||||
|
* 받은 Esc(입력 취소 · 창 닫기 · 도구 끝)는 그쪽 몫이라 건드리지 않음.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { NO_SELECTION, selectDesign } from "./design_selection";
|
||||||
|
|
||||||
|
/** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */
|
||||||
|
export const ESCAPE_FROM = "esc";
|
||||||
|
|
||||||
|
const OWN_ESCAPE =
|
||||||
|
"input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop";
|
||||||
|
|
||||||
|
/** 보기마다 건다 — 보이는 보기 하나만 받음 · 보기를 떠나면 뗌. */
|
||||||
|
export function bindEscapeRelease(
|
||||||
|
projectId: string | null,
|
||||||
|
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
|
||||||
|
): void {
|
||||||
|
const onKey = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key !== "Escape" || event.defaultPrevented || !host.visible()) return;
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target?.closest?.(OWN_ESCAPE) || document.querySelector(".ui-modal__backdrop")) return;
|
||||||
|
selectDesign(projectId, NO_SELECTION, { from: ESCAPE_FROM });
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
host.onDispose(() => window.removeEventListener("keydown", onKey));
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/design/design_pick_match.ts
|
||||||
|
* 고름 강조 한 규칙(323-5) — 그린 것(카드 형상 · 3D 부재 · 알약 · 측점선)마다 신원표 `{구조물 id · 부분 · 자리}`
|
||||||
|
* 를 달고, 고름 한 칸(`structure-pick`)과 이 함수 하나로 켜짐을 가름.
|
||||||
|
*
|
||||||
|
* ① 고름 · 그림 둘 다 구조물 id 가 있으면 id 가 같아야(관 = 세션 id `design_pipe_ids` · 옮겨도 그대로)
|
||||||
|
* ② 아니면 같은 자리(`PIPE_MATCH_M` — 같은 자리 거리는 이 한 값)
|
||||||
|
* ③ 고른 부분(부재키)이 있으면 그 부분만 · 부분을 안 골랐으면 그 구조물 그림 모두
|
||||||
|
* DOM 은 `data-structure-id` · `data-part`(`tagShape`) — 부분 그림(관 · 벽 · 날개벽 · 물넘이 콘크리트)은
|
||||||
|
* 조정창 손잡이가 같은 고름으로 칠함(`paintPicked` 는 부분 없는 형상만 · 307 「측점 길은 부분 안 켬」).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { isNear, type DesignSelection } from "./design_selection";
|
||||||
|
|
||||||
|
export interface PickTag {
|
||||||
|
structureId?: string | null;
|
||||||
|
part?: string | null;
|
||||||
|
chainageM?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PickSelection =
|
||||||
|
(Partial<DesignSelection> & { partKey?: string | null }) | null | undefined;
|
||||||
|
|
||||||
|
/** 이 그림이 고름에 켜지나 — 위 ① ② ③. */
|
||||||
|
export function pickLights(selection: PickSelection, tag: PickTag): boolean {
|
||||||
|
if (!selection) return false;
|
||||||
|
let here: boolean;
|
||||||
|
if (selection.structureId && tag.structureId) here = selection.structureId === tag.structureId;
|
||||||
|
else if (selection.chainageM == null || tag.chainageM == null) return false;
|
||||||
|
else here = isNear(tag.chainageM, selection.chainageM);
|
||||||
|
if (!here) return false;
|
||||||
|
return !selection.partKey || tag.part === selection.partKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 그린 형상에 신원표를 닮(부분 없으면 통째 형상). */
|
||||||
|
export function tagShape(el: Element, structureId: string | null, part?: string | null): void {
|
||||||
|
if (structureId) el.setAttribute("data-structure-id", structureId);
|
||||||
|
if (part) el.setAttribute("data-part", part);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 카드 안 통째 형상 칠하기 — 고른 구조물 id 와 같은 형상만. 부분 그림(`data-part`) · 관 계열(`pipe-`) ·
|
||||||
|
* 노면공 포장층은 제 손잡이(조정창 · 포장층 고름)가 칠함 — 측점 길(알약 · 목록 · 측점선)은 부분을 안 켬(307). */
|
||||||
|
export function paintPicked(root: ParentNode, selection: PickSelection): void {
|
||||||
|
for (const shape of root.querySelectorAll<SVGElement>("[data-structure-id]")) {
|
||||||
|
const id = shape.getAttribute("data-structure-id") ?? "";
|
||||||
|
if (id.startsWith("pipe-") || shape.getAttribute("data-part") !== null) continue;
|
||||||
|
if (shape.getAttribute("data-pave-type") !== null) continue;
|
||||||
|
shape.classList.toggle(
|
||||||
|
"is-active",
|
||||||
|
!!selection?.structureId && pickLights(selection, { structureId: id }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/design/design_pick_profile.ts
|
||||||
|
* 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) ·
|
||||||
|
* 3D(측점 띠 · 핀 · 부재 · 233-2) · 배수유역도(관 마커 · 유역 · 측점 마름모 · 233-3) (조사 AF (나) ② ③ ⑤).
|
||||||
|
*
|
||||||
|
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고,
|
||||||
|
* 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이
|
||||||
|
* 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는
|
||||||
|
* 그릴 때마다 지금 관 목록에서 다시 찾고 관 id 는 세션 id(323-5)라 관이 옮겨져도 끊기지 않는다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import {
|
||||||
|
nearestWithin,
|
||||||
|
readSelection,
|
||||||
|
subscribeSelection,
|
||||||
|
type DesignSelection,
|
||||||
|
} from "./design_selection";
|
||||||
|
import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape";
|
||||||
|
import { pipeIdAt } from "./design_pipe_ids";
|
||||||
|
import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
|
||||||
|
import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||||
|
|
||||||
|
/** 종단도가 켤 것 — 측점선(알약은 그 측점 짝을 종단도가 맞춤) 또는 알약(구조물 · 관 `pipe-*`). */
|
||||||
|
export type ProfilePick = { stationId: string | null } | { structureId: string };
|
||||||
|
|
||||||
|
export interface ProfilePickSource {
|
||||||
|
/** 구조물 측점(수동 + 관 투영) — id 는 `irregularStationId` 를 씌우기 전 값. */
|
||||||
|
irregular: () => ReadonlyArray<{ id: string; chainage_m: number; origin?: string }>;
|
||||||
|
/** 관 지점 정본(배수유역도). */
|
||||||
|
pipes: () => ReadonlyArray<{ chainage_m: number }>;
|
||||||
|
/** 일반 측점 카드. */
|
||||||
|
sections: () => ReadonlyArray<{ station_id: string; chainage_m: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfilePickTarget {
|
||||||
|
setSelectedStation: (stationId: string | null) => void;
|
||||||
|
setSelectedStructure: (structureId: string | null) => void;
|
||||||
|
/** 배수유역도 — 관 마커 · 유역(알림 없이) · 측점 마름모. */
|
||||||
|
drainage?: {
|
||||||
|
selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void;
|
||||||
|
markStation: (chainageM: number | null) => void;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */
|
||||||
|
const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1)
|
||||||
|
* ② 그 자리 구조물 측점(같은 m 면 측점 하나 · 구조물이 위 · 272 — 일반 카드 id 는 종단도가 숨긴 측점)
|
||||||
|
* ③ 고른 측점 그대로(일반 카드) ④ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1).
|
||||||
|
*/
|
||||||
|
export function profilePickOf(
|
||||||
|
selection: DesignSelection | null,
|
||||||
|
source: ProfilePickSource,
|
||||||
|
): ProfilePick {
|
||||||
|
if (!selection || selection.chainageM === null) return { stationId: null };
|
||||||
|
if (selection.structureId) return { structureId: selection.structureId };
|
||||||
|
const at = selection.chainageM;
|
||||||
|
const irregular = source.irregular();
|
||||||
|
const exact = irregular.find((entry) => sameAt(entry.chainage_m, at));
|
||||||
|
if (exact) return { stationId: irregularStationId(exact.id) };
|
||||||
|
const card = source.sections().find((entry) => sameAt(entry.chainage_m, at));
|
||||||
|
if (card) return { stationId: card.station_id };
|
||||||
|
const own = nearestWithin(
|
||||||
|
irregular.filter((entry) => entry.origin === "pipe"),
|
||||||
|
at,
|
||||||
|
(entry) => entry.chainage_m,
|
||||||
|
);
|
||||||
|
if (own) return { stationId: irregularStationId(own.id) };
|
||||||
|
const target = graphTargetAt(null, at, source.pipes(), source.sections());
|
||||||
|
return "pipeChainageM" in target ? { structureId: pipeIdAt(target.pipeChainageM) } : target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void {
|
||||||
|
if ("structureId" in pick) target.setSelectedStructure(pick.structureId);
|
||||||
|
else target.setSelectedStation(pick.stationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 배수유역도 = 그 자리 관 마커 · 유역(없으면 풂) + 측점 마름모 — 재계산이 지운 뒤에도 다시 얹음(a3 · a6). */
|
||||||
|
export function paintMapPick(
|
||||||
|
map: NonNullable<ProfilePickTarget["drainage"]>,
|
||||||
|
selection: DesignSelection | null,
|
||||||
|
): void {
|
||||||
|
const at = selection?.chainageM ?? null;
|
||||||
|
map.selectPipeAtChainage(at, false);
|
||||||
|
map.markStation(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewerPickTarget {
|
||||||
|
markers: { selectStation: (stationId: string | null, notify?: boolean) => void };
|
||||||
|
structurePick: {
|
||||||
|
select: (
|
||||||
|
pick: { chainageM: number; kind: string; key?: string; structureId?: string | null } | null,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) ·
|
||||||
|
* 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */
|
||||||
|
export function viewerPickOf(
|
||||||
|
selection: (DesignSelection & { partKey?: string }) | null,
|
||||||
|
source: ProfilePickSource,
|
||||||
|
): {
|
||||||
|
stationId: string | null;
|
||||||
|
member: { chainageM: number; kind: string; key?: string; structureId?: string | null } | null;
|
||||||
|
} {
|
||||||
|
if (!selection || selection.chainageM === null) return { stationId: null, member: null };
|
||||||
|
const at = selection.chainageM;
|
||||||
|
const pick = profilePickOf(selection, source);
|
||||||
|
const own = selection.structureId
|
||||||
|
? nearestWithin(source.irregular(), at, (entry) => entry.chainage_m)
|
||||||
|
: undefined;
|
||||||
|
const stationId = "stationId" in pick ? pick.stationId : own ? irregularStationId(own.id) : null;
|
||||||
|
return {
|
||||||
|
stationId,
|
||||||
|
member: { chainageM: at, kind: "", key: selection.partKey, structureId: selection.structureId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paintViewerPick(
|
||||||
|
target: ViewerPickTarget,
|
||||||
|
pick: ReturnType<typeof viewerPickOf>,
|
||||||
|
): void {
|
||||||
|
target.markers.selectStation(pick.stationId, false); // 칠하기만 — 알림이 사슬을 다시 돌리지 않게
|
||||||
|
target.structurePick.select(pick.member);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 종단 보기(종단도 · 3D · 배수유역도)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이
|
||||||
|
* 바뀐 뒤 · 재계산 뒤 · 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠)
|
||||||
|
* 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. Esc(풀기 한 길)는 좌측 폼까지 닫음.
|
||||||
|
*/
|
||||||
|
export function bindProfilePick(
|
||||||
|
projectId: string | null,
|
||||||
|
target: {
|
||||||
|
profile: ProfilePickTarget;
|
||||||
|
viewer?: ViewerPickTarget;
|
||||||
|
form?: { hasSelection: () => boolean; selectById: (id: null) => void };
|
||||||
|
},
|
||||||
|
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
|
||||||
|
source: ProfilePickSource,
|
||||||
|
): { paint: () => void } {
|
||||||
|
const paint = (): void => {
|
||||||
|
if (!host.visible()) return;
|
||||||
|
const selection = readSelection(projectId);
|
||||||
|
paintProfilePick(target.profile, profilePickOf(selection, source));
|
||||||
|
if (target.viewer) paintViewerPick(target.viewer, viewerPickOf(selection, source));
|
||||||
|
if (target.profile.drainage) paintMapPick(target.profile.drainage, selection);
|
||||||
|
};
|
||||||
|
host.onDispose(
|
||||||
|
subscribeSelection(projectId, (_selection, from) => {
|
||||||
|
paint();
|
||||||
|
if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection())
|
||||||
|
target.form.selectById(null);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
bindEscapeRelease(projectId, host);
|
||||||
|
return { paint };
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/design/design_pipe_ids.ts
|
||||||
|
* 관 세션 id(323-5) — 관을 처음 볼 때 자리마다 붙이는 id · 옮겨도 그대로 · 저장 안 함(파일 모양 그대로).
|
||||||
|
*
|
||||||
|
* id = `pipe-s` + 차례(`pipe-s3`) — 머리 `pipe-` 로 관임을 가름(옛 자리 이름 `pipe-{누가거리}` 와 같은 머리) ·
|
||||||
|
* 누가거리는 id 에서 읽지 않음(`pipeChainageOf`). 장부 = 세션 `pipe-ids`(고름 `structure-pick` 과 같은 수명 ·
|
||||||
|
* 초안 비우기에 함께 비움). 자리 → id = 찾는 거리(`PIPE_MATCH_M`) 안 가장 가까운 것 · 없으면 새 차례 ·
|
||||||
|
* 옮김 = `movePipeId`(고름 따라가기 `followMovedPipe` 가 부름).
|
||||||
|
* 프로젝트 = 보기가 열 때 정함(`usePipeIds`) — 그리는 함수(카드 · 알약)가 프로젝트를 몰라도 같은 id.
|
||||||
|
* 프로젝트가 없으면(시험 · 도움 파일) 옛 자리 이름.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { readState, writeState } from "../b_page_state";
|
||||||
|
import { isNear, nearestWithin, readSelection, selectDesign } from "./design_selection";
|
||||||
|
|
||||||
|
/** 관 찾는 거리 — 정의는 `design_selection` 한 곳(배수 패널이 관 옮김과 함께 가져감). */
|
||||||
|
export { PIPE_MATCH_M } from "./design_selection";
|
||||||
|
|
||||||
|
interface Book {
|
||||||
|
next: number;
|
||||||
|
ids: Array<{ id: string; at: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active: string | null = null;
|
||||||
|
|
||||||
|
/** 지금 프로젝트 — 종단 · 횡단 보기가 열 때. */
|
||||||
|
export function usePipeIds(projectId: string | null): void {
|
||||||
|
active = projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookOf = (projectId: string): Book =>
|
||||||
|
readState<Book>("pipe-ids", projectId) ?? { next: 1, ids: [] };
|
||||||
|
|
||||||
|
/** 그 자리 관의 id — 장부에 없으면 새 차례로 적음. */
|
||||||
|
export function pipeIdAt(chainageM: number, projectId: string | null = active): string {
|
||||||
|
if (!projectId) return `pipe-${chainageM.toFixed(2)}`;
|
||||||
|
const book = bookOf(projectId);
|
||||||
|
const hit = nearestWithin(book.ids, chainageM, (entry) => entry.at);
|
||||||
|
if (hit) return hit.id;
|
||||||
|
const id = `pipe-s${book.next}`;
|
||||||
|
writeState(
|
||||||
|
"pipe-ids",
|
||||||
|
{ next: book.next + 1, ids: [...book.ids, { id, at: chainageM }] },
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** id 의 관 자리 — 장부 · 옛 자리 이름 순 · 관 id 가 아니면 null. */
|
||||||
|
export function pipeChainageOf(id: string, projectId: string | null = active): number | null {
|
||||||
|
if (!id.startsWith("pipe-")) return null;
|
||||||
|
const hit = projectId ? bookOf(projectId).ids.find((entry) => entry.id === id) : undefined;
|
||||||
|
if (hit) return hit.at;
|
||||||
|
const legacy = Number(id.slice("pipe-".length));
|
||||||
|
return Number.isFinite(legacy) ? legacy : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관을 옮김 — 옛 자리 id 가 새 자리로(새 자리에 남은 옛 기록은 뺌) · 옮긴 관의 id. */
|
||||||
|
export function movePipeId(fromM: number, toM: number, projectId: string | null = active): string {
|
||||||
|
if (!projectId) return `pipe-${toM.toFixed(2)}`;
|
||||||
|
const book = bookOf(projectId);
|
||||||
|
const moving = nearestWithin(book.ids, fromM, (entry) => entry.at);
|
||||||
|
if (!moving) return pipeIdAt(toM, projectId);
|
||||||
|
const stale = nearestWithin(
|
||||||
|
book.ids.filter((entry) => entry !== moving),
|
||||||
|
toM,
|
||||||
|
(entry) => entry.at,
|
||||||
|
);
|
||||||
|
const ids = book.ids
|
||||||
|
.filter((entry) => entry !== stale)
|
||||||
|
.map((entry) => (entry === moving ? { ...entry, at: toM } : entry));
|
||||||
|
writeState("pipe-ids", { next: book.next, ids }, projectId);
|
||||||
|
return moving.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */
|
||||||
|
export function withPipeIds<T extends { chainage_m: number }>(
|
||||||
|
pipes: ReadonlyArray<T>,
|
||||||
|
projectId: string | null = active,
|
||||||
|
): Array<T & { id: string }> {
|
||||||
|
return pipes.map((pipe) => ({ ...pipe, id: pipeIdAt(pipe.chainage_m, projectId) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 관을 옮김 — 관 세션 id 가 새 자리로(323-5 · id 그대로) · 그 관을 골라 둔 고름이면 자리만 새로(부재키
|
||||||
|
* 그대로 · 옛 자리 이름 id 면 세션 id 로). 다른 것을 골랐으면 그대로. */
|
||||||
|
export function followMovedPipe(projectId: string | null, fromM: number, toM: number): void {
|
||||||
|
const id = movePipeId(fromM, toM, projectId);
|
||||||
|
const picked = readSelection(projectId);
|
||||||
|
if (!picked || picked.chainageM === null || !isNear(picked.chainageM, fromM)) return;
|
||||||
|
const sid = picked.structureId;
|
||||||
|
if (sid !== null && !sid.startsWith("pipe-")) return;
|
||||||
|
selectDesign(
|
||||||
|
projectId,
|
||||||
|
{ chainageM: toM, structureId: sid === null ? null : id },
|
||||||
|
{ partKey: picked.partKey },
|
||||||
|
);
|
||||||
|
}
|
||||||
+109
-107
@@ -15,13 +15,10 @@ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drain
|
|||||||
import { readState, writeState } from "../b_page_state";
|
import { readState, writeState } from "../b_page_state";
|
||||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||||
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
|
||||||
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
|
|
||||||
import type {
|
import type {
|
||||||
StructureInstance,
|
StructureInstance,
|
||||||
StructureType,
|
StructureType,
|
||||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||||
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
|
|
||||||
import {
|
import {
|
||||||
hasLegacyAlignment,
|
hasLegacyAlignment,
|
||||||
readAlignment,
|
readAlignment,
|
||||||
@@ -45,7 +42,6 @@ import type {
|
|||||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||||
import {
|
import {
|
||||||
buildAlignment,
|
buildAlignment,
|
||||||
chainageKey,
|
|
||||||
emptyEdits,
|
emptyEdits,
|
||||||
toAlignmentBase,
|
toAlignmentBase,
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||||
@@ -56,11 +52,7 @@ import {
|
|||||||
structureIdKeeping,
|
structureIdKeeping,
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||||
import {
|
import type { RouteMassHaulContext } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||||
createRouteMassHaulPanel,
|
|
||||||
type MassHaulSummaryValues,
|
|
||||||
type RouteMassHaulContext,
|
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
|
||||||
import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance";
|
import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance";
|
||||||
import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights";
|
import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights";
|
||||||
import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
|
import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
|
||||||
@@ -77,15 +69,19 @@ import {
|
|||||||
createElevationHold,
|
createElevationHold,
|
||||||
createProfileDrag,
|
createProfileDrag,
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
|
||||||
|
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
||||||
import {
|
import {
|
||||||
planCurveWarning,
|
irregularGraphStations,
|
||||||
sampleAt,
|
maxChainageOf,
|
||||||
snapToMasterStations,
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
|
||||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||||
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
|
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||||
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
import { frameOf } from "@util/common_util_view_window";
|
||||||
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
|
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
|
||||||
|
import { noticeDroppedEdits } from "./design_save";
|
||||||
|
import { createProfileOverlays } from "./design_profile_overlays";
|
||||||
|
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
|
||||||
import "../../B06_Section/B06_Section_UI_Style.css";
|
import "../../B06_Section/B06_Section_UI_Style.css";
|
||||||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||||||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||||||
@@ -107,8 +103,8 @@ export interface DesignProfileContext {
|
|||||||
view: DesignView;
|
view: DesignView;
|
||||||
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */
|
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */
|
||||||
side?: DesignSideFactory;
|
side?: DesignSideFactory;
|
||||||
/** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */
|
/** 도구줄 [초기화] 바로 왼쪽에 놓는 단추([새 창]) — 다시 그려도 그 자리. */
|
||||||
sideTool?: (board: DesignSideBoard) => HTMLElement;
|
toolLead?: HTMLElement;
|
||||||
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
|
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
|
||||||
onSelectStation: (stationId: string | null) => void;
|
onSelectStation: (stationId: string | null) => void;
|
||||||
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
|
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
|
||||||
@@ -161,40 +157,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
if (drag.isSubDragging()) scheduleLightSync();
|
if (drag.isSubDragging()) scheduleLightSync();
|
||||||
else draw();
|
else draw();
|
||||||
};
|
};
|
||||||
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
|
// 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계).
|
||||||
* 유토곡선을 접어도 이 값은 남아야 한다. */
|
const { massHaul, tableOverlay } = createProfileOverlays({
|
||||||
const massBadge = createMassHaulBadge();
|
|
||||||
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
|
|
||||||
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
|
|
||||||
bodyWrap.append(massBadge.root);
|
|
||||||
let massHaulSummary: MassHaulSummaryValues | null = null;
|
|
||||||
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
|
||||||
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
|
|
||||||
const haulPrefetch = createHaulPlanPrefetch(
|
|
||||||
projectId,
|
projectId,
|
||||||
() => routeId ?? undefined,
|
routeId: () => routeId,
|
||||||
subPanelChanged,
|
bodyWrap,
|
||||||
);
|
body,
|
||||||
const massHaul = createRouteMassHaulPanel(
|
onChange: subPanelChanged,
|
||||||
subPanelChanged,
|
});
|
||||||
(summary) => {
|
|
||||||
const before = massHaulSummary;
|
|
||||||
massHaulSummary = summary;
|
|
||||||
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
|
|
||||||
},
|
|
||||||
haulPrefetch,
|
|
||||||
);
|
|
||||||
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
|
||||||
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
|
||||||
massHaul.attachScrollSync(body);
|
|
||||||
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
|
|
||||||
const tableOverlay = createProfileTableOverlay(subPanelChanged);
|
|
||||||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
|
||||||
tableOverlay.attachScrollSync(body);
|
|
||||||
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
|
|
||||||
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
|
|
||||||
body.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
||||||
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
|
|
||||||
const content = document.createElement("div");
|
const content = document.createElement("div");
|
||||||
content.className = "b05-route-profile__content";
|
content.className = "b05-route-profile__content";
|
||||||
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
|
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
|
||||||
@@ -249,6 +219,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
|
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
|
||||||
let stationDisplay = { station: 0, cumulative: 0 };
|
let stationDisplay = { station: 0, cumulative: 0 };
|
||||||
let base: AlignmentBase | null = null;
|
let base: AlignmentBase | null = null;
|
||||||
|
/** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */
|
||||||
|
let storedBase: AlignmentBase | null = null;
|
||||||
let alignment: ProfileAlignment | null = null;
|
let alignment: ProfileAlignment | null = null;
|
||||||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||||||
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
||||||
@@ -261,9 +233,16 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
|
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
|
||||||
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
|
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
|
||||||
const structureIdAtStation = (stationId: string | null): string | null =>
|
const structureIdAtStation = (stationId: string | null): string | null =>
|
||||||
structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail);
|
structureIdKeeping(
|
||||||
|
stationId,
|
||||||
|
selectedStructureId,
|
||||||
|
irregularStations,
|
||||||
|
structures,
|
||||||
|
detail,
|
||||||
|
structureTypes,
|
||||||
|
);
|
||||||
const stationIdAtStructure = (structureId: string | null): string | null =>
|
const stationIdAtStructure = (structureId: string | null): string | null =>
|
||||||
stationIdAtStructureOf(structureId, irregularStations, structures);
|
stationIdAtStructureOf(structureId, irregularStations, structures, structureTypes);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
|
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
|
||||||
@@ -288,7 +267,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
planCurve: planCurveWarning(alignment, detail),
|
planCurve: planCurveWarning(alignment, detail),
|
||||||
minCoverViolations,
|
minCoverViolations,
|
||||||
balanceBar,
|
balanceBar,
|
||||||
tools: tools.render(),
|
tools: withToolLead(tools.render()),
|
||||||
trailing,
|
trailing,
|
||||||
alignment,
|
alignment,
|
||||||
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
|
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
|
||||||
@@ -310,11 +289,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
|
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
|
||||||
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
|
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
|
||||||
const elevationHold = createElevationHold(body, () => draw());
|
const elevationHold = createElevationHold(body, () => draw());
|
||||||
// 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리.
|
|
||||||
const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null;
|
|
||||||
const trailing = document.createElement("div");
|
const trailing = document.createElement("div");
|
||||||
trailing.className = "b05-route-profile__trailing";
|
trailing.className = "b05-route-profile__trailing";
|
||||||
trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : []));
|
trailing.append(profileZoom.bar);
|
||||||
|
/** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */
|
||||||
|
const withToolLead = (wrap: HTMLElement): HTMLElement => {
|
||||||
|
if (ctx.toolLead) wrap.prepend(ctx.toolLead);
|
||||||
|
return wrap;
|
||||||
|
};
|
||||||
|
|
||||||
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
|
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
|
||||||
const { tools, history, handleToolPick, setListening } = createPanelTools({
|
const { tools, history, handleToolPick, setListening } = createPanelTools({
|
||||||
@@ -360,6 +342,33 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
history.record();
|
history.record();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리
|
||||||
|
* 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물
|
||||||
|
* 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */
|
||||||
|
function listed(): AlignmentBase | null {
|
||||||
|
if (!storedBase || !detail) return storedBase;
|
||||||
|
const server = detail.longitudinal.stations;
|
||||||
|
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
|
||||||
|
const list = oneStationList(server, planted);
|
||||||
|
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
|
||||||
|
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
|
||||||
|
const gone = server
|
||||||
|
.filter((station) => station.kind === "irregular" && !station.grid)
|
||||||
|
.map((station) => station.chainage_m)
|
||||||
|
.filter((chainage) => !after.has(chainage.toFixed(3)));
|
||||||
|
const born = list
|
||||||
|
.map((station) => station.chainage_m)
|
||||||
|
.filter((chainage) => !before.has(chainage.toFixed(3)));
|
||||||
|
return {
|
||||||
|
...storedBase,
|
||||||
|
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
|
||||||
|
stations: list.map((station) => ({
|
||||||
|
station_id: station.station_id,
|
||||||
|
chainage_m: station.chainage_m,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
||||||
* 실제 렌더링하도록 모은다.
|
* 실제 렌더링하도록 모은다.
|
||||||
@@ -372,7 +381,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
scheduleCrossPreview();
|
scheduleCrossPreview();
|
||||||
if (redrawPending) return;
|
if (redrawPending) return;
|
||||||
redrawPending = true;
|
redrawPending = true;
|
||||||
requestAnimationFrame(() => {
|
frameOf(root, () => {
|
||||||
redrawPending = false;
|
redrawPending = false;
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
@@ -418,6 +427,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
|
|
||||||
function draw(): void {
|
function draw(): void {
|
||||||
renderProfile({
|
renderProfile({
|
||||||
|
onStaleDesigns: crossPreview.scheduleIfStale, // 그린 뒤 생긴 낡음도 다시 셈(317-1)
|
||||||
body,
|
body,
|
||||||
callbacks,
|
callbacks,
|
||||||
massHaul,
|
massHaul,
|
||||||
@@ -453,7 +463,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
zoom: profileZoom.state,
|
zoom: profileZoom.state,
|
||||||
holdElevationRange: elevationHold.hold,
|
holdElevationRange: elevationHold.hold,
|
||||||
toolActive: () => tools.mode() !== "none",
|
toolActive: () => tools.mode() !== "none",
|
||||||
selectedRuns: () => tools.selectedRuns(),
|
tools,
|
||||||
stationIdAtStructure,
|
stationIdAtStructure,
|
||||||
redraw: draw,
|
redraw: draw,
|
||||||
setElevationWindowUpdater: (update) => {
|
setElevationWindowUpdater: (update) => {
|
||||||
@@ -462,46 +472,27 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
/** 다른 영역에서 고른 자리가 화면 밖이면 가운데로(칠한 뒤 · `design_profile_viewport`). */
|
||||||
const SCROLL_SETTLE_MS = 80;
|
const reveal = createSelectionReveal(body);
|
||||||
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
|
const revealPick = (): void =>
|
||||||
let settledScrollLeft = 0;
|
reveal(
|
||||||
let scrollSettleTimer = 0;
|
(selectedStationId ?? selectedStructureId)
|
||||||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
|
? `${selectedStationId}|${selectedStructureId}`
|
||||||
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
|
: null,
|
||||||
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
|
);
|
||||||
body.addEventListener("scroll", () => {
|
// 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계).
|
||||||
if (elevationHold.editing()) return;
|
const unbindViewport = bindProfileViewport({
|
||||||
const fitted = updateElevationWindow?.() ?? null;
|
body,
|
||||||
window.clearTimeout(scrollSettleTimer);
|
editing: () => elevationHold.editing(),
|
||||||
if (!needsFullRedraw(fitted)) return;
|
fitElevation: () => updateElevationWindow?.() ?? null,
|
||||||
scrollSettleTimer = window.setTimeout(() => {
|
draw: () => draw(),
|
||||||
if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
lastSize: () => ({ width: lastWidth, height: lastHeight }),
|
||||||
settledScrollLeft = body.scrollLeft;
|
scheduleLightSync,
|
||||||
draw();
|
scheduleDraw: () => drag.scheduleDraw(),
|
||||||
}, SCROLL_SETTLE_MS);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
|
||||||
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
|
||||||
attachWheelHorizontalScroll(body);
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (
|
|
||||||
body.clientWidth <= 0 ||
|
|
||||||
body.clientHeight <= 0 ||
|
|
||||||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
|
|
||||||
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
|
|
||||||
scheduleLightSync();
|
|
||||||
drag.scheduleDraw();
|
|
||||||
});
|
|
||||||
resizeObserver.observe(body);
|
|
||||||
|
|
||||||
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
|
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
|
||||||
bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw));
|
bindProfileCollapse(root, panelHandle, () => frameOf(root, draw));
|
||||||
|
|
||||||
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
|
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
|
||||||
function syncDrainageSpans(): void {
|
function syncDrainageSpans(): void {
|
||||||
@@ -529,7 +520,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
savedEdits = stored?.edits ?? emptyEdits();
|
savedEdits = stored?.edits ?? emptyEdits();
|
||||||
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||||||
}
|
}
|
||||||
base = stored ? toAlignmentBase(stored) : null;
|
storedBase = stored ? toAlignmentBase(stored) : null;
|
||||||
|
base = listed();
|
||||||
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
||||||
if (carried) store.replace(carried);
|
if (carried) store.replace(carried);
|
||||||
alignment = base ? buildAlignment(base, store.edits()) : null;
|
alignment = base ? buildAlignment(base, store.edits()) : null;
|
||||||
@@ -540,11 +532,10 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
nextDetail.balloon_offsets ?? undefined,
|
nextDetail.balloon_offsets ?? undefined,
|
||||||
);
|
);
|
||||||
draw();
|
draw();
|
||||||
requestAnimationFrame(draw);
|
frameOf(root, draw);
|
||||||
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
|
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
|
||||||
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
|
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
|
||||||
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
|
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
|
||||||
//
|
|
||||||
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
|
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
|
||||||
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
|
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
|
||||||
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
|
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
|
||||||
@@ -578,6 +569,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
|
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
|
||||||
selectedStructureId = structureIdAtStation(stationId);
|
selectedStructureId = structureIdAtStation(stationId);
|
||||||
draw();
|
draw();
|
||||||
|
revealPick();
|
||||||
},
|
},
|
||||||
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
|
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
|
||||||
setEnforceMinCover(enforce: boolean) {
|
setEnforceMinCover(enforce: boolean) {
|
||||||
@@ -592,9 +584,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
|
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
|
||||||
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
|
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
|
||||||
setGradeLimit(maxGradePct: number) {
|
setGradeLimit(maxGradePct: number) {
|
||||||
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
if (!base || !storedBase || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
||||||
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
|
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
|
||||||
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } };
|
const policy = { ...base.policy, max_grade_pct: maxGradePct };
|
||||||
|
storedBase = { ...storedBase, policy };
|
||||||
|
base = { ...base, policy };
|
||||||
alignment = buildAlignment(base, store.edits());
|
alignment = buildAlignment(base, store.edits());
|
||||||
draw();
|
draw();
|
||||||
},
|
},
|
||||||
@@ -604,7 +598,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
},
|
},
|
||||||
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
||||||
setIrregularStations(stations: IrregularStation[]) {
|
setIrregularStations(stations: IrregularStation[]) {
|
||||||
irregularStations = snapToMasterStations(alignment, stations);
|
irregularStations = stations;
|
||||||
|
base = listed();
|
||||||
|
if (base) alignment = buildAlignment(base, store.edits());
|
||||||
draw();
|
draw();
|
||||||
},
|
},
|
||||||
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
|
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
|
||||||
@@ -629,6 +625,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
|
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
|
||||||
selectedStationId = keep ? selectedStationId : own;
|
selectedStationId = keep ? selectedStationId : own;
|
||||||
draw();
|
draw();
|
||||||
|
revealPick();
|
||||||
},
|
},
|
||||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||||||
setStationDisplay(next: { station: number; cumulative: number }) {
|
setStationDisplay(next: { station: number; cumulative: number }) {
|
||||||
@@ -640,20 +637,24 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
|
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
|
||||||
*/
|
*/
|
||||||
resetStationEdit(chainageM: number, moveTo?: number) {
|
resetStationEdit(chainageM: number, moveTo?: number) {
|
||||||
const key = chainageKey(chainageM);
|
// 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272)
|
||||||
const edits = store.edits();
|
const edits = store.edits();
|
||||||
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
|
const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find(
|
||||||
store.resetStation(chainageM, moveTo);
|
(entry) => Math.abs(Number(entry) - chainageM) < 0.006,
|
||||||
|
);
|
||||||
|
if (key !== undefined) store.resetStation(Number(key), moveTo);
|
||||||
},
|
},
|
||||||
isDirty: () => store.dirty(),
|
isDirty: () => store.dirty(),
|
||||||
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
||||||
async save(): Promise<void> {
|
async save(): Promise<void> {
|
||||||
if (!routeId || !store.dirty()) return;
|
if (!routeId || !store.dirty()) return;
|
||||||
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
||||||
|
noticeDroppedEdits(saved);
|
||||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||||
if (next?.base_pvi?.length) {
|
if (next?.base_pvi?.length) {
|
||||||
base = toAlignmentBase(next);
|
storedBase = toAlignmentBase(next);
|
||||||
alignment = next;
|
base = listed();
|
||||||
|
alignment = buildAlignment(base!, store.edits());
|
||||||
if (detail) detail.longitudinal.profile_alignment = next;
|
if (detail) detail.longitudinal.profile_alignment = next;
|
||||||
}
|
}
|
||||||
store.markSaved();
|
store.markSaved();
|
||||||
@@ -662,6 +663,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
clear() {
|
clear() {
|
||||||
detail = null;
|
detail = null;
|
||||||
base = null;
|
base = null;
|
||||||
|
storedBase = null;
|
||||||
alignment = null;
|
alignment = null;
|
||||||
selectedStationId = null;
|
selectedStationId = null;
|
||||||
balanceBar.replaceChildren();
|
balanceBar.replaceChildren();
|
||||||
@@ -689,7 +691,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
|||||||
setListening(false);
|
setListening(false);
|
||||||
drag.dispose();
|
drag.dispose();
|
||||||
crossPreview.dispose();
|
crossPreview.dispose();
|
||||||
resizeObserver.disconnect();
|
unbindViewport();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/design/design_profile_free.ts
|
||||||
|
* 종단 [자유지정] 계산 — 범위 · 클릭 점 · 측점선 붙음 · 높이 변환 · 기울기 판정 · 적용(215-1).
|
||||||
|
*
|
||||||
|
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
|
||||||
|
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
|
||||||
|
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기.
|
||||||
|
*
|
||||||
|
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
|
||||||
|
* [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L
|
||||||
|
* (R = L / |대수차|). 양 끝 세로선의 라운드는 직선화처럼 그대로 둔다.
|
||||||
|
*
|
||||||
|
* 화면(`design_profile_free_view.ts`)은 클릭 · 마우스 · 키만 넘기고 여기 표시 자료를 그린다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AlignmentBase,
|
||||||
|
AlignmentEdits,
|
||||||
|
ProfileAlignment,
|
||||||
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||||
|
import {
|
||||||
|
buildAlignment,
|
||||||
|
chainageKey,
|
||||||
|
controlElevationAt,
|
||||||
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||||
|
import {
|
||||||
|
baseElevationAt,
|
||||||
|
defaultCurveLength,
|
||||||
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Straighten";
|
||||||
|
|
||||||
|
/** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */
|
||||||
|
const SAME_M = 1e-6;
|
||||||
|
/** 클릭 높이를 이 단위(m)로 끊는다. */
|
||||||
|
const ELEVATION_STEP_M = 0.01;
|
||||||
|
|
||||||
|
export interface FreeVertex {
|
||||||
|
chainage_m: number;
|
||||||
|
elevation_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** range = 범위 고르는 중 · armed = 시작 세로선을 기다림 · drawing = 점 찍는 중. */
|
||||||
|
export type FreePhase = "range" | "armed" | "drawing";
|
||||||
|
|
||||||
|
/** 그래프 세로 창 — 그릴 때 기준(`data-yw-*`)과 지금 걸린 변환. */
|
||||||
|
export interface ChartWindow {
|
||||||
|
center: number;
|
||||||
|
pxPerM: number;
|
||||||
|
zero: number;
|
||||||
|
scale: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 세로 창 변환 글(`translate(0 a) scale(1 b)`)을 읽는다 — 없으면 그릴 때 그대로. */
|
||||||
|
export function parseWindowTransform(text: string | null): { scale: number; offset: number } {
|
||||||
|
const num = "([-+0-9.eE]+)";
|
||||||
|
const match = new RegExp(`translate\\(\\s*0[ ,]+${num}\\s*\\)\\s*scale\\(\\s*1[ ,]+${num}`).exec(
|
||||||
|
text ?? "",
|
||||||
|
);
|
||||||
|
if (!match) return { scale: 1, offset: 0 };
|
||||||
|
const offset = Number(match[1]);
|
||||||
|
const scale = Number(match[2]);
|
||||||
|
return Number.isFinite(offset) && Number.isFinite(scale) && scale > 0
|
||||||
|
? { scale, offset }
|
||||||
|
: { scale: 1, offset: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 그래프 y(px) → 표고(m). 과장 배율은 `pxPerM` 에 들어 있다. */
|
||||||
|
export function elevationAtChartY(view: ChartWindow, y: number): number {
|
||||||
|
const baked = (y - view.offset) / view.scale;
|
||||||
|
return view.center + (view.zero - baked) / view.pxPerM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 표고(m) → 그래프 y(px). `elevationAtChartY` 의 역. */
|
||||||
|
export function chartYAtElevation(view: ChartWindow, elevationM: number): number {
|
||||||
|
return view.scale * (view.zero - (elevationM - view.center) * view.pxPerM) + view.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 가장 가까운 측점 세로선. 범위를 주면 그 안(양 끝 포함)에서만 고른다. */
|
||||||
|
export function snapStation(
|
||||||
|
stations: number[],
|
||||||
|
chainageM: number,
|
||||||
|
range: [number, number] | null = null,
|
||||||
|
): number | null {
|
||||||
|
let best: number | null = null;
|
||||||
|
for (const station of stations) {
|
||||||
|
if (range && (station < range[0] - SAME_M || station > range[1] + SAME_M)) continue;
|
||||||
|
if (best === null || Math.abs(station - chainageM) < Math.abs(best - chainageM)) {
|
||||||
|
best = station;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 두 점 사이 기울기(%). */
|
||||||
|
export function gradePct(from: FreeVertex, to: FreeVertex): number {
|
||||||
|
const span = to.chainage_m - from.chainage_m;
|
||||||
|
return Math.abs(span) > 1e-12 ? ((to.elevation_m - from.elevation_m) / span) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 임도 기준 최대 기울기를 넘는가 — 판정은 선형 계산의 위반 판정(`violations`)과 같은 식. */
|
||||||
|
export function overGrade(pct: number, maxPct: number): boolean {
|
||||||
|
return Math.abs(pct) > maxPct + 1e-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 점들을 잇는 꺾은선 위 표고(chainage 오름차순 꺾은선). */
|
||||||
|
function elevationOnPath(path: FreeVertex[], chainageM: number): number {
|
||||||
|
for (let index = 1; index < path.length; index += 1) {
|
||||||
|
const left = path[index - 1];
|
||||||
|
const right = path[index];
|
||||||
|
if (chainageM > right.chainage_m + SAME_M) continue;
|
||||||
|
const span = right.chainage_m - left.chainage_m;
|
||||||
|
const ratio = span > 1e-12 ? (chainageM - left.chainage_m) / span : 0;
|
||||||
|
return left.elevation_m + (right.elevation_m - left.elevation_m) * ratio;
|
||||||
|
}
|
||||||
|
return path[path.length - 1].elevation_m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를
|
||||||
|
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음).
|
||||||
|
*/
|
||||||
|
export function buildFreeEdits(
|
||||||
|
base: AlignmentBase,
|
||||||
|
edits: AlignmentEdits,
|
||||||
|
vertices: FreeVertex[],
|
||||||
|
): AlignmentEdits {
|
||||||
|
const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||||
|
if (path.length < 2) return edits;
|
||||||
|
const fromM = path[0].chainage_m;
|
||||||
|
const toM = path[path.length - 1].chainage_m;
|
||||||
|
if (toM - fromM <= SAME_M) return edits;
|
||||||
|
const inner = new Map<string, number>();
|
||||||
|
buildAlignment(base, edits).pvi.forEach((node) => {
|
||||||
|
if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) {
|
||||||
|
inner.set(chainageKey(node.chainage_m), node.chainage_m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
path
|
||||||
|
.slice(1, -1)
|
||||||
|
.forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m));
|
||||||
|
const offsets = { ...edits.station_offsets };
|
||||||
|
const radii = { ...edits.curve_radii };
|
||||||
|
inner.forEach((chainage, key) => {
|
||||||
|
const target = elevationOnPath(path, chainage);
|
||||||
|
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
|
||||||
|
delete radii[key];
|
||||||
|
});
|
||||||
|
for (let index = 1; index < path.length - 1; index += 1) {
|
||||||
|
const gradeIn = gradePct(path[index - 1], path[index]) / 100;
|
||||||
|
const gradeOut = gradePct(path[index], path[index + 1]) / 100;
|
||||||
|
const deltaGrade = Math.abs(gradeOut - gradeIn);
|
||||||
|
if (deltaGrade < 1e-9) continue;
|
||||||
|
radii[chainageKey(path[index].chainage_m)] = Number(
|
||||||
|
(defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ...edits, station_offsets: offsets, curve_radii: radii };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreeSegment {
|
||||||
|
from: FreeVertex;
|
||||||
|
to: FreeVertex;
|
||||||
|
grade_pct: number;
|
||||||
|
over: boolean;
|
||||||
|
/** 마지막 점 → 마우스 자리(아직 안 찍음). */
|
||||||
|
preview: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 오버레이 표시 자료 — 화면은 이것만 그린다. */
|
||||||
|
export interface FreeMarks {
|
||||||
|
/** 고른 범위(강조 띠) — 둘 다 고르기 전엔 null. */
|
||||||
|
band: [number, number] | null;
|
||||||
|
/** 세로 강조선 chainage(범위 첫 측점 또는 범위 양 끝). */
|
||||||
|
edges: number[];
|
||||||
|
segments: FreeSegment[];
|
||||||
|
/** 찍힌 점(시작 점 포함). */
|
||||||
|
dots: FreeVertex[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreeDrawDeps {
|
||||||
|
base: () => AlignmentBase | null;
|
||||||
|
alignment: () => ProfileAlignment | null;
|
||||||
|
edits: () => AlignmentEdits;
|
||||||
|
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */
|
||||||
|
apply: (next: AlignmentEdits) => void;
|
||||||
|
/** 모드를 끈다(적용 뒤 · Esc). */
|
||||||
|
close: () => void;
|
||||||
|
/** 단계 · 안내가 바뀜. */
|
||||||
|
changed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreeDraw {
|
||||||
|
phase: () => FreePhase;
|
||||||
|
reset: () => void;
|
||||||
|
/** 그래프 클릭 — 날 chainage · 표고와 그래프 측점선 목록. 먹었으면 true. */
|
||||||
|
click: (chainageM: number, elevationM: number, stations: number[]) => boolean;
|
||||||
|
/** 마우스 자리 — 미리 보기 대상. 그래프 밖이면 null. */
|
||||||
|
hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void;
|
||||||
|
/** Enter · Escape · Backspace — 먹었으면 true. */
|
||||||
|
key: (key: string) => boolean;
|
||||||
|
marks: () => FreeMarks;
|
||||||
|
hint: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||||
|
let phase: FreePhase = "range";
|
||||||
|
let pending: number | null = null;
|
||||||
|
let range: [number, number] | null = null;
|
||||||
|
let anchor: number | null = null;
|
||||||
|
let points: FreeVertex[] = [];
|
||||||
|
let target: FreeVertex | null = null;
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
phase = "range";
|
||||||
|
pending = null;
|
||||||
|
range = null;
|
||||||
|
anchor = null;
|
||||||
|
points = [];
|
||||||
|
target = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */
|
||||||
|
function heightAt(chainageM: number): number | null {
|
||||||
|
const alignment = deps.alignment();
|
||||||
|
return alignment ? controlElevationAt(alignment, chainageM) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishM = (): number | null =>
|
||||||
|
range && anchor !== null ? (anchor === range[0] ? range[1] : range[0]) : null;
|
||||||
|
const same = (a: number | null, b: number | null): boolean =>
|
||||||
|
a !== null && b !== null && Math.abs(a - b) < SAME_M;
|
||||||
|
|
||||||
|
/** 지나온 점(시작 포함) — 마지막 것이 다음 선의 출발점. */
|
||||||
|
function path(): FreeVertex[] {
|
||||||
|
if (anchor === null) return [];
|
||||||
|
const z = heightAt(anchor);
|
||||||
|
return z === null ? [] : [{ chainage_m: anchor, elevation_m: z }, ...points];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 클릭 · 마우스 자리 → 다음 점 후보. 끝 세로선이면 그 계획고, 뒤로 가면 null. */
|
||||||
|
function candidate(chainageM: number, elevationM: number, stations: number[]): FreeVertex | null {
|
||||||
|
if (phase !== "drawing" || !range) return null;
|
||||||
|
const station = snapStation(stations, chainageM, range);
|
||||||
|
const end = finishM();
|
||||||
|
if (station === null || end === null) return null;
|
||||||
|
if (same(station, end)) {
|
||||||
|
const z = heightAt(end);
|
||||||
|
return z === null ? null : { chainage_m: end, elevation_m: z };
|
||||||
|
}
|
||||||
|
const walked = path();
|
||||||
|
const last = walked[walked.length - 1];
|
||||||
|
if (!last || (station - last.chainage_m) * Math.sign(end - last.chainage_m) <= SAME_M) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const z = Math.round(elevationM / ELEVATION_STEP_M) * ELEVATION_STEP_M;
|
||||||
|
return { chainage_m: station, elevation_m: Number(z.toFixed(3)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(): void {
|
||||||
|
const base = deps.base();
|
||||||
|
const end = finishM();
|
||||||
|
const z = end === null ? null : heightAt(end);
|
||||||
|
if (!base || end === null || z === null) return;
|
||||||
|
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
|
||||||
|
const next = buildFreeEdits(base, deps.edits(), vertices);
|
||||||
|
reset();
|
||||||
|
deps.apply(next);
|
||||||
|
deps.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase: () => phase,
|
||||||
|
reset,
|
||||||
|
click(chainageM, elevationM, stations) {
|
||||||
|
if (phase === "range") {
|
||||||
|
const station = snapStation(stations, chainageM);
|
||||||
|
if (station === null) return true;
|
||||||
|
if (pending === null || same(pending, station)) pending = station;
|
||||||
|
else {
|
||||||
|
range = [Math.min(pending, station), Math.max(pending, station)];
|
||||||
|
pending = null;
|
||||||
|
phase = "armed";
|
||||||
|
}
|
||||||
|
} else if (phase === "armed" && range) {
|
||||||
|
const station = snapStation(stations, chainageM, range);
|
||||||
|
if (same(station, range[0]) || same(station, range[1])) {
|
||||||
|
anchor = station;
|
||||||
|
points = [];
|
||||||
|
phase = "drawing";
|
||||||
|
}
|
||||||
|
} else if (phase === "drawing") {
|
||||||
|
const next = candidate(chainageM, elevationM, stations);
|
||||||
|
if (!next) return true;
|
||||||
|
if (same(next.chainage_m, finishM())) {
|
||||||
|
finish();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
points.push(next);
|
||||||
|
}
|
||||||
|
target = null;
|
||||||
|
deps.changed();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
hover(pointer) {
|
||||||
|
target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null;
|
||||||
|
},
|
||||||
|
key(key) {
|
||||||
|
if (key === "Escape") {
|
||||||
|
reset();
|
||||||
|
deps.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key === "Enter") {
|
||||||
|
if (phase !== "drawing") return false;
|
||||||
|
finish();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key === "Backspace") {
|
||||||
|
if (phase !== "drawing") return false;
|
||||||
|
if (points.length) points.pop();
|
||||||
|
else {
|
||||||
|
anchor = null;
|
||||||
|
phase = "armed";
|
||||||
|
}
|
||||||
|
target = null;
|
||||||
|
deps.changed();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
marks() {
|
||||||
|
const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY;
|
||||||
|
const dots = path();
|
||||||
|
const line = target ? [...dots, target] : dots;
|
||||||
|
const segments: FreeSegment[] = [];
|
||||||
|
for (let index = 1; index < line.length; index += 1) {
|
||||||
|
const grade = gradePct(line[index - 1], line[index]);
|
||||||
|
segments.push({
|
||||||
|
from: line[index - 1],
|
||||||
|
to: line[index],
|
||||||
|
grade_pct: grade,
|
||||||
|
over: overGrade(grade, maxPct),
|
||||||
|
preview: target !== null && index === line.length - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
band: range,
|
||||||
|
edges: range ? [...range] : pending !== null ? [pending] : [],
|
||||||
|
segments,
|
||||||
|
dots,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hint() {
|
||||||
|
if (phase === "range") {
|
||||||
|
return pending === null
|
||||||
|
? "자유지정 — 범위 첫 세로 측점선을 고르세요"
|
||||||
|
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
|
||||||
|
}
|
||||||
|
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
|
||||||
|
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/design/design_profile_free_view.ts
|
||||||
|
* 종단 [자유지정] 화면 — 그래프 클릭 · 마우스 · 오버레이(범위 강조 · 점선 · 기울기 글)(215-1).
|
||||||
|
*
|
||||||
|
* 계산 · 상태는 `design_profile_free.ts` 몫. 여기서는 마우스 자리를 (chainage, 표고)로 바꿔
|
||||||
|
* 넘기고 돌려받은 표시 자료를 그린다. 표고 변환은 종단 그래프 세로 창(`data-yw-*` + 지금 걸린
|
||||||
|
* 변환)을 그대로 되짚는다 — 스크롤로 창이 바뀌어도 같은 식이다.
|
||||||
|
*
|
||||||
|
* 그래프는 그릴 때마다 새로 만들어지므로 그릴 때마다 `mount` 를 다시 부른다.
|
||||||
|
* 자유지정 중엔 그래프 클릭 · 누르기를 여기서 먹는다(측점 고르기 · 구조물 끌기로 새지 않게).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../../B06_Section/B06_Section_UI_Longitudinal";
|
||||||
|
import {
|
||||||
|
chartYAtElevation,
|
||||||
|
elevationAtChartY,
|
||||||
|
parseWindowTransform,
|
||||||
|
type ChartWindow,
|
||||||
|
type FreeDraw,
|
||||||
|
} from "./design_profile_free";
|
||||||
|
|
||||||
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
/** 종단 그래프 한 벌의 가로 매핑 — 렌더러가 그릴 때마다 넘긴다. */
|
||||||
|
export interface FreeChartGeom {
|
||||||
|
/** 그래프 래퍼(`b05-profile__chart`). */
|
||||||
|
chart: HTMLElement;
|
||||||
|
/** 가로 스크롤러 — 스크롤하면 세로 창이 바뀌어 다시 그린다. */
|
||||||
|
scroller: HTMLElement;
|
||||||
|
x: (chainageM: number) => number;
|
||||||
|
toChainage: (px: number) => number;
|
||||||
|
widthPx: number;
|
||||||
|
heightPx: number;
|
||||||
|
/** 그래프 세로 측점선 chainage(정규 + 구조물). */
|
||||||
|
stations: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreeView {
|
||||||
|
/** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */
|
||||||
|
mount: (geom: FreeChartGeom | null, active: boolean) => void;
|
||||||
|
redraw: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgEl(tag: string, attrs: Record<string, string | number>): SVGElement {
|
||||||
|
const element = document.createElementNS(SVG_NS, tag);
|
||||||
|
Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value)));
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지금 그래프의 세로 창 — 자동 창 SVG 가 없으면 null. */
|
||||||
|
function readWindow(chart: HTMLElement): { view: ChartWindow; top: number } | null {
|
||||||
|
const svg = chart.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
|
||||||
|
if (!svg) return null;
|
||||||
|
const read = (name: string): number => Number(svg.getAttribute(name));
|
||||||
|
const layer = svg.querySelector(`.${Y_WINDOW_CLASS}`);
|
||||||
|
const view: ChartWindow = {
|
||||||
|
center: read(Y_WINDOW_ATTRS.center),
|
||||||
|
pxPerM: read(Y_WINDOW_ATTRS.pxPerM),
|
||||||
|
zero: read(Y_WINDOW_ATTRS.zero),
|
||||||
|
...parseWindowTransform(layer?.getAttribute("transform") ?? null),
|
||||||
|
};
|
||||||
|
if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 그래프 SVG 가 래퍼 위쪽에서 떨어져 있으면 그만큼 오버레이 y 를 맞춘다.
|
||||||
|
const top = svg.getBoundingClientRect().top - chart.getBoundingClientRect().top;
|
||||||
|
return { view, top };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFreeView(free: FreeDraw): FreeView {
|
||||||
|
let geom: FreeChartGeom | null = null;
|
||||||
|
let active = false;
|
||||||
|
let overlay: SVGSVGElement | null = null;
|
||||||
|
let listeners: AbortController | null = null;
|
||||||
|
|
||||||
|
/** 마우스 자리 → (chainage, 표고). 그래프 밖이면 null. */
|
||||||
|
function pointerAt(event: MouseEvent) {
|
||||||
|
if (!geom) return null;
|
||||||
|
const win = readWindow(geom.chart);
|
||||||
|
if (!win) return null;
|
||||||
|
const rect = geom.chart.getBoundingClientRect();
|
||||||
|
const chainageM = geom.toChainage(event.clientX - rect.left);
|
||||||
|
if (!Number.isFinite(chainageM)) return null;
|
||||||
|
const elevationM = elevationAtChartY(win.view, event.clientY - rect.top - win.top);
|
||||||
|
return { chainageM, elevationM, stations: geom.stations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function redraw(): void {
|
||||||
|
overlay?.remove();
|
||||||
|
overlay = null;
|
||||||
|
if (!geom || !active) return;
|
||||||
|
const win = readWindow(geom.chart);
|
||||||
|
if (!win) return;
|
||||||
|
const { x, widthPx, heightPx } = geom;
|
||||||
|
const y = (elevationM: number): number => chartYAtElevation(win.view, elevationM) + win.top;
|
||||||
|
const marks = free.marks();
|
||||||
|
const svg = svgEl("svg", {
|
||||||
|
class: "b05-profile-freemark",
|
||||||
|
width: widthPx,
|
||||||
|
height: heightPx,
|
||||||
|
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||||||
|
}) as SVGSVGElement;
|
||||||
|
if (marks.band) {
|
||||||
|
const [left, right] = marks.band.map(x);
|
||||||
|
svg.append(
|
||||||
|
svgEl("rect", {
|
||||||
|
class: "b05-profile-freemark__band",
|
||||||
|
x: left,
|
||||||
|
y: 0,
|
||||||
|
width: Math.max(0, right - left),
|
||||||
|
height: heightPx,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
marks.edges.forEach((chainage) =>
|
||||||
|
svg.append(
|
||||||
|
svgEl("line", {
|
||||||
|
class: "b05-profile-freemark__edge",
|
||||||
|
x1: x(chainage),
|
||||||
|
x2: x(chainage),
|
||||||
|
y1: 0,
|
||||||
|
y2: heightPx,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
marks.segments.forEach((segment) => {
|
||||||
|
const x1 = x(segment.from.chainage_m);
|
||||||
|
const x2 = x(segment.to.chainage_m);
|
||||||
|
const y1 = y(segment.from.elevation_m);
|
||||||
|
const y2 = y(segment.to.elevation_m);
|
||||||
|
const over = segment.over ? " is-over" : "";
|
||||||
|
svg.append(
|
||||||
|
svgEl("line", {
|
||||||
|
class: `b05-profile-freemark__line${over}${segment.preview ? " is-preview" : ""}`,
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const label = svgEl("text", {
|
||||||
|
class: `b05-profile-freemark__grade${over}`,
|
||||||
|
x: (x1 + x2) / 2,
|
||||||
|
y: (y1 + y2) / 2 - 6,
|
||||||
|
"text-anchor": "middle",
|
||||||
|
});
|
||||||
|
label.textContent = `${segment.grade_pct.toFixed(1)}%`;
|
||||||
|
svg.append(label);
|
||||||
|
});
|
||||||
|
marks.dots.forEach((dot) =>
|
||||||
|
svg.append(
|
||||||
|
svgEl("circle", {
|
||||||
|
class: "b05-profile-freemark__dot",
|
||||||
|
cx: x(dot.chainage_m),
|
||||||
|
cy: y(dot.elevation_m),
|
||||||
|
r: 3.5,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
overlay = svg;
|
||||||
|
geom.chart.append(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(next: FreeChartGeom | null, on: boolean): void {
|
||||||
|
listeners?.abort();
|
||||||
|
listeners = null;
|
||||||
|
if (next) geom = next;
|
||||||
|
active = on;
|
||||||
|
if (!on) free.reset();
|
||||||
|
if (!geom || !on || !geom.chart.isConnected) {
|
||||||
|
overlay?.remove();
|
||||||
|
overlay = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listeners = new AbortController();
|
||||||
|
const { signal } = listeners;
|
||||||
|
const chart = geom.chart;
|
||||||
|
const swallow = (event: Event): void => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
};
|
||||||
|
// 누르기는 삼킴 — 구조물 측점선 끌기 · 계획고 버튼 길게 누르기가 시작되지 않게.
|
||||||
|
chart.addEventListener("pointerdown", swallow, { capture: true, signal });
|
||||||
|
chart.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
swallow(event);
|
||||||
|
event.preventDefault();
|
||||||
|
const pointer = pointerAt(event);
|
||||||
|
if (pointer) free.click(pointer.chainageM, pointer.elevationM, pointer.stations);
|
||||||
|
free.hover(pointerAt(event));
|
||||||
|
redraw();
|
||||||
|
},
|
||||||
|
{ capture: true, signal },
|
||||||
|
);
|
||||||
|
chart.addEventListener(
|
||||||
|
"mousemove",
|
||||||
|
(event) => {
|
||||||
|
free.hover(pointerAt(event));
|
||||||
|
redraw();
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
chart.addEventListener(
|
||||||
|
"mouseleave",
|
||||||
|
() => {
|
||||||
|
free.hover(null);
|
||||||
|
redraw();
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
geom.scroller.addEventListener("scroll", redraw, { signal, passive: true });
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mount, redraw };
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* design_profile_overlays.ts
|
||||||
|
* 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌).
|
||||||
|
* 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { attachMassHaulBadgeDrag, createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||||
|
import { readStateRaw, writeStateRaw } from "../b_page_state";
|
||||||
|
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
|
||||||
|
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
|
||||||
|
import {
|
||||||
|
createRouteMassHaulPanel,
|
||||||
|
type MassHaulSummaryValues,
|
||||||
|
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||||
|
|
||||||
|
export interface ProfileOverlaysDeps {
|
||||||
|
projectId: string;
|
||||||
|
routeId: () => number | null;
|
||||||
|
/** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */
|
||||||
|
bodyWrap: HTMLElement;
|
||||||
|
/** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */
|
||||||
|
body: HTMLElement;
|
||||||
|
/** 서브패널이 바뀜(펼침 · 범례 · 리사이저 · 유토 배분 도착). */
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProfileOverlays(deps: ProfileOverlaysDeps) {
|
||||||
|
const { bodyWrap, body, onChange } = deps;
|
||||||
|
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
|
||||||
|
* 유토곡선을 접어도 이 값은 남아야 한다. */
|
||||||
|
const massBadge = createMassHaulBadge();
|
||||||
|
// 그래프 위 도구줄 · 측점 ▲ 줄(바닥 47px) 아래로 내려 겹치지 않게 한다(289).
|
||||||
|
massBadge.root.style.setProperty("--mass-haul-badge-top", "52px");
|
||||||
|
bodyWrap.append(massBadge.root);
|
||||||
|
// 그래프 안 끌어 옮김(309-2) — 자리는 화면 설정 한 칸(B05 · B06 같은 값)
|
||||||
|
attachMassHaulBadgeDrag(massBadge.root, body, {
|
||||||
|
read: () => readStateRaw("mass-haul-badge-pos"),
|
||||||
|
write: (value) => writeStateRaw("mass-haul-badge-pos", value),
|
||||||
|
});
|
||||||
|
let massHaulSummary: MassHaulSummaryValues | null = null;
|
||||||
|
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
||||||
|
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
|
||||||
|
const haulPrefetch = createHaulPlanPrefetch(
|
||||||
|
deps.projectId,
|
||||||
|
() => deps.routeId() ?? undefined,
|
||||||
|
onChange,
|
||||||
|
);
|
||||||
|
const massHaul = createRouteMassHaulPanel(
|
||||||
|
onChange,
|
||||||
|
(summary) => {
|
||||||
|
const before = massHaulSummary;
|
||||||
|
massHaulSummary = summary;
|
||||||
|
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
|
||||||
|
},
|
||||||
|
haulPrefetch,
|
||||||
|
);
|
||||||
|
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
||||||
|
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||||
|
massHaul.attachScrollSync(body);
|
||||||
|
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
|
||||||
|
const tableOverlay = createProfileTableOverlay(onChange);
|
||||||
|
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||||||
|
tableOverlay.attachScrollSync(body);
|
||||||
|
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
|
||||||
|
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
|
||||||
|
body.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||||
|
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||||
|
return { massHaul, tableOverlay };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* A00_Common/design/design_profile_popout.ts
|
* A00_Common/design/design_profile_popout.ts
|
||||||
* 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35).
|
* 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35).
|
||||||
|
* 유토곡선 · 배수유역 판은 따로 또 뗀다(계획서 325-3) — 같은 몸통(`attachPopout`), 창 이름만 다름.
|
||||||
*
|
*
|
||||||
* 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다.
|
* 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다.
|
||||||
* 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난
|
* 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난
|
||||||
@@ -13,30 +14,39 @@ import type { DesignProfile } from "./design_profile";
|
|||||||
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
|
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
|
||||||
const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"];
|
const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"];
|
||||||
|
|
||||||
/** 종단 도구줄 [배수유역도] 단추 뒤에 [새 창] 단추를 붙인다 — 두 보기가 같은 한 벌을 쓴다. */
|
/** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */
|
||||||
export function attachProfilePopout(
|
const viewOfNode = (node: Node): Window => node.ownerDocument?.defaultView ?? window;
|
||||||
profile: DesignProfile,
|
|
||||||
slot: HTMLElement,
|
/** 조각 하나를 새 창으로 떼는 단추 — 창 이름은 조각마다 달라야 함(같으면 앞 창 body 를 덮어 조각이 사라짐). */
|
||||||
sideTool: HTMLElement | null,
|
interface PopoutSpec {
|
||||||
): void {
|
piece: HTMLElement;
|
||||||
const { root } = profile;
|
button: HTMLButtonElement;
|
||||||
|
name: string;
|
||||||
|
features: string;
|
||||||
|
docTitle: string;
|
||||||
|
/** 조각을 제자리로 돌림. */
|
||||||
|
home: () => void;
|
||||||
|
/** 뗐다 · 붙였다(조각이 옮겨 간 뒤). */
|
||||||
|
onMove?: (popped: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 조각을 새 창으로 떼고 붙이는 몸통 — 새 창은 단추가 놓인 창에서 엶(종단 새 창 안에서 다시 떼기). */
|
||||||
|
function attachPopout(profile: DesignProfile, spec: PopoutSpec): void {
|
||||||
|
const { piece, button } = spec;
|
||||||
let popup: Window | null = null;
|
let popup: Window | null = null;
|
||||||
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
|
button.textContent = "창 분할";
|
||||||
button.textContent = "새 창";
|
|
||||||
button.title = "종단 하단 패널을 브라우저 새 창으로 띄움";
|
|
||||||
button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open()));
|
button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open()));
|
||||||
sideTool?.after(button);
|
|
||||||
|
|
||||||
const restore = (): void => {
|
const restore = (): void => {
|
||||||
if (!root.classList.contains("is-popout")) return;
|
if (!piece.classList.contains("is-popout")) return;
|
||||||
root.classList.remove("is-popout");
|
piece.classList.remove("is-popout");
|
||||||
button.textContent = "새 창";
|
button.textContent = "창 분할";
|
||||||
slot.append(root);
|
spec.home();
|
||||||
popup = null;
|
popup = null;
|
||||||
requestAnimationFrame(profile.redraw);
|
spec.onMove?.(false);
|
||||||
|
viewOfNode(piece).requestAnimationFrame(profile.redraw);
|
||||||
};
|
};
|
||||||
const closePopup = (): void => {
|
const closePopup = (): void => {
|
||||||
popup?.close();
|
popup?.close();
|
||||||
@@ -51,28 +61,31 @@ export function attachProfilePopout(
|
|||||||
};
|
};
|
||||||
|
|
||||||
function open(): void {
|
function open(): void {
|
||||||
const next = window.open("", "aislo-profile-popout", "popup,width=1400,height=720");
|
const opener = viewOfNode(button);
|
||||||
|
const next = opener.open("", spec.name, spec.features);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error");
|
showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
popup = next;
|
popup = next;
|
||||||
|
const source = opener.document;
|
||||||
const doc = next.document;
|
const doc = next.document;
|
||||||
doc.title = "종단 — Aislo";
|
doc.title = spec.docTitle;
|
||||||
// 스타일 · 테마 — 메인 창 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다.
|
// 스타일 · 테마 — 단추가 놓인 창의 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다.
|
||||||
doc.head.replaceChildren(
|
doc.head.replaceChildren(
|
||||||
...Array.from(document.querySelectorAll('link[rel="stylesheet"], style'), (node) =>
|
...Array.from(source.querySelectorAll('link[rel="stylesheet"], style'), (node) =>
|
||||||
doc.importNode(node, true),
|
doc.importNode(node, true),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
Array.from(document.documentElement.attributes).forEach((attr) =>
|
Array.from(source.documentElement.attributes).forEach((attr) =>
|
||||||
doc.documentElement.setAttribute(attr.name, attr.value),
|
doc.documentElement.setAttribute(attr.name, attr.value),
|
||||||
);
|
);
|
||||||
doc.body.className = document.body.className;
|
doc.body.className = source.body.className;
|
||||||
doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`;
|
doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${opener.getComputedStyle(source.body).backgroundColor}`;
|
||||||
root.classList.add("is-popout");
|
piece.classList.add("is-popout");
|
||||||
button.textContent = "제자리로";
|
button.textContent = "창 결합";
|
||||||
doc.body.replaceChildren(root);
|
doc.body.replaceChildren(piece);
|
||||||
|
spec.onMove?.(true);
|
||||||
|
|
||||||
// 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정).
|
// 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정).
|
||||||
FORWARD_TYPES.forEach((type) =>
|
FORWARD_TYPES.forEach((type) =>
|
||||||
@@ -95,7 +108,75 @@ export function attachProfilePopout(
|
|||||||
new (next as Window & typeof globalThis).ResizeObserver(() => {
|
new (next as Window & typeof globalThis).ResizeObserver(() => {
|
||||||
next.cancelAnimationFrame(pending);
|
next.cancelAnimationFrame(pending);
|
||||||
pending = next.requestAnimationFrame(profile.redraw);
|
pending = next.requestAnimationFrame(profile.redraw);
|
||||||
}).observe(root);
|
}).observe(piece);
|
||||||
next.addEventListener("pagehide", restore);
|
next.addEventListener("pagehide", restore);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */
|
||||||
|
export function attachProfilePopout(
|
||||||
|
profile: DesignProfile,
|
||||||
|
slot: HTMLElement,
|
||||||
|
button: HTMLButtonElement,
|
||||||
|
): void {
|
||||||
|
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
|
||||||
|
button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합";
|
||||||
|
attachPopout(profile, {
|
||||||
|
piece: profile.root,
|
||||||
|
button,
|
||||||
|
name: "aislo-profile-popout",
|
||||||
|
features: "popup,width=1400,height=720",
|
||||||
|
docTitle: "종단 — Aislo",
|
||||||
|
home: () => slot.append(profile.root),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */
|
||||||
|
export function attachSubPanelPopouts(profile: DesignProfile): void {
|
||||||
|
const subButton = (title: string): HTMLButtonElement => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "ui-btn ui-btn--ghost b05-subpanel__popout";
|
||||||
|
button.title = title;
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
const massOverlay = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-overlay");
|
||||||
|
const massHandle = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-handle");
|
||||||
|
if (massOverlay && massHandle) {
|
||||||
|
const button = subButton("유토곡선 창 분할");
|
||||||
|
massOverlay.append(button);
|
||||||
|
attachPopout(profile, {
|
||||||
|
piece: massOverlay,
|
||||||
|
button,
|
||||||
|
name: "aislo-masshaul-popout",
|
||||||
|
features: "popup,width=1400,height=420",
|
||||||
|
docTitle: "유토곡선 — Aislo",
|
||||||
|
// 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판).
|
||||||
|
home: () => massHandle.before(massOverlay),
|
||||||
|
onMove: (popped) => {
|
||||||
|
// 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀).
|
||||||
|
massHandle.hidden = popped;
|
||||||
|
massOverlay.style.height = "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const drainage = profile.side;
|
||||||
|
const header = drainage?.root.querySelector<HTMLElement>(".b05-drainage__header");
|
||||||
|
if (drainage && header) {
|
||||||
|
const button = subButton("배수유역 창 분할");
|
||||||
|
header.prepend(button);
|
||||||
|
const content = drainage.root.parentElement;
|
||||||
|
attachPopout(profile, {
|
||||||
|
piece: drainage.root,
|
||||||
|
button,
|
||||||
|
name: "aislo-drainage-popout",
|
||||||
|
features: "popup,width=1000,height=720",
|
||||||
|
docTitle: "배수유역 — Aislo",
|
||||||
|
// 제자리 = 종단 content 끝(종단 본문 오른쪽).
|
||||||
|
home: () => content?.append(drainage.root),
|
||||||
|
// 지도 크기 관찰을 놓인 창 것으로(325-1).
|
||||||
|
onMove: () => drainage.rehome(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* design_profile_viewport.ts
|
||||||
|
* 종단 스크롤러의 가로 스크롤 · 휠 · 크기 바뀜 듣개(213-3 · 700줄 한계로 뗌).
|
||||||
|
* 다시 그리기 · 높이 동기화는 종단도가 넘긴 함수를 부른다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
|
||||||
|
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||||
|
import { viewOf } from "@util/common_util_view_window";
|
||||||
|
|
||||||
|
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||||
|
const SCROLL_SETTLE_MS = 80;
|
||||||
|
|
||||||
|
export interface ProfileViewportDeps {
|
||||||
|
body: HTMLElement;
|
||||||
|
/** ▲▼ 계획고 편집으로 Y 축을 잡고 있는 중. */
|
||||||
|
editing: () => boolean;
|
||||||
|
/** 세로 창 갱신기 — 그릴 때마다 렌더러가 새로 준다(없으면 null). */
|
||||||
|
fitElevation: () => ElevationWindowResult | null;
|
||||||
|
draw: () => void;
|
||||||
|
/** 마지막으로 그린 본문 크기 — 같으면 크기 바뀜을 무시한다. */
|
||||||
|
lastSize: () => { width: number; height: number };
|
||||||
|
scheduleLightSync: () => void;
|
||||||
|
scheduleDraw: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 듣개를 붙이고 떼는 함수를 돌려준다. */
|
||||||
|
export function bindProfileViewport(deps: ProfileViewportDeps): () => void {
|
||||||
|
const { body } = deps;
|
||||||
|
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
|
||||||
|
let settledScrollLeft = 0;
|
||||||
|
let scrollSettleTimer = 0;
|
||||||
|
/** 시계를 건 창 — 새 창으로 빼면 그 창 시계를 쓴다(가려진 메인 창 시계는 늦게 돎 · 230-1). */
|
||||||
|
let timerView: Window = window;
|
||||||
|
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
|
||||||
|
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
|
||||||
|
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
|
||||||
|
body.addEventListener("scroll", () => {
|
||||||
|
if (deps.editing()) return;
|
||||||
|
const fitted = deps.fitElevation();
|
||||||
|
timerView.clearTimeout(scrollSettleTimer);
|
||||||
|
if (!needsFullRedraw(fitted)) return;
|
||||||
|
timerView = viewOf(body);
|
||||||
|
scrollSettleTimer = timerView.setTimeout(() => {
|
||||||
|
if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
||||||
|
settledScrollLeft = body.scrollLeft;
|
||||||
|
deps.draw();
|
||||||
|
}, SCROLL_SETTLE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
||||||
|
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
||||||
|
attachWheelHorizontalScroll(body);
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
const last = deps.lastSize();
|
||||||
|
if (
|
||||||
|
body.clientWidth <= 0 ||
|
||||||
|
body.clientHeight <= 0 ||
|
||||||
|
(Math.abs(body.clientWidth - last.width) < 1 && Math.abs(body.clientHeight - last.height) < 1)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
|
||||||
|
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
|
||||||
|
deps.scheduleLightSync();
|
||||||
|
deps.scheduleDraw();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(body);
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 고른 것이 보이는 칸 가장자리에서 이만큼(폭 비율) 안쪽이면 그대로 둔다. */
|
||||||
|
const REVEAL_EDGE = 0.1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고른 측점선(없으면 고른 구조물 알약)이 종단도 보이는 칸 밖이면 가운데로 민다 — 다른 영역(횡단 카드 ·
|
||||||
|
* 3D · 배수유역도 · 좌측 목록)에서 고른 자리도 종단에서 바로 보이게. 칠한 결과(강조된 선 · 알약)를
|
||||||
|
* 보고 옮긴다 · 같은 고름을 다시 칠할 때는 안 밂(사용자가 옮겨 본 자리를 지킴) · 그래프 안에서
|
||||||
|
* 누른 것은 이미 보이는 자리라 안 움직임. `key` = 고름 열쇠(풀면 null).
|
||||||
|
*/
|
||||||
|
export function createSelectionReveal(body: HTMLElement): (key: string | null) => void {
|
||||||
|
let shown: string | null = null;
|
||||||
|
return (key) => {
|
||||||
|
if (key === null || key === shown) {
|
||||||
|
shown = key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mark =
|
||||||
|
body.querySelector(".b06-chart__station--selected") ??
|
||||||
|
body.querySelector(".b05-structure__mark.is-selected");
|
||||||
|
if (!mark || body.clientWidth <= 0) return; // 아직 안 그려짐 — 다음 칠하기에서
|
||||||
|
shown = key;
|
||||||
|
const box = mark.getBoundingClientRect();
|
||||||
|
const view = body.getBoundingClientRect();
|
||||||
|
const at = box.left + box.width / 2 - view.left;
|
||||||
|
if (at >= view.width * REVEAL_EDGE && at <= view.width * (1 - REVEAL_EDGE)) return;
|
||||||
|
// 바로 옮김 — 고른 뒤 잇따르는 다시 그리기가 부드러운 이동을 끊음(다시 그리기는 지금 자리를 지킴)
|
||||||
|
body.scrollLeft += at - view.width / 2;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -57,6 +57,13 @@ export interface DesignSaveContext {
|
|||||||
failed: (error: unknown, confirm: boolean) => void;
|
failed: (error: unknown, confirm: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saving = new Set<string>();
|
||||||
|
|
||||||
|
/** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */
|
||||||
|
export function savingDesign(projectId: string | null): boolean {
|
||||||
|
return projectId !== null && saving.has(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
|
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
|
||||||
function reportOnly(label: string): (error: unknown) => void {
|
function reportOnly(label: string): (error: unknown) => void {
|
||||||
return (error) => {
|
return (error) => {
|
||||||
@@ -65,11 +72,26 @@ function reportOnly(label: string): (error: unknown) => void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */
|
||||||
|
export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void {
|
||||||
|
const dropped = saved.dropped_edit_chainages ?? [];
|
||||||
|
if (!dropped.length) return;
|
||||||
|
const where = dropped
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((value) => `${value.toFixed(2)}m`)
|
||||||
|
.join(" · ");
|
||||||
|
showToast(
|
||||||
|
`계획고 편집 ${dropped.length}곳을 지웠습니다 — 측점 · 관 · 구조물이 없는 자리: ${where}` +
|
||||||
|
(dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""),
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
|
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
|
||||||
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
|
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
|
||||||
const draft = pendingAlignment(routeId);
|
const draft = pendingAlignment(routeId);
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
await saveProfileAlignment(projectId, routeId, draft);
|
noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft));
|
||||||
markAlignmentSaved(routeId);
|
markAlignmentSaved(routeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +137,7 @@ export async function saveDesign(
|
|||||||
const routeId = ctx.routeId();
|
const routeId = ctx.routeId();
|
||||||
if (routeId === null) return false;
|
if (routeId === null) return false;
|
||||||
showLoadingOverlay();
|
showLoadingOverlay();
|
||||||
|
saving.add(ctx.projectId);
|
||||||
try {
|
try {
|
||||||
await ctx.prepare?.();
|
await ctx.prepare?.();
|
||||||
for (const step of DESIGN_SAVE_ORDER) {
|
for (const step of DESIGN_SAVE_ORDER) {
|
||||||
@@ -135,12 +158,14 @@ export async function saveDesign(
|
|||||||
await (label ? run().catch(reportOnly(label)) : run());
|
await (label ? run().catch(reportOnly(label)) : run());
|
||||||
}
|
}
|
||||||
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
|
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
|
||||||
|
saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림
|
||||||
await ctx.done?.(confirm);
|
await ctx.done?.(confirm);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.failed(error, confirm);
|
ctx.failed(error, confirm);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
saving.delete(ctx.projectId);
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import { readState, writeState } from "../b_page_state";
|
import { readState, writeState } from "../b_page_state";
|
||||||
|
|
||||||
/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */
|
/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
|
||||||
export const PIPE_MATCH_M = 0.51;
|
export const PIPE_MATCH_M = 0.51;
|
||||||
|
|
||||||
export interface DesignSelection {
|
export interface DesignSelection {
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch";
|
|||||||
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
|
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
|
||||||
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||||
import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
|
import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
|
||||||
import { createDesignPanel, type DesignPanel } from "./design_panel";
|
import { createDesignPanel, type DesignPanel } from "./design_panel";
|
||||||
import { createDesignProfile, type DesignProfile } from "./design_profile";
|
import { createDesignProfile, type DesignProfile } from "./design_profile";
|
||||||
import { attachProfilePopout } from "./design_profile_popout";
|
import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
|
||||||
import { saveDesign, type DesignSaveStep } from "./design_save";
|
import { saveDesign, type DesignSaveStep } from "./design_save";
|
||||||
import { resetDesign } from "./design_reset";
|
import { resetDesign } from "./design_reset";
|
||||||
import type { DesignView } from "./design_dock";
|
import type { DesignView } from "./design_dock";
|
||||||
@@ -150,12 +150,12 @@ function toAll<K extends keyof RouteProfilePanelCallbacks>(
|
|||||||
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
|
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
|
||||||
export function sharedProfile(shared: DesignPageShared): DesignProfile {
|
export function sharedProfile(shared: DesignPageShared): DesignProfile {
|
||||||
if (shared.profile) return shared.profile;
|
if (shared.profile) return shared.profile;
|
||||||
let sideTool: HTMLElement | null = null;
|
const popoutButton = document.createElement("button");
|
||||||
const profile = createDesignProfile({
|
const profile = createDesignProfile({
|
||||||
projectId: shared.projectId,
|
projectId: shared.projectId,
|
||||||
view: shared.view(),
|
view: shared.view(),
|
||||||
side: drainageSide,
|
side: drainageSide,
|
||||||
sideTool: (board) => (sideTool = drainageSideTool(board)),
|
toolLead: popoutButton,
|
||||||
onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId),
|
onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId),
|
||||||
onResetAll: () => bound(shared).profile?.onResetAll?.(),
|
onResetAll: () => bound(shared).profile?.onResetAll?.(),
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -178,7 +178,8 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
shared.profileSlot.append(profile.root);
|
shared.profileSlot.append(profile.root);
|
||||||
attachProfilePopout(profile, shared.profileSlot, sideTool);
|
attachProfilePopout(profile, shared.profileSlot, popoutButton);
|
||||||
|
attachSubPanelPopouts(profile);
|
||||||
shared.profile = profile;
|
shared.profile = profile;
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
|
import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
|
||||||
|
import type { SpecForm } from "./design_structure_form";
|
||||||
|
import type { SpecContainer, SpecItem } from "./design_structure_spec";
|
||||||
|
|
||||||
/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */
|
/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */
|
||||||
export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null {
|
export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null {
|
||||||
@@ -23,6 +25,54 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe
|
|||||||
return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null;
|
return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(52-4 · 65-4 · 65-5 · 287-3).
|
||||||
|
* · 폭을 바꾸면 높이 = 필요 수심 · 높이를 비우거나 그 아래로 넣으면 필요 수심으로.
|
||||||
|
* · 새 시설(`arm(true)`) 빈 높이 = 유량이 오면 필요 수심(사용자가 넣은 것처럼 — [추가] · [수정]이 읽음).
|
||||||
|
* · 상자 끝 안내 — 「필요 수심 n 이상」 · 「설계유량 없음 — 수기 입력」(유량이 없으면 손 입력).
|
||||||
|
* `box` = 폭 · 높이 칸이 든 상자 id(세월교 `ford` · 물넘이 `slope`) · `flow` = 담당 유역 설계유량.
|
||||||
|
*/
|
||||||
|
export function overflowSync(
|
||||||
|
form: () => Pick<SpecForm, "state" | "setValue" | "refresh">,
|
||||||
|
box: string,
|
||||||
|
flow: () => number | null,
|
||||||
|
emit: () => void,
|
||||||
|
) {
|
||||||
|
let pending = false;
|
||||||
|
const values = () => form().state()[box]?.[0] ?? {};
|
||||||
|
const width = () => Number(values().width ?? "");
|
||||||
|
const need = (): number | null => fordHeightM(flow(), width());
|
||||||
|
return {
|
||||||
|
arm: (fresh: boolean): void => {
|
||||||
|
pending = fresh;
|
||||||
|
},
|
||||||
|
onChange: (c: SpecContainer, item: SpecItem): void => {
|
||||||
|
if (c.id === box && item.id === "height") pending = false;
|
||||||
|
const want = c.id === box ? need() : null;
|
||||||
|
const height = values().height ?? "";
|
||||||
|
const low = height === "" || !(Number(height) >= (want ?? 0));
|
||||||
|
if (want !== null && (item.id === "width" || (item.id === "height" && low)))
|
||||||
|
form().setValue(box, "height", want.toFixed(2));
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
/** 유량이 왔거나 시설을 올림 — 새 시설 빈 높이를 채우고 안내를 다시 그림. */
|
||||||
|
fill: (): void => {
|
||||||
|
const want = pending && !values().height ? need() : null;
|
||||||
|
if (want === null) return form().refresh();
|
||||||
|
form().setValue(box, "height", want.toFixed(2));
|
||||||
|
pending = false;
|
||||||
|
},
|
||||||
|
/** 상자 끝 안내 글(폼 `fields` 훅). */
|
||||||
|
fields: (c: SpecContainer): HTMLElement[] => {
|
||||||
|
if (c.id !== box) return [];
|
||||||
|
const hint = document.createElement("p");
|
||||||
|
hint.className = "b05-structure__flow-hint";
|
||||||
|
hint.textContent = fordHeightHint(flow(), width());
|
||||||
|
return [hint];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 높이 빈 칸 안내. */
|
/** 높이 빈 칸 안내. */
|
||||||
export function fordHeightHint(designFlowM3s: number | null, widthM: number): string {
|
export function fordHeightHint(designFlowM3s: number | null, widthM: number): string {
|
||||||
const need = fordHeightM(designFlowM3s, widthM);
|
const need = fordHeightM(designFlowM3s, widthM);
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
* design_structure_ford_pavement.ts
|
* design_structure_ford_pavement.ts
|
||||||
* 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로.
|
* 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로.
|
||||||
*
|
*
|
||||||
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임).
|
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 다른 구조물과
|
||||||
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure` ·
|
* 같은 칸 `CHUTE_ITEMS` + 맨 아래 길이 · 286-3).
|
||||||
|
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_*` ·
|
||||||
* 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`).
|
* 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`).
|
||||||
* 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음.
|
* 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음.
|
||||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
* 길이(`ford_chute_{n}_length_m`) = 횡단 · 3D 띠를 사면 따라 그릴 길이 — 그림 전용(수량 · 내역 · 일위대가에 안 씀) ·
|
||||||
|
* 비면 원지반까지.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { CHUTE_ITEMS } from "./design_structure_guard";
|
||||||
import type { StructureSpec } from "./design_structure_spec";
|
import type { StructureSpec } from "./design_structure_spec";
|
||||||
|
|
||||||
export const FORD_PAVEMENT_SPEC: StructureSpec = {
|
export const FORD_PAVEMENT_SPEC: StructureSpec = {
|
||||||
@@ -32,20 +35,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
|
|||||||
prefix: "ford_chute",
|
prefix: "ford_chute",
|
||||||
link: "gfordpavechute",
|
link: "gfordpavechute",
|
||||||
max: 1,
|
max: 1,
|
||||||
// 구조(기본 도수로) · 두께(cm · 기본 45 · 173-5) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2)
|
// 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 맨 아래 길이 — 그림 전용 · 286-3 · 294-4)
|
||||||
items: [
|
items: CHUTE_ITEMS,
|
||||||
{
|
|
||||||
id: "structure",
|
|
||||||
label: "구조",
|
|
||||||
kind: "select",
|
|
||||||
choices: [
|
|
||||||
{ value: "도수로", label: "도수로" },
|
|
||||||
{ value: "돌붙임", label: "돌붙임" },
|
|
||||||
],
|
|
||||||
default: "도수로",
|
|
||||||
},
|
|
||||||
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
|
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
linkOf,
|
linkOf,
|
||||||
readState,
|
readState,
|
||||||
solveSplit,
|
solveSplit,
|
||||||
|
specContainerHidden,
|
||||||
|
specItemHidden,
|
||||||
TEXT_MAX,
|
TEXT_MAX,
|
||||||
writeOptions,
|
writeOptions,
|
||||||
type SpecContainer,
|
type SpecContainer,
|
||||||
@@ -82,6 +84,8 @@ export interface SpecForm {
|
|||||||
groupNames?: Record<string, string>,
|
groupNames?: Record<string, string>,
|
||||||
/** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */
|
/** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */
|
||||||
defaults?: Record<string, string | number>,
|
defaults?: Record<string, string | number>,
|
||||||
|
/** 숨긴 옵션 키(정의 「표시」 · 322-2) — 그 키만 쓰는 칸 · 상자는 안 그림(값은 그대로). */
|
||||||
|
hidden?: ReadonlySet<string>,
|
||||||
) => void;
|
) => void;
|
||||||
read: () => Record<string, string | number>;
|
read: () => Record<string, string | number>;
|
||||||
state: () => SpecState;
|
state: () => SpecState;
|
||||||
@@ -104,6 +108,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
|||||||
let names: SpecNames = {};
|
let names: SpecNames = {};
|
||||||
let groupNames: Record<string, string> = {};
|
let groupNames: Record<string, string> = {};
|
||||||
let defaults: Record<string, string | number> = {};
|
let defaults: Record<string, string | number> = {};
|
||||||
|
let hidden: ReadonlySet<string> = new Set();
|
||||||
const recent = new Map<Instance, SplitRole[]>();
|
const recent = new Map<Instance, SplitRole[]>();
|
||||||
const slots = new Map<string, HTMLElement>();
|
const slots = new Map<string, HTMLElement>();
|
||||||
const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = [];
|
const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = [];
|
||||||
@@ -235,6 +240,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
|||||||
}
|
}
|
||||||
const fields = c.items
|
const fields = c.items
|
||||||
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, view))
|
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, view))
|
||||||
|
.filter((item) => !specItemHidden(item, hidden))
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const input = control(c, item, view, tags.get(item.id));
|
const input = control(c, item, view, tags.get(item.id));
|
||||||
const wrap = labeled(itemLabel(c, item, names), input);
|
const wrap = labeled(itemLabel(c, item, names), input);
|
||||||
@@ -316,6 +322,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
|||||||
const fitted: SpecContainer[] = [];
|
const fitted: SpecContainer[] = [];
|
||||||
for (const c of spec?.containers ?? []) {
|
for (const c of spec?.containers ?? []) {
|
||||||
const list = (state[c.id] ??= c.required ? [{}] : []);
|
const list = (state[c.id] ??= c.required ? [{}] : []);
|
||||||
|
if (specContainerHidden(c, hidden)) continue; // 숨긴 상자 — 값은 그대로(322-2)
|
||||||
if (fit(c, list)) fitted.push(c);
|
if (fit(c, list)) fitted.push(c);
|
||||||
shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index)));
|
shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index)));
|
||||||
if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue;
|
if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue;
|
||||||
@@ -341,9 +348,10 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}) {
|
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}, nextHidden) {
|
||||||
spec = next;
|
spec = next;
|
||||||
defaults = nextDefaults;
|
defaults = nextDefaults;
|
||||||
|
hidden = nextHidden ?? new Set();
|
||||||
names = nextNames;
|
names = nextNames;
|
||||||
groupNames = nextGroups;
|
groupNames = nextGroups;
|
||||||
state = readState(next, options);
|
state = readState(next, options);
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ export const CHUTE_ITEMS: SpecItem[] = [
|
|||||||
},
|
},
|
||||||
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
|
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
|
||||||
{ id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" },
|
{ id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" },
|
||||||
|
// 길이(294-4 · `{접두}_{n}_length_m`) — 띠를 사면 따라 그릴 길이 · 그림 전용(수량 · 내역 · 일위대가에 안 씀)
|
||||||
|
{ id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
|
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { BOX_SPEC } from "./design_structure_box";
|
|||||||
import { FORD_SPEC } from "./design_structure_ford";
|
import { FORD_SPEC } from "./design_structure_ford";
|
||||||
import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement";
|
import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement";
|
||||||
import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road";
|
import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road";
|
||||||
|
import { baseType } from "../../common_util/common_util_structure_catalog";
|
||||||
|
|
||||||
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
|
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
|
||||||
const BASIN = "집수정";
|
const BASIN = "집수정";
|
||||||
@@ -116,3 +117,7 @@ export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = {
|
|||||||
pavement_concrete: PAVEMENT_SPEC,
|
pavement_concrete: PAVEMENT_SPEC,
|
||||||
etc: ETC_SPEC,
|
etc: ETC_SPEC,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 그 종류의 명세 — 복사한 종류는 본보기 명세(323-7 · `baseType`). 화면은 이것으로 고름(종류 키 직접 안 씀). */
|
||||||
|
export const specFor = (typeId: string): StructureSpec | undefined =>
|
||||||
|
STRUCTURE_SPECS[baseType(typeId)];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch";
|
import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||||
import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts";
|
import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts";
|
||||||
export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin";
|
export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin";
|
||||||
|
export { pipeLengthText } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; // 「8 m (7.6)」(225-1)
|
||||||
import { PIPE_MATCH_M } from "./design_selection";
|
import { PIPE_MATCH_M } from "./design_selection";
|
||||||
|
|
||||||
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
|
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
|
||||||
|
|||||||
@@ -120,6 +120,23 @@ export const linkOf = (c: SpecContainer, n: number): string =>
|
|||||||
const text = (value: unknown): string =>
|
const text = (value: unknown): string =>
|
||||||
value === undefined || value === null ? "" : String(value);
|
value === undefined || value === null ? "" : String(value);
|
||||||
|
|
||||||
|
/** 항목이 쓰는 정의 옵션 키 — 필수 상자 저장 키 · 갈래 키(선택 상자 인스턴스 키는 옵션 밖 · 322-2 보임). */
|
||||||
|
export const specItemKeys = (item: SpecItem): string[] =>
|
||||||
|
[item.key, ...Object.values(item.by?.map ?? {}).map((b) => b.key)].filter(
|
||||||
|
(key): key is string => !!key,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 숨긴 항목인가 — 쓰는 키가 있고 모두 숨김(정의 「표시」 · 322-2). */
|
||||||
|
export const specItemHidden = (item: SpecItem, hidden: ReadonlySet<string>): boolean => {
|
||||||
|
const keys = specItemKeys(item);
|
||||||
|
return keys.length > 0 && keys.every((key) => hidden.has(key));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 숨긴 상자인가 — 키 있는 항목이 하나 이상이고 모두 숨김. */
|
||||||
|
export const specContainerHidden = (c: SpecContainer, hidden: ReadonlySet<string>): boolean =>
|
||||||
|
c.items.some((item) => specItemKeys(item).length > 0) &&
|
||||||
|
c.items.every((item) => !specItemKeys(item).length || specItemHidden(item, hidden));
|
||||||
|
|
||||||
/** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */
|
/** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */
|
||||||
export function readState(
|
export function readState(
|
||||||
spec: StructureSpec,
|
spec: StructureSpec,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/design/design_structure_status.ts
|
||||||
|
* 구조물 현황 한 벌(322-3) — 저장분 둘(관 `pipe_points.json` · 구조물 `structures.json`) + 초안 둘(세션)
|
||||||
|
* → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) →
|
||||||
|
* 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴).
|
||||||
|
*
|
||||||
|
* 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||||
|
import type { PlannedStation } from "../../common_util/common_util_structure_station";
|
||||||
|
import { useDesignStore, type DesignStoreValues } from "./design_store";
|
||||||
|
|
||||||
|
/** 현황 — 종류마다 초안이 앞섬(초안 null = 만진 적 없음 · 빈 목록 = 다 지움). */
|
||||||
|
export function structureStatus<P, S>(
|
||||||
|
saved: readonly [ReadonlyArray<P>, ReadonlyArray<S>],
|
||||||
|
drafts: readonly [ReadonlyArray<P> | null, ReadonlyArray<S> | null],
|
||||||
|
): { pipes: P[]; structures: S[] } {
|
||||||
|
return { pipes: [...(drafts[0] ?? saved[0])], structures: [...(drafts[1] ?? saved[1])] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
|
||||||
|
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
|
||||||
|
* 「배관」 꼴(관 끌기 · 지우기 길).
|
||||||
|
*/
|
||||||
|
export function plannedIrregular(
|
||||||
|
planned: ReadonlyArray<PlannedStation>,
|
||||||
|
intervalM: number,
|
||||||
|
): IrregularStation[] {
|
||||||
|
const interval = intervalM > 0 ? intervalM : 20;
|
||||||
|
return planned.map((station) => {
|
||||||
|
const owner = station.owners[0];
|
||||||
|
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
|
||||||
|
const number = Math.floor(station.chainage_m / interval);
|
||||||
|
return {
|
||||||
|
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
|
||||||
|
station: number,
|
||||||
|
remainder: station.chainage_m - number * interval,
|
||||||
|
chainage_m: station.chainage_m,
|
||||||
|
structure: station.title,
|
||||||
|
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 바뀜 신호 하나(323-6) — 관 · 구조물 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
|
||||||
|
* 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
|
||||||
|
* 돌려주는 함수로 끊음.
|
||||||
|
*/
|
||||||
|
export function watchDrafts(projectId: string | null, onChange: () => void): () => void {
|
||||||
|
const shop = useDesignStore(projectId, null);
|
||||||
|
type Pipe = NonNullable<DesignStoreValues["pipes"]>[number];
|
||||||
|
const layout = (pipes: ReadonlyArray<Pipe> | null): string =>
|
||||||
|
JSON.stringify(
|
||||||
|
(pipes ?? []).map((p) => [
|
||||||
|
p.chainage_m,
|
||||||
|
p.facility ?? "pipe",
|
||||||
|
p.start_m ?? null,
|
||||||
|
p.end_m ?? null,
|
||||||
|
p.summary_item ?? null,
|
||||||
|
p.group_items ?? null,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
let seen = layout(shop.get("pipes"));
|
||||||
|
const offs = [
|
||||||
|
shop.subscribe("structures", onChange),
|
||||||
|
shop.subscribe("pipes", (pipes) => {
|
||||||
|
const key = layout(pipes);
|
||||||
|
if (key === seen) return;
|
||||||
|
seen = key;
|
||||||
|
onChange();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
return () => offs.forEach((off) => off());
|
||||||
|
}
|
||||||
@@ -17,10 +17,8 @@ import { leaveForDashboard } from "../b_missing_data_guard";
|
|||||||
import { readByKey, stateKey, writeByKey } from "../b_page_state";
|
import { readByKey, stateKey, writeByKey } from "../b_page_state";
|
||||||
import { showToast } from "@ui/ui_template_elements";
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations";
|
import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations";
|
||||||
import type {
|
import { onStaleStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||||
StructureInstance,
|
import { pipeIdAt } from "./design_pipe_ids";
|
||||||
StructureType,
|
|
||||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
|
||||||
import {
|
import {
|
||||||
fetchSectionContext,
|
fetchSectionContext,
|
||||||
getSections,
|
getSections,
|
||||||
@@ -29,6 +27,7 @@ import {
|
|||||||
type StandardCrossSection,
|
type StandardCrossSection,
|
||||||
} from "../../B06_Section/B06_Section_Api_Fetch";
|
} from "../../B06_Section/B06_Section_Api_Fetch";
|
||||||
import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
|
import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
|
||||||
|
import { setStationAdjustReload } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||||
import {
|
import {
|
||||||
createCutSlopeStore,
|
createCutSlopeStore,
|
||||||
createRockBoundaryStore,
|
createRockBoundaryStore,
|
||||||
@@ -37,13 +36,15 @@ import {
|
|||||||
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||||
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||||
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
|
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
|
||||||
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
import { paintMapPick } from "./design_pick_profile";
|
||||||
|
import { watchDrafts } from "./design_structure_status";
|
||||||
|
import { savingDesign } from "./design_save";
|
||||||
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
|
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
|
||||||
|
import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
|
||||||
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
|
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
|
||||||
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
|
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
|
||||||
import {
|
import {
|
||||||
applyPipeOptionsToCache,
|
applyPipeOptionsToCache,
|
||||||
replayDraftPipeOptions,
|
|
||||||
type PipeOptionsContext,
|
type PipeOptionsContext,
|
||||||
} from "../../B06_Section/B06_Section_UI_Page_Pipe_Options";
|
} from "../../B06_Section/B06_Section_UI_Page_Pipe_Options";
|
||||||
import {
|
import {
|
||||||
@@ -131,8 +132,7 @@ export async function mountCrossView(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b).
|
// 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b).
|
||||||
let structureMarksSink:
|
let structureMarksSink: Parameters<typeof createB06StructuresPanel>[0]["onMarks"] | null = null;
|
||||||
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
|
|
||||||
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
|
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
|
||||||
let pipeReloadTimer = 0;
|
let pipeReloadTimer = 0;
|
||||||
let wallAdjustTimer = 0;
|
let wallAdjustTimer = 0;
|
||||||
@@ -140,12 +140,11 @@ export async function mountCrossView(
|
|||||||
projectId,
|
projectId,
|
||||||
shared,
|
shared,
|
||||||
// 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4).
|
// 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4).
|
||||||
onMarks: (structures, types) => {
|
onMarks: (structures, types, pipes) => {
|
||||||
structureMarksSink?.(structures, types);
|
structureMarksSink?.(structures, types, pipes);
|
||||||
syncBermSpans(structures);
|
syncBermSpans(structures);
|
||||||
},
|
},
|
||||||
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06).
|
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06).
|
||||||
onStructuresChanged: () => void refreshDetailForStructures(),
|
|
||||||
onPipesWritten: () => {
|
onPipesWritten: () => {
|
||||||
window.clearTimeout(pipeReloadTimer);
|
window.clearTimeout(pipeReloadTimer);
|
||||||
pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200);
|
pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200);
|
||||||
@@ -191,7 +190,7 @@ export async function mountCrossView(
|
|||||||
if (!isWallSelecting()) focusStationAt(chainageM, revealOnly);
|
if (!isWallSelecting()) focusStationAt(chainageM, revealOnly);
|
||||||
},
|
},
|
||||||
releaseCard: () => releaseCross(),
|
releaseCard: () => releaseCross(),
|
||||||
markStructure: (structureId) => sectionView.selectMark(structureId),
|
markStructure: (structureId, span) => sectionView.selectMark(structureId, span),
|
||||||
applyPipeOptions: (chainageM, patch) =>
|
applyPipeOptions: (chainageM, patch) =>
|
||||||
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
||||||
});
|
});
|
||||||
@@ -220,6 +219,7 @@ export async function mountCrossView(
|
|||||||
async function refreshDetailForStructures(): Promise<void> {
|
async function refreshDetailForStructures(): Promise<void> {
|
||||||
if (!projectId || currentRouteId === null) return;
|
if (!projectId || currentRouteId === null) return;
|
||||||
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
|
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
|
||||||
|
if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9)
|
||||||
try {
|
try {
|
||||||
const fresh = await loadSectionDetail(projectId, currentRouteId);
|
const fresh = await loadSectionDetail(projectId, currentRouteId);
|
||||||
if (seq !== structuresRefreshSeq) return;
|
if (seq !== structuresRefreshSeq) return;
|
||||||
@@ -233,6 +233,7 @@ export async function mountCrossView(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
renderSectionDetail();
|
renderSectionDetail();
|
||||||
|
void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||||
showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error");
|
showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error");
|
||||||
@@ -301,6 +302,7 @@ export async function mountCrossView(
|
|||||||
wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120);
|
wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
setStationAdjustReload(() => stationControls.load()); // 종류 바꿈이 세션을 비운 뒤(318-4)
|
||||||
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
|
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
|
||||||
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
|
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
|
||||||
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
|
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
|
||||||
@@ -332,7 +334,7 @@ export async function mountCrossView(
|
|||||||
shared,
|
shared,
|
||||||
);
|
);
|
||||||
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
|
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
|
||||||
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
|
structureMarksSink = sectionView.setStructureMarks;
|
||||||
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙.
|
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙.
|
||||||
|
|
||||||
// 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로
|
// 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로
|
||||||
@@ -341,15 +343,15 @@ export async function mountCrossView(
|
|||||||
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
|
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
|
||||||
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
|
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
|
||||||
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
|
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
|
||||||
removeStructure: (structureId) => {
|
removeStructure: (structureId) => void structuresPanel.removeStructureById(structureId),
|
||||||
structuresPanel.removeStructureById(structureId);
|
|
||||||
},
|
|
||||||
movePipe: (fromChainageM, toChainageM) =>
|
movePipe: (fromChainageM, toChainageM) =>
|
||||||
structuresPanel.movePipeTo(fromChainageM, toChainageM),
|
structuresPanel.movePipeTo(fromChainageM, toChainageM),
|
||||||
moveStructure: (structureId, toChainageM) => {
|
moveStructure: (structureId, toChainageM) => {
|
||||||
structuresPanel.moveStructureTo(structureId, toChainageM);
|
structuresPanel.moveStructureTo(structureId, toChainageM);
|
||||||
},
|
},
|
||||||
selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
|
selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
|
||||||
|
selectPipe: (chainageM) => structuresPanel.selectPipeAt(chainageM), // 측점 없는 관(요구 178)
|
||||||
|
pipesChanged: () => (structuresPanel.adoptFordHeights(), void refreshDetailForStructures()),
|
||||||
});
|
});
|
||||||
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
|
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
|
||||||
const pipeOptionsContext: PipeOptionsContext = {
|
const pipeOptionsContext: PipeOptionsContext = {
|
||||||
@@ -387,19 +389,18 @@ export async function mountCrossView(
|
|||||||
: null;
|
: null;
|
||||||
if (!target) {
|
if (!target) {
|
||||||
structuresPanel.showAtChainage(null);
|
structuresPanel.showAtChainage(null);
|
||||||
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다.
|
releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null;
|
|
||||||
const preferred = structuresPanel.preferred() !== null;
|
const preferred = structuresPanel.preferred() !== null;
|
||||||
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
|
const opened = structuresPanel.showAtChainage(structureSelection.formChainageOf(target));
|
||||||
// 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1)
|
// 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1)
|
||||||
const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null };
|
const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null };
|
||||||
if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" });
|
if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" });
|
||||||
// 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면
|
// 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면
|
||||||
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
|
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
|
||||||
const pipeAt = structuresPanel.currentChainage();
|
const pipeAt = structuresPanel.currentChainage();
|
||||||
const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`);
|
const mark = opened.structureId ?? (pipeAt === null ? null : pipeIdAt(pipeAt, projectId));
|
||||||
sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 덮는 것 없으면 알약 · 강조 풂(152-5)
|
sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 덮는 것 없으면 알약 · 강조 풂(152-5)
|
||||||
structureSelection.syncInletStructure();
|
structureSelection.syncInletStructure();
|
||||||
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
||||||
@@ -474,16 +475,11 @@ export async function mountCrossView(
|
|||||||
handoff,
|
handoff,
|
||||||
sectionDetail,
|
sectionDetail,
|
||||||
{
|
{
|
||||||
// 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15).
|
// 넘겨받은 구조물 id 가 먼저(52-15) · 폼은 id 로 직접(53-1) · 측점 없는 구조물은 카드 · 스크롤 없음(307)
|
||||||
focusStation: (stationId) => {
|
focusStation: (stationId) =>
|
||||||
structuresPanel.prefer(handoff?.structureId ?? null);
|
structuresPanel.focusPicked(handoff?.structureId ?? null, () =>
|
||||||
sectionView.focusStation(stationId);
|
sectionView.focusStation(stationId),
|
||||||
structuresPanel.prefer(null);
|
),
|
||||||
// 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1)
|
|
||||||
const sid = handoff?.structureId;
|
|
||||||
if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid))
|
|
||||||
structuresPanel.selectStructure(sid);
|
|
||||||
},
|
|
||||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||||
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
|
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
|
||||||
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
|
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
|
||||||
@@ -494,23 +490,21 @@ export async function mountCrossView(
|
|||||||
if (applied) appliedPick = signature;
|
if (applied) appliedPick = signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */
|
// 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2)
|
||||||
|
const crossPick = bindCrossPick(projectId, host, {
|
||||||
|
sections: () => sectionDetail?.cross_sections ?? [],
|
||||||
|
controls: stationControls,
|
||||||
|
releaseForm: () => structuresPanel.showAtChainage(null),
|
||||||
|
view: sectionView,
|
||||||
|
reapply: () => {
|
||||||
|
appliedPick = null;
|
||||||
|
restoreStructurePick();
|
||||||
|
},
|
||||||
|
paintMap: (selection) => shared.profile?.side && paintMapPick(shared.profile.side, selection),
|
||||||
|
});
|
||||||
|
/** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */
|
||||||
function releaseCross(): void {
|
function releaseCross(): void {
|
||||||
const { revetOffset, ford, box } = stationControls;
|
crossPick.release();
|
||||||
const lit = (sectionDetail?.cross_sections ?? []).filter(
|
|
||||||
(s) =>
|
|
||||||
revetOffset.highlightFor(s) ||
|
|
||||||
ford.selectedFor(s.chainage_m) ||
|
|
||||||
box.selectedFor(s.chainage_m),
|
|
||||||
);
|
|
||||||
for (const s of lit) {
|
|
||||||
if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null);
|
|
||||||
if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null);
|
|
||||||
if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null);
|
|
||||||
}
|
|
||||||
structuresPanel.releasePipe();
|
|
||||||
sectionView.focusStation(null);
|
|
||||||
sectionView.refreshCards(lit.map((s) => s.chainage_m));
|
|
||||||
appliedPick = null;
|
appliedPick = null;
|
||||||
selectDesign(projectId, NO_SELECTION, { from: "b06" });
|
selectDesign(projectId, NO_SELECTION, { from: "b06" });
|
||||||
}
|
}
|
||||||
@@ -543,6 +537,7 @@ export async function mountCrossView(
|
|||||||
// 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로
|
// 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로
|
||||||
// 실려 나가던 자리(2026-09-09 실측).
|
// 실려 나가던 자리(2026-09-09 실측).
|
||||||
reconcileDesigns: () => reconcileStaleDesigns({ force: true }),
|
reconcileDesigns: () => reconcileStaleDesigns({ force: true }),
|
||||||
|
redraw: () => (host.visible() ? refreshDetailForStructures() : Promise.resolve()),
|
||||||
patchSources: () => ({
|
patchSources: () => ({
|
||||||
rockOffsets: rockStore.offsets,
|
rockOffsets: rockStore.offsets,
|
||||||
cutSlopeRatios: cutSlopeStore.ratios,
|
cutSlopeRatios: cutSlopeStore.ratios,
|
||||||
@@ -568,8 +563,13 @@ export async function mountCrossView(
|
|||||||
// 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38)
|
// 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38)
|
||||||
if (!readStructurePick(projectId)) releaseCross();
|
if (!readStructurePick(projectId)) releaseCross();
|
||||||
void structuresPanel.load().then(restoreStructurePick);
|
void structuresPanel.load().then(restoreStructurePick);
|
||||||
void refreshDetailForStructures().then(() => reconcileStaleDesigns());
|
void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314)
|
||||||
});
|
});
|
||||||
|
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
|
||||||
|
const reloadPanel = (): void => void structuresPanel.load().then(restoreStructurePick);
|
||||||
|
host.onDispose(onStaleStructureTypes(() => host.visible() && reloadPanel()));
|
||||||
|
// 바뀜 신호 하나(323-6 · `watchDrafts`) — 초안이 어느 길로 바뀌든 보일 때 카드 · 그림 다시(옛 되부름 갈음)
|
||||||
|
host.onDispose(watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures()));
|
||||||
host.onDispose(() => {
|
host.onDispose(() => {
|
||||||
window.clearTimeout(pipeReloadTimer);
|
window.clearTimeout(pipeReloadTimer);
|
||||||
window.clearTimeout(wallAdjustTimer);
|
window.clearTimeout(wallAdjustTimer);
|
||||||
@@ -669,8 +669,6 @@ export async function mountCrossView(
|
|||||||
bindPipeOptions(shared, (chainageM, patch) =>
|
bindPipeOptions(shared, (chainageM, patch) =>
|
||||||
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
||||||
);
|
);
|
||||||
// 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a).
|
|
||||||
replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId));
|
|
||||||
renderSectionDetail();
|
renderSectionDetail();
|
||||||
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
|
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
|
||||||
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
|
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
|
||||||
|
|||||||
@@ -31,14 +31,9 @@ import {
|
|||||||
restoreStructurePick,
|
restoreStructurePick,
|
||||||
wireStructurePick,
|
wireStructurePick,
|
||||||
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||||
import {
|
import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection";
|
||||||
coversAtStation,
|
|
||||||
NO_SELECTION,
|
|
||||||
nearestWithin,
|
|
||||||
readSelection,
|
|
||||||
selectDesign,
|
|
||||||
} from "./design_selection";
|
|
||||||
import type { DesignPageHost } from "./design_page";
|
import type { DesignPageHost } from "./design_page";
|
||||||
|
import { bindProfilePick } from "./design_pick_profile";
|
||||||
import {
|
import {
|
||||||
bindDesignView,
|
bindDesignView,
|
||||||
designReady,
|
designReady,
|
||||||
@@ -46,7 +41,6 @@ import {
|
|||||||
type DesignPageShared,
|
type DesignPageShared,
|
||||||
} from "./design_shared";
|
} from "./design_shared";
|
||||||
import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life";
|
import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life";
|
||||||
import { structureAnchorM } from "../../B05_Profile/B05_Profile_Api_Structures";
|
|
||||||
import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
|
import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
|
||||||
import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
|
import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
|
||||||
import {
|
import {
|
||||||
@@ -62,7 +56,11 @@ import {
|
|||||||
type SectionDetailResponse,
|
type SectionDetailResponse,
|
||||||
} from "../../B06_Section/B06_Section_Api_Fetch";
|
} from "../../B06_Section/B06_Section_Api_Fetch";
|
||||||
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||||
import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures";
|
import {
|
||||||
|
migrateLegacyStations,
|
||||||
|
onStaleStructureTypes,
|
||||||
|
structureAnchorM,
|
||||||
|
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||||
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||||
import {
|
import {
|
||||||
loadCorridorIfFresh,
|
loadCorridorIfFresh,
|
||||||
@@ -82,6 +80,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css";
|
|||||||
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
|
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
|
||||||
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
|
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
|
||||||
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
|
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
|
||||||
|
import { pipeIdAt } from "./design_pipe_ids";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ROAD_WIDTHS,
|
DEFAULT_ROAD_WIDTHS,
|
||||||
fetchRoadWidths,
|
fetchRoadWidths,
|
||||||
@@ -127,6 +126,7 @@ export async function mountProfileView(
|
|||||||
bridge.setPipes(pipes);
|
bridge.setPipes(pipes);
|
||||||
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
|
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
|
||||||
profilePanel.setMinCoverPipes(pipes);
|
profilePanel.setMinCoverPipes(pipes);
|
||||||
|
snapOld();
|
||||||
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
|
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
|
||||||
};
|
};
|
||||||
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
|
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
|
||||||
@@ -135,10 +135,8 @@ export async function mountProfileView(
|
|||||||
const structurePick = !!readSelection(activeProjectId)?.structureId;
|
const structurePick = !!readSelection(activeProjectId)?.structureId;
|
||||||
const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []);
|
const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []);
|
||||||
const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections);
|
const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections);
|
||||||
if ("pipeChainageM" in target) {
|
if ("pipeChainageM" in target)
|
||||||
profilePanel.setSelectedStructure(`pipe-${target.pipeChainageM.toFixed(2)}`);
|
return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId));
|
||||||
return;
|
|
||||||
}
|
|
||||||
profilePanel.setSelectedStation(target.stationId);
|
profilePanel.setSelectedStation(target.stationId);
|
||||||
if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId);
|
if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId);
|
||||||
};
|
};
|
||||||
@@ -173,13 +171,15 @@ export async function mountProfileView(
|
|||||||
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
|
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
|
||||||
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
|
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
|
||||||
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
|
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
|
||||||
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
|
onBasinSelected: (chainageM) => pickFromMap(chainageM),
|
||||||
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM),
|
onPipeSelected: (chainageM) => pickFromMap(chainageM),
|
||||||
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
|
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
|
||||||
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
|
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
|
||||||
onStructureSelect: (structureId) => {
|
onStructureSelect: (structureId) => {
|
||||||
const chainage = bridge.pipeMarkChainage(structureId);
|
const chainage = bridge.pipeMarkChainage(structureId);
|
||||||
rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null);
|
// 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178)
|
||||||
|
const keepId = chainage === null || bridge.stationlessPipeMark(structureId);
|
||||||
|
rememberPick(bridge.markChainage(structureId), keepId ? structureId : null);
|
||||||
if (chainage !== null) {
|
if (chainage !== null) {
|
||||||
panel.structures.selectPipeByChainage(chainage);
|
panel.structures.selectPipeByChainage(chainage);
|
||||||
selectStationOfPipe(chainage);
|
selectStationOfPipe(chainage);
|
||||||
@@ -220,7 +220,7 @@ export async function mountProfileView(
|
|||||||
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
|
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
|
||||||
const uphillOverrides = loadUphillOverrides(activeProjectId);
|
const uphillOverrides = loadUphillOverrides(activeProjectId);
|
||||||
|
|
||||||
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
|
const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({
|
||||||
stations: () => bridge.irregularStations(),
|
stations: () => bridge.irregularStations(),
|
||||||
isSyncing: () => selectionSyncing,
|
isSyncing: () => selectionSyncing,
|
||||||
setSyncing: (value) => {
|
setSyncing: (value) => {
|
||||||
@@ -244,9 +244,24 @@ export async function mountProfileView(
|
|||||||
remember: (chainageM) => rememberPick(chainageM, null),
|
remember: (chainageM) => rememberPick(chainageM, null),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 배수유역도 유역 · 관 고름도 고름 한 칸에(사슬 도중 되울림은 안 적음 · 233-3). */
|
||||||
|
const pickFromMap = (chainageM: number | null): void => {
|
||||||
|
if (!selectionSyncing) rememberPick(chainageM, null);
|
||||||
|
selectStationOfPipe(chainageM);
|
||||||
|
};
|
||||||
|
|
||||||
|
let oldSnapped = false;
|
||||||
|
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */
|
||||||
|
function snapOld(): void {
|
||||||
|
if (oldSnapped || restoring || !lastPipes) return;
|
||||||
|
oldSnapped = true;
|
||||||
|
panel.structures.snapOld();
|
||||||
|
}
|
||||||
|
|
||||||
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
|
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
|
||||||
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
|
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
|
||||||
function restorePick(): void {
|
function restorePick(): void {
|
||||||
|
profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1)
|
||||||
if (panel.structures.hasSelection()) return;
|
if (panel.structures.hasSelection()) return;
|
||||||
const pick = viewer.structurePick;
|
const pick = viewer.structurePick;
|
||||||
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
|
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
|
||||||
@@ -318,45 +333,31 @@ export async function mountProfileView(
|
|||||||
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
|
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
|
||||||
},
|
},
|
||||||
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
|
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
|
||||||
|
// 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3)
|
||||||
onPipeFacilitySelect: (chainage) => {
|
onPipeFacilitySelect: (chainage) => {
|
||||||
rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다.
|
rememberPick(chainage, null);
|
||||||
if (selectionSyncing) return;
|
if (!selectionSyncing) profilePick.paint();
|
||||||
selectionSyncing = true;
|
|
||||||
try {
|
|
||||||
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
|
|
||||||
const station =
|
|
||||||
chainage === null
|
|
||||||
? undefined
|
|
||||||
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
|
|
||||||
const id = station ? irregularStationId(station.id) : null;
|
|
||||||
viewer.markers.selectStation(id);
|
|
||||||
selectGraphAt(id, chainage);
|
|
||||||
syncBasinHighlight(id);
|
|
||||||
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
|
|
||||||
profilePanel.drainage.selectPipeAtChainage(chainage);
|
|
||||||
viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2)
|
|
||||||
} finally {
|
|
||||||
selectionSyncing = false;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3)
|
||||||
onStructureSelect: (structure) => {
|
onStructureSelect: (structure) => {
|
||||||
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
|
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
|
||||||
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
|
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
|
||||||
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
|
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
|
||||||
if (selectionSyncing) return;
|
// 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2)
|
||||||
selectionSyncing = true;
|
if (!selectionSyncing) profilePick.paint();
|
||||||
try {
|
|
||||||
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
|
|
||||||
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
|
|
||||||
} finally {
|
|
||||||
selectionSyncing = false;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
shared,
|
shared,
|
||||||
);
|
);
|
||||||
|
|
||||||
wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe);
|
// 3D 부재 고름도 측점 고름과 같은 거름 — 노면공 id 를 고름 한 칸에 적지 않음(231-1)
|
||||||
|
wireStructurePick(
|
||||||
|
viewer.structurePick,
|
||||||
|
activeProjectId,
|
||||||
|
panel.structures,
|
||||||
|
selectStationOfPipe,
|
||||||
|
(entry) => coversAtStation(bridge.typeGroup(entry.type_id)),
|
||||||
|
);
|
||||||
|
|
||||||
/** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의
|
/** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의
|
||||||
* 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
|
* 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
|
||||||
@@ -402,6 +403,17 @@ export async function mountProfileView(
|
|||||||
},
|
},
|
||||||
isRestoring: () => restoring,
|
isRestoring: () => restoring,
|
||||||
});
|
});
|
||||||
|
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
|
||||||
|
const profilePick = bindProfilePick(
|
||||||
|
activeProjectId,
|
||||||
|
{ profile: profilePanel, viewer, form: panel.structures },
|
||||||
|
host,
|
||||||
|
{
|
||||||
|
irregular: () => bridge.irregularStations(),
|
||||||
|
pipes: () => lastPipes ?? [],
|
||||||
|
sections: () => currentSectionDetail?.cross_sections ?? [],
|
||||||
|
},
|
||||||
|
);
|
||||||
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
|
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
|
||||||
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
|
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
|
||||||
|
|
||||||
@@ -538,6 +550,9 @@ export async function mountProfileView(
|
|||||||
onReset: () => uphillOverrides.clear(),
|
onReset: () => uphillOverrides.clear(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
|
||||||
|
const reloadIfShown = (): void => void (host.visible() && !restoring && bridge.load());
|
||||||
|
host.onDispose(onStaleStructureTypes(reloadIfShown));
|
||||||
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
|
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
|
||||||
bindProfileViewLife(host, {
|
bindProfileViewLife(host, {
|
||||||
projectId: activeProjectId,
|
projectId: activeProjectId,
|
||||||
@@ -558,8 +573,7 @@ export async function mountProfileView(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
viewer.markers.selectStation(null);
|
profilePick.paint();
|
||||||
profilePanel.setSelectedStation(null);
|
|
||||||
},
|
},
|
||||||
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
||||||
onHide: () => {
|
onHide: () => {
|
||||||
@@ -655,6 +669,7 @@ export async function mountProfileView(
|
|||||||
} finally {
|
} finally {
|
||||||
progress.remove();
|
progress.remove();
|
||||||
restoring = false;
|
restoring = false;
|
||||||
|
snapOld();
|
||||||
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
|
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* device_context.css — 기기 대응 공통 규칙(PLAN_기기대응 2-6)
|
||||||
|
* 모든 규칙은 `<html>` 표시(data-touch · data-size) 안에서만 — 데스크탑 마우스 화면은 안 바뀜.
|
||||||
|
* 표시는 `device_context.ts` 가 적음.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/* 누를 자리 44px — 손가락(주 입력 coarse) 또는 터치 모드 켬 */
|
||||||
|
html[data-touch="on"]
|
||||||
|
:is(
|
||||||
|
.ui-btn,
|
||||||
|
.ui-workflow-overlay__toggle,
|
||||||
|
.ui-sidebar-row,
|
||||||
|
.ui-workflow-layout__step,
|
||||||
|
.ui-ctx-menu__item,
|
||||||
|
select,
|
||||||
|
input:not([type="checkbox"], [type="radio"], [type="range"], [type="hidden"], [type="color"])
|
||||||
|
) {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-touch="on"] .ui-btn {
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 체크 · 라디오 — 상자를 키우고 글까지 한 줄 전체가 누를 자리 */
|
||||||
|
html[data-touch="on"] :is(input[type="checkbox"], input[type="radio"]) {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-touch="on"] .ui-checkbox {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 접힌 좌측 패널 손잡이 — 손가락 폭 · 화면 왼쪽 끝 안 */
|
||||||
|
html[data-touch="on"]
|
||||||
|
.ui-workflow-overlay__panel--title.is-collapsed
|
||||||
|
> .ui-workflow-overlay__handle--side {
|
||||||
|
width: 32px;
|
||||||
|
right: -32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 팝업(2-4) — 폭 600 미만은 전체 화면 · 겹치면 나중 것(맨 위)만 보이고 × 로 앞 창.
|
||||||
|
공용 모달(.ui-modal) · 페이지 팝업은 공용 클래스 `.ui-popup` 만 붙여 맞춤(자리 · 크기 인라인 값도 덮음) */
|
||||||
|
html[data-size="compact"] :is(.ui-modal, .ui-popup) {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0 !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-size="compact"] .ui-confirm__panel {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: calc(100vw - var(--spacing-32));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 공용 표(2-5) — 폭 840 미만은 고정 열이 없는 표도 첫 열을 붙임(머리줄은 늘 붙어 있음 · 가로는 밀기) */
|
||||||
|
html:is([data-size="compact"], [data-size="medium"])
|
||||||
|
.ui-data-table__table:not(:has(.is-frozen))
|
||||||
|
tr
|
||||||
|
> :first-child {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:is([data-size="compact"], [data-size="medium"])
|
||||||
|
.ui-data-table__table:not(:has(.is-frozen))
|
||||||
|
thead
|
||||||
|
tr
|
||||||
|
> :first-child {
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 터치 모드에서만 보이는 단추(자판 없는 기기용 · 4-3) — 마우스 화면엔 없음 */
|
||||||
|
html:not([data-touch="on"]) .ui-touch-only {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 위 줄 높이(6-1) — 화면 높이 계산이 모두 이 값을 씀 · 로그인 뒤 쪽(B · M)은 위 줄을 걷어 0(7-1)
|
||||||
|
☰ 폭(7-4) · 들여쓰기(7-2) = 왼쪽 위에 뜬 ☰(자리 8 + 폭) + 틈 8 · 그 자리에 걸치는 첫 줄이 이만큼 왼쪽을 비움 */
|
||||||
|
:root {
|
||||||
|
--app-header-h: 64px;
|
||||||
|
--app-burger-w: 36px;
|
||||||
|
--app-burger-inset: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.is-bm-route {
|
||||||
|
--app-header-h: 0px;
|
||||||
|
--app-burger-inset: calc(var(--spacing-8) * 2 + var(--app-burger-w));
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* device_context.ts
|
||||||
|
* 기기 상태 한 곳(요구 193~195 · PLAN_기기대응 2-1) — `<html>` 에 지금 상태를 늘 적음.
|
||||||
|
*
|
||||||
|
* - data-size = compact(<600) · medium(<840) · expanded(<1200) · large ← 배치는 이것만 봄
|
||||||
|
* - data-pointer = 주 입력 coarse · fine(미디어 `pointer`)
|
||||||
|
* - data-input = 마지막 누름의 pointerType(mouse · touch · pen)
|
||||||
|
* - data-touch-mode = 사용자 스위치(auto · on · off · 이 기기 브라우저에만 저장)
|
||||||
|
* - data-touch = on · off — 누를 자리 크기를 키울지(auto 면 주 입력 coarse 일 때)
|
||||||
|
* - data-burger = on · off — 공개 쪽에서 ☰ 서랍이 메뉴를 맡는지(폭 900 이하 · 터치) · 로그인 뒤 쪽(B · M)은 늘 ☰(7-1)
|
||||||
|
* 바뀔 때만 window 에 `app:context` 이벤트(detail = 지금 상태).
|
||||||
|
* 경계 표는 여기 한 벌 — CSS 는 위 표시(data-size)로 가르고, 숫자가 필요하면 --bp-* 변수.
|
||||||
|
* 첫 화면 깜빡임 — 화면은 모두 JS 가 그리므로 main.ts 가 맨 먼저 import 하면 그리기 전에 적힘.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import "./device_context.css";
|
||||||
|
|
||||||
|
export const SIZE_BREAKS = { medium: 600, expanded: 840, large: 1200 } as const;
|
||||||
|
/** 위 메뉴가 숨는 폭(app_shell `@media (max-width: 900px)` 과 짝) — 이하면 ☰ 서랍 */
|
||||||
|
export const BURGER_MAX = 900;
|
||||||
|
export type SizeClass = "compact" | "medium" | "expanded" | "large";
|
||||||
|
export type TouchMode = "auto" | "on" | "off";
|
||||||
|
|
||||||
|
export interface DeviceContext {
|
||||||
|
size: SizeClass;
|
||||||
|
pointer: "coarse" | "fine";
|
||||||
|
input: string;
|
||||||
|
touchMode: TouchMode;
|
||||||
|
touch: boolean;
|
||||||
|
burger: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOUCH_MODE_KEY = "frd_touch_mode";
|
||||||
|
|
||||||
|
export function sizeClassOf(width: number): SizeClass {
|
||||||
|
if (width < SIZE_BREAKS.medium) return "compact";
|
||||||
|
if (width < SIZE_BREAKS.expanded) return "medium";
|
||||||
|
if (width < SIZE_BREAKS.large) return "expanded";
|
||||||
|
return "large";
|
||||||
|
}
|
||||||
|
|
||||||
|
let modeInMemory: TouchMode | null = null; // 저장이 막힌 브라우저용
|
||||||
|
|
||||||
|
export function readTouchMode(): TouchMode {
|
||||||
|
if (modeInMemory) return modeInMemory;
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(TOUCH_MODE_KEY);
|
||||||
|
return v === "on" || v === "off" ? v : "auto";
|
||||||
|
} catch {
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const coarseQuery = window.matchMedia("(pointer: coarse)");
|
||||||
|
let current: DeviceContext | null = null;
|
||||||
|
let lastInput = "";
|
||||||
|
|
||||||
|
function read(): DeviceContext {
|
||||||
|
const pointer = coarseQuery.matches ? "coarse" : "fine";
|
||||||
|
const touchMode = readTouchMode();
|
||||||
|
const touch = touchMode === "on" || (touchMode === "auto" && pointer === "coarse");
|
||||||
|
return {
|
||||||
|
size: sizeClassOf(window.innerWidth),
|
||||||
|
pointer,
|
||||||
|
input: lastInput || (pointer === "coarse" ? "touch" : "mouse"),
|
||||||
|
touchMode,
|
||||||
|
touch,
|
||||||
|
burger: touch || window.innerWidth <= BURGER_MAX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(): void {
|
||||||
|
const next = read();
|
||||||
|
if (current && JSON.stringify(current) === JSON.stringify(next)) return;
|
||||||
|
const html = document.documentElement;
|
||||||
|
html.dataset.size = next.size;
|
||||||
|
html.dataset.pointer = next.pointer;
|
||||||
|
html.dataset.input = next.input;
|
||||||
|
html.dataset.touchMode = next.touchMode;
|
||||||
|
html.dataset.touch = next.touch ? "on" : "off";
|
||||||
|
html.dataset.burger = next.burger ? "on" : "off";
|
||||||
|
const first = !current;
|
||||||
|
current = next;
|
||||||
|
if (!first) window.dispatchEvent(new CustomEvent("app:context", { detail: next }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deviceContext(): DeviceContext {
|
||||||
|
return current ?? read();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 터치 모드 스위치(☰ 서랍) — 이 기기 브라우저에만 저장 */
|
||||||
|
export function setTouchMode(mode: TouchMode): void {
|
||||||
|
modeInMemory = mode;
|
||||||
|
try {
|
||||||
|
if (mode === "auto") localStorage.removeItem(TOUCH_MODE_KEY);
|
||||||
|
else localStorage.setItem(TOUCH_MODE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
/* 저장 막힌 브라우저 — 이번 화면만 */
|
||||||
|
}
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(): void {
|
||||||
|
const html = document.documentElement;
|
||||||
|
for (const [name, px] of Object.entries(SIZE_BREAKS))
|
||||||
|
html.style.setProperty(`--bp-${name}`, `${px}px`);
|
||||||
|
apply();
|
||||||
|
coarseQuery.addEventListener("change", apply);
|
||||||
|
window.addEventListener("resize", apply);
|
||||||
|
window.visualViewport?.addEventListener("resize", apply);
|
||||||
|
screen.orientation?.addEventListener("change", apply);
|
||||||
|
window.addEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
(e) => {
|
||||||
|
if (e.pointerType === lastInput) return;
|
||||||
|
lastInput = e.pointerType;
|
||||||
|
apply();
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* input_prefs.ts
|
||||||
|
* 마우스 · 단축키 설정 읽기 층(PLAN 332) — ☰ [설정] 창이 [저장] 때 계정 pref 로 씀.
|
||||||
|
*
|
||||||
|
* - 값 = `aislo:pref:input-prefs`(등록표 `input-prefs`) JSON 한 벌 · 부를 때마다 읽음(새로고침 없이 바로 따름)
|
||||||
|
* - 없음 · 깨짐 · Node(저장소 없음) = 기본값(= 지금 동작) · 의존 없음 · 불러올 때 window 안 만짐(CAD 도 import)
|
||||||
|
* - 휠 확대 방향 하나로 모든 화면 일체 — 기본 밂 = 휠을 앞으로(deltaY < 0) 굴리면 확대(오토캐드) · 당김 = 몸 쪽으로
|
||||||
|
* - 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대(deltaY < 0)
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
export type WheelZoom = "pull" | "push";
|
||||||
|
|
||||||
|
export interface InputPrefs {
|
||||||
|
/** 휠 확대 방향 — pull = 당기면 확대 · push = 밀면 확대 */
|
||||||
|
wheelZoom: WheelZoom;
|
||||||
|
/** CAD 내 별칭 `{명령: [별칭]}`(대문자 · acad.pgp 식 — 명령 이름 > 내 별칭 > 기본 별칭) */
|
||||||
|
cadAliases: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `stateKey("input-prefs")` 와 같은 키(시험이 맞춰 봄) — 등록표를 불러오면 window 가 딸려 와서 글자로 둠 */
|
||||||
|
export const INPUT_PREFS_KEY = "aislo:pref:input-prefs";
|
||||||
|
|
||||||
|
export const DEFAULT_INPUT_PREFS: InputPrefs = { wheelZoom: "push", cadAliases: {} };
|
||||||
|
|
||||||
|
const cleanAliases = (raw: unknown): Record<string, string[]> => {
|
||||||
|
const out: Record<string, string[]> = {};
|
||||||
|
if (!raw || typeof raw !== "object") return out;
|
||||||
|
for (const [id, list] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (!Array.isArray(list)) continue;
|
||||||
|
const aliases = list
|
||||||
|
.filter((a): a is string => typeof a === "string")
|
||||||
|
.map((a) => a.toUpperCase());
|
||||||
|
if (aliases.length) out[id.toUpperCase()] = aliases;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 지금 설정 — 부를 때마다 저장소에서 읽음 */
|
||||||
|
export function inputPrefs(): InputPrefs {
|
||||||
|
try {
|
||||||
|
const raw = globalThis.localStorage?.getItem(INPUT_PREFS_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
|
||||||
|
const value = JSON.parse(raw) as Partial<InputPrefs> | null;
|
||||||
|
return {
|
||||||
|
wheelZoom: value?.wheelZoom === "pull" ? "pull" : "push",
|
||||||
|
cadAliases: cleanAliases(value?.cadAliases),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 휠 한 번이 확대인가 — 설정 방향을 따름 */
|
||||||
|
export const wheelIn = (deltaY: number): boolean =>
|
||||||
|
inputPrefs().wheelZoom === "pull" ? deltaY > 0 : deltaY < 0;
|
||||||
|
|
||||||
|
/** 트랙패드 핀치 — 브라우저가 Ctrl+휠 · 잘게 쪼갠 값으로 냄
|
||||||
|
* ponytail: |deltaY| < 50 로 마우스 Ctrl+휠(한 칸 ≥ 약 100)과 가름 · 잘게 도는 마우스는 핀치로 봄 */
|
||||||
|
export const isTrackpadPinch = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">): boolean =>
|
||||||
|
e.ctrlKey && Math.abs(e.deltaY) < 50;
|
||||||
|
|
||||||
|
/** 휠 사건이 확대인가 — 핀치(`pinch` 손가락 벌림 · 트랙패드)는 늘 벌리면 확대 · 나머지는 설정 방향 */
|
||||||
|
export const wheelEventIn = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">, pinch = false): boolean =>
|
||||||
|
pinch || isTrackpadPinch(e) ? e.deltaY < 0 : wheelIn(e.deltaY);
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* key_shortcut.ts — Ctrl(⌘)+글자 단축키 판정 공용(PLAN 214-2)
|
||||||
|
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞음 — 영문 글자·숫자면 그 글자 ·
|
||||||
|
* 아니면 자판 자리(`code`: KeyZ · Digit1)로 판정. Shift 의 대문자도 소문자로 맞춤.
|
||||||
|
* 쓰는 곳 — 페이지(B05 · M02 · 시트 키) + CAD(`B07_DesignDetail/openwebcad` 의 `input-shortcut.ts` 가 다시 내보냄).
|
||||||
|
* 시험 = `resources/tester/cad/ctrl-shortcut-hangul.test.ts`
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
type KeyLike = Pick<KeyboardEvent, "code"> & Partial<Pick<KeyboardEvent, "key">>;
|
||||||
|
|
||||||
|
/** 누른 글자·숫자(소문자) — 글자·숫자 자리가 아니면 빈 글 */
|
||||||
|
export function keyLetter(evt: KeyLike): string {
|
||||||
|
const key = evt.key ?? "";
|
||||||
|
if (/^[a-zA-Z0-9]$/.test(key)) return key.toLowerCase();
|
||||||
|
const m = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(evt.code ?? "");
|
||||||
|
return m ? (m[1] ?? m[2]).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ctrl(Mac ⌘)과 함께 누른 `letter`(소문자 글자·숫자)인가 — Shift · Alt 는 보지 않음 */
|
||||||
|
export function isCtrlKey(
|
||||||
|
evt: KeyLike & Pick<KeyboardEvent, "ctrlKey" | "metaKey">,
|
||||||
|
letter: string,
|
||||||
|
): boolean {
|
||||||
|
return (evt.ctrlKey || evt.metaKey) && keyLetter(evt) === letter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ctrl 단축키 이름 */
|
||||||
|
export type CtrlShortcut = "undo" | "redo" | "save" | "selectAll";
|
||||||
|
|
||||||
|
/** 공용 Ctrl 단축키 — Z · Shift+Z · Y · S · A (Alt 함께는 아님 · Shift 는 Z 만) */
|
||||||
|
export function ctrlShortcut(
|
||||||
|
evt: KeyLike &
|
||||||
|
Pick<KeyboardEvent, "ctrlKey" | "altKey" | "shiftKey"> &
|
||||||
|
Partial<Pick<KeyboardEvent, "metaKey">>,
|
||||||
|
): CtrlShortcut | null {
|
||||||
|
if (!(evt.ctrlKey || evt.metaKey) || evt.altKey) return null;
|
||||||
|
switch (keyLetter(evt)) {
|
||||||
|
case "z":
|
||||||
|
return evt.shiftKey ? "redo" : "undo";
|
||||||
|
case "y":
|
||||||
|
return evt.shiftKey ? null : "redo";
|
||||||
|
case "s":
|
||||||
|
return evt.shiftKey ? null : "save";
|
||||||
|
case "a":
|
||||||
|
return evt.shiftKey ? null : "selectAll";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* m_settings_nav.ts
|
||||||
|
* 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는
|
||||||
|
* 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
||||||
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||||
|
import { createStepBar } from "@ui/ui_template_workflow_layout";
|
||||||
|
import { setDrawerSteps } from "./app_shell";
|
||||||
|
import { currentRoute, navigateTo, takeLeaveGuard } from "./router";
|
||||||
|
|
||||||
|
interface SettingUser {
|
||||||
|
role?: string;
|
||||||
|
is_master?: boolean;
|
||||||
|
company_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** key = M01 은 「m01」 · M02 는 진입 인자 `open` */
|
||||||
|
export interface SettingLink {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
route: RoutePath;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function L(key: keyof typeof ui_locales): string {
|
||||||
|
return ui_locales[key][currentLanguageIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<string, string> = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" };
|
||||||
|
|
||||||
|
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
|
||||||
|
export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] {
|
||||||
|
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
|
||||||
|
const company: Record<string, string> =
|
||||||
|
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
|
||||||
|
const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({
|
||||||
|
key: open,
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
route: ROUTES.M02_MASTER_TEMPLATE,
|
||||||
|
query: { open, root, ...company },
|
||||||
|
});
|
||||||
|
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
|
||||||
|
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
|
||||||
|
if (user.role === "SYSTEM_ADMIN") {
|
||||||
|
const desc = L("B01_Dashboard_PermMasterWrite");
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "m01",
|
||||||
|
title: L("B01_Dashboard_MasterElements"),
|
||||||
|
desc: L("B01_Dashboard_PermSourceRead"),
|
||||||
|
route: ROUTES.M01_MASTER_DATA,
|
||||||
|
},
|
||||||
|
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
|
||||||
|
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
|
||||||
|
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
|
||||||
|
const companyAdmin = user.role === "ADMIN" || user.is_master;
|
||||||
|
const root = companyAdmin ? "company" : "personal";
|
||||||
|
const desc = [
|
||||||
|
L("B01_Dashboard_PermMasterRead"),
|
||||||
|
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
|
||||||
|
L("B01_Dashboard_PermPersonalWrite"),
|
||||||
|
].join(" · ");
|
||||||
|
return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */
|
||||||
|
export function mountSettingsDrawer(
|
||||||
|
user: SettingUser,
|
||||||
|
activeKey: string,
|
||||||
|
companyId: number | string | null,
|
||||||
|
): void {
|
||||||
|
const links = settingLinks(user, companyId);
|
||||||
|
const bar = createStepBar(
|
||||||
|
links.map((link) => link.title),
|
||||||
|
links.findIndex((link) => link.key === activeKey),
|
||||||
|
{
|
||||||
|
orientation: "vertical",
|
||||||
|
icons: links.map((link) => ICONS[link.key] ?? ""),
|
||||||
|
routes: links.map((link) => link.route),
|
||||||
|
homeButton: true,
|
||||||
|
onStepClick: (index) => void go(links[index]),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setDrawerSteps(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function go(link: SettingLink): Promise<void> {
|
||||||
|
const guard = link.route === currentRoute() ? takeLeaveGuard() : null;
|
||||||
|
if (guard && !(await guard())) return;
|
||||||
|
navigateTo(link.route, link.query);
|
||||||
|
}
|
||||||
+8
-1
@@ -9,9 +9,12 @@
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import "./random_uuid_polyfill";
|
import "./random_uuid_polyfill";
|
||||||
|
import "./device_context"; // 맨 앞 — 그리기 전에 <html> 기기 표시(기기대응 2-1)
|
||||||
|
import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-1)
|
||||||
import "@ui/ui_template_theme.css";
|
import "@ui/ui_template_theme.css";
|
||||||
import { injectBaseStyles } from "@ui/ui_template_elements";
|
import { injectBaseStyles } from "@ui/ui_template_elements";
|
||||||
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
|
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
|
||||||
|
import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe";
|
||||||
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
import { ROUTES, type RoutePath } from "@config/config_frontend";
|
||||||
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
|
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +22,7 @@ import {
|
|||||||
renderCurrentRoute,
|
renderCurrentRoute,
|
||||||
ensureInitialHash,
|
ensureInitialHash,
|
||||||
setLeaveGuard,
|
setLeaveGuard,
|
||||||
|
draftRoute,
|
||||||
showWithinPage,
|
showWithinPage,
|
||||||
takeLeaveGuard,
|
takeLeaveGuard,
|
||||||
} from "./router";
|
} from "./router";
|
||||||
@@ -54,6 +58,8 @@ function bootstrap(): void {
|
|||||||
|
|
||||||
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
|
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
|
||||||
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
|
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
|
||||||
|
// B01~B09 · M01 · M02 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1 · 220)
|
||||||
|
initLoupe(window, () => LOUPE_ROUTE.test(currentRoute()));
|
||||||
|
|
||||||
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
|
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
|
||||||
let shown = "";
|
let shown = "";
|
||||||
@@ -67,7 +73,8 @@ function bootstrap(): void {
|
|||||||
shown = currentRoute();
|
shown = currentRoute();
|
||||||
shownHash = window.location.hash;
|
shownHash = window.location.hash;
|
||||||
if (within) return;
|
if (within) return;
|
||||||
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
|
const showFooter =
|
||||||
|
!FOOTERLESS_ROUTES.includes(currentRoute()) && !draftRoute(currentRoute())?.footerless;
|
||||||
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
|
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
|
||||||
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
|
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
|
||||||
};
|
};
|
||||||
|
|||||||
+29
-4
@@ -74,6 +74,29 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
|||||||
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
|
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 초안 길(302-1) — 페이지 폴더 `draft/*_Route.ts` 의 `ROUTE` 선언 · 폴더째 지우면 길도 없음.
|
||||||
|
* 정적 표에 이름을 박지 않음 — 보호 · 단계 화면(로그인 · 회사) · 프로젝트 · 꼬리말 없음도 선언에서. */
|
||||||
|
export interface DraftRoute {
|
||||||
|
path: string;
|
||||||
|
load: () => Promise<PageRenderer>;
|
||||||
|
/** 로그인 · 회사 필요(B 단계 화면) — 로그인만이면 protected */
|
||||||
|
workflow?: boolean;
|
||||||
|
protected?: boolean;
|
||||||
|
/** 현재 프로젝트로 여는 화면(지워진 프로젝트 안내) */
|
||||||
|
project?: boolean;
|
||||||
|
footerless?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAFT_ROUTES = new Map(
|
||||||
|
Object.values(import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true }))
|
||||||
|
.map((m) => m.ROUTE)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((r) => [r.path, r] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 초안 길 선언(없으면 undefined) */
|
||||||
|
export const draftRoute = (route: string): DraftRoute | undefined => DRAFT_ROUTES.get(route);
|
||||||
|
|
||||||
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
|
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
|
||||||
let leaveGuard: (() => Promise<boolean>) | null = null;
|
let leaveGuard: (() => Promise<boolean>) | null = null;
|
||||||
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
|
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
|
||||||
@@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
|||||||
mounted?.dispose();
|
mounted?.dispose();
|
||||||
mounted = null;
|
mounted = null;
|
||||||
|
|
||||||
if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) {
|
const draft = draftRoute(route);
|
||||||
|
const guarded = PROTECTED_ROUTES.includes(route) || !!(draft?.protected || draft?.workflow);
|
||||||
|
if (guarded && !(await isAuthenticated())) {
|
||||||
navigateTo(ROUTES.A06_LOGIN);
|
navigateTo(ROUTES.A06_LOGIN);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -178,7 +203,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
|||||||
ROUTES.B11_LOADING,
|
ROUTES.B11_LOADING,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (workflowRoutes.includes(route)) {
|
if (workflowRoutes.includes(route) || draft?.workflow) {
|
||||||
const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch");
|
const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch");
|
||||||
try {
|
try {
|
||||||
const user = await fetchSessionUser();
|
const user = await fetchSessionUser();
|
||||||
@@ -197,7 +222,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다.
|
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다.
|
||||||
if (PROJECT_ROUTES.includes(route)) {
|
if (PROJECT_ROUTES.includes(route) || draft?.project) {
|
||||||
const { dropGoneProject } = await import("./b_workflow_nav");
|
const { dropGoneProject } = await import("./b_workflow_nav");
|
||||||
if (await dropGoneProject()) {
|
if (await dropGoneProject()) {
|
||||||
if (seq !== renderSeq) return;
|
if (seq !== renderSeq) return;
|
||||||
@@ -208,7 +233,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loader = routeTable[route];
|
const loader = routeTable[route] ?? draft?.load;
|
||||||
if (!loader) {
|
if (!loader) {
|
||||||
renderPlaceholder(outlet, route);
|
renderPlaceholder(outlet, route);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
|
|||||||
import { attachClipboard, pasteTableAt, type PasteCheck } from "./spreadsheet_clipboard";
|
import { attachClipboard, pasteTableAt, type PasteCheck } from "./spreadsheet_clipboard";
|
||||||
import { applyCommand, CommandError } from "./spreadsheet_commands";
|
import { applyCommand, CommandError } from "./spreadsheet_commands";
|
||||||
import { createEditor, slotOf } from "./spreadsheet_editor";
|
import { createEditor, slotOf } from "./spreadsheet_editor";
|
||||||
|
import { mountTouchKeys } from "./spreadsheet_touch_keys";
|
||||||
import { toCalcValue } from "./spreadsheet_eval";
|
import { toCalcValue } from "./spreadsheet_eval";
|
||||||
import { attachExtras } from "./spreadsheet_extras";
|
import { attachExtras } from "./spreadsheet_extras";
|
||||||
import { createCalcEngine } from "./spreadsheet_graph";
|
import { createCalcEngine } from "./spreadsheet_graph";
|
||||||
@@ -27,7 +28,7 @@ import type { LinkOptions, Links } from "./spreadsheet_links";
|
|||||||
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
|
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
|
||||||
import { attachMenu } from "./spreadsheet_menu";
|
import { attachMenu } from "./spreadsheet_menu";
|
||||||
import { attachMouse } from "./spreadsheet_mouse";
|
import { attachMouse } from "./spreadsheet_mouse";
|
||||||
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
|
import { LAST_C, LAST_R, selectCell, selectRanges } from "./spreadsheet_selection";
|
||||||
import { createStatusBar } from "./spreadsheet_statusbar";
|
import { createStatusBar } from "./spreadsheet_statusbar";
|
||||||
import { mountTabs } from "./spreadsheet_tabs";
|
import { mountTabs } from "./spreadsheet_tabs";
|
||||||
import { mountToolbar } from "./spreadsheet_toolbar";
|
import { mountToolbar } from "./spreadsheet_toolbar";
|
||||||
@@ -89,6 +90,9 @@ export interface SpreadsheetHandle {
|
|||||||
/** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) ·
|
/** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) ·
|
||||||
* `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */
|
* `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */
|
||||||
pasteTable(html: string, check?: PasteCheck): Promise<string | null>;
|
pasteTable(html: string, check?: PasteCheck): Promise<string | null>;
|
||||||
|
/** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) ·
|
||||||
|
* 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */
|
||||||
|
select(범위: string | string[], 시트?: string): boolean;
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +165,7 @@ export function createSpreadsheet(
|
|||||||
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
|
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
|
||||||
others.push(...extras.parts);
|
others.push(...extras.parts);
|
||||||
const detachKeys = attachKeys(ctx, ed, extras.keys);
|
const detachKeys = attachKeys(ctx, ed, extras.keys);
|
||||||
|
mountTouchKeys(ctx, ed); // 자판 없는 기기 단추 줄(기기대응 4-3 · 터치 모드에서만 보임)
|
||||||
const detachMouse = attachMouse(ctx, ed, extras.mouse);
|
const detachMouse = attachMouse(ctx, ed, extras.mouse);
|
||||||
/** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */
|
/** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */
|
||||||
let lateTabs: PartHandle | null = null;
|
let lateTabs: PartHandle | null = null;
|
||||||
@@ -348,6 +353,18 @@ export function createSpreadsheet(
|
|||||||
return 이름;
|
return 이름;
|
||||||
},
|
},
|
||||||
pasteTable: (html, check) => pasteTableAt(ctx, html, check),
|
pasteTable: (html, check) => pasteTableAt(ctx, html, check),
|
||||||
|
select(range, sheetKey) {
|
||||||
|
const target = sheetKey
|
||||||
|
? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey)
|
||||||
|
: ctx.sheet();
|
||||||
|
const next = target && selectRanges(target, typeof range === "string" ? [range] : range);
|
||||||
|
if (!target || !next) return false;
|
||||||
|
showSheet(target.id);
|
||||||
|
if (ctx.selection.시트 !== target.id) return false; // 편집기 유효성에 막힘
|
||||||
|
select(next, true);
|
||||||
|
grid.reveal(next.활성.r, next.활성.c); // 범위가 판보다 크면 왼위가 보이게
|
||||||
|
return true;
|
||||||
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
clearTimeout(toastTimer);
|
clearTimeout(toastTimer);
|
||||||
detachKeys();
|
detachKeys();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address";
|
import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address";
|
||||||
import { cellStyleAt } from "./spreadsheet_cellstyle";
|
import { cellStyleAt, shownHAlign } from "./spreadsheet_cellstyle";
|
||||||
import { createCalcEngine } from "./spreadsheet_graph";
|
import { createCalcEngine } from "./spreadsheet_graph";
|
||||||
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
|
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
|
||||||
import { resolveName } from "./spreadsheet_names";
|
import { resolveName } from "./spreadsheet_names";
|
||||||
@@ -110,7 +110,7 @@ export function areaTable(ask: TableAsk) {
|
|||||||
: { 글: "", 정렬: "left" as const };
|
: { 글: "", 정렬: "left" as const };
|
||||||
const key = `${sheet.id}!${toA1(r, c)}`;
|
const key = `${sheet.id}!${toA1(r, c)}`;
|
||||||
값[key] = shown.글;
|
값[key] = shown.글;
|
||||||
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]);
|
const align: TableCell["align"] = ALIGN[shownHAlign(style.가로)] ?? "center"; // 안 준 칸 = 가운데(237-1)
|
||||||
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
|
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
|
||||||
};
|
};
|
||||||
const cells = rows.map((r) =>
|
const cells = rows.map((r) =>
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ function borderCss(side: BorderSide | undefined, scale: number): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */
|
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). 안 준 칸(일반) = 가운데(237-1). */
|
||||||
const HALIGN_CSS: Record<HAlign, string> = {
|
const HALIGN_CSS: Record<HAlign, string> = {
|
||||||
general: "left",
|
general: "center",
|
||||||
left: "left",
|
left: "left",
|
||||||
right: "right",
|
right: "right",
|
||||||
center: "center",
|
center: "center",
|
||||||
@@ -149,13 +149,23 @@ const HALIGN_CSS: Record<HAlign, string> = {
|
|||||||
centerContinuous: "center",
|
centerContinuous: "center",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 칸에 맞춤을 안 준(없음 · 일반) 보이는 가로 맞춤 = 가운데 — 화면 · 도구줄 · 대화상자 · 내보내기 공통(237-1). */
|
||||||
|
export function shownHAlign(h: HAlign | undefined): HAlign {
|
||||||
|
return !h || h === "general" ? "center" : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 칸에 맞춤을 안 준 보이는 세로 맞춤 = 가운데(237-1). */
|
||||||
|
export function shownVAlign(v: VAlign | undefined): VAlign {
|
||||||
|
return v ?? "center";
|
||||||
|
}
|
||||||
|
|
||||||
const VALIGN_CSS: Record<VAlign, string> = {
|
const VALIGN_CSS: Record<VAlign, string> = {
|
||||||
top: "flex-start",
|
top: "flex-start",
|
||||||
center: "center",
|
center: "center",
|
||||||
bottom: "flex-end",
|
bottom: "flex-end",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
|
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 = 가운데).
|
||||||
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
|
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
|
||||||
export function applyCellStyle(
|
export function applyCellStyle(
|
||||||
cell: HTMLElement,
|
cell: HTMLElement,
|
||||||
@@ -178,7 +188,7 @@ export function applyCellStyle(
|
|||||||
s.borderLeft = borderCss(borders.왼, scale);
|
s.borderLeft = borderCss(borders.왼, scale);
|
||||||
s.borderBottom = borderCss(borders.아래, scale);
|
s.borderBottom = borderCss(borders.아래, scale);
|
||||||
s.borderRight = borderCss(borders.오른, scale);
|
s.borderRight = borderCss(borders.오른, scale);
|
||||||
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
|
s.justifyContent = VALIGN_CSS[shownVAlign(style.세로)];
|
||||||
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
|
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
|
||||||
|
|
||||||
let text = cell.firstElementChild as HTMLElement | null;
|
let text = cell.firstElementChild as HTMLElement | null;
|
||||||
@@ -187,7 +197,7 @@ export function applyCellStyle(
|
|||||||
text.className = "aislo-cell__text";
|
text.className = "aislo-cell__text";
|
||||||
cell.replaceChildren(text);
|
cell.replaceChildren(text);
|
||||||
}
|
}
|
||||||
text.style.textAlign = HALIGN_CSS[hAlign];
|
text.style.textAlign = HALIGN_CSS[shownHAlign(hAlign)];
|
||||||
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
|
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { showToast } from "@ui/ui_template_elements";
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
|
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
|
||||||
|
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
|
||||||
import { clipWholeToUsed } from "./spreadsheet_grid_cells";
|
import { clipWholeToUsed } from "./spreadsheet_grid_cells";
|
||||||
import { formatValue } from "./spreadsheet_numfmt";
|
import { formatValue } from "./spreadsheet_numfmt";
|
||||||
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
|
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
|
||||||
@@ -70,10 +71,11 @@ function styleToCss(style: CellStyle): string {
|
|||||||
if (deco) parts.push(`text-decoration:${deco}`);
|
if (deco) parts.push(`text-decoration:${deco}`);
|
||||||
if (style.글자색) parts.push(`color:${style.글자색}`);
|
if (style.글자색) parts.push(`color:${style.글자색}`);
|
||||||
if (style.채움) parts.push(`background-color:${style.채움}`);
|
if (style.채움) parts.push(`background-color:${style.채움}`);
|
||||||
if (style.가로 && style.가로 !== "general") {
|
// 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양
|
||||||
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`);
|
const h = shownHAlign(style.가로);
|
||||||
}
|
parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`);
|
||||||
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`);
|
const v = shownVAlign(style.세로);
|
||||||
|
parts.push(`vertical-align:${v === "center" ? "middle" : v}`);
|
||||||
if (style.줄바꿈) parts.push("white-space:pre-wrap");
|
if (style.줄바꿈) parts.push("white-space:pre-wrap");
|
||||||
if (style.테두리) {
|
if (style.테두리) {
|
||||||
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
|
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
|
|||||||
attrs: { type: "button", title: TEXT.close },
|
attrs: { type: "button", title: TEXT.close },
|
||||||
});
|
});
|
||||||
const dialog = el("div", {
|
const dialog = el("div", {
|
||||||
className: "ss-dialog",
|
className: "ss-dialog ui-popup",
|
||||||
attrs: { hidden: "" },
|
attrs: { hidden: "" },
|
||||||
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
|
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
|
|||||||
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
|
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
|
||||||
ta.style.fontWeight = s.굵게 ? "bold" : "";
|
ta.style.fontWeight = s.굵게 ? "bold" : "";
|
||||||
ta.style.fontStyle = s.기울임 ? "italic" : "";
|
ta.style.fontStyle = s.기울임 ? "italic" : "";
|
||||||
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
|
ta.style.textAlign = s.가로 === "left" || s.가로 === "right" ? s.가로 : "center";
|
||||||
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
|
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
|
||||||
ta.style.width = "0";
|
ta.style.width = "0";
|
||||||
ta.style.height = "0";
|
ta.style.height = "0";
|
||||||
@@ -303,6 +303,14 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function focus(): void {
|
function focus(): void {
|
||||||
|
// 손가락 — 고칠 때만 화면 자판(기기대응 4-2) · 칸 고름만으로는 안 뜸
|
||||||
|
if (document.documentElement.dataset.touch === "on") {
|
||||||
|
const mode = st ? "text" : "none";
|
||||||
|
if (ta.inputMode !== mode) {
|
||||||
|
ta.inputMode = mode;
|
||||||
|
if (document.activeElement === ta) ta.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (document.activeElement !== ta) ta.focus({ preventScroll: true });
|
if (document.activeElement !== ta) ta.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function openEvalSteps(ctx: SpreadsheetContext): () => void {
|
|||||||
onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
|
onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
|
||||||
});
|
});
|
||||||
const box = el("div", {
|
const box = el("div", {
|
||||||
className: "ss-steps__box",
|
className: "ss-steps__box ui-popup",
|
||||||
attrs: { role: "dialog", "aria-label": t("Title") },
|
attrs: { role: "dialog", "aria-label": t("Title") },
|
||||||
});
|
});
|
||||||
const shade = el("div", { className: "ss-steps", children: [box] });
|
const shade = el("div", { className: "ss-steps", children: [box] });
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
type NegStyle,
|
type NegStyle,
|
||||||
} from "./spreadsheet_format_code";
|
} from "./spreadsheet_format_code";
|
||||||
import { dialogKeys } from "./spreadsheet_actions";
|
import { dialogKeys } from "./spreadsheet_actions";
|
||||||
import { isAutoInk } from "./spreadsheet_cellstyle";
|
import { isAutoInk, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
|
||||||
import { formatValue } from "./spreadsheet_numfmt";
|
import { formatValue } from "./spreadsheet_numfmt";
|
||||||
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
|
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
|
||||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||||
@@ -233,7 +233,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
|
|||||||
["centerContinuous", "선택 영역 가운데"],
|
["centerContinuous", "선택 영역 가운데"],
|
||||||
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
|
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
|
||||||
}) as HTMLSelectElement;
|
}) as HTMLSelectElement;
|
||||||
hSelect.value = draft.가로 ?? "general";
|
hSelect.value = shownHAlign(draft.가로);
|
||||||
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
|
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
|
||||||
|
|
||||||
const vSelect = el("select", {
|
const vSelect = el("select", {
|
||||||
@@ -243,7 +243,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
|
|||||||
["bottom", "아래"],
|
["bottom", "아래"],
|
||||||
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
|
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
|
||||||
}) as HTMLSelectElement;
|
}) as HTMLSelectElement;
|
||||||
vSelect.value = draft.세로 ?? "bottom";
|
vSelect.value = shownVAlign(draft.세로);
|
||||||
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
|
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
|
||||||
|
|
||||||
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
|
||||||
|
|||||||
@@ -263,3 +263,16 @@
|
|||||||
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
|
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 손가락(기기대응 4-2) — 채우기 손잡이를 키움 · 격자는 한 손가락 밀기만 브라우저에(두 손가락 = 격자 확대) */
|
||||||
|
html[data-touch="on"] .aislo-grid__fill-handle {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin: -4px 0 0 -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (any-pointer: coarse) {
|
||||||
|
.aislo-grid {
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import "./spreadsheet_grid.css";
|
import "./spreadsheet_grid.css";
|
||||||
import { colIndex, toA1 } from "./spreadsheet_address";
|
import { colIndex, toA1 } from "./spreadsheet_address";
|
||||||
|
import { wheelZoomsIn } from "../touch_bridge";
|
||||||
import {
|
import {
|
||||||
buildColGeometry,
|
buildColGeometry,
|
||||||
buildOutlineRuns,
|
buildOutlineRuns,
|
||||||
@@ -271,7 +272,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
|||||||
if (!ev.ctrlKey) return;
|
if (!ev.ctrlKey) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const before = zoom;
|
const before = zoom;
|
||||||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015)));
|
const step = (wheelZoomsIn(ev) ? 1 : -1) * Math.abs(ev.deltaY) * 0.0015; // ☰ [설정] 방향(332)
|
||||||
|
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(step)));
|
||||||
if (next === before) return;
|
if (next === before) return;
|
||||||
zoom = next;
|
zoom = next;
|
||||||
colGeo = scaledGeometry(baseColGeo, zoom);
|
colGeo = scaledGeometry(baseColGeo, zoom);
|
||||||
@@ -553,7 +555,12 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
|||||||
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
|
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
|
||||||
if (handleAt) {
|
if (handleAt) {
|
||||||
const screen = screenBoxOf(handleAt.r, handleAt.c);
|
const screen = screenBoxOf(handleAt.r, handleAt.c);
|
||||||
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) {
|
// 손가락이면 손잡이 잡는 자리를 넓힘(기기대응 4-2)
|
||||||
|
const grip = document.documentElement.dataset.touch === "on" ? 20 : 5;
|
||||||
|
if (
|
||||||
|
Math.abs(x - (screen.x + screen.w)) <= grip &&
|
||||||
|
Math.abs(y - (screen.y + screen.h)) <= grip
|
||||||
|
) {
|
||||||
return { kind: "fillHandle", r: handleAt.r, c: handleAt.c };
|
return { kind: "fillHandle", r: handleAt.r, c: handleAt.c };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
isAutoInk,
|
isAutoInk,
|
||||||
resolveBoxBorders,
|
resolveBoxBorders,
|
||||||
setCellText,
|
setCellText,
|
||||||
|
shownHAlign,
|
||||||
type ResolvedBorders,
|
type ResolvedBorders,
|
||||||
} from "./spreadsheet_cellstyle";
|
} from "./spreadsheet_cellstyle";
|
||||||
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
|
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
|
||||||
@@ -139,7 +140,7 @@ export function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
|
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
|
||||||
general: "left",
|
general: "center",
|
||||||
left: "left",
|
left: "left",
|
||||||
right: "right",
|
right: "right",
|
||||||
center: "center",
|
center: "center",
|
||||||
@@ -218,7 +219,7 @@ function cellDisplay(
|
|||||||
const scalar = scalarAt(ctx, sheet, r, c);
|
const scalar = scalarAt(ctx, sheet, r, c);
|
||||||
const formatted = formatValue(scalar, code);
|
const formatted = formatValue(scalar, code);
|
||||||
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
|
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
|
||||||
const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬;
|
const hAlign: HAlign = shownHAlign(hAlign0); // 안 준 칸 = 가운데(237-1)
|
||||||
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
|
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
|
||||||
const isNumeric =
|
const isNumeric =
|
||||||
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
|
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
|
* 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { keyLetter } from "../key_shortcut";
|
||||||
import { cycleAbs } from "./spreadsheet_editor";
|
import { cycleAbs } from "./spreadsheet_editor";
|
||||||
import { fillCells } from "./spreadsheet_fill";
|
import { fillCells } from "./spreadsheet_fill";
|
||||||
import {
|
import {
|
||||||
@@ -127,7 +128,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
|
|||||||
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
|
go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
const k = keyLetter(e) || (e.key.length === 1 ? e.key.toLowerCase() : e.key);
|
||||||
if (ctrl && !e.altKey) {
|
if (ctrl && !e.altKey) {
|
||||||
switch (k) {
|
switch (k) {
|
||||||
case "z":
|
case "z":
|
||||||
@@ -233,7 +234,8 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent): void {
|
function onKey(e: KeyboardEvent): void {
|
||||||
if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫
|
// 한글 조합 중 — 브라우저 몫 (Ctrl+글자 단축키는 조합 중에도 판정 · 한글 자판 대응 214-2)
|
||||||
|
if ((e.isComposing || e.keyCode === 229) && !((e.ctrlKey || e.metaKey) && keyLetter(e))) return;
|
||||||
const inBar = e.target === ed.formula;
|
const inBar = e.target === ed.formula;
|
||||||
const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
|
const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e);
|
||||||
if (done) {
|
if (done) {
|
||||||
|
|||||||
@@ -255,11 +255,61 @@ export function attachMouse(
|
|||||||
};
|
};
|
||||||
const onLeave = (): void => hooks.hover?.(null);
|
const onLeave = (): void => hooks.hover?.(null);
|
||||||
|
|
||||||
|
// 손가락(기기대응 4-2) — 톡 = 칸 고름(브라우저 호환 마우스 그대로) · 고른 칸 · 행열 머리 · 채우기 손잡이에서
|
||||||
|
// 끌기 = 범위 · 채우기(마우스 끌기와 같은 길) · 안 고른 칸에서 끌기 = 화면 밀기 ·
|
||||||
|
// 두 번 톡 = 고침(손 동작 안에서 바로 열어 화면 자판이 뜸)
|
||||||
|
let touchDrag = false;
|
||||||
|
let lastTap = 0;
|
||||||
|
const asMouse = (t: Touch, type: string): MouseEvent =>
|
||||||
|
new MouseEvent(type, { clientX: t.clientX, clientY: t.clientY, button: 0, bubbles: true });
|
||||||
|
const onTouchStart = (e: TouchEvent): void => {
|
||||||
|
if (e.touches.length !== 1 || e.target === ed.ta) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
|
||||||
|
const grab =
|
||||||
|
hit &&
|
||||||
|
(hit.kind === "fillHandle" ||
|
||||||
|
hit.kind === "rowHead" ||
|
||||||
|
hit.kind === "colHead" ||
|
||||||
|
(hit.kind === "cell" && inSelection(hit.r, hit.c)));
|
||||||
|
if (!grab) return;
|
||||||
|
e.preventDefault(); // 화면 밀기 대신 범위 · 호환 마우스 이벤트도 안 옴
|
||||||
|
touchDrag = true;
|
||||||
|
onDown(asMouse(t, "mousedown"));
|
||||||
|
};
|
||||||
|
const onTouchMove = (e: TouchEvent): void => {
|
||||||
|
if (!touchDrag) return;
|
||||||
|
e.preventDefault();
|
||||||
|
root.dispatchEvent(asMouse(e.touches[0], "mousemove")); // 올라가 window 끌기 듣개에 닿음
|
||||||
|
};
|
||||||
|
const onTouchEnd = (e: TouchEvent): void => {
|
||||||
|
if (touchDrag) {
|
||||||
|
touchDrag = false;
|
||||||
|
root.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); // M02 칸 고르기도 받음
|
||||||
|
}
|
||||||
|
if (e.touches.length || e.changedTouches.length !== 1) return;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTap < 350 && hit?.kind === "cell" && !ed.editing() && !ctx.readOnly) {
|
||||||
|
lastTap = 0;
|
||||||
|
ed.begin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTap = now;
|
||||||
|
};
|
||||||
|
|
||||||
root.addEventListener("mousedown", onDown);
|
root.addEventListener("mousedown", onDown);
|
||||||
root.addEventListener("dblclick", onDbl);
|
root.addEventListener("dblclick", onDbl);
|
||||||
root.addEventListener("mousemove", onHover);
|
root.addEventListener("mousemove", onHover);
|
||||||
root.addEventListener("mouseleave", onLeave);
|
root.addEventListener("mouseleave", onLeave);
|
||||||
|
root.addEventListener("touchstart", onTouchStart, { passive: false });
|
||||||
|
root.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||||
|
root.addEventListener("touchend", onTouchEnd);
|
||||||
return () => {
|
return () => {
|
||||||
|
root.removeEventListener("touchstart", onTouchStart);
|
||||||
|
root.removeEventListener("touchmove", onTouchMove);
|
||||||
|
root.removeEventListener("touchend", onTouchEnd);
|
||||||
root.removeEventListener("mousemove", onHover);
|
root.removeEventListener("mousemove", onHover);
|
||||||
root.removeEventListener("mouseleave", onLeave);
|
root.removeEventListener("mouseleave", onLeave);
|
||||||
stopDrag?.();
|
stopDrag?.();
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress):
|
|||||||
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
|
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A1 범위 글 여럿 → 고름(329-2 손잡이 `select`) — 병합만큼 늘림 · 활성 = 첫 범위 왼위 · 틀린 글 · 빈 목록 = null */
|
||||||
|
export function selectRanges(sheet: Sheet, texts: string[]): Selection | null {
|
||||||
|
const list = merges(sheet);
|
||||||
|
const 범위: CellRange[] = [];
|
||||||
|
for (const t of texts) {
|
||||||
|
const g = parseRange(t);
|
||||||
|
if (!g) return null;
|
||||||
|
범위.push(expand(g, list));
|
||||||
|
}
|
||||||
|
if (!범위.length) return null;
|
||||||
|
const at = { r: 범위[0].r0, c: 범위[0].c0 };
|
||||||
|
return { 시트: sheet.id, 범위, 활성: at, 기준: { ...at } };
|
||||||
|
}
|
||||||
|
|
||||||
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
|
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
|
||||||
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
|
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
|
||||||
const r0 = Math.min(a, b);
|
const r0 = Math.min(a, b);
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function openSortDialog(ctx: SpreadsheetContext): () => void {
|
|||||||
const caseBox = el("input", { attrs: { type: "checkbox" } });
|
const caseBox = el("input", { attrs: { type: "checkbox" } });
|
||||||
const list = el("div", { className: "ss-sort__keys" });
|
const list = el("div", { className: "ss-sort__keys" });
|
||||||
const box = el("div", {
|
const box = el("div", {
|
||||||
className: "ss-sort__box",
|
className: "ss-sort__box ui-popup",
|
||||||
attrs: { role: "dialog", "aria-label": t("Title") },
|
attrs: { role: "dialog", "aria-label": t("Title") },
|
||||||
});
|
});
|
||||||
const shade = el("div", { className: "ss-sort", children: [box] });
|
const shade = el("div", { className: "ss-sort", children: [box] });
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function setSpreadsheetLanguage(index: 0 | 1): void {
|
|||||||
|
|
||||||
const TEXT = {
|
const TEXT = {
|
||||||
Undo: ["되돌리기", "Undo"],
|
Undo: ["되돌리기", "Undo"],
|
||||||
|
CellMenu: ["칸 메뉴(우클릭과 같음)", "Cell menu (same as right-click)"],
|
||||||
Redo: ["다시", "Redo"],
|
Redo: ["다시", "Redo"],
|
||||||
FontName: ["글꼴", "Font"],
|
FontName: ["글꼴", "Font"],
|
||||||
FontSize: ["크기", "Size"],
|
FontSize: ["크기", "Size"],
|
||||||
|
|||||||
@@ -291,3 +291,23 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--spacing-8);
|
gap: var(--spacing-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ⋯ 칸 메뉴(기기대응 3-5) — 터치 모드에서만 */
|
||||||
|
.ss-toolbar .ss-toolbar__more {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-touch="on"] .ss-toolbar .ss-toolbar__more {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 자판 없는 기기 단추 줄(기기대응 4-3 · spreadsheet_touch_keys.ts) — 터치 모드에서만 */
|
||||||
|
.ss-touch-keys {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-touch="on"] .ss-touch-keys {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { createButton, el } from "@ui/ui_template_elements";
|
import { createButton, el } from "@ui/ui_template_elements";
|
||||||
import { confirmMerge } from "./spreadsheet_actions";
|
import { confirmMerge } from "./spreadsheet_actions";
|
||||||
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
|
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||||
|
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
|
||||||
import { cellToText, textToCell } from "./spreadsheet_editor";
|
import { cellToText, textToCell } from "./spreadsheet_editor";
|
||||||
import { st } from "./spreadsheet_text";
|
import { st } from "./spreadsheet_text";
|
||||||
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
|
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
|
||||||
@@ -147,6 +148,30 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
|
|||||||
undoBtn.title = st("Undo");
|
undoBtn.title = st("Undo");
|
||||||
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
|
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
|
||||||
redoBtn.title = st("Redo");
|
redoBtn.title = st("Redo");
|
||||||
|
// 손가락 — 우클릭 대신 활성 칸 메뉴를 여는 ⋯(기기대응 3-5 · 터치 모드에서만 보임)
|
||||||
|
const moreBtn = createButton({
|
||||||
|
label: "⋯",
|
||||||
|
variant: "ghost",
|
||||||
|
onClick: () => {
|
||||||
|
const { r, c } = ctx.selection.활성;
|
||||||
|
const { box } = ctx.grid.editorSlot(r, c);
|
||||||
|
const at = ctx.grid.root.getBoundingClientRect();
|
||||||
|
const x = at.left + box.x + Math.min(box.w / 2, 24);
|
||||||
|
const y = at.top + box.y + box.h / 2;
|
||||||
|
const target = document.elementFromPoint(x, y) ?? ctx.grid.root;
|
||||||
|
target.dispatchEvent(
|
||||||
|
new MouseEvent("contextmenu", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
clientX: x,
|
||||||
|
clientY: y,
|
||||||
|
button: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
moreBtn.classList.add("ss-toolbar__more");
|
||||||
|
moreBtn.title = st("CellMenu");
|
||||||
|
|
||||||
// ── 글꼴 · 크기 ────────────────────────────────────────────────────────
|
// ── 글꼴 · 크기 ────────────────────────────────────────────────────────
|
||||||
const fontInput = el("input", {
|
const fontInput = el("input", {
|
||||||
@@ -500,7 +525,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
|
|||||||
});
|
});
|
||||||
|
|
||||||
root.append(
|
root.append(
|
||||||
group(undoBtn, redoBtn),
|
group(undoBtn, redoBtn, moreBtn),
|
||||||
group(fontInput, sizeInput),
|
group(fontInput, sizeInput),
|
||||||
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
|
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
|
||||||
group(textColor, textColorClear, fillColor, fillColorClear),
|
group(textColor, textColorClear, fillColor, fillColorClear),
|
||||||
@@ -528,8 +553,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
|
|||||||
strikeBtn.classList.toggle("is-on", !!style.취소선);
|
strikeBtn.classList.toggle("is-on", !!style.취소선);
|
||||||
textColor.value = style.글자색 ?? "#000000";
|
textColor.value = style.글자색 ?? "#000000";
|
||||||
fillColor.value = style.채움 ?? "#ffffff";
|
fillColor.value = style.채움 ?? "#ffffff";
|
||||||
hAlignSelect.value = style.가로 ?? "general";
|
hAlignSelect.value = shownHAlign(style.가로);
|
||||||
vAlignSelect.value = style.세로 ?? "bottom";
|
vAlignSelect.value = shownVAlign(style.세로);
|
||||||
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
|
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
|
||||||
numfmtInput.value = style.형식 ?? "";
|
numfmtInput.value = style.형식 ?? "";
|
||||||
const range = ctx.selection.범위[0];
|
const range = ctx.selection.범위[0];
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* spreadsheet_touch_keys.ts
|
||||||
|
* 자판 없는 기기용 단추 줄(기기대응 4-3) — 단축키로만 되던 일을 같은 키 길(`attachKeys`)로 보냄.
|
||||||
|
* 입력줄(`ed.bar`) 끝에 붙고 터치 모드에서만 보임(spreadsheet_toolbar.css). 마우스 화면은 그대로.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { createButton } from "@ui/ui_template_elements";
|
||||||
|
import type { Editor } from "./spreadsheet_editor";
|
||||||
|
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||||
|
|
||||||
|
const KEYS: [label: string, title: string, init: KeyboardEventInit, edit: boolean][] = [
|
||||||
|
["🔍", "찾기 · 바꾸기(Ctrl+F)", { key: "f", code: "KeyF", ctrlKey: true }, false],
|
||||||
|
["⇉", "고른 칸 모두 같은 값(Ctrl+Enter)", { key: "Enter", ctrlKey: true }, true],
|
||||||
|
["↵", "칸 안 줄바꿈(Alt+Enter)", { key: "Enter", altKey: true }, true],
|
||||||
|
["✕", "고침 취소(Esc)", { key: "Escape" }, true],
|
||||||
|
["📅", "오늘 날짜(Ctrl+;)", { key: ";", code: "Semicolon", ctrlKey: true }, true],
|
||||||
|
["$", "참조 $ 돌리기(F4)", { key: "F4" }, true],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function mountTouchKeys(ctx: SpreadsheetContext, ed: Editor): void {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ss-touch-keys";
|
||||||
|
for (const [label, title, init, edit] of KEYS) {
|
||||||
|
if (edit && ctx.readOnly) continue;
|
||||||
|
const b = createButton({
|
||||||
|
label,
|
||||||
|
variant: "ghost",
|
||||||
|
onClick: () => {
|
||||||
|
// 고치는 중이면 그 칸(입력줄 · 칸 편집기) — 아니면 칸 편집기로
|
||||||
|
const target = document.activeElement === ed.formula ? ed.formula : ed.ta;
|
||||||
|
target.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
b.title = title;
|
||||||
|
b.setAttribute("aria-label", title);
|
||||||
|
// 누를 때 초점을 뺏지 않음 — 고치던 칸이 그대로(손가락은 호환 mousedown 이 옴)
|
||||||
|
b.addEventListener("mousedown", (e) => e.preventDefault());
|
||||||
|
row.append(b);
|
||||||
|
}
|
||||||
|
ed.bar.append(row);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* touch_bridge.ts
|
||||||
|
* 손가락 → 마우스 번역 한 곳(요구 193~195 · PLAN_기기대응 3-1) — 페이지 코드는 마우스 그대로.
|
||||||
|
*
|
||||||
|
* - 길게 누름(500ms · 10px 안) → 그 자리에 `contextmenu` · 뒤따르는 click 막음
|
||||||
|
* (안드로이드처럼 브라우저가 먼저 내면 그것 하나만)
|
||||||
|
* - 두 번 톡 → 브라우저가 `dblclick` 을 안 낼 때만 대신
|
||||||
|
* - 두 손가락 벌림 → 가운데 자리에 Ctrl+휠(deltaY = −100·ln(배율) · 배율 10% 마다 한 번 · 트랙패드 핀치와 같은 꼴)
|
||||||
|
* 받는 면이 touch-action 으로 브라우저 확대를 막은 곳에서만 끝까지 옴
|
||||||
|
* - `[data-touch-own]` 요소(3D · 공용 SVG 확대)는 건너뜀 — 자체 손가락 처리 · 입력 칸 길게 누름도 브라우저 몫
|
||||||
|
* 마우스 · 펜 이벤트는 건드리지 않음.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { wheelEventIn } from "./input_prefs";
|
||||||
|
|
||||||
|
const LONG_MS = 500;
|
||||||
|
const SLOP_PX = 10;
|
||||||
|
const DOUBLE_MS = 300;
|
||||||
|
const DOUBLE_PX = 25;
|
||||||
|
/** 브라우저 dblclick 을 기다리는 틈 */
|
||||||
|
const NATIVE_WAIT_MS = 350;
|
||||||
|
/** 길게 누름 뒤 따라오는 click · 원래 contextmenu 를 막는 틈 */
|
||||||
|
const AFTER_LONG_MS = 800;
|
||||||
|
const PINCH_STEP = Math.log(1.1);
|
||||||
|
|
||||||
|
const synthetic = new WeakSet<Event>();
|
||||||
|
|
||||||
|
/** 손가락 벌림으로 만든 휠인지 — 「당기면 확대」 화면이 방향을 맞출 때 씀 */
|
||||||
|
export const isPinchWheel = (e: Event): boolean => synthetic.has(e);
|
||||||
|
|
||||||
|
let contactPx = 0;
|
||||||
|
|
||||||
|
/** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */
|
||||||
|
export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px);
|
||||||
|
|
||||||
|
/** 휠 화면(B04 · B05 지도 · M02 그림 · 시트)의 확대 판정 — ☰ [설정] 방향(332) · 손가락 벌림은 늘 벌리면 확대 */
|
||||||
|
export const wheelZoomsIn = (e: WheelEvent): boolean => wheelEventIn(e, isPinchWheel(e));
|
||||||
|
|
||||||
|
const ownsTouch = (t: EventTarget | null): boolean =>
|
||||||
|
t instanceof Element && !!t.closest("[data-touch-own]");
|
||||||
|
const isEditable = (t: EventTarget | null): boolean =>
|
||||||
|
t instanceof Element &&
|
||||||
|
!!t.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]');
|
||||||
|
|
||||||
|
interface Pt {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touches = new Map<number, Pt>();
|
||||||
|
let press: (Pt & { id: number; target: Element; timer: ReturnType<typeof setTimeout> }) | null =
|
||||||
|
null;
|
||||||
|
let longAt = 0;
|
||||||
|
let nativeMenuAt = 0;
|
||||||
|
let nativeDblAt = 0;
|
||||||
|
let lastTap: (Pt & { t: number }) | null = null;
|
||||||
|
let pinch: { dist: number; acc: number } | null = null;
|
||||||
|
|
||||||
|
const at = (p: Pt, fallback: Element): Element => document.elementFromPoint(p.x, p.y) ?? fallback;
|
||||||
|
|
||||||
|
function fire(type: "contextmenu" | "dblclick", target: Element, p: Pt): void {
|
||||||
|
const ev = new MouseEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
view: window,
|
||||||
|
clientX: p.x,
|
||||||
|
clientY: p.y,
|
||||||
|
screenX: p.x,
|
||||||
|
screenY: p.y,
|
||||||
|
button: type === "contextmenu" ? 2 : 0,
|
||||||
|
detail: type === "dblclick" ? 2 : 1,
|
||||||
|
});
|
||||||
|
synthetic.add(ev);
|
||||||
|
target.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireWheel(target: Element, p: Pt, deltaY: number): void {
|
||||||
|
const ev = new WheelEvent("wheel", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
composed: true,
|
||||||
|
view: window,
|
||||||
|
clientX: p.x,
|
||||||
|
clientY: p.y,
|
||||||
|
ctrlKey: true,
|
||||||
|
deltaY,
|
||||||
|
deltaMode: 0,
|
||||||
|
});
|
||||||
|
synthetic.add(ev);
|
||||||
|
target.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPress(): void {
|
||||||
|
if (press) clearTimeout(press.timer);
|
||||||
|
press = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair = (): { mid: Pt; dist: number } => {
|
||||||
|
const [a, b] = [...touches.values()];
|
||||||
|
return {
|
||||||
|
mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
||||||
|
dist: Math.hypot(a.x - b.x, a.y - b.y),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 그림 면(SVG · 캔버스)을 처음 만지면 한 번 — 「길게 누르면 메뉴」(3-5 · 이 탭에서 한 번) */
|
||||||
|
const HINT_KEY = "frd_touch_hint_shown";
|
||||||
|
function hintOnce(target: Element): void {
|
||||||
|
if (!target.closest("svg, canvas")) return;
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(HINT_KEY)) return;
|
||||||
|
sessionStorage.setItem(HINT_KEY, "1");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void Promise.all([import("@ui/ui_template_elements"), import("@ui/ui_template_locale")]).then(
|
||||||
|
([ui, loc]) => ui.showToast(loc.t("App_Touch_LongPressHint"), "info", 4000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDown(e: PointerEvent): void {
|
||||||
|
contactPx = e.pointerType === "touch" ? Math.max(24, e.width / 2, e.height / 2) : 0;
|
||||||
|
if (e.pointerType !== "touch") return;
|
||||||
|
const p = { x: e.clientX, y: e.clientY };
|
||||||
|
touches.set(e.pointerId, p);
|
||||||
|
const target = e.target as Element;
|
||||||
|
hintOnce(target);
|
||||||
|
if (touches.size === 1) {
|
||||||
|
if (ownsTouch(target) || isEditable(target)) return;
|
||||||
|
press = {
|
||||||
|
...p,
|
||||||
|
id: e.pointerId,
|
||||||
|
target,
|
||||||
|
timer: setTimeout(() => {
|
||||||
|
const was = press;
|
||||||
|
press = null;
|
||||||
|
if (!was || Date.now() - nativeMenuAt < LONG_MS) return;
|
||||||
|
longAt = Date.now();
|
||||||
|
fire("contextmenu", at(was, was.target), was);
|
||||||
|
}, LONG_MS),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelPress();
|
||||||
|
lastTap = null;
|
||||||
|
pinch = touches.size === 2 && !ownsTouch(target) ? { dist: pair().dist, acc: 0 } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e: PointerEvent): void {
|
||||||
|
if (e.pointerType !== "touch" || !touches.has(e.pointerId)) return;
|
||||||
|
const p = { x: e.clientX, y: e.clientY };
|
||||||
|
touches.set(e.pointerId, p);
|
||||||
|
if (press && press.id === e.pointerId && Math.hypot(p.x - press.x, p.y - press.y) > SLOP_PX) {
|
||||||
|
cancelPress();
|
||||||
|
lastTap = null;
|
||||||
|
}
|
||||||
|
if (!pinch || touches.size !== 2) return;
|
||||||
|
const { mid, dist } = pair();
|
||||||
|
if (pinch.dist > 0 && dist > 0) pinch.acc += Math.log(dist / pinch.dist);
|
||||||
|
pinch.dist = dist;
|
||||||
|
if (Math.abs(pinch.acc) < PINCH_STEP) return;
|
||||||
|
fireWheel(at(mid, e.target as Element), mid, -100 * pinch.acc);
|
||||||
|
pinch.acc = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp(e: PointerEvent): void {
|
||||||
|
if (e.pointerType !== "touch") return;
|
||||||
|
touches.delete(e.pointerId);
|
||||||
|
if (touches.size < 2) pinch = null;
|
||||||
|
if (!press || press.id !== e.pointerId) return;
|
||||||
|
const tap = press;
|
||||||
|
cancelPress();
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
lastTap &&
|
||||||
|
now - lastTap.t < DOUBLE_MS &&
|
||||||
|
Math.hypot(tap.x - lastTap.x, tap.y - lastTap.y) < DOUBLE_PX
|
||||||
|
) {
|
||||||
|
lastTap = null;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (nativeDblAt < now) fire("dblclick", at(tap, tap.target), tap);
|
||||||
|
}, NATIVE_WAIT_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTap = { x: tap.x, y: tap.y, t: now };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel(e: PointerEvent): void {
|
||||||
|
if (e.pointerType !== "touch") return;
|
||||||
|
touches.delete(e.pointerId);
|
||||||
|
cancelPress();
|
||||||
|
pinch = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNativeMenu(e: Event): void {
|
||||||
|
if (synthetic.has(e)) return;
|
||||||
|
if (Date.now() - longAt < AFTER_LONG_MS) {
|
||||||
|
// 우리가 이미 냄 — 브라우저 것은 하나 더라 버림
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
} else if (press) {
|
||||||
|
nativeMenuAt = Date.now(); // 브라우저가 먼저 냄 — 우리 것은 안 냄
|
||||||
|
cancelPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e: Event): void {
|
||||||
|
if (synthetic.has(e) || Date.now() - longAt >= AFTER_LONG_MS) return;
|
||||||
|
longAt = 0;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(): void {
|
||||||
|
const opts = { capture: true, passive: true } as const;
|
||||||
|
window.addEventListener("pointerdown", onDown, opts);
|
||||||
|
window.addEventListener("pointermove", onMove, opts);
|
||||||
|
window.addEventListener("pointerup", onUp, opts);
|
||||||
|
window.addEventListener("pointercancel", onCancel, opts);
|
||||||
|
window.addEventListener("contextmenu", onNativeMenu, true);
|
||||||
|
window.addEventListener("click", onClick, true);
|
||||||
|
window.addEventListener(
|
||||||
|
"dblclick",
|
||||||
|
(e) => {
|
||||||
|
if (!synthetic.has(e)) nativeDblAt = Date.now();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 브라우저에서만(노드 시험이 이 모듈을 거쳐 불러도 됨)
|
||||||
|
if (typeof window !== "undefined" && typeof window.addEventListener === "function") listen();
|
||||||
@@ -114,3 +114,30 @@ def _search_sync(query: str, size: int) -> list[dict[str, Any]]:
|
|||||||
|
|
||||||
async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
|
async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
|
||||||
return await asyncio.to_thread(_search_sync, query.strip(), size)
|
return await asyncio.to_thread(_search_sync, query.strip(), size)
|
||||||
|
|
||||||
|
|
||||||
|
def _reverse_sync(lon: float, lat: float) -> str | None:
|
||||||
|
"""좌표 → 주소 한 줄(VWorld getAddress · 도로명 먼저 · 없으면 지번)."""
|
||||||
|
query = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"service": "address",
|
||||||
|
"request": "getAddress",
|
||||||
|
"version": "2.0",
|
||||||
|
"crs": "epsg:4326",
|
||||||
|
"point": f"{lon},{lat}",
|
||||||
|
"type": "both",
|
||||||
|
"format": "json",
|
||||||
|
"key": VWORLD_API_KEY,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
items = (body.get("response") or {}).get("result") or []
|
||||||
|
texts = {item.get("type"): item.get("text") for item in items if isinstance(item, dict)}
|
||||||
|
return texts.get("road") or texts.get("parcel") or None
|
||||||
|
|
||||||
|
|
||||||
|
async def reverse_geocode(lon: float, lat: float) -> str | None:
|
||||||
|
return await asyncio.to_thread(_reverse_sync, lon, lat)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface AddressFieldHandle {
|
|||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
/** 저장할 한 줄 주소. 비면 null. */
|
/** 저장할 한 줄 주소. 비면 null. */
|
||||||
value: () => string | null;
|
value: () => string | null;
|
||||||
|
/** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */
|
||||||
|
point: () => { lat: number; lon: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
|
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
|
||||||
@@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
|
|||||||
label: "지도 확인",
|
label: "지도 확인",
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
onClick: async function onB01_Address_Map_Click() {
|
onClick: async function onB01_Address_Map_Click() {
|
||||||
await map.show(base.input.value.trim());
|
picked = await map.show(base.input.value.trim());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mapBtn.hidden = true;
|
mapBtn.hidden = true;
|
||||||
@@ -102,6 +104,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
|
|||||||
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
|
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
|
||||||
|
|
||||||
let manual = false;
|
let manual = false;
|
||||||
|
let picked: { lat: number; lon: number } | null = null;
|
||||||
|
|
||||||
function pick(item: AddressCandidate): void {
|
function pick(item: AddressCandidate): void {
|
||||||
base.input.value = candidateLine(item);
|
base.input.value = candidateLine(item);
|
||||||
@@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
|
|||||||
zip.hidden = !item.zipcode;
|
zip.hidden = !item.zipcode;
|
||||||
list.hidden = true;
|
list.hidden = true;
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
|
picked = { lat: item.lat, lon: item.lon };
|
||||||
map.showPoint(item.lat, item.lon);
|
map.showPoint(item.lat, item.lon);
|
||||||
detail.input.focus();
|
detail.input.focus();
|
||||||
}
|
}
|
||||||
@@ -160,7 +164,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 이미 있는 주소는 열자마자 지도로 보여 준다.
|
// 이미 있는 주소는 열자마자 지도로 보여 준다.
|
||||||
if (initial && initial.trim()) void map.show(initial.trim());
|
if (initial && initial.trim()) void map.show(initial.trim()).then((p) => (picked ??= p));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
@@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
|
|||||||
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
|
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
|
||||||
return merged || null;
|
return merged || null;
|
||||||
},
|
},
|
||||||
|
point: () => picked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,11 +58,17 @@ export function buildKnowledgeChat(): HTMLElement {
|
|||||||
input.rows = 2;
|
input.rows = 2;
|
||||||
input.required = true;
|
input.required = true;
|
||||||
input.maxLength = 500;
|
input.maxLength = 500;
|
||||||
input.placeholder = "메시지를 입력하세요";
|
input.placeholder = "메시지를 입력하세요 (Shift+Enter 줄바꿈)";
|
||||||
input.setAttribute("aria-label", "질문");
|
input.setAttribute("aria-label", "질문");
|
||||||
const submit = document.createElement("button");
|
const submit = document.createElement("button");
|
||||||
submit.type = "submit";
|
submit.type = "submit";
|
||||||
submit.textContent = "보내기";
|
submit.textContent = "보내기";
|
||||||
|
input.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||||
|
event.preventDefault();
|
||||||
|
submit.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
form.append(input, submit);
|
form.append(input, submit);
|
||||||
panel.append(header, messages, form);
|
panel.append(header, messages, form);
|
||||||
wrap.append(toggle, panel);
|
wrap.append(toggle, panel);
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ function tileIndex(
|
|||||||
|
|
||||||
export function buildAddressMap(): {
|
export function buildAddressMap(): {
|
||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
show: (address: string) => Promise<void>;
|
/** 찾은 좌표를 돌려줌 — 못 찾으면 null. */
|
||||||
|
show: (address: string) => Promise<{ lat: number; lon: number } | null>;
|
||||||
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
|
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
|
||||||
showPoint: (lat: number, lon: number) => void;
|
showPoint: (lat: number, lon: number) => void;
|
||||||
} {
|
} {
|
||||||
@@ -110,17 +111,18 @@ export function buildAddressMap(): {
|
|||||||
note.textContent = message;
|
note.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
const show = async (address: string): Promise<void> => {
|
const show = async (address: string): Promise<{ lat: number; lon: number } | null> => {
|
||||||
if (!address.trim()) {
|
if (!address.trim()) {
|
||||||
fail("주소를 먼저 입력하십시오.");
|
fail("주소를 먼저 입력하십시오.");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
const point = await geocodeAddress(address).catch(() => null);
|
const point = await geocodeAddress(address).catch(() => null);
|
||||||
if (!point) {
|
if (!point) {
|
||||||
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
|
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
draw(point.lat, point.lon);
|
draw(point.lat, point.lon);
|
||||||
|
return { lat: point.lat, lon: point.lon };
|
||||||
};
|
};
|
||||||
|
|
||||||
return { root, show, showPoint: draw };
|
return { root, show, showPoint: draw };
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
} from "@ui/ui_template_elements";
|
} from "@ui/ui_template_elements";
|
||||||
import { section } from "@ui/ui_template_general_blocks";
|
import { section } from "@ui/ui_template_general_blocks";
|
||||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||||
import { navigateTo } from "../A00_Common/router";
|
import { createStepBar } from "@ui/ui_template_workflow_layout";
|
||||||
|
import { setDrawerSteps } from "../A00_Common/app_shell";
|
||||||
|
import { settingLinks } from "../A00_Common/m_settings_nav";
|
||||||
|
import { currentRoute, navigateTo } from "../A00_Common/router";
|
||||||
import {
|
import {
|
||||||
fetchAllCompanies,
|
fetchAllCompanies,
|
||||||
fetchAllUsers,
|
fetchAllUsers,
|
||||||
@@ -162,40 +165,15 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
|
/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) */
|
||||||
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
|
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "b01-dashboard__setting-grid";
|
wrap.className = "b01-dashboard__setting-grid";
|
||||||
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
|
wrap.append(
|
||||||
const company: Record<string, string> =
|
...settingLinks(user, companyId).map((link) =>
|
||||||
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
|
settingCard(link.title, link.desc, () => navigateTo(link.route, link.query)),
|
||||||
const m02 = (title: string, open: string, root: string, desc: string): HTMLElement =>
|
),
|
||||||
settingCard(title, desc, () =>
|
);
|
||||||
navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root, ...company }),
|
|
||||||
);
|
|
||||||
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
|
|
||||||
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
|
|
||||||
if (user.role === "SYSTEM_ADMIN") {
|
|
||||||
const desc = L("B01_Dashboard_PermMasterWrite");
|
|
||||||
wrap.append(
|
|
||||||
settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () =>
|
|
||||||
navigateTo(ROUTES.M01_MASTER_DATA),
|
|
||||||
),
|
|
||||||
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
|
|
||||||
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
|
|
||||||
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
|
|
||||||
);
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
// 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
|
|
||||||
const companyAdmin = user.role === "ADMIN" || user.is_master;
|
|
||||||
const root = companyAdmin ? "company" : "personal";
|
|
||||||
const desc = [
|
|
||||||
L("B01_Dashboard_PermMasterRead"),
|
|
||||||
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
|
|
||||||
L("B01_Dashboard_PermPersonalWrite"),
|
|
||||||
].join(" · ");
|
|
||||||
wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc));
|
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +209,38 @@ function buildSystemSettingsSection(state: DashboardState): HTMLElement {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ☰ 구역 바로가기 아이콘(334) — 구역 제목 키로 짝(EN 글에서도 같음) */
|
||||||
|
const SECTION_ICONS: [Parameters<typeof L>[0], string][] = [
|
||||||
|
["B01_Dashboard_Resources", "📊"],
|
||||||
|
["B01_Dashboard_SystemSettings", "⚙️"],
|
||||||
|
["B01_Dashboard_Projects", "🗂️"],
|
||||||
|
["B01_Temp_Section", "📦"],
|
||||||
|
["B01_Dashboard_Users", "👥"],
|
||||||
|
["B01_Dashboard_Members", "👥"],
|
||||||
|
["B01_Dashboard_JoinRequests", "📨"],
|
||||||
|
["B01_Dashboard_Companies", "🏢"],
|
||||||
|
["B01_Dashboard_Company", "🏢"],
|
||||||
|
["B01_Dashboard_Profile", "👤"],
|
||||||
|
["B01_Account_Section_Security", "🔒"],
|
||||||
|
["B01_Dashboard_AuditLogs", "📜"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** ☰ 구역 바로가기(334) — 그린 구역 그대로 · 화면 차례 · 누름 = 서랍 닫힘 + 그 구역으로 굴림 */
|
||||||
|
function mountSectionShortcuts(grid: HTMLElement): void {
|
||||||
|
// 자료 기다리는 사이 다른 쪽으로 떠났으면 맡기지 않음
|
||||||
|
if (currentRoute() !== ROUTES.B01_ACCOUNT) return;
|
||||||
|
const sections = [...grid.children] as HTMLElement[];
|
||||||
|
const titles = sections.map((s) => s.querySelector(".ui-card__title")?.textContent ?? "");
|
||||||
|
const icons = titles.map((title) => SECTION_ICONS.find(([key]) => L(key) === title)?.[1] ?? "");
|
||||||
|
const bar = createStepBar(titles, -1, {
|
||||||
|
orientation: "vertical",
|
||||||
|
icons,
|
||||||
|
routes: titles,
|
||||||
|
onStepClick: (index) => sections[index].scrollIntoView({ behavior: "smooth", block: "start" }),
|
||||||
|
});
|
||||||
|
setDrawerSteps(bar);
|
||||||
|
}
|
||||||
|
|
||||||
function buildPage(state: DashboardState): HTMLElement {
|
function buildPage(state: DashboardState): HTMLElement {
|
||||||
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
|
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
|
||||||
const page = document.createElement("div");
|
const page = document.createElement("div");
|
||||||
@@ -311,6 +321,7 @@ function buildPage(state: DashboardState): HTMLElement {
|
|||||||
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
|
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
|
||||||
}
|
}
|
||||||
page.append(grid);
|
page.append(grid);
|
||||||
|
mountSectionShortcuts(grid);
|
||||||
page.append(buildKnowledgeChat());
|
page.append(buildKnowledgeChat());
|
||||||
const layout = createGeneralLayout({
|
const layout = createGeneralLayout({
|
||||||
pageClass: "b01-dashboard",
|
pageClass: "b01-dashboard",
|
||||||
|
|||||||
@@ -536,8 +536,8 @@
|
|||||||
.b01-knowledge-chat__toggle,
|
.b01-knowledge-chat__toggle,
|
||||||
.b01-knowledge-chat__form button {
|
.b01-knowledge-chat__form button {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: var(--color-royal-amethyst, #6843a7);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: var(--color-primary-text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -560,12 +560,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: min(380px, calc(100vw - 32px));
|
width: min(380px, calc(100vw - 32px));
|
||||||
height: min(680px, calc(100vh - 104px));
|
height: min(680px, calc(100dvh - 104px));
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--color-surface, #fff);
|
background: var(--color-surface, #fff);
|
||||||
box-shadow: 0 8px 28px #0003;
|
box-shadow: 0 8px 28px #0003;
|
||||||
color: var(--color-text-primary, #222);
|
color: var(--color-text-body);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
@@ -588,8 +588,8 @@
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-royal-amethyst, #6843a7);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: var(--color-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-knowledge-chat__avatar svg {
|
.b01-knowledge-chat__avatar svg {
|
||||||
@@ -631,14 +631,15 @@
|
|||||||
.b01-knowledge-chat__user {
|
.b01-knowledge-chat__user {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
border-bottom-right-radius: 4px;
|
border-bottom-right-radius: 4px;
|
||||||
background: var(--color-royal-amethyst, #6843a7);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: var(--color-primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-knowledge-chat__reply {
|
.b01-knowledge-chat__reply {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
border-bottom-left-radius: 4px;
|
border-bottom-left-radius: 4px;
|
||||||
background: var(--color-background, #f4f4f4);
|
background: var(--color-surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-knowledge-chat__reply details {
|
.b01-knowledge-chat__reply details {
|
||||||
@@ -722,7 +723,7 @@
|
|||||||
.b01-knowledge-source-modal__content code {
|
.b01-knowledge-source-modal__content code {
|
||||||
padding: 0.1em 0.35em;
|
padding: 0.1em 0.35em;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--color-background, #f4f4f4);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-knowledge-source-modal__content .ui-markdown-table {
|
.b01-knowledge-source-modal__content .ui-markdown-table {
|
||||||
@@ -746,7 +747,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.b01-knowledge-source-modal__content th {
|
.b01-knowledge-source-modal__content th {
|
||||||
background: var(--color-background, #f4f4f4);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-knowledge-chat__form {
|
.b01-knowledge-chat__form {
|
||||||
@@ -765,11 +766,17 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-text-body);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.b01-knowledge-chat__form textarea::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.b01-knowledge-chat__form button {
|
.b01-knowledge-chat__form button {
|
||||||
padding: 9px 14px;
|
padding: 9px 14px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
@@ -350,6 +350,18 @@ async def run_auto_design_chain(
|
|||||||
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
|
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
|
||||||
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
|
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
|
||||||
|
|
||||||
|
# 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도
|
||||||
|
# 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다.
|
||||||
|
try:
|
||||||
|
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
|
||||||
|
from B05_Profile.B05_Profile_Router_RouteInitial import precompute_defaults
|
||||||
|
|
||||||
|
_schedule_background_task(
|
||||||
|
precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}"
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다
|
||||||
|
logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"route_id": route_id,
|
"route_id": route_id,
|
||||||
"length_m": float(solve_result.total_length_m or 0.0),
|
"length_m": float(solve_result.total_length_m or 0.0),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.b03-file {
|
.b03-file {
|
||||||
min-height: calc(100vh - var(--app-header-height, 0px));
|
min-height: calc(100dvh - var(--app-header-height, 0px));
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
var(--color-canvas, #ffffff) 0%,
|
var(--color-canvas, #ffffff) 0%,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function createTempPicker(
|
|||||||
const backdrop = document.createElement("div");
|
const backdrop = document.createElement("div");
|
||||||
backdrop.className = "b03-file__modal-backdrop";
|
backdrop.className = "b03-file__modal-backdrop";
|
||||||
const modal = document.createElement("div");
|
const modal = document.createElement("div");
|
||||||
modal.className = "b03-file__modal b03-file__temp-modal";
|
modal.className = "b03-file__modal b03-file__temp-modal ui-popup";
|
||||||
modal.setAttribute("role", "dialog");
|
modal.setAttribute("role", "dialog");
|
||||||
modal.setAttribute("aria-modal", "true");
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
|
||||||
|
|||||||
@@ -471,17 +471,10 @@ export async function fetchRoadInflow(
|
|||||||
export type PipeSource = "stream" | "spacing" | "user";
|
export type PipeSource = "stream" | "spacing" | "user";
|
||||||
|
|
||||||
/** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에
|
/** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에
|
||||||
* 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */
|
* 따라 택일한다 — 관 지점 정본 종류 = 구조물 표 정본(`catalogRow().owner` · 323-7) · 이름 = 표 이름 ·
|
||||||
|
* 형상마다 손코딩 폼이 있어 종류 더하기(본보기)에서 뺌. 교량은 임도용이 아니다. */
|
||||||
export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment";
|
export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment";
|
||||||
|
|
||||||
export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [
|
|
||||||
["pipe", "배수관"],
|
|
||||||
["box_culvert", "BOX암거"],
|
|
||||||
["ford_pavement", "물넘이포장"],
|
|
||||||
["ford_bridge", "세월교"],
|
|
||||||
["revetment", "기슭막이"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface DetailPipePoint {
|
export interface DetailPipePoint {
|
||||||
chainage_m: number;
|
chainage_m: number;
|
||||||
lonlat: [number, number];
|
lonlat: [number, number];
|
||||||
@@ -585,7 +578,7 @@ export async function fetchDetailPipePoints(projectId: string): Promise<DetailBa
|
|||||||
|
|
||||||
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
||||||
*
|
*
|
||||||
* `points`를 비우면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다. */
|
* 빈 목록 = 관 0개(312) · 기본 관 + 자동 보충으로 되돌리기는 `resetDetailPipePoints`(DELETE)만. */
|
||||||
export async function computeDetailBasins(
|
export async function computeDetailBasins(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
points: DetailPipeInput[],
|
points: DetailPipeInput[],
|
||||||
|
|||||||
@@ -62,16 +62,16 @@ def _build(
|
|||||||
) -> tuple[DrainageDetail | None, list[PipePoint]]:
|
) -> tuple[DrainageDetail | None, list[PipePoint]]:
|
||||||
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
|
"""세부유역을 계산하고, 그 결과에 쓰인 관 목록을 함께 돌려준다.
|
||||||
|
|
||||||
`points`가 없으면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다.
|
`points`가 None 이면 B04 기본 관에 최대 간격 규칙으로 자동 보충한 목록이 만들어진다.
|
||||||
있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의
|
있으면 그 자리를 그대로 쓰되, 관이 왜 거기 있는지(기본/자동/수동)는 넘겨받은 목록의
|
||||||
표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다.
|
표시를 유지한다 — 계산기는 확정 좌표만 보므로 그대로 두면 전부 "수동"이 된다.
|
||||||
"""
|
"""
|
||||||
directory = drainage_dir(stored_path)
|
directory = drainage_dir(stored_path)
|
||||||
chainages = [point.chainage_m for point in points] if points else None
|
chainages = [point.chainage_m for point in points] if points is not None else None
|
||||||
detail = build_detail(directory, context.vertices, chainages)
|
detail = build_detail(directory, context.vertices, chainages)
|
||||||
if detail is None:
|
if detail is None:
|
||||||
return None, []
|
return None, []
|
||||||
if points:
|
if points is not None:
|
||||||
_retag(detail.pipes, points)
|
_retag(detail.pipes, points)
|
||||||
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
|
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
|
||||||
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
|
# 원본에 없던 자리 = 이번에 새로 놓인 관(자동 배치 · 간격 보충) — 등록부 기본값을 받을 관
|
||||||
@@ -357,26 +357,30 @@ def _basin_features(
|
|||||||
async def _resolve(
|
async def _resolve(
|
||||||
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool
|
project_id: UUID, payload: dict[str, Any] | None, *, use_stored: bool
|
||||||
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse:
|
) -> tuple[DrainageContext, DrainageDetail, list[PipePoint], bool] | JSONResponse:
|
||||||
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다."""
|
"""요청 본문 → 저장분 → 자동 생성 순으로 관 목록을 정하고 세부유역까지 계산한다.
|
||||||
|
|
||||||
|
빈 목록(`[]`)은 「관 0개」로 그대로 쓴다 — 자동 생성은 요청에 칸이 없고 저장분도 없을(None) 때만(312).
|
||||||
|
"""
|
||||||
context, reason = await load_drainage_context(project_id)
|
context, reason = await load_drainage_context(project_id)
|
||||||
if context is None:
|
if context is None:
|
||||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
requested = parse_pipe_points((payload or {}).get("points"))
|
raw = (payload or {}).get("points")
|
||||||
|
requested = None if raw is None else parse_pipe_points(raw)
|
||||||
except ValueError as error: # 집계 항목(summary_item) 모양이 틀림
|
except ValueError as error: # 집계 항목(summary_item) 모양이 틀림
|
||||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
|
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
|
||||||
signature = route_signature(context.vertices)
|
signature = route_signature(context.vertices)
|
||||||
stored = (
|
stored = (
|
||||||
load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None
|
load_pipe_points(context.stored_path, signature, context.vertices) if use_stored else None
|
||||||
)
|
)
|
||||||
points = requested or stored or None
|
points = requested if requested is not None else stored
|
||||||
|
|
||||||
result = await asyncio.to_thread(_build, context.stored_path, context, points)
|
result = await asyncio.to_thread(_build, context.stored_path, context, points)
|
||||||
detail, resolved = result
|
detail, resolved = result
|
||||||
if detail is None:
|
if detail is None:
|
||||||
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
|
return JSONResponse(status_code=404, content={"status": "error", "message": _NO_ANALYSIS})
|
||||||
return context, detail, resolved, bool(stored) and not requested
|
return context, detail, resolved, requested is None and stored is not None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/drainage/pipe-points", response_model=None)
|
@router.get("/{project_id}/drainage/pipe-points", response_model=None)
|
||||||
@@ -395,8 +399,8 @@ async def post_detail_basins(
|
|||||||
) -> dict[str, Any] | JSONResponse:
|
) -> dict[str, Any] | JSONResponse:
|
||||||
"""화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
"""화면에서 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
|
||||||
|
|
||||||
`points`를 비우고 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 —
|
`points` 칸 없이 부르면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다 —
|
||||||
노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다.
|
노선이 바뀌어 전체 분석을 다시 돌린 직후의 경로다. 빈 목록은 관 0개(312).
|
||||||
"""
|
"""
|
||||||
resolved = await _resolve(project_id, payload, use_stored=False)
|
resolved = await _resolve(project_id, payload, use_stored=False)
|
||||||
if isinstance(resolved, JSONResponse):
|
if isinstance(resolved, JSONResponse):
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
resampleRoute,
|
resampleRoute,
|
||||||
type RoutePoint,
|
type RoutePoint,
|
||||||
} from "./B04_PreProcess_UI_RouteSamples";
|
} from "./B04_PreProcess_UI_RouteSamples";
|
||||||
|
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||||
|
|
||||||
function L(key: keyof typeof ui_locales): string {
|
function L(key: keyof typeof ui_locales): string {
|
||||||
return ui_locales[key][currentLanguageIndex];
|
return ui_locales[key][currentLanguageIndex];
|
||||||
@@ -296,7 +297,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
|
|||||||
function selectBasinOfPipe(pipeIndex: number): void {
|
function selectBasinOfPipe(pipeIndex: number): void {
|
||||||
const pipe = pipes[pipeIndex];
|
const pipe = pipes[pipeIndex];
|
||||||
if (!pipe) return;
|
if (!pipe) return;
|
||||||
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < 0.51);
|
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage) < PIPE_MATCH_M);
|
||||||
selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null;
|
selectedBasin = basin && basin.index !== selectedBasin ? basin.index : null;
|
||||||
say(summary());
|
say(summary());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch";
|
import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch";
|
||||||
|
import { wheelEventIn } from "../A00_Common/input_prefs";
|
||||||
|
|
||||||
export const SURFACE_CAMERA_FOV = 50;
|
export const SURFACE_CAMERA_FOV = 50;
|
||||||
const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9;
|
const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9;
|
||||||
@@ -225,10 +226,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
|||||||
if (options.blocked?.() || !controls.enabled) return;
|
if (options.blocked?.() || !controls.enabled) return;
|
||||||
if (event.button === 1 && panPointerId === null) {
|
if (event.button === 1 && panPointerId === null) {
|
||||||
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
|
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
|
||||||
pickPivot(event);
|
startGrabPan(event);
|
||||||
panAnchor.copy(pivot);
|
|
||||||
getCamera().getWorldDirection(viewDirection);
|
|
||||||
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
|
|
||||||
panPointerId = event.pointerId;
|
panPointerId = event.pointerId;
|
||||||
element.setPointerCapture?.(event.pointerId);
|
element.setPointerCapture?.(event.pointerId);
|
||||||
return;
|
return;
|
||||||
@@ -249,13 +247,17 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
|
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
|
||||||
* 휠을 위로 올리면 멀어진다(사용자 지시). */
|
* 방향 = ☰ [설정](332 · 기본 당기면 가까워짐 · 위로 올리면 멀어짐). */
|
||||||
function onWheel(event: WheelEvent): void {
|
function onWheel(event: WheelEvent): void {
|
||||||
const camera = getCamera();
|
|
||||||
if (!controls.enabled || options.blocked?.()) return;
|
if (!controls.enabled || options.blocked?.()) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
pickPivot(event);
|
zoomAround(event, wheelEventIn(event) ? ZOOM_STEP : 1 / ZOOM_STEP);
|
||||||
const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
|
}
|
||||||
|
|
||||||
|
/** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */
|
||||||
|
function zoomAround(at: { clientX: number; clientY: number }, factor: number): void {
|
||||||
|
const camera = getCamera();
|
||||||
|
pickPivot(at);
|
||||||
const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor);
|
const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor);
|
||||||
const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor);
|
const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor);
|
||||||
// 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다.
|
// 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다.
|
||||||
@@ -276,19 +278,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
|||||||
function onPointerMove(event: PointerEvent): void {
|
function onPointerMove(event: PointerEvent): void {
|
||||||
const camera = getCamera();
|
const camera = getCamera();
|
||||||
if (panPointerId === event.pointerId) {
|
if (panPointerId === event.pointerId) {
|
||||||
// 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다.
|
grabPanTo(event);
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
if (rect.width <= 0 || rect.height <= 0) return;
|
|
||||||
pointer.set(
|
|
||||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
|
||||||
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
|
||||||
);
|
|
||||||
raycaster.setFromCamera(pointer, camera);
|
|
||||||
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
|
|
||||||
const delta = panAnchor.clone().sub(panPoint);
|
|
||||||
camera.position.add(delta);
|
|
||||||
controls.target.add(delta);
|
|
||||||
controls.update();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pointerId !== event.pointerId) return;
|
if (pointerId !== event.pointerId) return;
|
||||||
@@ -351,6 +341,67 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
|||||||
syncPivotMarker();
|
syncPivotMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 그랩 팬 — 잡은 점이 커서(손가락 가운데) 아래에 계속 오도록 카메라·target을 평행 이동한다. */
|
||||||
|
function grabPanTo(at: { clientX: number; clientY: number }): void {
|
||||||
|
const camera = getCamera();
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) return;
|
||||||
|
pointer.set(
|
||||||
|
((at.clientX - rect.left) / rect.width) * 2 - 1,
|
||||||
|
-((at.clientY - rect.top) / rect.height) * 2 + 1,
|
||||||
|
);
|
||||||
|
raycaster.setFromCamera(pointer, camera);
|
||||||
|
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
|
||||||
|
const delta = panAnchor.clone().sub(panPoint);
|
||||||
|
camera.position.add(delta);
|
||||||
|
controls.target.add(delta);
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGrabPan(at: { clientX: number; clientY: number }): void {
|
||||||
|
pickPivot(at);
|
||||||
|
panAnchor.copy(pivot);
|
||||||
|
getCamera().getWorldDirection(viewDirection);
|
||||||
|
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 손가락(기기대응 3-1) — 하나 = 회전(왼쪽 단추와 같음) · 둘 = 벌려 확대 · 같이 끌어 이동.
|
||||||
|
// 3D 는 자체 처리 — 손가락 번역(`touch_bridge`)은 이 요소를 건너뜀.
|
||||||
|
element.dataset.touchOwn = "3d";
|
||||||
|
element.style.touchAction = "none";
|
||||||
|
const touches = new Map<number, { clientX: number; clientY: number }>();
|
||||||
|
let pinchDist = 0;
|
||||||
|
const touchMid = (): { clientX: number; clientY: number; dist: number } => {
|
||||||
|
const [a, b] = [...touches.values()];
|
||||||
|
return {
|
||||||
|
clientX: (a.clientX + b.clientX) / 2,
|
||||||
|
clientY: (a.clientY + b.clientY) / 2,
|
||||||
|
dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
function onTouchDown(event: PointerEvent): void {
|
||||||
|
if (event.pointerType !== "touch" || options.blocked?.() || !controls.enabled) return;
|
||||||
|
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
|
||||||
|
if (touches.size !== 2) return;
|
||||||
|
stop(); // 한 손가락 회전을 멈추고 두 손가락으로
|
||||||
|
const m = touchMid();
|
||||||
|
pinchDist = m.dist;
|
||||||
|
startGrabPan(m);
|
||||||
|
}
|
||||||
|
function onTouchMove(event: PointerEvent): void {
|
||||||
|
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
|
||||||
|
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
|
||||||
|
if (touches.size !== 2) return;
|
||||||
|
const m = touchMid();
|
||||||
|
grabPanTo(m);
|
||||||
|
if (pinchDist > 0 && m.dist > 0) zoomAround(m, pinchDist / m.dist);
|
||||||
|
pinchDist = m.dist;
|
||||||
|
startGrabPan(m); // 확대 뒤 다시 잡음 — 다음 이동이 새 배율 기준
|
||||||
|
}
|
||||||
|
function onTouchUp(event: PointerEvent): void {
|
||||||
|
touches.delete(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
function stop(): void {
|
function stop(): void {
|
||||||
if (pointerId !== null && element.hasPointerCapture?.(pointerId)) {
|
if (pointerId !== null && element.hasPointerCapture?.(pointerId)) {
|
||||||
element.releasePointerCapture(pointerId);
|
element.releasePointerCapture(pointerId);
|
||||||
@@ -381,6 +432,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
|||||||
element.addEventListener("pointercancel", onPointerEnd);
|
element.addEventListener("pointercancel", onPointerEnd);
|
||||||
element.addEventListener("pointerleave", onPointerLeave);
|
element.addEventListener("pointerleave", onPointerLeave);
|
||||||
element.addEventListener("wheel", onWheel, { passive: false });
|
element.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
element.addEventListener("pointerdown", onTouchDown);
|
||||||
|
element.addEventListener("pointermove", onTouchMove);
|
||||||
|
element.addEventListener("pointerup", onTouchUp);
|
||||||
|
element.addEventListener("pointercancel", onTouchUp);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
element.removeEventListener("pointerdown", onPointerDown);
|
element.removeEventListener("pointerdown", onPointerDown);
|
||||||
@@ -389,6 +444,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
|||||||
element.removeEventListener("pointercancel", onPointerEnd);
|
element.removeEventListener("pointercancel", onPointerEnd);
|
||||||
element.removeEventListener("pointerleave", onPointerLeave);
|
element.removeEventListener("pointerleave", onPointerLeave);
|
||||||
element.removeEventListener("wheel", onWheel);
|
element.removeEventListener("wheel", onWheel);
|
||||||
|
element.removeEventListener("pointerdown", onTouchDown);
|
||||||
|
element.removeEventListener("pointermove", onTouchMove);
|
||||||
|
element.removeEventListener("pointerup", onTouchUp);
|
||||||
|
element.removeEventListener("pointercancel", onTouchUp);
|
||||||
if (pivotMarker) {
|
if (pivotMarker) {
|
||||||
pivotMarker.removeFromParent();
|
pivotMarker.removeFromParent();
|
||||||
pivotMarker.material.map?.dispose();
|
pivotMarker.material.map?.dispose();
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ export interface StationTickOptions {
|
|||||||
/** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고,
|
/** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고,
|
||||||
* 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */
|
* 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */
|
||||||
onLabel?: (box: StationLabelBox) => void;
|
onLabel?: (box: StationLabelBox) => void;
|
||||||
|
/** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). */
|
||||||
|
chainages?: ReadonlyArray<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */
|
/** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */
|
||||||
@@ -253,7 +255,10 @@ export function drawStationTicks(
|
|||||||
// 이미 그린 라벨과 겹치는 자리는 비켜 놓는다.
|
// 이미 그린 라벨과 겹치는 자리는 비켜 놓는다.
|
||||||
const drawn: Array<{ x: number; y: number; half: number }> = [];
|
const drawn: Array<{ x: number; y: number; half: number }> = [];
|
||||||
let cursor = 1;
|
let cursor = 1;
|
||||||
for (let chainage = 0; chainage <= total; chainage += interval) {
|
const marks: number[] = [];
|
||||||
|
if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total));
|
||||||
|
else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage);
|
||||||
|
for (const chainage of marks) {
|
||||||
const stationNo = Math.round(chainage / interval);
|
const stationNo = Math.round(chainage / interval);
|
||||||
if (stationNo % step !== 0) continue;
|
if (stationNo % step !== 0) continue;
|
||||||
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||||
|
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
|
||||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||||
import {
|
import {
|
||||||
@@ -546,7 +547,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
|||||||
wheelWidth,
|
wheelWidth,
|
||||||
8,
|
8,
|
||||||
);
|
);
|
||||||
scale = Math.min(maxScale, Math.max(0.5, scale * (event.deltaY > 0 ? 1.15 : 0.87)));
|
scale = Math.min(maxScale, Math.max(0.5, scale * (wheelZoomsIn(event) ? 1.15 : 0.87)));
|
||||||
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
|
// 마우스 커서 아래 지점이 줌 전후로 같은 화면 위치에 머물도록 offset 보정.
|
||||||
// screen = center + (base - center)·scale + offset 이므로,
|
// screen = center + (base - center)·scale + offset 이므로,
|
||||||
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
|
// 커서 고정 조건을 풀면 offset' = (cursor - center)·(1 - r) + offset·r (r = scale'/scale).
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
min-height: calc(100vh - var(--spacing-64));
|
min-height: calc(100dvh - var(--app-header-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
.b04-surface__workspace > .b04-surface__status {
|
.b04-surface__workspace > .b04-surface__status {
|
||||||
|
|||||||
@@ -219,6 +219,8 @@ export interface ProfileAlignmentSaveResponse {
|
|||||||
route_id: number;
|
route_id: number;
|
||||||
profile_alignment: unknown;
|
profile_alignment: unknown;
|
||||||
grade_summary: RouteGradeSummary | null;
|
grade_summary: RouteGradeSummary | null;
|
||||||
|
/** 측점 · 관 · 구조물 자리가 없어 서버가 버린 계획고 편집 자리(m · 213-2). */
|
||||||
|
dropped_edit_chainages?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export interface RoutePlanResponse {
|
|||||||
violation_count: number;
|
violation_count: number;
|
||||||
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
|
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
|
||||||
edited: boolean;
|
edited: boolean;
|
||||||
|
/** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */
|
||||||
|
station_snap?: boolean;
|
||||||
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
|
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
|
||||||
current_initial?: { logic: string; weights: Record<string, number> | null };
|
current_initial?: { logic: string; weights: Record<string, number> | null };
|
||||||
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
|
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
|
||||||
@@ -220,6 +222,8 @@ export interface CrossPreviewRequest {
|
|||||||
station_interval_m: number;
|
station_interval_m: number;
|
||||||
/** 횡단 반폭(m) — 없으면 서버 기본값. */
|
/** 횡단 반폭(m) — 없으면 서버 기본값. */
|
||||||
cross_half_width_m?: number;
|
cross_half_width_m?: number;
|
||||||
|
/** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */
|
||||||
|
station_snap?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
|
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
* 하려는 것이라, 목록은 반드시 서버에서 받아 온다.
|
* 하려는 것이라, 목록은 반드시 서버에서 받아 온다.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
|
||||||
import { noteSnapshotFailed } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
import { noteSnapshotFailed } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
||||||
|
import { useCatalogDefinition } from "../common_util/common_util_structure_catalog";
|
||||||
|
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
|
||||||
|
import { useDesignStore } from "../A00_Common/design/design_store";
|
||||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||||
import {
|
import {
|
||||||
type DefinedRule,
|
type DefinedRule,
|
||||||
@@ -77,7 +79,6 @@ export interface StructureType {
|
|||||||
placement: StructurePlacement;
|
placement: StructurePlacement;
|
||||||
options: StructureOptionField[];
|
options: StructureOptionField[];
|
||||||
style: { color?: string; abbr?: string };
|
style: { color?: string; abbr?: string };
|
||||||
drawing_views: string[];
|
|
||||||
/** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */
|
/** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */
|
||||||
managed_by: string | null;
|
managed_by: string | null;
|
||||||
/** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름.
|
/** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름.
|
||||||
@@ -140,6 +141,8 @@ export interface StructureListResponse {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
structures: StructureInstance[];
|
structures: StructureInstance[];
|
||||||
|
/** 정본을 적을 때의 구조물 정의 판(322-2) — 없으면 옛 파일 · 아직 안 적음. */
|
||||||
|
definition_version?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StructureSaveResponse {
|
export interface StructureSaveResponse {
|
||||||
@@ -192,8 +195,66 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */
|
/** 타입 레지스트리 캐시 — 정의 판(`typesVersion`)이 바뀌면(M02 저장) 버리고 다시 받음(322-0). */
|
||||||
let typesCache: Promise<StructureType[]> | null = null;
|
let typesCache: Promise<StructureType[]> | null = null;
|
||||||
|
let typesVersion: number | null = null;
|
||||||
|
|
||||||
|
/** 정의 판이 캐시 판과 다르면 종류 캐시를 버림 — 버렸으면 true(B05 · B06 보기를 열 때 · 탭이 다시 보일 때). */
|
||||||
|
export async function dropStaleStructureTypes(): Promise<boolean> {
|
||||||
|
if (!typesCache || typesVersion === null) return false;
|
||||||
|
const now = await requestJson<{ 판: number }>("/m02/structure-definition/version", {
|
||||||
|
method: "GET",
|
||||||
|
})
|
||||||
|
.then((payload) => payload.판)
|
||||||
|
.catch(() => null);
|
||||||
|
if (now === null || now === typesVersion) return false;
|
||||||
|
typesCache = null;
|
||||||
|
typesVersion = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noticed = new Set<string>();
|
||||||
|
const staleSaved = new Set<string>(); // 정본이 옛 정의 판으로 적힌 프로젝트(323-9)
|
||||||
|
|
||||||
|
/** 정본이 옛 정의 판으로 적혔나 — 그러면 고침이 없어도 [저장]이 정본을 다시 써 지금 판 도장(323-9). */
|
||||||
|
export function definitionStale(projectId: string): boolean {
|
||||||
|
return staleSaved.has(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 프로젝트 정본이 옛 정의 판으로 적혔으면 알림 한 줄(322-2 · 탭 수명 동안 프로젝트 · 판마다 한 번). */
|
||||||
|
export function staleDefinitionNotice(
|
||||||
|
projectId: string,
|
||||||
|
saved: number | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (saved === null || saved === undefined || typesVersion === null || saved === typesVersion) {
|
||||||
|
staleSaved.delete(projectId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
staleSaved.add(projectId);
|
||||||
|
const key = `${projectId}:${saved}:${typesVersion}`;
|
||||||
|
if (noticed.has(key)) return null;
|
||||||
|
noticed.add(key);
|
||||||
|
return `구조물 정의가 판 ${typesVersion} 으로 바뀜 — 저장하면 반영`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staleListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function checkStaleOnShow(): void {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
void dropStaleStructureTypes().then((stale) => {
|
||||||
|
if (stale) staleListeners.forEach((reload) => reload());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 탭이 다시 보일 때 정의 판이 바뀌었으면 `reload`(종류 · 패널 다시 읽음) — 떼는 함수를 돌려줌. */
|
||||||
|
export function onStaleStructureTypes(reload: () => void): () => void {
|
||||||
|
if (!staleListeners.size) document.addEventListener("visibilitychange", checkStaleOnShow);
|
||||||
|
staleListeners.add(reload);
|
||||||
|
return () => {
|
||||||
|
staleListeners.delete(reload);
|
||||||
|
if (!staleListeners.size) document.removeEventListener("visibilitychange", checkStaleOnShow);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 구조물 타입 목록.
|
* 구조물 타입 목록.
|
||||||
@@ -212,7 +273,12 @@ export function fetchStructureTypes(includeDisabled = false): Promise<StructureT
|
|||||||
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
|
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
})
|
})
|
||||||
.then(async (payload) => withDefinition(payload.types, await definition))
|
.then(async (payload) => {
|
||||||
|
const defined = await definition;
|
||||||
|
typesVersion = defined?.판 ?? null;
|
||||||
|
useCatalogDefinition({ types: payload.types, ...defined }); // 구조물 표(322-1 · 반영 · 부품)
|
||||||
|
return withDefinition(payload.types, defined);
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
|
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
|
||||||
throw error;
|
throw error;
|
||||||
@@ -239,6 +305,7 @@ export async function saveStructures(
|
|||||||
{ method: "PUT", body: JSON.stringify({ base_revision: baseRevision, structures }) },
|
{ method: "PUT", body: JSON.stringify({ base_revision: baseRevision, structures }) },
|
||||||
);
|
);
|
||||||
noteSnapshotFailed(saved.snapshot_failed);
|
noteSnapshotFailed(saved.snapshot_failed);
|
||||||
|
staleSaved.delete(projectId); // 서버가 지금 판으로 적음
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +334,7 @@ export function pipesToStructureMarks(
|
|||||||
}>,
|
}>,
|
||||||
): StructureInstance[] {
|
): StructureInstance[] {
|
||||||
return pipes.map((pipe) => ({
|
return pipes.map((pipe) => ({
|
||||||
structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
structure_id: pipeIdAt(pipe.chainage_m), // 관 세션 id(323-5)
|
||||||
type_id: pipe.facility,
|
type_id: pipe.facility,
|
||||||
placement: "point",
|
placement: "point",
|
||||||
chainage_m: pipe.chainage_m,
|
chainage_m: pipe.chainage_m,
|
||||||
@@ -304,21 +371,22 @@ export function defaultOptions(type: StructureType): Record<string, string | num
|
|||||||
|
|
||||||
/** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
|
/** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
|
||||||
export function readPendingStructures(projectId: string): StructureInstance[] | null {
|
export function readPendingStructures(projectId: string): StructureInstance[] | null {
|
||||||
return readState<StructureInstance[]>("structures", projectId);
|
return useDesignStore(projectId, null).get("structures");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 설계 가게로 씀 — 관 초안과 같은 바뀜 신호(구독 · 323-6). 칸은 그대로(`structures`). */
|
||||||
export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void {
|
export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void {
|
||||||
writeState("structures", next, projectId);
|
useDesignStore(projectId, null).set("structures", next);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는
|
* 미저장분을 정본에 쓴다. B05 화면 밖(B06 [저장]·[확정])에서 부르는 경로라 판번호는
|
||||||
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다.
|
* 서버에서 다시 받아 쓴다. 미저장분이 없으면 아무것도 하지 않는다 — 정본이 옛 정의 판이면 그대로 다시 씀(323-9).
|
||||||
*/
|
*/
|
||||||
export async function flushPendingStructures(projectId: string): Promise<void> {
|
export async function flushPendingStructures(projectId: string): Promise<void> {
|
||||||
const pending = readPendingStructures(projectId);
|
const pending = readPendingStructures(projectId);
|
||||||
if (!pending) return;
|
if (!pending && !definitionStale(projectId)) return;
|
||||||
const stored = await fetchStructures(projectId);
|
const stored = await fetchStructures(projectId);
|
||||||
await saveStructures(projectId, stored.revision, pending);
|
await saveStructures(projectId, stored.revision, pending ?? stored.structures); // 고침 없음 = 판 도장만
|
||||||
writePendingStructures(projectId, null);
|
writePendingStructures(projectId, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,30 @@ def snap_to_stations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def drop_unplaced_edits(
|
||||||
|
edits: dict[str, dict[str, float]], places: np.ndarray
|
||||||
|
) -> tuple[dict[str, dict[str, float]], list[float]]:
|
||||||
|
"""측점 · 관 · 구조물 자리(`places`) 0.006m 안에 없는 편집 키를 버린다(213-2).
|
||||||
|
|
||||||
|
옮긴 관의 옛 자리 · 지운 시설 자리에 남은 키는 측점선 없는 꼭짓점(유령)이 된다 — 세션 초안이
|
||||||
|
옛 키를 다시 보내도 [저장]에서 걸러진다. (남은 편집, 버린 자리 누가거리).
|
||||||
|
"""
|
||||||
|
kept: dict[str, dict[str, float]] = {}
|
||||||
|
dropped: set[float] = set()
|
||||||
|
for name, values in edits.items():
|
||||||
|
kept[name] = {}
|
||||||
|
for key, value in values.items():
|
||||||
|
try:
|
||||||
|
chainage = float(key)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if places.size and float(np.min(np.abs(places - chainage))) < SAME_STATION_SNAP_M:
|
||||||
|
kept[name][key] = value
|
||||||
|
else:
|
||||||
|
dropped.add(round(chainage, _KEY_DECIMALS))
|
||||||
|
return kept, sorted(dropped)
|
||||||
|
|
||||||
|
|
||||||
def resolve_pvi(
|
def resolve_pvi(
|
||||||
base_s: np.ndarray,
|
base_s: np.ndarray,
|
||||||
base_z: np.ndarray,
|
base_z: np.ndarray,
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 ·
|
예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 ·
|
||||||
공사비 어림 최소 · 비중.
|
공사비 어림 최소 · 비중.
|
||||||
|
|
||||||
계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이
|
계산 자리 = 서버 단독(③). 이 모듈은 파일을 쓰지 않는다 — 결과는 라우터가 입력 지문 키로
|
||||||
받은 노드로 기존 `POST /route/replan`([확인])을 부른다.
|
`B05_Profile/route_recommend/` 에 남긴다(`..._Store` · PLAN 327). 적용은 화면이 받은 노드로
|
||||||
|
기존 `POST /route/replan`([확인])을 부른다.
|
||||||
|
|
||||||
갈래(`mode`):
|
갈래(`mode`):
|
||||||
· follow — 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 선.
|
· follow — 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 선.
|
||||||
@@ -182,6 +183,20 @@ def write_initial_choice(
|
|||||||
return choice
|
return choice
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_inputs(
|
||||||
|
max_grade: float,
|
||||||
|
stream_offset_m: float | None,
|
||||||
|
grade_smooth_m: float | None,
|
||||||
|
target_grade: float | None,
|
||||||
|
) -> tuple[float, float, float]:
|
||||||
|
"""(계류 이격, 기울기 잴 지반 폭, 영선 목표 기울기) — 비운 값은 기본값 · 저장 키와 같은 풀이."""
|
||||||
|
stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m)
|
||||||
|
grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m)
|
||||||
|
# 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만)
|
||||||
|
goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade)
|
||||||
|
return stream_offset, grade_smooth, goal_grade
|
||||||
|
|
||||||
|
|
||||||
def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]:
|
def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]:
|
||||||
"""갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값."""
|
"""갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값."""
|
||||||
if mode != "weighted":
|
if mode != "weighted":
|
||||||
@@ -240,10 +255,9 @@ def generate_initial_route(
|
|||||||
if len(expected) < 2:
|
if len(expected) < 2:
|
||||||
raise ValueError("예상노선 정점이 2개 미만입니다.")
|
raise ValueError("예상노선 정점이 2개 미만입니다.")
|
||||||
used = resolve_weights(mode, weights)
|
used = resolve_weights(mode, weights)
|
||||||
stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m)
|
stream_offset, grade_smooth, goal_grade = resolve_inputs(
|
||||||
grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m)
|
max_grade, stream_offset_m, grade_smooth_m, target_grade
|
||||||
# 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만)
|
)
|
||||||
goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade)
|
|
||||||
|
|
||||||
def measure(
|
def measure(
|
||||||
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
|
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
|
||||||
|
|||||||
@@ -37,12 +37,14 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
|
|||||||
COST_LENGTH_M,
|
COST_LENGTH_M,
|
||||||
)
|
)
|
||||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
|
||||||
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen
|
||||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
|
||||||
CROSSING_BAND,
|
CROSSING_BAND,
|
||||||
OBJECTIVES,
|
OBJECTIVES,
|
||||||
NoRoute,
|
NoRoute,
|
||||||
RouteGraph,
|
RouteGraph,
|
||||||
)
|
)
|
||||||
|
from common_util.common_util_route_geometry import densify_route
|
||||||
from common_util.common_util_route_polyline import build_planned_polyline
|
from common_util.common_util_route_polyline import build_planned_polyline
|
||||||
|
|
||||||
ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
|
ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0}
|
||||||
@@ -70,6 +72,8 @@ MIN_RANGE_SHARE = 0.05
|
|||||||
SIMILAR_M = 10.0
|
SIMILAR_M = 10.0
|
||||||
# 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기
|
# 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기
|
||||||
CONSTRAINT_SHARE = 1.0
|
CONSTRAINT_SHARE = 1.0
|
||||||
|
# 탐색 경로를 예정노선처럼 폴리라인화하기 전 점 간격(m) — R 맞춤이 꺾임점 사이 점으로 잼
|
||||||
|
TRACE_DENSIFY_M = 2.0
|
||||||
|
|
||||||
Measure = Callable[..., dict[str, Any]]
|
Measure = Callable[..., dict[str, Any]]
|
||||||
Route = tuple[Any, dict[str, Any]]
|
Route = tuple[Any, dict[str, Any]]
|
||||||
@@ -103,14 +107,28 @@ def _trace(
|
|||||||
traced: dict[tuple, Route],
|
traced: dict[tuple, Route],
|
||||||
**kw: Any,
|
**kw: Any,
|
||||||
) -> Route:
|
) -> Route:
|
||||||
# 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선.
|
# 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판
|
||||||
|
# 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색
|
||||||
|
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 ·
|
||||||
|
# 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴.
|
||||||
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
|
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
|
||||||
key = (tuple(nodes), tuple(sorted(kw.items())))
|
key = (tuple(nodes), tuple(sorted(kw.items())))
|
||||||
if key not in traced:
|
if key not in traced:
|
||||||
outline = build_planned_polyline(
|
floor = build_planned_polyline(
|
||||||
nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes)
|
nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes)
|
||||||
)
|
)
|
||||||
traced[key] = (outline, measure(outline, **kw))
|
least = (floor, measure(floor, **kw))
|
||||||
|
path = densify_route(nodes, TRACE_DENSIFY_M)
|
||||||
|
fitted = build_planned_polyline(path, min_radius_m=min_radius_m)
|
||||||
|
larger = (widen(fitted, path, min_radius_m), widen(floor, path, min_radius_m), fitted)
|
||||||
|
traced[key] = next(
|
||||||
|
(
|
||||||
|
route
|
||||||
|
for route in ((outline, measure(outline, **kw)) for outline in larger)
|
||||||
|
if route[1]["over_limit"]["total"] <= least[1]["over_limit"]["total"]
|
||||||
|
),
|
||||||
|
least,
|
||||||
|
)
|
||||||
return traced[key]
|
return traced[key]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A).
|
"""노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A).
|
||||||
|
|
||||||
후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다.
|
후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다.
|
||||||
편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 파일 · 캐시 · initial_snapshot · 확정 종단을
|
편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 작업본 파일 · initial_snapshot · 확정 종단을
|
||||||
바꾸지 않는다. 확정 체인의 종단 규칙(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다.
|
바꾸지 않는다(추천 결과째 저장본에 남는 것은 라우터 몫 · PLAN 327). 확정 체인의 종단 규칙
|
||||||
|
(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다.
|
||||||
|
|
||||||
계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다.
|
계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""추천 노선 큰 R — 2단(PLAN 328-2 · 요구 224-4 「R 은 클수록 좋음」).
|
||||||
|
|
||||||
|
`B05_Profile_Engine_RouteInitial_Pool._trace` 가 부름. 예상노선 추적(전처리 초기값)은 안 거침.
|
||||||
|
|
||||||
|
1단(`_trace`)이 예정노선 규칙(단순화 · 같은 방향 묶음 · R 맞춤)으로 짠 선을 받아, 곡선마다 R 을
|
||||||
|
[맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로 점과 가장 먼 거리 ≤ `ROUTE_RECOMMEND_MAX_GAP_M`
|
||||||
|
인 가장 큰 값(1 m 내림)으로 고른다. 맞는 값이 없으면 맞춘 R 그대로.
|
||||||
|
|
||||||
|
고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로
|
||||||
|
펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from shapely.geometry import LineString, Point
|
||||||
|
|
||||||
|
from common_util.common_util_route_polyline import (
|
||||||
|
PlannedPolyline,
|
||||||
|
_inner_angle_deg,
|
||||||
|
build_planned_polyline,
|
||||||
|
)
|
||||||
|
from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M
|
||||||
|
|
||||||
|
Xy = tuple[float, float]
|
||||||
|
|
||||||
|
|
||||||
|
def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | None]]:
|
||||||
|
"""곡선 묶음을 교각점 하나로 편 노드 · 곡선 켬 · R — 화면 `flattenServerPlan` 과 같은 꼴."""
|
||||||
|
starts = {curve.node_first: curve for curve in outline.curves}
|
||||||
|
inside = {i for c in outline.curves for i in range(c.node_first + 1, c.node_last + 1)}
|
||||||
|
points: list[Xy] = []
|
||||||
|
flags: list[bool] = []
|
||||||
|
radii: list[float | None] = []
|
||||||
|
for index, node in enumerate(outline.nodes):
|
||||||
|
if index in inside:
|
||||||
|
continue
|
||||||
|
curve = starts.get(index)
|
||||||
|
points.append(curve.apex if curve else (node.x, node.y))
|
||||||
|
flags.append(curve is not None)
|
||||||
|
radii.append(round(curve.radius_m, 2) if curve else None)
|
||||||
|
return points, flags, radii
|
||||||
|
|
||||||
|
|
||||||
|
def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray:
|
||||||
|
return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0]
|
||||||
|
|
||||||
|
|
||||||
|
def _gap(samples: np.ndarray, before: Xy, apex: Xy, after: Xy, radii: np.ndarray) -> np.ndarray:
|
||||||
|
"""R 마다 점들이 「직선 · 원호 · 직선」(앞뒤 직선은 절반까지)에서 가장 먼 거리(m)."""
|
||||||
|
a = np.asarray(apex, dtype=float)
|
||||||
|
p = samples - a # (S, 2) 교각점 기준
|
||||||
|
units = [(np.asarray(end, dtype=float) - a) for end in (before, after)]
|
||||||
|
halves = [float(np.hypot(*u)) / 2 for u in units]
|
||||||
|
units = [u / (2 * h) for u, h in zip(units, halves)]
|
||||||
|
inner = math.radians(_inner_angle_deg(before, apex, after))
|
||||||
|
tangent = radii[:, None] * math.tan((math.pi - inner) / 2) # (M, 1)
|
||||||
|
legs = []
|
||||||
|
for unit, half in zip(units, halves):
|
||||||
|
along = p @ unit
|
||||||
|
side = np.abs(_cross(unit, p))
|
||||||
|
legs.append(np.hypot(side, np.maximum(0.0, np.maximum(tangent - along, along - half))))
|
||||||
|
bisector = (units[0] + units[1]) / np.hypot(*(units[0] + units[1]))
|
||||||
|
center = (radii / math.sin(inner / 2))[:, None, None] * bisector # (M, 1, 2)
|
||||||
|
starts = [tangent[..., None] * unit - center for unit in units] # 중심 → 접선점
|
||||||
|
q = p[None] - center # (M, S, 2) 중심 → 점
|
||||||
|
turn = _cross(starts[0], starts[1])
|
||||||
|
within = (_cross(starts[0], q) * turn >= 0) & (_cross(q, starts[1]) * turn >= 0)
|
||||||
|
arc = np.where(within, np.abs(np.hypot(q[..., 0], q[..., 1]) - radii[:, None]), np.inf)
|
||||||
|
return np.minimum(np.minimum(legs[0], legs[1]), arc).max(axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def widen(
|
||||||
|
outline: PlannedPolyline,
|
||||||
|
path: list[Xy],
|
||||||
|
min_radius_m: float,
|
||||||
|
max_gap_m: float = ROUTE_RECOMMEND_MAX_GAP_M,
|
||||||
|
) -> PlannedPolyline:
|
||||||
|
"""곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로."""
|
||||||
|
if not outline.curves:
|
||||||
|
return outline
|
||||||
|
points, flags, radii = _flat(outline)
|
||||||
|
line = LineString(path)
|
||||||
|
coords = np.asarray(path, dtype=float)
|
||||||
|
along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))])
|
||||||
|
for index in range(1, len(points) - 1):
|
||||||
|
if not flags[index]:
|
||||||
|
continue
|
||||||
|
before, apex, after = points[index - 1], points[index], points[index + 1]
|
||||||
|
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2)
|
||||||
|
if half_tan <= 1e-9:
|
||||||
|
continue
|
||||||
|
room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan
|
||||||
|
candidates = np.arange(math.floor(room), float(radii[index]), -1.0) # 큰 것부터
|
||||||
|
# 이 곡선이 쓰는 자리 = 앞뒤 직선 가운데점 사이 — 그 사이 탐색 경로 점만 잼
|
||||||
|
ends = [
|
||||||
|
line.project(Point((apex[0] + end[0]) / 2, (apex[1] + end[1]) / 2))
|
||||||
|
for end in (before, after)
|
||||||
|
]
|
||||||
|
samples = coords[(along >= min(ends)) & (along <= max(ends))]
|
||||||
|
if not candidates.size or not len(samples):
|
||||||
|
continue
|
||||||
|
ok = candidates[_gap(samples, before, apex, after, candidates) <= max_gap_m]
|
||||||
|
if ok.size:
|
||||||
|
radii[index] = float(ok[0])
|
||||||
|
return build_planned_polyline(
|
||||||
|
points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii
|
||||||
|
)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3).
|
||||||
|
|
||||||
|
같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면
|
||||||
|
셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 상한 없음).
|
||||||
|
|
||||||
|
층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리
|
||||||
|
`B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라
|
||||||
|
[초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다.
|
||||||
|
|
||||||
|
키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표
|
||||||
|
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때
|
||||||
|
다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
|
from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||||
|
DEFAULT_WEIGHTS,
|
||||||
|
Terrain,
|
||||||
|
_pool_key,
|
||||||
|
resolve_inputs,
|
||||||
|
resolve_weights,
|
||||||
|
)
|
||||||
|
from common_util.common_util_json import atomic_write_json
|
||||||
|
|
||||||
|
ALGO_VERSION = 2
|
||||||
|
STORE_DIR = "B05_Profile/route_recommend"
|
||||||
|
# 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐
|
||||||
|
DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow")
|
||||||
|
|
||||||
|
# 묶음 지문마다 잠금 — 진행 중 같은 묶음(같은 키 포함)은 기다렸다 꺼냄(두 번 셈 막음)
|
||||||
|
_LOCKS: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def store_dir(project_root: Path) -> Path:
|
||||||
|
return Path(project_root) / STORE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def request_fingerprint(
|
||||||
|
expected: list[tuple[float, float]],
|
||||||
|
terrain: Terrain,
|
||||||
|
streams: list[list[tuple[float, float]]],
|
||||||
|
*,
|
||||||
|
mode: str,
|
||||||
|
weights: dict[str, float] | None,
|
||||||
|
max_grade: float,
|
||||||
|
min_radius_m: float,
|
||||||
|
stream_offset_m: float | None,
|
||||||
|
grade_smooth_m: float | None,
|
||||||
|
target_grade: float | None,
|
||||||
|
with_profile: bool,
|
||||||
|
design_speed: int,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키."""
|
||||||
|
pool = _pool_key(
|
||||||
|
expected,
|
||||||
|
terrain,
|
||||||
|
streams,
|
||||||
|
max_grade,
|
||||||
|
min_radius_m,
|
||||||
|
*resolve_inputs(max_grade, stream_offset_m, grade_smooth_m, target_grade),
|
||||||
|
).hex()
|
||||||
|
used = resolve_weights(mode, weights)
|
||||||
|
tail = (mode, sorted(used.items()), bool(with_profile), int(design_speed), ALGO_VERSION)
|
||||||
|
key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest()
|
||||||
|
return key, pool
|
||||||
|
|
||||||
|
|
||||||
|
def is_default(
|
||||||
|
mode: str, weights: dict[str, float] | None, criteria: dict[str, Any] | None, terrain: Any
|
||||||
|
) -> bool:
|
||||||
|
"""기본값 결과 — 빈 제약 · 50/25/25 · 설계 지형. 표시일 뿐 지우는 데 쓰지 않음."""
|
||||||
|
empty = not any(value is not None for value in (criteria or {}).values())
|
||||||
|
plain = mode != "weighted" or resolve_weights(mode, weights) == DEFAULT_WEIGHTS
|
||||||
|
return empty and plain and terrain is None
|
||||||
|
|
||||||
|
|
||||||
|
def read_stored(project_root: Path, key: str) -> dict[str, Any] | None:
|
||||||
|
"""저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None."""
|
||||||
|
try:
|
||||||
|
data = json.loads((store_dir(project_root) / f"{key}.json").read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict) or data.get("version") != ALGO_VERSION:
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def write_stored(
|
||||||
|
project_root: Path,
|
||||||
|
key: str,
|
||||||
|
pool: str,
|
||||||
|
request: dict[str, Any],
|
||||||
|
result: dict[str, Any],
|
||||||
|
default: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""새 키만 적는다 — 이미 있으면 그대로 두고 False(덮어쓰기 없음)."""
|
||||||
|
path = store_dir(project_root) / f"{key}.json"
|
||||||
|
if path.exists():
|
||||||
|
return False
|
||||||
|
atomic_write_json(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
"version": ALGO_VERSION,
|
||||||
|
"key": key,
|
||||||
|
"pool": pool,
|
||||||
|
"default": default,
|
||||||
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"request": request,
|
||||||
|
"result": result,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def pool_lock(pool: str) -> AsyncIterator[None]:
|
||||||
|
# ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로
|
||||||
|
async with _LOCKS.setdefault(pool, asyncio.Lock()):
|
||||||
|
yield
|
||||||
@@ -25,13 +25,9 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
|||||||
)
|
)
|
||||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||||
is_station_planting_type,
|
|
||||||
structure_type_map,
|
structure_type_map,
|
||||||
)
|
)
|
||||||
from common_util.common_util_drainage_pipes import (
|
from common_util.common_util_drainage_pipes import (
|
||||||
PIPE_FACILITY_FORD_PAVEMENT,
|
|
||||||
PIPE_FACILITY_PIPE,
|
|
||||||
PIPE_FACILITY_REVET,
|
|
||||||
PipePoint,
|
PipePoint,
|
||||||
load_pipe_points_file,
|
load_pipe_points_file,
|
||||||
parse_pipe_points,
|
parse_pipe_points,
|
||||||
@@ -44,14 +40,14 @@ from common_util.common_util_route_geometry import (
|
|||||||
planned_route_initial_path,
|
planned_route_initial_path,
|
||||||
planned_route_working_path,
|
planned_route_working_path,
|
||||||
)
|
)
|
||||||
|
from common_util.common_util_structure_catalog import card_title, catalog
|
||||||
|
from common_util.common_util_structure_station import plan_stations
|
||||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||||
from config.config_system import (
|
from config.config_system import (
|
||||||
DRAINAGE_CACHE_DIRNAME,
|
DRAINAGE_CACHE_DIRNAME,
|
||||||
DRAINAGE_EDITS_DIRNAME,
|
DRAINAGE_EDITS_DIRNAME,
|
||||||
DRAINAGE_PIPE_POINTS_FILENAME,
|
DRAINAGE_PIPE_POINTS_FILENAME,
|
||||||
FOREST_ROAD_PROFILE_CRITERIA,
|
FOREST_ROAD_PROFILE_CRITERIA,
|
||||||
PIPE_DEFAULT_DIAMETER_MM,
|
|
||||||
PIPE_DEFAULT_TYPE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -83,21 +79,31 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
|
|||||||
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고,
|
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고,
|
||||||
받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다.
|
받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다.
|
||||||
"""
|
"""
|
||||||
|
curves = _read_design_curve_table(project_root).get("curves")
|
||||||
|
return (
|
||||||
|
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_station_snap(project_root: Path) -> bool:
|
||||||
|
"""[측점 스냅] 켬 — 곡선표 `"station_snap"`(요구 224-2 · [확인] 때 적힘). 칸 없으면 끔."""
|
||||||
|
return _read_design_curve_table(project_root).get("station_snap") is True
|
||||||
|
|
||||||
|
|
||||||
|
def _read_design_curve_table(project_root: Path) -> dict[str, Any]:
|
||||||
|
"""설계가 쓰는 곡선표 파일 통째 — 수정본이 있으면 그 곁 것 · 없거나 못 읽으면 빈 dict."""
|
||||||
route_path = planned_route_working_path(project_root)
|
route_path = planned_route_working_path(project_root)
|
||||||
if not route_path.is_file():
|
if not route_path.is_file():
|
||||||
route_path = planned_route_initial_path(project_root)
|
route_path = planned_route_initial_path(project_root)
|
||||||
target = route_path.with_name(f"{route_path.stem}_curves.json")
|
target = route_path.with_name(f"{route_path.stem}_curves.json")
|
||||||
if not target.is_file():
|
if not target.is_file():
|
||||||
return []
|
return {}
|
||||||
try:
|
try:
|
||||||
data = json.loads(target.read_text(encoding="utf-8"))
|
data = json.loads(target.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
|
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
|
||||||
return []
|
return {}
|
||||||
curves = data.get("curves") if isinstance(data, dict) else None
|
return data if isinstance(data, dict) else {}
|
||||||
return (
|
|
||||||
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ────────────
|
# ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ────────────
|
||||||
@@ -208,16 +214,10 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
|
|||||||
|
|
||||||
|
|
||||||
def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None:
|
def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None:
|
||||||
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음."""
|
"""관 지점 측점 라벨 = 표 카드 이름(322-1 · 배관 = 「관종 D관경」 · 빈칸 = 정의 기본값)."""
|
||||||
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
if pipe.facility not in types:
|
||||||
return None
|
return None
|
||||||
if pipe.facility == PIPE_FACILITY_PIPE:
|
return card_title(pipe.facility, pipe.options)
|
||||||
values = pipe.options or {}
|
|
||||||
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
|
||||||
diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM))
|
|
||||||
return f"{kind} D{diameter}"
|
|
||||||
definition = types.get(pipe.facility)
|
|
||||||
return definition.name if definition is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None:
|
def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None:
|
||||||
@@ -239,7 +239,7 @@ def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any
|
|||||||
except (OSError, ValueError, AttributeError):
|
except (OSError, ValueError, AttributeError):
|
||||||
return
|
return
|
||||||
types = structure_type_map()
|
types = structure_type_map()
|
||||||
pipe_names = {definition.name for definition in types.values() if definition.managed_by}
|
pipe_names = {row["name"] for row in catalog().values() if row["owner"] == "pipe_points"}
|
||||||
for section in cross_sections:
|
for section in cross_sections:
|
||||||
label = section.get("structure")
|
label = section.get("structure")
|
||||||
if not isinstance(label, str) or not (
|
if not isinstance(label, str) or not (
|
||||||
@@ -267,51 +267,22 @@ def resolve_extra_stations(
|
|||||||
(2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다
|
(2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다
|
||||||
— B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다.
|
— B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다.
|
||||||
|
|
||||||
물넘이포장은 비정규 측점으로 올리지 않는다 — 종단 그래프 하단 구조물 표시로만 다룬다
|
물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1).
|
||||||
(2026-08-28 사용자 확정).
|
|
||||||
|
|
||||||
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
|
측점 · 이름 = 측점 계획 한 벌(322-3 · `plan_stations` · TS `planStations` 짝) — 표 횡단도
|
||||||
되짚기 때문이다(B05_Profile_Structures_Migration).
|
(anchor = 기준 한 곳 · span3 = 시작 · 기준 · 끝) · 카드 이름 · 같은 m(0.005) 는 하나(이름 = 관이
|
||||||
|
먼저 · 아니면 앞선 것). 관 지점 정본 종류는 structures.json 에 없어 겹치지 않는다.
|
||||||
|
거울 시험 = `resources/tester/test_322_3_plan_mirror.py` · `test_272_1_station_list.py`.
|
||||||
"""
|
"""
|
||||||
types = structure_type_map()
|
|
||||||
derived: list[tuple[float, str]] = []
|
|
||||||
for pipe in pipes:
|
|
||||||
label = pipe_station_label(pipe, types)
|
|
||||||
if label is None:
|
|
||||||
continue
|
|
||||||
# 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 —
|
|
||||||
# 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은
|
|
||||||
# 기준 한 곳(관이 노선을 가로지르는 점형).
|
|
||||||
if pipe.facility == PIPE_FACILITY_REVET:
|
|
||||||
marks = (pipe.start_m, pipe.chainage_m, pipe.end_m)
|
|
||||||
else:
|
|
||||||
marks = (pipe.chainage_m,)
|
|
||||||
for mark in marks:
|
|
||||||
if mark is not None:
|
|
||||||
derived.append((float(mark), label))
|
|
||||||
# 구조물 정본에서 측점이 필요한 것들. 관 정본이 관리하는 타입(managed_by)은
|
|
||||||
# structures.json에 저장되지 않아 중복되지 않는다.
|
|
||||||
# · 점형(A군 노출형 횡단수로·개거): 기준 측점 한 곳.
|
|
||||||
# · 구간형(D군 기슭막이): **시작·기준·종료** — 길이가 길수록 횡단도가 여러 장
|
|
||||||
# 나와야 한다(2026-08-28 사용자). 격자와 같은 정수 미터면 generate_sections가
|
|
||||||
# 격자로 스냅해 파일이 겹치지 않는다.
|
|
||||||
try:
|
try:
|
||||||
for structure in load_structures(str(project_root))[1]:
|
structures = load_structures(str(project_root))[1]
|
||||||
definition = types.get(structure.type_id)
|
|
||||||
if definition is None or definition.managed_by:
|
|
||||||
continue
|
|
||||||
if not is_station_planting_type(definition):
|
|
||||||
continue
|
|
||||||
if definition.placement == "interval":
|
|
||||||
marks = (structure.start_m, structure.chainage_m, structure.end_m)
|
|
||||||
else:
|
|
||||||
marks = (structure.anchor_m(),)
|
|
||||||
for mark in marks:
|
|
||||||
if mark is not None:
|
|
||||||
derived.append((float(mark), definition.name))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)")
|
logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)")
|
||||||
return tuple(sorted(derived))
|
structures = []
|
||||||
|
return tuple(
|
||||||
|
(float(station["chainage_m"]), str(station["title"]))
|
||||||
|
for station in plan_stations(pipes, structures)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _append_design_profiles(
|
def _append_design_profiles(
|
||||||
@@ -474,6 +445,7 @@ def run_section_generation(
|
|||||||
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
|
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
|
||||||
# 종전의 측점별 실측 반경으로 물러선다.
|
# 종전의 측점별 실측 반경으로 물러선다.
|
||||||
design_curves=_load_design_curves(project_root),
|
design_curves=_load_design_curves(project_root),
|
||||||
|
station_snap=load_station_snap(project_root),
|
||||||
)
|
)
|
||||||
|
|
||||||
stage_root = project_root / _STAGE_SUBDIR
|
stage_root = project_root / _STAGE_SUBDIR
|
||||||
@@ -549,7 +521,7 @@ def run_section_generation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_irregular_sections(
|
def sample_structure_sections(
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
route_data_path: str,
|
route_data_path: str,
|
||||||
filter_key: str,
|
filter_key: str,
|
||||||
@@ -559,16 +531,15 @@ def generate_irregular_sections(
|
|||||||
extra_stations: tuple[tuple[float, str], ...],
|
extra_stations: tuple[tuple[float, str], ...],
|
||||||
options: SectionGenerationOptions | None = None,
|
options: SectionGenerationOptions | None = None,
|
||||||
crs: str | None = None,
|
crs: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
"""비정규 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다.
|
"""구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀.
|
||||||
|
|
||||||
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다. 격자 측점과 똑같은 접선·오프셋
|
격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점
|
||||||
샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각
|
(`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기
|
||||||
`cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
|
(`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2).
|
||||||
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
|
|
||||||
"""
|
"""
|
||||||
if not extra_stations:
|
if not extra_stations:
|
||||||
return []
|
return [], []
|
||||||
base = options or SectionGenerationOptions()
|
base = options or SectionGenerationOptions()
|
||||||
merged_options = SectionGenerationOptions(
|
merged_options = SectionGenerationOptions(
|
||||||
station_interval_m=base.station_interval_m,
|
station_interval_m=base.station_interval_m,
|
||||||
@@ -588,19 +559,51 @@ def generate_irregular_sections(
|
|||||||
crs=crs,
|
crs=crs,
|
||||||
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
|
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
|
||||||
design_curves=_load_design_curves(project_root),
|
design_curves=_load_design_curves(project_root),
|
||||||
|
# 규칙 측점과 같은 스냅 — 한쪽만 켜면 [저장] 때 옮기기 전 격자(640)가 되살아남.
|
||||||
|
station_snap=load_station_snap(project_root),
|
||||||
|
)
|
||||||
|
|
||||||
|
def structural(entry: dict[str, Any]) -> bool:
|
||||||
|
return entry.get("kind") == "irregular" or bool(entry.get("structure"))
|
||||||
|
|
||||||
|
return (
|
||||||
|
[station for station in result["longitudinal"]["stations"] if structural(station)],
|
||||||
|
[cross for cross in result["cross_sections"] if structural(cross)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_irregular_sections(
|
||||||
|
project_root: Path,
|
||||||
|
route_data_path: str,
|
||||||
|
filter_key: str,
|
||||||
|
method: str,
|
||||||
|
smooth: bool,
|
||||||
|
*,
|
||||||
|
extra_stations: tuple[tuple[float, str], ...],
|
||||||
|
options: SectionGenerationOptions | None = None,
|
||||||
|
crs: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음).
|
||||||
|
|
||||||
|
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272).
|
||||||
|
각각 `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
|
||||||
|
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
|
||||||
|
"""
|
||||||
|
irregular_stations, crosses = sample_structure_sections(
|
||||||
|
project_root,
|
||||||
|
route_data_path,
|
||||||
|
filter_key,
|
||||||
|
method,
|
||||||
|
smooth,
|
||||||
|
extra_stations=extra_stations,
|
||||||
|
options=options,
|
||||||
|
crs=crs,
|
||||||
)
|
)
|
||||||
irregular_stations = [
|
|
||||||
station
|
|
||||||
for station in result["longitudinal"]["stations"]
|
|
||||||
if station.get("kind") == "irregular"
|
|
||||||
]
|
|
||||||
if not irregular_stations:
|
if not irregular_stations:
|
||||||
return []
|
return []
|
||||||
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
|
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
|
||||||
cross_dir.mkdir(parents=True, exist_ok=True)
|
cross_dir.mkdir(parents=True, exist_ok=True)
|
||||||
for cross_section in result["cross_sections"]:
|
for cross_section in crosses:
|
||||||
if cross_section.get("kind") != "irregular":
|
|
||||||
continue
|
|
||||||
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
|
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
|
||||||
atomic_write_json(cross_file, cross_section)
|
atomic_write_json(cross_file, cross_section)
|
||||||
return irregular_stations
|
return irregular_stations
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
|
|
||||||
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
|
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
|
||||||
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
|
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
|
||||||
|
⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔
|
||||||
|
`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험
|
||||||
|
`resources/tester/test_224_2_station_snap.py`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
@@ -275,6 +278,35 @@ def _design_curve_spans(
|
|||||||
return sorted(spans)
|
return sorted(spans)
|
||||||
|
|
||||||
|
|
||||||
|
#: 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」 — 관 · 구조물 겹침 판정과 같은 값(m).
|
||||||
|
CURVE_STATION_EDGE_M = 0.005
|
||||||
|
|
||||||
|
|
||||||
|
def snap_curve_stations(
|
||||||
|
stations: np.ndarray | list[float],
|
||||||
|
spans: list[tuple[float, ...]],
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 정수 MC
|
||||||
|
(floor(MC+0.5))로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 측점과 같은 m 이면 하나로.
|
||||||
|
|
||||||
|
`spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리).
|
||||||
|
"""
|
||||||
|
values = [float(value) for value in stations]
|
||||||
|
for span in spans:
|
||||||
|
start, end = float(span[0]), float(span[1])
|
||||||
|
middle = (start + end) / 2.0
|
||||||
|
inside = [
|
||||||
|
index
|
||||||
|
for index, value in enumerate(values)
|
||||||
|
if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M
|
||||||
|
]
|
||||||
|
if not inside:
|
||||||
|
continue
|
||||||
|
nearest = min(inside, key=lambda index: abs(values[index] - middle))
|
||||||
|
values[nearest] = float(math.floor(middle + 0.5))
|
||||||
|
return np.unique(np.asarray(values, dtype=np.float64))
|
||||||
|
|
||||||
|
|
||||||
def _design_curve_widenings(
|
def _design_curve_widenings(
|
||||||
station_chainage: np.ndarray,
|
station_chainage: np.ndarray,
|
||||||
spans: list[tuple[float, float, float, str]],
|
spans: list[tuple[float, float, float, str]],
|
||||||
@@ -319,8 +351,12 @@ def generate_sections(
|
|||||||
source_snapshot: dict[str, Any] | None = None,
|
source_snapshot: dict[str, Any] | None = None,
|
||||||
crs: str | None = None,
|
crs: str | None = None,
|
||||||
design_curves: list[dict[str, Any]] | None = None,
|
design_curves: list[dict[str, Any]] | None = None,
|
||||||
|
station_snap: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
|
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.
|
||||||
|
|
||||||
|
`station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2).
|
||||||
|
"""
|
||||||
options = options or SectionGenerationOptions()
|
options = options or SectionGenerationOptions()
|
||||||
options.validate()
|
options.validate()
|
||||||
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
|
points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64))
|
||||||
@@ -331,6 +367,9 @@ def generate_sections(
|
|||||||
long_z, long_valid = sampler.sample_xy(long_xy)
|
long_z, long_valid = sampler.sample_xy(long_xy)
|
||||||
|
|
||||||
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
|
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
|
||||||
|
spans = _design_curve_spans(points, route_chainage, design_curves or [])
|
||||||
|
if station_snap:
|
||||||
|
station_chainage = snap_curve_stations(station_chainage, spans)
|
||||||
# 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다.
|
# 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다.
|
||||||
regular_rounded = {round(float(value), 6) for value in station_chainage}
|
regular_rounded = {round(float(value), 6) for value in station_chainage}
|
||||||
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
|
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
|
||||||
@@ -372,7 +411,6 @@ def generate_sections(
|
|||||||
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
|
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
|
||||||
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
|
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
|
||||||
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
|
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
|
||||||
spans = _design_curve_spans(points, route_chainage, design_curves or [])
|
|
||||||
if spans:
|
if spans:
|
||||||
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
|
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
|
||||||
station_chainage, spans
|
station_chainage, spans
|
||||||
@@ -415,8 +453,10 @@ def generate_sections(
|
|||||||
kind = "bp"
|
kind = "bp"
|
||||||
elif abs(float(value) - total) <= 1e-6:
|
elif abs(float(value) - total) <= 1e-6:
|
||||||
kind = "ep"
|
kind = "ep"
|
||||||
elif rounded in extras and rounded not in regular_rounded:
|
elif rounded in extras:
|
||||||
# 격자와 겹치지 않는 비정규 측점만 irregular. 규칙 격자와 겹치면 규칙 측점 우선.
|
# 구조물 측점이 위(272 · 요구 167) — 격자와 같은 m 여도 구조물 측점(irregular) 하나 ·
|
||||||
|
# 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림
|
||||||
|
# (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`).
|
||||||
kind = "irregular"
|
kind = "irregular"
|
||||||
else:
|
else:
|
||||||
kind = "regular"
|
kind = "regular"
|
||||||
@@ -466,9 +506,10 @@ def generate_sections(
|
|||||||
"uphill_side": uphill_side,
|
"uphill_side": uphill_side,
|
||||||
"frame": frame,
|
"frame": frame,
|
||||||
}
|
}
|
||||||
# 구조물은 kind와 무관하게 부착한다(규칙 격자와 겹쳐 regular가 돼도 구조물 정보는 유지).
|
|
||||||
if rounded in extras:
|
if rounded in extras:
|
||||||
station["structure"] = extras[rounded]
|
station["structure"] = extras[rounded]
|
||||||
|
if rounded in regular_rounded:
|
||||||
|
station["grid"] = True # 숨은 일반 측점 자리(272)
|
||||||
stations.append(station)
|
stations.append(station)
|
||||||
|
|
||||||
samples = []
|
samples = []
|
||||||
|
|||||||
@@ -241,6 +241,45 @@ async def update_longitudinal_grade_summary(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_cross_section_rows(
|
||||||
|
connection: aiomysql.Connection, route_id: int, chainages: list[float]
|
||||||
|
) -> int:
|
||||||
|
"""종단 정본 측점에 횡단 행을 맞춘다 — 정본에 없는 측점의 행(옮긴 · 지운 구조물 측점의 옛 자리).
|
||||||
|
|
||||||
|
0.5 m 안에 행 없는 새 측점이 있으면 그리로 옮긴다(옛 소수 → 정수 m · 사용자 설계 값 그대로 ·
|
||||||
|
면적은 저장 뒤 서버 재계산) · 나머지는 지운다 — 남기면 수량(B08 토적)이 옛 자리 측점을 그대로
|
||||||
|
셈(272). 같은 측점 = 0.01 m 안(`_rowless_station_chainages` 와 같은 폭). 측점 목록이 비면
|
||||||
|
아무것도 안 건드린다. 옮기거나 지운 행 수.
|
||||||
|
"""
|
||||||
|
if not chainages:
|
||||||
|
return 0
|
||||||
|
async with connection.cursor() as cursor:
|
||||||
|
await cursor.execute(
|
||||||
|
"SELECT id, chainage_m FROM cross_sections WHERE route_id = %s", (route_id,)
|
||||||
|
)
|
||||||
|
rows = [(int(row[0]), float(row[1])) for row in await cursor.fetchall()]
|
||||||
|
orphans = [
|
||||||
|
(row_id, at) for row_id, at in rows if all(abs(at - c) >= 0.01 for c in chainages)
|
||||||
|
]
|
||||||
|
free = [c for c in chainages if all(abs(c - at) >= 0.01 for _, at in rows)]
|
||||||
|
pairs = sorted(
|
||||||
|
(abs(c - at), row_id, c) for row_id, at in orphans for c in free if abs(c - at) <= 0.5
|
||||||
|
)
|
||||||
|
moved: dict[int, float] = {}
|
||||||
|
for _gap, row_id, chainage in pairs:
|
||||||
|
if row_id not in moved and chainage not in moved.values():
|
||||||
|
moved[row_id] = chainage
|
||||||
|
for row_id, chainage in moved.items():
|
||||||
|
await cursor.execute(
|
||||||
|
"UPDATE cross_sections SET chainage_m = %s WHERE id = %s", (chainage, row_id)
|
||||||
|
)
|
||||||
|
dropped = [row_id for row_id, _ in orphans if row_id not in moved]
|
||||||
|
if dropped:
|
||||||
|
marks = ", ".join(["%s"] * len(dropped))
|
||||||
|
await cursor.execute(f"DELETE FROM cross_sections WHERE id IN ({marks})", dropped)
|
||||||
|
return len(orphans)
|
||||||
|
|
||||||
|
|
||||||
async def get_surface_crs_epsg(
|
async def get_surface_crs_epsg(
|
||||||
connection: aiomysql.Connection, project_id: UUID, surface_model_id: int
|
connection: aiomysql.Connection, project_id: UUID, surface_model_id: int
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Any
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import aiomysql
|
import aiomysql
|
||||||
|
import numpy as np
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_
|
|||||||
from B05_Profile.B05_Profile_Debug import log_b05_debug
|
from B05_Profile.B05_Profile_Debug import log_b05_debug
|
||||||
from B05_Profile.B05_Profile_Engine import run_route_design
|
from B05_Profile.B05_Profile_Engine import run_route_design
|
||||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
|
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
|
||||||
|
from B05_Profile.B05_Profile_Engine_Grade_Alignment import drop_unplaced_edits
|
||||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||||
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
|
from B05_Profile.B05_Profile_Engine_Sections import run_section_generation
|
||||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||||
@@ -384,13 +386,30 @@ async def update_contour_interval(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _structure_marks(project_root: Path, route_data_path: str) -> list[float]:
|
||||||
|
"""관 · 구조물 자리(m) — 관 지점 전부 + 구조물 측점 자리(시작 · 기준 · 종료)."""
|
||||||
|
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||||
|
_load_pipe_points,
|
||||||
|
_load_route_polyline,
|
||||||
|
resolve_extra_stations,
|
||||||
|
)
|
||||||
|
|
||||||
|
pipes = _load_pipe_points(project_root, _load_route_polyline(project_root, route_data_path))
|
||||||
|
extras = resolve_extra_stations(project_root, pipes)
|
||||||
|
return [float(pipe.chainage_m) for pipe in pipes] + [float(mark) for mark, _ in extras]
|
||||||
|
|
||||||
|
|
||||||
def _apply_alignment_edits(
|
def _apply_alignment_edits(
|
||||||
project_root: Path, longitudinal_file_path: str, edits: dict[str, Any]
|
project_root: Path,
|
||||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
longitudinal_file_path: str,
|
||||||
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약)을 반환한다.
|
edits: dict[str, Any],
|
||||||
|
marks: list[float] | None = None,
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any], list[float]]:
|
||||||
|
"""저장된 종단 JSON에 계획선 편집을 반영해 정본을 다시 쓰고 (선형, 요약, 버린 자리)를 반환한다.
|
||||||
|
|
||||||
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
|
화면이 즉시 계산해 보여준 값과 같은 기하식을 서버에서도 다시 적용해, 파일에
|
||||||
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다.
|
남는 정본이 항상 한 곳(백엔드)에서 만들어지게 한다. `marks`(관 · 구조물 자리)를 주면
|
||||||
|
측점 · 관 · 구조물 어디에도 없는 편집 키를 버린다(213-2 — 저장 차례가 관 · 구조물 뒤라 최신).
|
||||||
"""
|
"""
|
||||||
root = project_root.resolve()
|
root = project_root.resolve()
|
||||||
path = (root / longitudinal_file_path).resolve()
|
path = (root / longitudinal_file_path).resolve()
|
||||||
@@ -399,6 +418,16 @@ def _apply_alignment_edits(
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
|
raise FileNotFoundError("종단 데이터 파일을 찾을 수 없습니다.")
|
||||||
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
dropped: list[float] = []
|
||||||
|
if marks is not None:
|
||||||
|
stations = [float(s.get("chainage_m", 0.0)) for s in longitudinal.get("stations") or []]
|
||||||
|
edits, dropped = drop_unplaced_edits(
|
||||||
|
{
|
||||||
|
"station_offsets": dict(edits.get("station_offsets") or {}),
|
||||||
|
"curve_radii": dict(edits.get("curve_radii") or {}),
|
||||||
|
},
|
||||||
|
np.array(stations + marks, dtype=np.float64),
|
||||||
|
)
|
||||||
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
|
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
|
||||||
longitudinal["profile_alignment"] = alignment
|
longitudinal["profile_alignment"] = alignment
|
||||||
profiles = longitudinal.get("design_profiles")
|
profiles = longitudinal.get("design_profiles")
|
||||||
@@ -407,7 +436,7 @@ def _apply_alignment_edits(
|
|||||||
else:
|
else:
|
||||||
longitudinal["design_profiles"] = [profile]
|
longitudinal["design_profiles"] = [profile]
|
||||||
atomic_write_json(path, longitudinal)
|
atomic_write_json(path, longitudinal)
|
||||||
return alignment, {"id": profile["id"], **profile["summary"]}
|
return alignment, {"id": profile["id"], **profile["summary"]}, dropped
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
|
@router.put("/{project_id}/route/profile-alignment", response_model=ProfileAlignmentSaveResponse)
|
||||||
@@ -426,11 +455,18 @@ async def save_profile_alignment(
|
|||||||
)
|
)
|
||||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
project_root = Path(resolve_stored_project_path(stored_path))
|
project_root = Path(resolve_stored_project_path(stored_path))
|
||||||
alignment, grade_summary = await asyncio.to_thread(
|
route = await get_latest_route(connection, project_id)
|
||||||
|
marks = None
|
||||||
|
if route and route["id"] == request.route_id and route.get("route_data_path"):
|
||||||
|
marks = await asyncio.to_thread(
|
||||||
|
_structure_marks, project_root, str(route["route_data_path"])
|
||||||
|
)
|
||||||
|
alignment, grade_summary, dropped = await asyncio.to_thread(
|
||||||
_apply_alignment_edits,
|
_apply_alignment_edits,
|
||||||
project_root,
|
project_root,
|
||||||
str(longitudinal["longitudinal_file_path"]),
|
str(longitudinal["longitudinal_file_path"]),
|
||||||
request.edits(),
|
request.edits(),
|
||||||
|
marks,
|
||||||
)
|
)
|
||||||
await connection.begin()
|
await connection.begin()
|
||||||
try:
|
try:
|
||||||
@@ -446,6 +482,7 @@ async def save_profile_alignment(
|
|||||||
route_id=request.route_id,
|
route_id=request.route_id,
|
||||||
profile_alignment=alignment,
|
profile_alignment=alignment,
|
||||||
grade_summary=grade_summary,
|
grade_summary=grade_summary,
|
||||||
|
dropped_edit_chainages=dropped,
|
||||||
)
|
)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -15,9 +16,19 @@ import aiomysql
|
|||||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
|
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
|
||||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||||
from B05_Profile.B05_Profile_Engine_Sections import generate_irregular_sections
|
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||||
|
_load_pipe_points,
|
||||||
|
_load_route_polyline,
|
||||||
|
find_cross_file,
|
||||||
|
generate_irregular_sections,
|
||||||
|
resolve_extra_stations,
|
||||||
|
)
|
||||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||||
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
|
from B05_Profile.B05_Profile_Repository import (
|
||||||
|
get_surface_crs_epsg,
|
||||||
|
sync_cross_section_rows,
|
||||||
|
update_longitudinal_grade_summary,
|
||||||
|
)
|
||||||
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
|
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
|
||||||
from B06_Section.B06_Section_Repository import (
|
from B06_Section.B06_Section_Repository import (
|
||||||
get_latest_section_options,
|
get_latest_section_options,
|
||||||
@@ -96,6 +107,7 @@ async def sync_uphill_overrides_into_designs(
|
|||||||
compute_cross_design,
|
compute_cross_design,
|
||||||
curve_widening_args,
|
curve_widening_args,
|
||||||
stored_shoulder_extend,
|
stored_shoulder_extend,
|
||||||
|
stored_tilt,
|
||||||
)
|
)
|
||||||
from B06_Section.B06_Section_Repository import get_cross_section_designs
|
from B06_Section.B06_Section_Repository import get_cross_section_designs
|
||||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||||
@@ -158,12 +170,13 @@ async def sync_uphill_overrides_into_designs(
|
|||||||
paved=bool(design.get("paved", False)),
|
paved=bool(design.get("paved", False)),
|
||||||
standard=stored_standard,
|
standard=stored_standard,
|
||||||
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
|
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
|
||||||
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
two_stage_slope=bool(design.get("two_stage_slope", False)),
|
||||||
ditch_enabled=design.get("ditch_enabled"),
|
ditch_enabled=design.get("ditch_enabled"),
|
||||||
ditch_choice=design.get("ditch_choice"),
|
ditch_choice=design.get("ditch_choice"),
|
||||||
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
|
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
|
||||||
**curve_widening_args(cross_record),
|
**curve_widening_args(cross_record),
|
||||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||||
|
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
|
||||||
)
|
)
|
||||||
next_design["status"] = design.get("status", "provisional")
|
next_design["status"] = design.get("status", "provisional")
|
||||||
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
|
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
|
||||||
@@ -188,12 +201,16 @@ async def sync_uphill_overrides_into_designs(
|
|||||||
_SAME_STATION_M = 0.006
|
_SAME_STATION_M = 0.006
|
||||||
|
|
||||||
|
|
||||||
def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[str, Any] | None:
|
def _follow_moved_stations(
|
||||||
|
data: dict[str, Any], before: list[float], previous: list[float] | None = None
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
"""사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다.
|
"""사라진 비정규 측점의 계획선 꼭짓점(base_pvi) · 편집을 새 측점으로 옮기거나 지운다.
|
||||||
|
|
||||||
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
|
남기면 측점선 없는 변화점이 계획선을 꺾는다(192-1 — 관 769.28 → 769.38 뒤 두 자리에
|
||||||
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안)만 옮기고 나머지는 지운다.
|
편집이 남아 0.1m 사이 −465% 계단). 반올림 차이(0.006m 안) · 옛 소수 측점의 정수 m 맞춤
|
||||||
바뀌었으면 새 등급 요약(DB 몫)을, 아니면 `None`.
|
자리(272)만 옮기고 나머지는 지운다.
|
||||||
|
측점 목록이 계획선 측점 줄과 달라져도 다시 센다(272). 다시 셌으면 새 등급 요약(DB 몫)을,
|
||||||
|
아니면 `None`.
|
||||||
"""
|
"""
|
||||||
stored = data.get("profile_alignment")
|
stored = data.get("profile_alignment")
|
||||||
if not isinstance(stored, dict) or not stored.get("base_pvi"):
|
if not isinstance(stored, dict) or not stored.get("base_pvi"):
|
||||||
@@ -213,6 +230,11 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
|
|||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
# 옛 소수 측점 → 정수 m 맞춤(272 · 요구 167) — 맞춘 자리가 새 측점이면 꼭짓점 · 편집도
|
||||||
|
# 그리로(지우면 계획선이 그 자리에서 바뀜 · 화면 `followMovedNodes` 와 짝)
|
||||||
|
snapped = chainage_key(math.floor(old + 0.5))
|
||||||
|
if near is None and snapped in after_keys and snapped not in before_keys:
|
||||||
|
near = float(snapped)
|
||||||
moves[chainage_key(old)] = None if near is None else chainage_key(near)
|
moves[chainage_key(old)] = None if near is None else chainage_key(near)
|
||||||
|
|
||||||
def moved(value: float) -> tuple[bool, str | None]:
|
def moved(value: float) -> tuple[bool, str | None]:
|
||||||
@@ -249,7 +271,15 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
|
|||||||
changed = True
|
changed = True
|
||||||
if new_key is not None:
|
if new_key is not None:
|
||||||
base.append({**node, "chainage_m": float(new_key)})
|
base.append({**node, "chainage_m": float(new_key)})
|
||||||
if not changed:
|
# 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
|
||||||
|
# 줄이 없는 판은 맞추기 전 측점(`previous`)과 견줌
|
||||||
|
rows = stored.get("stations")
|
||||||
|
listed = (
|
||||||
|
[round(float(row.get("chainage_m", 0.0)), 6) for row in rows]
|
||||||
|
if isinstance(rows, list)
|
||||||
|
else previous
|
||||||
|
)
|
||||||
|
if not changed and listed in (None, [round(value, 6) for value in after]):
|
||||||
return None
|
return None
|
||||||
stored["base_pvi"] = base
|
stored["base_pvi"] = base
|
||||||
alignment, profile = rebuild_alignment_profile(data, next_edits)
|
alignment, profile = rebuild_alignment_profile(data, next_edits)
|
||||||
@@ -265,12 +295,17 @@ def _follow_moved_stations(data: dict[str, Any], before: list[float]) -> dict[st
|
|||||||
def _merge_irregular_into_longitudinal(
|
def _merge_irregular_into_longitudinal(
|
||||||
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
|
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬).
|
"""종단 정본 stations 의 구조물 측점을 **지금 목록 전체로 다시 맞춘다**(272 · 요구 167).
|
||||||
|
|
||||||
B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과
|
`irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은
|
||||||
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 규칙 측점·표고 샘플은
|
`grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도),
|
||||||
손대지 않는다. 계획선은 사라진 비정규 측점 자리만 고친다(`_follow_moved_stations` —
|
구조물이 떠난 격자 자리(`grid`)는 일반 측점으로 되돌리고, 새 목록의 격자 자리 측점은 그
|
||||||
바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 재확정해도 중복이 생기지 않는다.
|
격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 표고 샘플은 손대지 않는다.
|
||||||
|
B06 상세는 종단 파일의 stations 에 맞는 cross 파일만 노출하고 남은 파일을 지운다
|
||||||
|
(`prune_stale_cross_files`). 계획선은 사라진 구조물 측점 자리만 고친다(`_follow_moved_stations`
|
||||||
|
— 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 다시 맞춰도 중복이 생기지 않는다.
|
||||||
|
TS 짝 = `common_util_structure_station.ts` `oneStationList`(화면 측점 한 벌) · 거울 시험 =
|
||||||
|
`resources/tester/test_272_1_station_list.py`.
|
||||||
"""
|
"""
|
||||||
root = project_root.resolve()
|
root = project_root.resolve()
|
||||||
path = (root / longitudinal_file_path).resolve()
|
path = (root / longitudinal_file_path).resolve()
|
||||||
@@ -285,15 +320,53 @@ def _merge_irregular_into_longitudinal(
|
|||||||
for station in stations
|
for station in stations
|
||||||
if station.get("kind") == "irregular"
|
if station.get("kind") == "irregular"
|
||||||
]
|
]
|
||||||
regular = [station for station in stations if station.get("kind") != "irregular"]
|
fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
|
||||||
merged = regular + list(irregular_stations)
|
kept: dict[float, dict[str, Any]] = {}
|
||||||
merged.sort(key=lambda station: float(station.get("chainage_m", 0.0)))
|
left: list[dict[str, Any]] = []
|
||||||
data["stations"] = merged
|
for station in stations:
|
||||||
summary = _follow_moved_stations(data, before)
|
if station.get("kind") == "irregular" and not station.get("grid"):
|
||||||
|
continue # 옛 구조물 측점 — 새 목록이 갈음
|
||||||
|
key = round(float(station.get("chainage_m", 0.0)), 6)
|
||||||
|
if key not in fresh and ("structure" in station or station.get("grid")):
|
||||||
|
# 구조물이 떠난 격자 · 시종점 자리 — 일반 측점으로(옛 판 「regular + 글」 포함)
|
||||||
|
station = {k: v for k, v in station.items() if k not in ("grid", "structure")}
|
||||||
|
if station.get("kind") == "irregular":
|
||||||
|
station["kind"] = "regular"
|
||||||
|
left.append(station)
|
||||||
|
kept[key] = station
|
||||||
|
kept.update(fresh)
|
||||||
|
data["stations"] = sorted(kept.values(), key=lambda s: float(s.get("chainage_m", 0.0)))
|
||||||
|
previous = [round(float(station.get("chainage_m", 0.0)), 6) for station in stations]
|
||||||
|
summary = _follow_moved_stations(data, before, previous)
|
||||||
atomic_write_json(path, data)
|
atomic_write_json(path, data)
|
||||||
|
_relabel_cross_files(path.parent.parent / "cross_sections", left)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
async def drop_rows_off_stations(
|
||||||
|
connection: aiomysql.Connection, route_id: int, project_root: Path, longitudinal_path: str
|
||||||
|
) -> None:
|
||||||
|
"""측점을 다시 맞춘 뒤 — 종단 정본에 없는 측점의 횡단 행을 새 측점으로 옮기거나 지운다(272)."""
|
||||||
|
path = (project_root.resolve() / longitudinal_path).resolve()
|
||||||
|
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
|
||||||
|
await sync_cross_section_rows(
|
||||||
|
connection, route_id, [float(station["chainage_m"]) for station in stations]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relabel_cross_files(cross_dir: Path, stations: list[dict[str, Any]]) -> None:
|
||||||
|
"""일반 측점으로 돌아온 자리의 횡단 파일 글(종류 · 구조물)도 맞춘다 — 카드 제목(272)."""
|
||||||
|
for station in stations:
|
||||||
|
cross_path = find_cross_file(cross_dir, float(station.get("chainage_m", 0.0)))
|
||||||
|
if not cross_path.is_file():
|
||||||
|
continue
|
||||||
|
cross = json.loads(cross_path.read_text(encoding="utf-8"))
|
||||||
|
cross.pop("structure", None)
|
||||||
|
cross.pop("grid", None)
|
||||||
|
cross["kind"] = station.get("kind")
|
||||||
|
atomic_write_json(cross_path, cross)
|
||||||
|
|
||||||
|
|
||||||
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
|
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
|
||||||
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
|
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
|
||||||
SAMPLING_SNAPSHOT_NAME = "sampling.json"
|
SAMPLING_SNAPSHOT_NAME = "sampling.json"
|
||||||
@@ -350,24 +423,38 @@ async def _append_irregular_cross_sections(
|
|||||||
save_sampling_snapshot(project_root, request)
|
save_sampling_snapshot(project_root, request)
|
||||||
stored_options = await get_latest_section_options(connection, project_id)
|
stored_options = await get_latest_section_options(connection, project_id)
|
||||||
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
|
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
|
||||||
irregular_stations = await asyncio.to_thread(
|
route_data_path = str(route["route_data_path"])
|
||||||
generate_irregular_sections,
|
|
||||||
project_root,
|
def _generate() -> list[dict[str, Any]]:
|
||||||
str(route["route_data_path"]),
|
# 측점 목록 = 서버 정본(관 · 구조물)에서 — 화면 목록(관 · A군)을 받아 적으면 서버가 심은
|
||||||
request.filter_key,
|
# 소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 종료 측점이 통째로 빠짐(238-1).
|
||||||
request.method,
|
polyline = _load_route_polyline(project_root, route_data_path)
|
||||||
request.smooth,
|
extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
|
||||||
extra_stations=request.extra_stations(),
|
return generate_irregular_sections(
|
||||||
options=_section_options_from_stored(stored_options),
|
project_root,
|
||||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
route_data_path,
|
||||||
)
|
request.filter_key,
|
||||||
if not irregular_stations:
|
request.method,
|
||||||
return
|
request.smooth,
|
||||||
|
extra_stations=extras,
|
||||||
|
options=_section_options_from_stored(stored_options),
|
||||||
|
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 빈 목록이어도 맞춤 — 옛 구조물 측점 · 떠난 격자 자리 글을 지워야 함(272)
|
||||||
|
irregular_stations = await asyncio.to_thread(_generate)
|
||||||
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
|
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
|
||||||
if longitudinal:
|
if longitudinal:
|
||||||
await asyncio.to_thread(
|
summary = await asyncio.to_thread(
|
||||||
_merge_irregular_into_longitudinal,
|
_merge_irregular_into_longitudinal,
|
||||||
project_root,
|
project_root,
|
||||||
str(longitudinal["longitudinal_file_path"]),
|
str(longitudinal["longitudinal_file_path"]),
|
||||||
irregular_stations,
|
irregular_stations,
|
||||||
)
|
)
|
||||||
|
if summary is not None: # 계획선을 다시 셈 — DB 등급 요약도 같이(B06 측점 만들기와 같은 길)
|
||||||
|
await update_longitudinal_grade_summary(
|
||||||
|
connection, route_id=route["id"], grade_summary=summary
|
||||||
|
)
|
||||||
|
await drop_rows_off_stations(
|
||||||
|
connection, route["id"], project_root, str(longitudinal["longitudinal_file_path"])
|
||||||
|
)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계)
|
GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계)
|
||||||
POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
|
POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
|
||||||
POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
|
POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
|
||||||
POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(파일 안 씀)
|
POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -41,10 +41,17 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import AliasChoices, BaseModel, Field
|
|
||||||
|
|
||||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||||
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
|
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
|
||||||
|
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
|
||||||
|
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||||
|
RouteInitialCriteria,
|
||||||
|
RouteInitialRequest,
|
||||||
|
RouteInitialWeights,
|
||||||
|
RouteReplanRequest,
|
||||||
|
RouteVertexInput,
|
||||||
|
)
|
||||||
from common_util.common_util_initial_snapshot import design_route_csv_path
|
from common_util.common_util_initial_snapshot import design_route_csv_path
|
||||||
from common_util.common_util_route_geometry import (
|
from common_util.common_util_route_geometry import (
|
||||||
densify_route,
|
densify_route,
|
||||||
@@ -69,74 +76,6 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
|||||||
_PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}
|
_PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}
|
||||||
|
|
||||||
|
|
||||||
class RouteVertexInput(BaseModel):
|
|
||||||
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
|
|
||||||
|
|
||||||
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
|
|
||||||
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
|
|
||||||
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
|
|
||||||
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
x: float
|
|
||||||
y: float
|
|
||||||
curve: bool = True
|
|
||||||
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
|
|
||||||
radius_m: float | None = None
|
|
||||||
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
|
|
||||||
|
|
||||||
|
|
||||||
class RouteInitialWeights(BaseModel):
|
|
||||||
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
|
|
||||||
|
|
||||||
expected: float = Field(50.0, ge=0, le=100)
|
|
||||||
earthwork: float = Field(25.0, ge=0, le=100)
|
|
||||||
grade: float = Field(25.0, ge=0, le=100)
|
|
||||||
|
|
||||||
|
|
||||||
class RouteInitialCriteria(BaseModel):
|
|
||||||
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
|
|
||||||
|
|
||||||
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
|
|
||||||
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
|
|
||||||
"""
|
|
||||||
|
|
||||||
max_grade_pct: float | None = Field(None, ge=0, le=100)
|
|
||||||
min_radius_m: float | None = Field(None, ge=0)
|
|
||||||
stream_offset_m: float | None = Field(None, ge=0)
|
|
||||||
grade_smooth_m: float | None = Field(None, ge=0)
|
|
||||||
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
|
|
||||||
target_grade_pct: float | None = Field(None, ge=0, le=100)
|
|
||||||
|
|
||||||
|
|
||||||
class RouteInitialRequest(BaseModel):
|
|
||||||
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
|
|
||||||
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
|
|
||||||
|
|
||||||
mode: Literal[
|
|
||||||
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
|
|
||||||
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
|
|
||||||
weights: RouteInitialWeights | None = None
|
|
||||||
criteria: RouteInitialCriteria | None = None
|
|
||||||
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
|
|
||||||
with_profile: bool = True
|
|
||||||
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
|
|
||||||
terrain: Literal["las", "sheet"] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RouteReplanRequest(BaseModel):
|
|
||||||
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
|
|
||||||
|
|
||||||
vertices: list[RouteVertexInput] = Field(default_factory=list)
|
|
||||||
initial: RouteInitialRequest | None = None
|
|
||||||
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
|
|
||||||
balanced_profile: bool = False
|
|
||||||
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
|
|
||||||
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
|
|
||||||
terrain: Literal["las", "sheet"] | None = None
|
|
||||||
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
|
|
||||||
|
|
||||||
|
|
||||||
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
|
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
|
||||||
"""(프로젝트 실경로, 저장소 상대경로). 없으면 None."""
|
"""(프로젝트 실경로, 저장소 상대경로). 없으면 None."""
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
@@ -305,9 +244,12 @@ def _write_planned_polyline(
|
|||||||
simplify: bool = True,
|
simplify: bool = True,
|
||||||
curve_flags: list[bool] | None = None,
|
curve_flags: list[bool] | None = None,
|
||||||
radii: list[float | None] | None = None,
|
radii: list[float | None] | None = None,
|
||||||
|
station_snap: bool | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
|
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
|
||||||
|
|
||||||
|
`station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔).
|
||||||
|
|
||||||
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
**노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이
|
||||||
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
|
||||||
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
|
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
|
||||||
@@ -321,13 +263,13 @@ def _write_planned_polyline(
|
|||||||
)
|
)
|
||||||
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
|
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
|
||||||
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
|
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
|
||||||
_curves_path(path).write_text(
|
table: dict[str, Any] = {
|
||||||
json.dumps(
|
"min_radius_m": round(radius_m, 3),
|
||||||
{"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]},
|
"curves": [c.as_dict() for c in result.curves],
|
||||||
ensure_ascii=False,
|
}
|
||||||
),
|
if station_snap is not None:
|
||||||
encoding="utf-8",
|
table["station_snap"] = bool(station_snap)
|
||||||
)
|
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
|
||||||
return {
|
return {
|
||||||
"nodes": len(result.nodes),
|
"nodes": len(result.nodes),
|
||||||
"curves": result.curve_count,
|
"curves": result.curve_count,
|
||||||
@@ -572,6 +514,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
|||||||
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
|
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
|
||||||
"violation_count": outline.violation_count,
|
"violation_count": outline.violation_count,
|
||||||
"edited": bool(working),
|
"edited": bool(working),
|
||||||
|
"station_snap": await asyncio.to_thread(load_station_snap, project_root),
|
||||||
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
|
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
|
||||||
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
|
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
|
||||||
}
|
}
|
||||||
@@ -604,11 +547,17 @@ async def replan_route(
|
|||||||
radii = [vertex.radius_m for vertex in request.vertices]
|
radii = [vertex.radius_m for vertex in request.vertices]
|
||||||
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
|
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
|
||||||
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
|
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
|
||||||
|
# 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음.
|
||||||
working_path = planned_route_working_path(project_root)
|
working_path = planned_route_working_path(project_root)
|
||||||
previous = working_path.read_bytes() if working_path.is_file() else None
|
keep = (working_path, _curves_path(working_path), _nodes_path(working_path))
|
||||||
|
previous = {path: path.read_bytes() if path.is_file() else None for path in keep}
|
||||||
summary = await asyncio.to_thread(
|
summary = await asyncio.to_thread(
|
||||||
functools.partial(
|
functools.partial(
|
||||||
_write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii
|
_write_planned_polyline,
|
||||||
|
simplify=False,
|
||||||
|
curve_flags=curve_flags,
|
||||||
|
radii=radii,
|
||||||
|
station_snap=request.station_snap,
|
||||||
),
|
),
|
||||||
working_path,
|
working_path,
|
||||||
nodes,
|
nodes,
|
||||||
@@ -633,10 +582,11 @@ async def replan_route(
|
|||||||
)
|
)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
|
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
|
||||||
if previous is None:
|
for path, saved in previous.items():
|
||||||
working_path.unlink(missing_ok=True)
|
if saved is None:
|
||||||
else:
|
path.unlink(missing_ok=True)
|
||||||
working_path.write_bytes(previous)
|
else:
|
||||||
|
path.write_bytes(saved)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s",
|
"계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s",
|
||||||
project_id,
|
project_id,
|
||||||
|
|||||||
@@ -30,14 +30,35 @@ async def initial_route(
|
|||||||
) -> dict[str, Any] | JSONResponse:
|
) -> dict[str, Any] | JSONResponse:
|
||||||
"""초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장).
|
"""초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장).
|
||||||
|
|
||||||
**파일을 쓰지 않는다** — 화면 캐시용. 적용은 받은 `nodes` 로 `POST /route/replan`([확인]).
|
작업본은 쓰지 않는다 — 추천 저장본(`..._Store` · PLAN 327)만. 적용은 받은 `nodes` 로
|
||||||
|
`POST /route/replan`([확인]).
|
||||||
|
"""
|
||||||
|
return await compute_initial(project_id, request)
|
||||||
|
|
||||||
|
|
||||||
|
async def compute_initial(
|
||||||
|
project_id: UUID, request: replan.RouteInitialRequest
|
||||||
|
) -> dict[str, Any] | JSONResponse:
|
||||||
|
"""같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로.
|
||||||
|
|
||||||
|
응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음).
|
||||||
"""
|
"""
|
||||||
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
|
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
|
||||||
from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||||
generate_initial_route,
|
generate_initial_route,
|
||||||
load_terrain_and_streams,
|
load_terrain_and_streams,
|
||||||
)
|
)
|
||||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import project_pipes
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
|
||||||
|
cover_shortfall,
|
||||||
|
project_pipes,
|
||||||
|
)
|
||||||
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
|
||||||
|
is_default,
|
||||||
|
pool_lock,
|
||||||
|
read_stored,
|
||||||
|
request_fingerprint,
|
||||||
|
write_stored,
|
||||||
|
)
|
||||||
from common_util.common_util_surface_confirmation import (
|
from common_util.common_util_surface_confirmation import (
|
||||||
analysis_terrain_params,
|
analysis_terrain_params,
|
||||||
get_surface_confirmation_params,
|
get_surface_confirmation_params,
|
||||||
@@ -81,37 +102,62 @@ async def initial_route(
|
|||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
selection = await get_surface_confirmation_params(connection, str(project_id))
|
selection = await get_surface_confirmation_params(connection, str(project_id))
|
||||||
selection = analysis_terrain_params(selection, request.terrain)
|
selection = analysis_terrain_params(selection, request.terrain)
|
||||||
|
inputs = {
|
||||||
|
"mode": request.mode,
|
||||||
|
"weights": weights,
|
||||||
|
"max_grade": max_grade,
|
||||||
|
"min_radius_m": radius_m,
|
||||||
|
"stream_offset_m": stream_offset_m,
|
||||||
|
"grade_smooth_m": grade_smooth_m,
|
||||||
|
"target_grade": None if target_pct is None else target_pct / 100.0,
|
||||||
|
"design_speed": design_speed or 20,
|
||||||
|
"with_profile": request.with_profile,
|
||||||
|
}
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
terrain, streams = await asyncio.to_thread(
|
terrain, streams = await asyncio.to_thread(
|
||||||
load_terrain_and_streams, project_root, selection
|
load_terrain_and_streams, project_root, selection
|
||||||
)
|
)
|
||||||
pipes = (
|
route = [(x, y) for x, y in expected]
|
||||||
await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None
|
key, pool_key = await asyncio.to_thread(
|
||||||
)
|
functools.partial(request_fingerprint, route, terrain, streams, **inputs)
|
||||||
result = await asyncio.to_thread(
|
|
||||||
functools.partial(
|
|
||||||
generate_initial_route,
|
|
||||||
[(x, y) for x, y in expected],
|
|
||||||
terrain,
|
|
||||||
streams,
|
|
||||||
mode=request.mode,
|
|
||||||
weights=weights,
|
|
||||||
max_grade=max_grade,
|
|
||||||
min_radius_m=radius_m,
|
|
||||||
stream_offset_m=stream_offset_m,
|
|
||||||
grade_smooth_m=grade_smooth_m,
|
|
||||||
target_grade=None if target_pct is None else target_pct / 100.0,
|
|
||||||
design_speed=design_speed or 20,
|
|
||||||
pipes=pipes,
|
|
||||||
with_profile=request.with_profile,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
async with pool_lock(pool_key):
|
||||||
|
stored = await asyncio.to_thread(read_stored, project_root, key)
|
||||||
|
if stored is not None:
|
||||||
|
result = stored["result"]
|
||||||
|
else:
|
||||||
|
# 관 지점은 키 밖 — 덮개 수 없이 셈해 남기고 아래에서 지금 관으로 센다.
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
functools.partial(
|
||||||
|
generate_initial_route, route, terrain, streams, pipes=None, **inputs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
write_stored,
|
||||||
|
project_root,
|
||||||
|
key,
|
||||||
|
pool_key,
|
||||||
|
request.model_dump(),
|
||||||
|
result,
|
||||||
|
is_default(
|
||||||
|
request.mode,
|
||||||
|
weights,
|
||||||
|
criteria.model_dump() if criteria else None,
|
||||||
|
request.terrain,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
profile = result["metrics"].get("profile")
|
||||||
|
if request.with_profile and profile and "error" not in profile:
|
||||||
|
pipes = await asyncio.to_thread(project_pipes, project_root)
|
||||||
|
if pipes:
|
||||||
|
profile.update(cover_shortfall(profile, result["planned"], pipes))
|
||||||
except (FileNotFoundError, ValueError) as error:
|
except (FileNotFoundError, ValueError) as error:
|
||||||
return JSONResponse(status_code=409, content={"status": "error", "message": str(error)})
|
return JSONResponse(status_code=409, content={"status": "error", "message": str(error)})
|
||||||
logger.info(
|
logger.info(
|
||||||
"초기 계획노선 %s: project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm",
|
"초기 계획노선 %s(%s): project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm",
|
||||||
request.mode,
|
request.mode,
|
||||||
|
"저장본" if stored is not None else "셈",
|
||||||
project_id,
|
project_id,
|
||||||
time.perf_counter() - started,
|
time.perf_counter() - started,
|
||||||
result["metrics"]["length_m"],
|
result["metrics"]["length_m"],
|
||||||
@@ -122,10 +168,28 @@ async def initial_route(
|
|||||||
"status": "success",
|
"status": "success",
|
||||||
"project_id": str(project_id),
|
"project_id": str(project_id),
|
||||||
**result,
|
**result,
|
||||||
|
"stored": stored is not None,
|
||||||
"current_initial": current,
|
"current_initial": current,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def precompute_defaults(project_id: UUID) -> None:
|
||||||
|
"""업로드 체인 끝 예약(PLAN 327-2) — 기본 다섯 알약을 미리 셈해 남김 · 있으면 꺼내기만."""
|
||||||
|
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
for mode in DEFAULT_MODES:
|
||||||
|
# 화면 기본과 같은 몸통 — 빈 제약 · 50/25/25 · 설계 지형 · 추적만 종단 끔(`withProfileOf`)
|
||||||
|
request = replan.RouteInitialRequest(mode=mode, with_profile=mode != "follow")
|
||||||
|
outcome = await compute_initial(project_id, request)
|
||||||
|
if isinstance(outcome, JSONResponse):
|
||||||
|
logger.warning(
|
||||||
|
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
|
||||||
|
|
||||||
|
|
||||||
def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
|
def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
|
||||||
"""종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다."""
|
"""종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다."""
|
||||||
import json
|
import json
|
||||||
@@ -173,7 +237,9 @@ async def apply_profile_target(
|
|||||||
root = Path(resolve_stored_project_path(stored_path))
|
root = Path(resolve_stored_project_path(stored_path))
|
||||||
relative = str(row["longitudinal_file_path"])
|
relative = str(row["longitudinal_file_path"])
|
||||||
count = await asyncio.to_thread(_set_base_pvi, root / relative, *target)
|
count = await asyncio.to_thread(_set_base_pvi, root / relative, *target)
|
||||||
_alignment, summary = await asyncio.to_thread(_apply_alignment_edits, root, relative, {})
|
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||||
|
_apply_alignment_edits, root, relative, {}
|
||||||
|
)
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
await connection.begin()
|
await connection.begin()
|
||||||
await update_longitudinal_grade_summary(
|
await update_longitudinal_grade_summary(
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ class CrossPreviewRequest(BaseModel):
|
|||||||
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
|
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
|
||||||
cross_half_width_m: float | None = Field(None, gt=0)
|
cross_half_width_m: float | None = Field(None, gt=0)
|
||||||
terrain: AnalysisTerrain | None = None
|
terrain: AnalysisTerrain | None = None
|
||||||
|
#: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로.
|
||||||
|
station_snap: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _cross_preview(
|
def _cross_preview(
|
||||||
@@ -177,7 +179,14 @@ def _cross_preview(
|
|||||||
else defaults.station_interval_m,
|
else defaults.station_interval_m,
|
||||||
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
|
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
|
||||||
)
|
)
|
||||||
result = generate_sections(built.vertices, sampler, options)
|
# 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙.
|
||||||
|
result = generate_sections(
|
||||||
|
built.vertices,
|
||||||
|
sampler,
|
||||||
|
options,
|
||||||
|
design_curves=[curve.as_dict() for curve in built.curves],
|
||||||
|
station_snap=request.station_snap,
|
||||||
|
)
|
||||||
sections = result["cross_sections"]
|
sections = result["cross_sections"]
|
||||||
if not sections:
|
if not sections:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod
|
from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod
|
||||||
from config.config_system import (
|
from config.config_system import (
|
||||||
@@ -225,6 +225,8 @@ class ProfileAlignmentSaveResponse(BaseModel):
|
|||||||
route_id: int
|
route_id: int
|
||||||
profile_alignment: dict[str, Any]
|
profile_alignment: dict[str, Any]
|
||||||
grade_summary: dict[str, Any] | None = None
|
grade_summary: dict[str, Any] | None = None
|
||||||
|
#: 측점 · 관 · 구조물 자리가 없어 버린 계획고 편집 자리(m) — 화면이 알림으로 보임(213-2)
|
||||||
|
dropped_edit_chainages: list[float] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class RouteSolveResponse(BaseModel):
|
class RouteSolveResponse(BaseModel):
|
||||||
@@ -266,7 +268,8 @@ class RouteConfirmRequest(BaseModel):
|
|||||||
"""경로 확정 요청.
|
"""경로 확정 요청.
|
||||||
|
|
||||||
확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값
|
확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값
|
||||||
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 비정규 측점이 없으면 재생성 없이 확정만 한다.
|
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 셋이 다 오면 구조물 측점을 서버 정본 목록
|
||||||
|
전체로 다시 맞춘다(빈 목록이어도 · 272).
|
||||||
모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다.
|
모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -284,12 +287,8 @@ class RouteConfirmRequest(BaseModel):
|
|||||||
return tuple((item.chainage_m, item.structure) for item in self.irregular_stations)
|
return tuple((item.chainage_m, item.structure) for item in self.irregular_stations)
|
||||||
|
|
||||||
def can_regenerate(self) -> bool:
|
def can_regenerate(self) -> bool:
|
||||||
return bool(
|
# 화면 목록이 비어도 맞춤 — 측점 목록은 서버 정본에서 뜨고, 옛 구조물 측점을 지워야 함(272)
|
||||||
self.irregular_stations
|
return bool(self.filter_key and self.method and self.surface_model_id is not None)
|
||||||
and self.filter_key
|
|
||||||
and self.method
|
|
||||||
and self.surface_model_id is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RouteConfirmResponse(BaseModel):
|
class RouteConfirmResponse(BaseModel):
|
||||||
@@ -310,3 +309,76 @@ class RouteLatestResponse(BaseModel):
|
|||||||
route_points: list[dict[str, Any]] = Field(default_factory=list)
|
route_points: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
surface_params: dict[str, Any]
|
surface_params: dict[str, Any]
|
||||||
route_params: dict[str, Any] | None = None
|
route_params: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 계획노선 편집 요청(`B05_Profile_Router_Replan`) ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class RouteVertexInput(BaseModel):
|
||||||
|
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
|
||||||
|
|
||||||
|
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) —
|
||||||
|
· **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것.
|
||||||
|
· **곡선 삭제·추가** = `curve` 를 끄고 켜는 것.
|
||||||
|
· **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
curve: bool = True
|
||||||
|
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
|
||||||
|
radius_m: float | None = None
|
||||||
|
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
|
||||||
|
|
||||||
|
|
||||||
|
class RouteInitialWeights(BaseModel):
|
||||||
|
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
|
||||||
|
|
||||||
|
expected: float = Field(50.0, ge=0, le=100)
|
||||||
|
earthwork: float = Field(25.0, ge=0, le=100)
|
||||||
|
grade: float = Field(25.0, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class RouteInitialCriteria(BaseModel):
|
||||||
|
"""공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다.
|
||||||
|
|
||||||
|
기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소
|
||||||
|
곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름.
|
||||||
|
"""
|
||||||
|
|
||||||
|
max_grade_pct: float | None = Field(None, ge=0, le=100)
|
||||||
|
min_radius_m: float | None = Field(None, ge=0)
|
||||||
|
stream_offset_m: float | None = Field(None, ge=0)
|
||||||
|
grade_smooth_m: float | None = Field(None, ge=0)
|
||||||
|
# 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5)
|
||||||
|
target_grade_pct: float | None = Field(None, ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class RouteInitialRequest(BaseModel):
|
||||||
|
"""초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle ·
|
||||||
|
safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5)."""
|
||||||
|
|
||||||
|
mode: Literal[
|
||||||
|
"follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"
|
||||||
|
] = Field("follow", validation_alias=AliasChoices("logic", "mode"))
|
||||||
|
weights: RouteInitialWeights | None = None
|
||||||
|
criteria: RouteInitialCriteria | None = None
|
||||||
|
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
|
||||||
|
with_profile: bool = True
|
||||||
|
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
|
||||||
|
terrain: Literal["las", "sheet"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RouteReplanRequest(BaseModel):
|
||||||
|
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
|
||||||
|
|
||||||
|
vertices: list[RouteVertexInput] = Field(default_factory=list)
|
||||||
|
initial: RouteInitialRequest | None = None
|
||||||
|
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
|
||||||
|
balanced_profile: bool = False
|
||||||
|
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
|
||||||
|
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
|
||||||
|
terrain: Literal["las", "sheet"] | None = None
|
||||||
|
"""편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로."""
|
||||||
|
station_snap: bool = False
|
||||||
|
"""[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음."""
|
||||||
|
|||||||
@@ -16,15 +16,16 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||||
StructureInstance,
|
StructureInstance,
|
||||||
station_planting_labels,
|
|
||||||
structure_type_map,
|
structure_type_map,
|
||||||
)
|
)
|
||||||
from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint
|
from common_util.common_util_drainage_pipes import PIPE_SOURCE_USER, PipePoint
|
||||||
|
from common_util.common_util_structure_station import SAME_STATION_M
|
||||||
|
|
||||||
PIPE_STRUCTURE_NAME = "배관"
|
PIPE_STRUCTURE_NAME = "배관"
|
||||||
# 구조물 정본이 아니라 관 지점 정본이 관리한다는 표식(레지스트리 `managed_by`).
|
# 구조물 정본이 아니라 관 지점 정본이 관리한다는 표식(레지스트리 `managed_by`).
|
||||||
@@ -54,28 +55,22 @@ _PIPE_LABEL = re.compile(r"^(배관|.*관)\s+D\d+", re.UNICODE)
|
|||||||
_ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE)
|
_ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE)
|
||||||
|
|
||||||
|
|
||||||
def _managed_elsewhere_labels() -> set[str]:
|
def _is_managed_elsewhere(entry: dict[str, Any], planted: Sequence[float]) -> bool:
|
||||||
"""서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 →
|
"""정본(관 지점 · 구조물)이 이미 관리하는 측점인가 — 재이관하면 정본 이중화 → 「기타」 유령.
|
||||||
"기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels).
|
|
||||||
|
|
||||||
관 지점 정본이 관리하는 타입 이름도 같이 막는다 — 그 측점은 이미 관 시설에서
|
자리로 가름(323-7 · 지금 관 · 구조물이 심는 측점 계획 자리 `planted`) — 옛 이름 되짚기
|
||||||
투영된 것이라 되옮기면 이름을 못 알아보고 "기타"로 굳는다(기슭막이 2026-08-28 이관).
|
(지난 판 이름 전부 · 322-0)는 걷음 · 이름을 바꿔도 자리는 그대로라 옛 이름 측점도 알아봄.
|
||||||
|
지금 판 종류 이름 · 관 라벨 꼴(관종 D관경)은 자리를 못 받은 부름(옛 확정분만)을 위해 그대로.
|
||||||
"""
|
"""
|
||||||
owned = {
|
|
||||||
item.name for item in structure_type_map().values() if item.managed_by == PIPE_POINTS_OWNER
|
|
||||||
}
|
|
||||||
return station_planting_labels() | owned
|
|
||||||
|
|
||||||
|
|
||||||
def _is_managed_elsewhere(entry: dict[str, Any]) -> bool:
|
|
||||||
"""다른 정본(pipe_points.json·구조물 A군)이 관리하는 측점인가."""
|
|
||||||
label = str(entry.get("structure", "")).strip()
|
label = str(entry.get("structure", "")).strip()
|
||||||
|
at = float(entry.get("chainage_m", 0.0))
|
||||||
return (
|
return (
|
||||||
entry.get("origin") == "pipe"
|
entry.get("origin") == "pipe"
|
||||||
or entry.get("structureType") == PIPE_STRUCTURE_NAME
|
or entry.get("structureType") == PIPE_STRUCTURE_NAME
|
||||||
or label == PIPE_STRUCTURE_NAME
|
or label == PIPE_STRUCTURE_NAME
|
||||||
or label in _managed_elsewhere_labels()
|
or label in {item.name for item in structure_type_map().values()}
|
||||||
or bool(_PIPE_LABEL.match(label))
|
or bool(_PIPE_LABEL.match(label))
|
||||||
|
or any(abs(at - p) < SAME_STATION_M for p in planted)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +102,9 @@ def _escape_width_of(entry: dict[str, Any]) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPlan:
|
def migrate_irregular_stations(
|
||||||
|
entries: Iterable[dict[str, Any]], planted: Sequence[float] = ()
|
||||||
|
) -> MigrationPlan:
|
||||||
"""기존 비정규 측점 목록을 정본별로 나눠 옮긴다(같은 입력이면 같은 결과).
|
"""기존 비정규 측점 목록을 정본별로 나눠 옮긴다(같은 입력이면 같은 결과).
|
||||||
|
|
||||||
타입이 다른 정본 소관(`managed_by`)이면 구조물 목록이 아니라 그 정본으로 간다 —
|
타입이 다른 정본 소관(`managed_by`)이면 구조물 목록이 아니라 그 정본으로 간다 —
|
||||||
@@ -120,7 +117,7 @@ def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> MigrationPl
|
|||||||
seen: set[tuple[str, float]] = set()
|
seen: set[tuple[str, float]] = set()
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if _is_managed_elsewhere(entry):
|
if _is_managed_elsewhere(entry, planted):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
chainage = float(entry.get("chainage_m", 0.0))
|
chainage = float(entry.get("chainage_m", 0.0))
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ def load_structures(project_root: str) -> tuple[int, list[StructureInstance]]:
|
|||||||
return revision, structures
|
return revision, structures
|
||||||
|
|
||||||
|
|
||||||
|
def load_saved_definition_version(project_root: str) -> int | None:
|
||||||
|
"""정본을 적을 때의 구조물 정의 판(322-2 판 알림) — 파일이 없거나 옛 파일이면 None."""
|
||||||
|
path = structures_file_path(project_root)
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
value = json.load(handle).get("definition_version")
|
||||||
|
except (OSError, ValueError, TypeError, AttributeError):
|
||||||
|
return None
|
||||||
|
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
||||||
|
|
||||||
|
|
||||||
def load_migrated_legacy(project_root: str) -> set[str]:
|
def load_migrated_legacy(project_root: str) -> set[str]:
|
||||||
"""이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합.
|
"""이미 구조물 정본으로 옮긴 구 비정규 측점의 표식 집합.
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user