Compare commits
529
Commits
dcedf35506
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d914b7647 | ||
|
|
6329ea2a3a | ||
|
|
6e981b1c90 | ||
|
|
08e72ab323 | ||
|
|
53d502d9a4 | ||
|
|
27ec7da7be | ||
|
|
fab776eb31 | ||
|
|
5180d001fc | ||
|
|
972baf989d | ||
|
|
5f54a09267 | ||
|
|
74a318a459 | ||
|
|
0bf2c6fa8c | ||
|
|
fbaa5b4838 | ||
|
|
b7eb44cd5c | ||
|
|
bcca4163df | ||
|
|
1dc815eae3 | ||
|
|
b72ab3a3ed | ||
|
|
64a64068e0 | ||
|
|
8500d4724f | ||
|
|
4bf8bb0e82 | ||
|
|
b60eef65d1 | ||
|
|
f3c8436551 | ||
|
|
ec6689ecfd | ||
|
|
7e056d182d | ||
|
|
e40cd1f687 | ||
|
|
5284402ce9 | ||
|
|
3de9ca043a | ||
|
|
8227972eec | ||
|
|
e12c4d6c28 | ||
|
|
8cf68188b7 | ||
|
|
b93a9ee24d | ||
|
|
63dfc9a357 | ||
|
|
04b645fd90 | ||
|
|
d3d038349b | ||
|
|
e657067bd3 | ||
|
|
608b6c80d9 | ||
|
|
0d3bb1154a | ||
|
|
e275bf353b | ||
|
|
0a8008ce09 | ||
|
|
5076c44425 | ||
|
|
17c785596a | ||
|
|
4ecf51c223 | ||
|
|
864114ee2b | ||
|
|
c06ad0835f | ||
|
|
4821e3b060 | ||
|
|
9e49b9c4db | ||
|
|
fbe0f982d3 | ||
|
|
3c4fc991c4 | ||
|
|
b4d2b86da4 | ||
|
|
1c635f3e02 | ||
|
|
3fc50044e1 | ||
|
|
0f9807699e | ||
|
|
68e82f9de9 | ||
|
|
e27753df82 | ||
|
|
c02602dcc0 | ||
|
|
a7cc8a304e | ||
|
|
f5e7c839d0 | ||
|
|
4e0d7d2a4a | ||
|
|
be3ae14b1b | ||
|
|
68a96e253f | ||
|
|
32e3a6723a | ||
|
|
f5c4887056 | ||
|
|
9423bd4202 | ||
|
|
aabd50a5c4 | ||
|
|
c672111d18 | ||
|
|
54cc2a4ef4 | ||
|
|
8883e7cee7 | ||
|
|
d4ad0d6685 | ||
|
|
13ba1fe298 | ||
|
|
2303798186 | ||
|
|
cd4eabbf0e | ||
|
|
61e9adf369 | ||
|
|
a53b499878 | ||
|
|
5ea602d3a8 | ||
|
|
9832374487 | ||
|
|
c7a438138d | ||
|
|
5e25f418cc | ||
|
|
feea843595 | ||
|
|
a4f55c3748 | ||
|
|
f4889352f9 | ||
|
|
8230788e96 | ||
|
|
55e565f3bf | ||
|
|
deab5a348f | ||
|
|
952ce4142a | ||
|
|
ed47306209 | ||
|
|
6c3831ce43 | ||
|
|
7993cdcd99 | ||
|
|
bc92038071 | ||
|
|
5034fa4ea8 | ||
|
|
d7ba07832c | ||
|
|
9a035bc188 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
ff19a0bc55 | ||
|
|
2c874a1066 | ||
|
|
50b3e15e54 | ||
|
|
eceda4ee35 | ||
|
|
64d5921b94 | ||
|
|
1de0e62949 | ||
|
|
536dacefac | ||
|
|
add01ef5de | ||
|
|
e95487967b | ||
|
|
e0136d4db6 | ||
|
|
81db8ee970 | ||
|
|
0038012872 | ||
|
|
98b9d1b459 | ||
|
|
9c747ed13d | ||
|
|
08b34194af | ||
|
|
d540395938 | ||
|
|
d1999ace14 | ||
|
|
e0ad372e70 | ||
|
|
dc8f201877 | ||
|
|
ffa7f9d236 | ||
|
|
37b653eb8a | ||
|
|
6f1e0c8202 | ||
|
|
b9f9795a83 | ||
|
|
cfde9dc0bd | ||
|
|
ca37861159 | ||
|
|
9749e3ec68 | ||
|
|
0d560350bd | ||
|
|
b6c4e98d38 | ||
|
|
f1ddb82c41 | ||
|
|
1fb027093c | ||
|
|
8212da9c19 | ||
|
|
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 사용자 확정 「출력물 전체를 공유해도 됨」).
|
||||||
|
|||||||
+267
-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,157 @@ 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;
|
||||||
|
|
||||||
|
export function setDrawerSteps(node: HTMLElement | null): void {
|
||||||
|
drawerSteps = node?.parentElement ? { 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 (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"))
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
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 +310,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 +382,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 +408,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;
|
||||||
|
|||||||
+33
-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,10 @@ 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" },
|
||||||
|
/** 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 +114,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 [저장]이 내보낸다. */
|
||||||
@@ -131,6 +137,8 @@ export const STATE_REGISTRY = {
|
|||||||
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 +193,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 +487,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 {
|
||||||
|
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,12 +61,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 +89,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 +136,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 +160,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 +194,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();
|
||||||
}
|
}
|
||||||
@@ -203,11 +219,41 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
|||||||
{ 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,6 +94,11 @@ 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 {
|
||||||
|
// 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
|
||||||
|
void enterProject(
|
||||||
|
projectId,
|
||||||
|
async (id) => (await fetchWorkflowState(id)).project_name || id,
|
||||||
|
).then(() => {
|
||||||
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
||||||
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
|
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
|
||||||
clearWorkflowStateCache();
|
clearWorkflowStateCache();
|
||||||
@@ -101,6 +107,7 @@ export function goToWorkflowStage(projectId: string, route: RoutePath): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void routeAfterPreloadCheck(projectId, route);
|
void routeAfterPreloadCheck(projectId, route);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,153 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* A00_Common/design/design_pick_profile.ts
|
||||||
|
* 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) ·
|
||||||
|
* 3D(측점 띠 · 핀 · 부재 · 233-2) · 배수유역도(관 마커 · 유역 · 측점 마름모 · 233-3) (조사 AF (나) ② ③ ⑤).
|
||||||
|
*
|
||||||
|
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고,
|
||||||
|
* 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이
|
||||||
|
* 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는
|
||||||
|
* 그릴 때마다 지금 관 목록에서 다시 찾으므로 관이 옮겨져도 끊기지 않는다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import {
|
||||||
|
nearestWithin,
|
||||||
|
readSelection,
|
||||||
|
subscribeSelection,
|
||||||
|
type DesignSelection,
|
||||||
|
} from "./design_selection";
|
||||||
|
import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape";
|
||||||
|
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: `pipe-${target.pipeChainageM.toFixed(2)}` }
|
||||||
|
: 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 } | null) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) ·
|
||||||
|
* 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */
|
||||||
|
export function viewerPickOf(
|
||||||
|
selection: (DesignSelection & { partKey?: string }) | null,
|
||||||
|
source: ProfilePickSource,
|
||||||
|
): { stationId: string | null; member: { chainageM: number; kind: string; key?: string } | 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 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
+108
-106
@@ -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,
|
||||||
@@ -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,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 };
|
||||||
|
}
|
||||||
@@ -13,27 +13,25 @@ import type { DesignProfile } from "./design_profile";
|
|||||||
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
|
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
|
||||||
const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"];
|
const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"];
|
||||||
|
|
||||||
/** 종단 도구줄 [배수유역도] 단추 뒤에 [새 창] 단추를 붙인다 — 두 보기가 같은 한 벌을 쓴다. */
|
/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */
|
||||||
export function attachProfilePopout(
|
export function attachProfilePopout(
|
||||||
profile: DesignProfile,
|
profile: DesignProfile,
|
||||||
slot: HTMLElement,
|
slot: HTMLElement,
|
||||||
sideTool: HTMLElement | null,
|
button: HTMLButtonElement,
|
||||||
): void {
|
): void {
|
||||||
const { root } = profile;
|
const { root } = profile;
|
||||||
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.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
|
||||||
button.textContent = "새 창";
|
button.textContent = "창 분할";
|
||||||
button.title = "종단 하단 패널을 브라우저 새 창으로 띄움";
|
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 (!root.classList.contains("is-popout")) return;
|
||||||
root.classList.remove("is-popout");
|
root.classList.remove("is-popout");
|
||||||
button.textContent = "새 창";
|
button.textContent = "창 분할";
|
||||||
slot.append(root);
|
slot.append(root);
|
||||||
popup = null;
|
popup = null;
|
||||||
requestAnimationFrame(profile.redraw);
|
requestAnimationFrame(profile.redraw);
|
||||||
@@ -71,7 +69,7 @@ export function attachProfilePopout(
|
|||||||
doc.body.className = document.body.className;
|
doc.body.className = document.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:${getComputedStyle(document.body).backgroundColor}`;
|
||||||
root.classList.add("is-popout");
|
root.classList.add("is-popout");
|
||||||
button.textContent = "제자리로";
|
button.textContent = "창 결합";
|
||||||
doc.body.replaceChildren(root);
|
doc.body.replaceChildren(root);
|
||||||
|
|
||||||
// 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정).
|
// 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,11 +65,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +21,7 @@ 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 } from "./design_profile_popout";
|
||||||
@@ -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,7 @@ 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);
|
||||||
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 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
|
||||||
|
|||||||
@@ -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) — 옛 둘 이상은 보이되 [추가]만 숨김. */
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
|
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
|
||||||
|
|||||||
@@ -29,6 +29,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,8 +38,10 @@ 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 { paintMapPick } from "./design_pick_profile";
|
||||||
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||||
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 {
|
||||||
@@ -191,7 +194,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),
|
||||||
});
|
});
|
||||||
@@ -233,6 +236,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 +305,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) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
|
||||||
@@ -341,15 +346,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,12 +392,11 @@ 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" });
|
||||||
@@ -474,16 +478,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 +493,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" });
|
||||||
}
|
}
|
||||||
@@ -568,7 +565,7 @@ 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)
|
||||||
});
|
});
|
||||||
host.onDispose(() => {
|
host.onDispose(() => {
|
||||||
window.clearTimeout(pipeReloadTimer);
|
window.clearTimeout(pipeReloadTimer);
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -127,6 +122,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) ·
|
||||||
@@ -173,13 +169,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 +218,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 +242,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 +331,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 +401,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 ?? []);
|
||||||
|
|
||||||
@@ -558,8 +568,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 +664,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();
|
||||||
+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";
|
||||||
@@ -161,6 +162,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -553,7 +553,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";
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
@@ -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,232 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* 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 확대)는 건너뜀 — 자체 손가락 처리 · 입력 칸 길게 누름도 브라우저 몫
|
||||||
|
* 마우스 · 펜 이벤트는 건드리지 않음.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
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 지도)의 확대 판정 — 손가락 벌림은 거꾸로라 뒤집음 */
|
||||||
|
export const wheelZoomsIn = (e: WheelEvent): boolean =>
|
||||||
|
isPinchWheel(e) ? e.deltaY < 0 : e.deltaY > 0;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -585,7 +585,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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,10 +225,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;
|
||||||
@@ -251,11 +248,15 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
|||||||
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
|
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
|
||||||
* 휠을 위로 올리면 멀어진다(사용자 지시). */
|
* 휠을 위로 올리면 멀어진다(사용자 지시). */
|
||||||
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, event.deltaY < 0 ? 1 / ZOOM_STEP : 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 +277,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 +340,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 +431,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 +443,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();
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from B05_Profile.B05_Profile_Structures_Schema import (
|
|||||||
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_PIPE,
|
||||||
PIPE_FACILITY_REVET,
|
PIPE_FACILITY_REVET,
|
||||||
PipePoint,
|
PipePoint,
|
||||||
@@ -208,9 +207,7 @@ 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관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음."""
|
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖(물넘이포장 포함 · 286-1) = 종류 이름."""
|
||||||
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
|
||||||
return None
|
|
||||||
if pipe.facility == PIPE_FACILITY_PIPE:
|
if pipe.facility == PIPE_FACILITY_PIPE:
|
||||||
values = pipe.options or {}
|
values = pipe.options or {}
|
||||||
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
||||||
@@ -267,11 +264,12 @@ def resolve_extra_stations(
|
|||||||
(2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다
|
(2026-08-28 사용자 보고). 정본 파일은 초기화에도 살아남으므로 여기서 매번 다시 만든다
|
||||||
— B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다.
|
— B05·B06은 같은 응답 하나를 보는 한 페이지라 공급자도 하나여야 한다.
|
||||||
|
|
||||||
물넘이포장은 비정규 측점으로 올리지 않는다 — 종단 그래프 하단 구조물 표시로만 다룬다
|
물넘이포장도 기준 측점 하나를 심는다 — 그 측점 횡단도에 파인 노면을 그린다(286-1).
|
||||||
(2026-08-28 사용자 확정).
|
|
||||||
|
|
||||||
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
|
라벨 형식은 프론트 structureLabel()과 같아야 한다 — 재진입 이관이 라벨 문자열로 종류를
|
||||||
되짚기 때문이다(B05_Profile_Structures_Migration).
|
되짚기 때문이다(B05_Profile_Structures_Migration).
|
||||||
|
TS 짝 = `common_util_structure_station.ts` `plantedChainages`(화면 측점 한 벌 · 272) ·
|
||||||
|
거울 시험 = `resources/tester/test_272_1_station_list.py`.
|
||||||
"""
|
"""
|
||||||
types = structure_type_map()
|
types = structure_type_map()
|
||||||
derived: list[tuple[float, str]] = []
|
derived: list[tuple[float, str]] = []
|
||||||
@@ -549,7 +547,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 +557,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,
|
||||||
@@ -589,18 +586,48 @@ def generate_irregular_sections(
|
|||||||
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
|
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
|
||||||
design_curves=_load_design_curves(project_root),
|
design_curves=_load_design_curves(project_root),
|
||||||
)
|
)
|
||||||
irregular_stations = [
|
|
||||||
station
|
def structural(entry: dict[str, Any]) -> bool:
|
||||||
for station in result["longitudinal"]["stations"]
|
return entry.get("kind") == "irregular" or bool(entry.get("structure"))
|
||||||
if station.get("kind") == "irregular"
|
|
||||||
]
|
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,
|
||||||
|
)
|
||||||
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
|
||||||
|
|||||||
@@ -415,8 +415,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 +468,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,
|
|
||||||
|
def _generate() -> list[dict[str, Any]]:
|
||||||
|
# 측점 목록 = 서버 정본(관 · 구조물)에서 — 화면 목록(관 · A군)을 받아 적으면 서버가 심은
|
||||||
|
# 소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 종료 측점이 통째로 빠짐(238-1).
|
||||||
|
polyline = _load_route_polyline(project_root, route_data_path)
|
||||||
|
extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
|
||||||
|
return generate_irregular_sections(
|
||||||
project_root,
|
project_root,
|
||||||
str(route["route_data_path"]),
|
route_data_path,
|
||||||
request.filter_key,
|
request.filter_key,
|
||||||
request.method,
|
request.method,
|
||||||
request.smooth,
|
request.smooth,
|
||||||
extra_stations=request.extra_stations(),
|
extra_stations=extras,
|
||||||
options=_section_options_from_stored(stored_options),
|
options=_section_options_from_stored(stored_options),
|
||||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||||
)
|
)
|
||||||
if not irregular_stations:
|
|
||||||
return
|
# 빈 목록이어도 맞춤 — 옛 구조물 측점 · 떠난 격자 자리 글을 지워야 함(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"])
|
||||||
|
)
|
||||||
|
|||||||
@@ -173,7 +173,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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
writeCachedBytes,
|
writeCachedBytes,
|
||||||
} from "../A00_Common/b_asset_cache";
|
} from "../A00_Common/b_asset_cache";
|
||||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||||
|
import { refreshStaleDesigns } from "../B06_Section/B06_Section_Cross_Refresh";
|
||||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||||
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||||
import {
|
import {
|
||||||
@@ -247,7 +248,7 @@ function withSections(
|
|||||||
|
|
||||||
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거).
|
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거).
|
||||||
* 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */
|
* 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */
|
||||||
export function refreshCorridor(
|
export async function refreshCorridor(
|
||||||
viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
|
viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
|
||||||
projectId: string,
|
projectId: string,
|
||||||
routeId: number | undefined,
|
routeId: number | undefined,
|
||||||
@@ -256,7 +257,9 @@ export function refreshCorridor(
|
|||||||
designSamples?: ProfileSamples,
|
designSamples?: ProfileSamples,
|
||||||
rebuild = true,
|
rebuild = true,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!routeId) return Promise.resolve();
|
if (!routeId) return;
|
||||||
|
// [3D 업데이트] — 낡은 횡단 설계(계획고 · 구조물 자리 값)를 먼저 다시 셈한 뒤 그 설계로 지음(317-1)
|
||||||
|
if (rebuild) await refreshStaleDesigns({ projectId, routeId, detail }).catch(() => []);
|
||||||
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild)
|
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild)
|
||||||
.then((build) => viewer.setCorridor(withSections(build, detail)))
|
.then((build) => viewer.setCorridor(withSections(build, detail)))
|
||||||
.catch(() => viewer.setCorridor(null));
|
.catch(() => viewer.setCorridor(null));
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ function fnv1a(text: string): string {
|
|||||||
// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다.
|
// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다.
|
||||||
// 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이
|
// 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이
|
||||||
// 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다.
|
// 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다.
|
||||||
const BUILD_VERSION = 93;
|
// 94 = 물넘이포장 노면을 파지 않음(287-4) — 설계 노면이 콘크리트 윗면 · 저장본을 만료한다.
|
||||||
|
const BUILD_VERSION = 94;
|
||||||
|
|
||||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
* (2026-08-23 사용자 지시)의 핵심.
|
* (2026-08-23 사용자 지시)의 핵심.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { fordDeckElevationAt } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
|
|
||||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||||
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
|
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
|
||||||
|
|
||||||
@@ -227,22 +226,8 @@ export function classifyStation(section: CrossSection): StationPieces | null {
|
|||||||
if (!design || !line || line.length < 2) return null;
|
if (!design || !line || line.length < 2) return null;
|
||||||
const roadL = design.road_edges.left.offset_m;
|
const roadL = design.road_edges.left.offset_m;
|
||||||
const roadR = design.road_edges.right.offset_m;
|
const roadR = design.road_edges.right.offset_m;
|
||||||
// 물넘이포장 — 노면(노견 포함)이 파인다. 횡단도와 **같은 산식**으로 표고를 내려
|
// 물넘이포장 — 설계 노면이 곧 콘크리트 윗면(287-4 · 서버가 지대 낮은 쪽 한 경사로 냄) · 파지 않음
|
||||||
// 3D도 같은 자리에 같은 깊이로 팬다(2026-08-28 사용자 지시). 노면 밖 비탈은 그대로라
|
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
|
||||||
// 노견 끝에 파임 벽(수직 단차)이 선다 — 횡단도 그림과 같은 표현이다.
|
|
||||||
const ford = section.ford_pavement;
|
|
||||||
const carved =
|
|
||||||
ford && ford.depth_m
|
|
||||||
? line.map((point) =>
|
|
||||||
point.offset_m <= roadL + 1e-9 && point.offset_m >= roadR - 1e-9
|
|
||||||
? {
|
|
||||||
...point,
|
|
||||||
elevation_m: fordDeckElevationAt(design, ford, point.offset_m, point.elevation_m),
|
|
||||||
}
|
|
||||||
: point,
|
|
||||||
)
|
|
||||||
: line;
|
|
||||||
const sorted = [...carved].sort((a, b) => a.offset_m - b.offset_m);
|
|
||||||
const cwL = design.carriageway_edges?.left.offset_m ?? roadL;
|
const cwL = design.carriageway_edges?.left.offset_m ?? roadL;
|
||||||
const cwR = design.carriageway_edges?.right.offset_m ?? roadR;
|
const cwR = design.carriageway_edges?.right.offset_m ?? roadR;
|
||||||
|
|
||||||
|
|||||||
@@ -487,6 +487,8 @@ export function buildCorridorStructures(
|
|||||||
chuteBands(
|
chuteBands(
|
||||||
fordLayout.sides.filter((side) => side.role === "outlet").flatMap((s) => s.fillSegments),
|
fordLayout.sides.filter((side) => side.role === "outlet").flatMap((s) => s.fillSegments),
|
||||||
section.ford?.chute,
|
section.ford?.chute,
|
||||||
|
undefined,
|
||||||
|
fordLayout.ground,
|
||||||
).forEach((band) => pushSwept("chute", band, half, half, null, "chute"));
|
).forEach((band) => pushSwept("chute", band, half, half, null, "chute"));
|
||||||
// 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면**
|
// 날개벽 4매 — BOX암거와 같은 규칙(2026-08-25 사용자). 뿌리는 **벽 외측면**
|
||||||
// (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은
|
// (성토 경사측면과 마주치는 면) 모서리다(2026-08-25 ⑤ — 종전 바닥판 바깥 끝은
|
||||||
@@ -595,7 +597,12 @@ export function buildCorridorStructures(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3).
|
// 도수로 · 돌붙임 띠 — 유출 성토 조각(단 사이 포함) · 묻힌 끝 3도 절토 조각(152-3) · 유출 벽 길이(135-3).
|
||||||
chuteBands(layout.outletFill.segments, section.culvert?.chute, isChuteSegment).forEach((band) =>
|
chuteBands(
|
||||||
|
layout.outletFill.segments,
|
||||||
|
section.culvert?.chute,
|
||||||
|
isChuteSegment,
|
||||||
|
layout.ground,
|
||||||
|
).forEach((band) =>
|
||||||
pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"),
|
pushSwept("chute", band, outletSpan.beforeM, outletSpan.afterM, null, "chute"),
|
||||||
);
|
);
|
||||||
// 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다).
|
// 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다).
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ export function boxSolids(
|
|||||||
const chutes: CorridorStructure[] = chuteBands(
|
const chutes: CorridorStructure[] = chuteBands(
|
||||||
layout.sides.filter((side) => side.wingRole === "outlet").flatMap((side) => side.fillSegments),
|
layout.sides.filter((side) => side.wingRole === "outlet").flatMap((side) => side.fillSegments),
|
||||||
box.chute,
|
box.chute,
|
||||||
|
undefined,
|
||||||
|
layout.ground,
|
||||||
).map((band) => ({
|
).map((band) => ({
|
||||||
chainage_m: chainage,
|
chainage_m: chainage,
|
||||||
kind: "chute",
|
kind: "chute",
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
|
|
||||||
import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
import type { ChuteSpec, CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||||
import type { OffsetPoint } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
|
import type { OffsetPoint } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
|
||||||
import { chuteBandOutline } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
|
import {
|
||||||
|
chuteBandOutline,
|
||||||
|
chuteRun,
|
||||||
|
groundLine,
|
||||||
|
} from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch";
|
||||||
import { outletFillLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
|
import { outletFillLine } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
|
||||||
|
|
||||||
/** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */
|
/** 성토 조각 하나 — B06 오버레이가 띠를 그리는 선(절토 조각은 뺀다). */
|
||||||
@@ -25,10 +29,11 @@ export function chuteBands(
|
|||||||
segments: ReadonlyArray<FillSegment>,
|
segments: ReadonlyArray<FillSegment>,
|
||||||
chute: ChuteSpec | null | undefined,
|
chute: ChuteSpec | null | undefined,
|
||||||
keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut",
|
keep: (segment: FillSegment) => boolean = (segment) => segment.kind !== "cut",
|
||||||
|
/** 원지반 — 길이가 사면보다 길면 이어 그림(294-4 · 횡단 띠와 같은 `chuteRun`). */
|
||||||
|
ground?: readonly OffsetPoint[],
|
||||||
): OffsetPoint[][] {
|
): OffsetPoint[][] {
|
||||||
if (!chute) return [];
|
if (!chute) return [];
|
||||||
return segments.flatMap((segment) => {
|
return chuteRun(segments.filter(keep), chute.length_m, ground).flatMap((segment) => {
|
||||||
if (!keep(segment)) return [];
|
|
||||||
const band = chuteBandOutline(segment.points, chute.thickness_m);
|
const band = chuteBandOutline(segment.points, chute.thickness_m);
|
||||||
return band ? [band.outline] : [];
|
return band ? [band.outline] : [];
|
||||||
});
|
});
|
||||||
@@ -51,6 +56,12 @@ export function fordPavementChute(
|
|||||||
);
|
);
|
||||||
if (owner !== section) return null;
|
if (owner !== section) return null;
|
||||||
}
|
}
|
||||||
const bands = chuteBands([{ points: outletFillLine(section.design) }], pave.chute);
|
const ground = groundLine(section.samples ?? []);
|
||||||
|
const bands = chuteBands(
|
||||||
|
[{ points: outletFillLine(section.design, ground) }],
|
||||||
|
pave.chute,
|
||||||
|
undefined,
|
||||||
|
ground,
|
||||||
|
);
|
||||||
return bands.length ? { bands, halfM: Math.max(pave.span_m, 0.5) / 2 } : null;
|
return bands.length ? { bands, halfM: Math.max(pave.span_m, 0.5) / 2 } : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import type {
|
|||||||
|
|
||||||
/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */
|
/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */
|
||||||
function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> {
|
function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> {
|
||||||
return chute ? ["ch", chute.structure, chute.thickness_m] : [];
|
if (!chute) return [];
|
||||||
|
// 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로
|
||||||
|
const base = ["ch", chute.structure, chute.thickness_m];
|
||||||
|
return chute.length_m ? [...base, chute.length_m] : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
|
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ import {
|
|||||||
type SpecForm,
|
type SpecForm,
|
||||||
type SpecLinks,
|
type SpecLinks,
|
||||||
} from "../A00_Common/design/design_structure_form";
|
} from "../A00_Common/design/design_structure_form";
|
||||||
import type { SpecContainer, SpecItem } from "../A00_Common/design/design_structure_spec";
|
|
||||||
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
|
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
|
||||||
import {
|
import {
|
||||||
BOX_SPEC,
|
BOX_SPEC,
|
||||||
@@ -65,11 +64,12 @@ import {
|
|||||||
boxOptionsIn,
|
boxOptionsIn,
|
||||||
} from "../A00_Common/design/design_structure_box";
|
} from "../A00_Common/design/design_structure_box";
|
||||||
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
|
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
|
||||||
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
|
import { overflowSync } from "../A00_Common/design/design_structure_ford_calc";
|
||||||
import {
|
import {
|
||||||
pipeDrawn,
|
pipeDrawn,
|
||||||
pipeExtraCount,
|
pipeExtraCount,
|
||||||
pipeLengthM,
|
pipeLengthM,
|
||||||
|
pipeLengthText,
|
||||||
} from "../A00_Common/design/design_structure_pipe_calc";
|
} from "../A00_Common/design/design_structure_pipe_calc";
|
||||||
import {
|
import {
|
||||||
hasLinks,
|
hasLinks,
|
||||||
@@ -339,7 +339,7 @@ export function createFacilityOptionsForm(
|
|||||||
auto: (_c, item) => {
|
auto: (_c, item) => {
|
||||||
if (item.id !== "length") return "";
|
if (item.id !== "length") return "";
|
||||||
const length = pipeLengthM(chainage);
|
const length = pipeLengthM(chainage);
|
||||||
return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length));
|
return length === null ? "횡단 설계 뒤 셈" : pipeLengthText(length);
|
||||||
},
|
},
|
||||||
// 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1)
|
// 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1)
|
||||||
count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined),
|
count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined),
|
||||||
@@ -371,38 +371,21 @@ export function createFacilityOptionsForm(
|
|||||||
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) —
|
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) —
|
||||||
// 물넘이 포장도 같은 키 · 같은 계산(65-5) · 새 시설은 유량이 오면 빈 높이를 필요 수심으로(65-4)
|
// 물넘이 포장도 같은 키 · 같은 계산(65-5) · 새 시설은 유량이 오면 빈 높이를 필요 수심으로(65-4)
|
||||||
let fordFlow: number | null = null;
|
let fordFlow: number | null = null;
|
||||||
let heightPending = false;
|
const fordFlowBox = overflowSync(
|
||||||
const overflow = (form: () => SpecForm, box: string) => {
|
() => fordForm,
|
||||||
const values = () => form().state()[box]?.[0] ?? {};
|
"ford",
|
||||||
const need = (): number | null => fordHeightM(fordFlow, Number(values().width ?? ""));
|
() => fordFlow,
|
||||||
return {
|
emit,
|
||||||
onChange: (c: SpecContainer, item: SpecItem) => {
|
);
|
||||||
if (c.id === box && item.id === "height") heightPending = false;
|
const guardFields = guardChoiceFields(
|
||||||
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: () => {
|
|
||||||
const want = heightPending && !values().height ? need() : null;
|
|
||||||
if (want === null) return;
|
|
||||||
form().setValue(box, "height", want.toFixed(2));
|
|
||||||
heightPending = false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const fordFlowBox = overflow(() => fordForm, "ford");
|
|
||||||
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
|
|
||||||
const fordForm: SpecForm = createSpecForm(specLinks, {
|
|
||||||
onChange: fordFlowBox.onChange,
|
|
||||||
fields: guardChoiceFields(
|
|
||||||
() => chainage,
|
() => chainage,
|
||||||
() => fordForm.refresh(),
|
() => fordForm.refresh(),
|
||||||
() => fordForm.recount(),
|
() => fordForm.recount(),
|
||||||
),
|
);
|
||||||
|
// 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1)
|
||||||
|
const fordForm: SpecForm = createSpecForm(specLinks, {
|
||||||
|
onChange: fordFlowBox.onChange,
|
||||||
|
fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)],
|
||||||
count: guardSoilCount(
|
count: guardSoilCount(
|
||||||
() => chainage,
|
() => chainage,
|
||||||
() => fordForm.state().outlet?.[0]?.structure,
|
() => fordForm.state().outlet?.[0]?.structure,
|
||||||
@@ -484,8 +467,16 @@ export function createFacilityOptionsForm(
|
|||||||
|
|
||||||
// ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도(+ 월류 폭 · 높이 · 65-5) · 도수로/돌붙임 ·
|
// ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도(+ 월류 폭 · 높이 · 65-5) · 도수로/돌붙임 ·
|
||||||
// 옛 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
|
// 옛 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
|
||||||
const paveFlowBox = overflow(() => paveForm, "slope");
|
const paveFlowBox = overflowSync(
|
||||||
const paveForm: SpecForm = createSpecForm(specLinks, { onChange: paveFlowBox.onChange });
|
() => paveForm,
|
||||||
|
"slope",
|
||||||
|
() => fordFlow,
|
||||||
|
emit,
|
||||||
|
);
|
||||||
|
const paveForm: SpecForm = createSpecForm(specLinks, {
|
||||||
|
onChange: paveFlowBox.onChange,
|
||||||
|
fields: paveFlowBox.fields,
|
||||||
|
});
|
||||||
|
|
||||||
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
|
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
|
||||||
|
|
||||||
@@ -602,7 +593,7 @@ export function createFacilityOptionsForm(
|
|||||||
},
|
},
|
||||||
setFacility(facility, options = {}, designFlow = null, definition = null, fresh = false) {
|
setFacility(facility, options = {}, designFlow = null, definition = null, fresh = false) {
|
||||||
current = facility;
|
current = facility;
|
||||||
heightPending = fresh;
|
[fordFlowBox, paveFlowBox].forEach((box) => box.arm(fresh));
|
||||||
hiddenOptions.remember(options);
|
hiddenOptions.remember(options);
|
||||||
fordFlow = designFlow ?? null;
|
fordFlow = designFlow ?? null;
|
||||||
if (facility === null) {
|
if (facility === null) {
|
||||||
|
|||||||
@@ -12,18 +12,29 @@ import {
|
|||||||
FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
||||||
} from "@config/config_frontend";
|
} from "@config/config_frontend";
|
||||||
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
|
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
|
||||||
|
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||||
|
|
||||||
const FORD_KINDS: Record<string, number> = {
|
const FORD_KINDS: Record<string, number> = {
|
||||||
ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M,
|
ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M,
|
||||||
ford_pavement: FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
ford_pavement: FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 프로젝트마다 높이를 기다리는 측점 — 배수유역도 판 · B06 [추가] 가 같은 표를 씀(298-1). */
|
||||||
|
const waits = new Map<string, Set<number>>();
|
||||||
|
export function fordWaitsOf(projectId: string | null): Set<number> {
|
||||||
|
const key = projectId ?? "";
|
||||||
|
if (!waits.has(key)) waits.set(key, new Set());
|
||||||
|
return waits.get(key) as Set<number>;
|
||||||
|
}
|
||||||
|
|
||||||
/** 새로 놓으면 높이를 기다리는 종류 — 높이 칸이 빈 세월교 · 물넘이포장. */
|
/** 새로 놓으면 높이를 기다리는 종류 — 높이 칸이 빈 세월교 · 물넘이포장. */
|
||||||
export function waitsFordHeight(
|
export function waitsFordHeight(
|
||||||
facility: string | undefined,
|
facility: string | undefined,
|
||||||
options?: Record<string, string | number>,
|
options?: Record<string, string | number>,
|
||||||
): boolean {
|
): boolean {
|
||||||
return !!facility && facility in FORD_KINDS && options?.ford_height_m === undefined;
|
// 빈 글자도 빈칸 — 채우는 쪽(`fillFordHeights`)과 같은 규칙(314)
|
||||||
|
const height = options?.ford_height_m;
|
||||||
|
return !!facility && facility in FORD_KINDS && (height === undefined || height === "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 기다리는 측점의 빈 높이를 채움 — 채운(또는 사용자 값이라 그만 기다릴) 측점은 `pending` 에서 뺌.
|
/** 기다리는 측점의 빈 높이를 채움 — 채운(또는 사용자 값이라 그만 기다릴) 측점은 `pending` 에서 뺌.
|
||||||
@@ -39,7 +50,7 @@ export function fillFordHeights(
|
|||||||
): boolean {
|
): boolean {
|
||||||
let filled = false;
|
let filled = false;
|
||||||
for (const wait of [...pending]) {
|
for (const wait of [...pending]) {
|
||||||
const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < 0.51);
|
const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < PIPE_MATCH_M);
|
||||||
if (!point?.facility || !(point.facility in FORD_KINDS)) {
|
if (!point?.facility || !(point.facility in FORD_KINDS)) {
|
||||||
pending.delete(wait); // 관이 사라졌거나 종류가 바뀜
|
pending.delete(wait); // 관이 사라졌거나 종류가 바뀜
|
||||||
continue;
|
continue;
|
||||||
@@ -48,7 +59,9 @@ export function fillFordHeights(
|
|||||||
pending.delete(wait); // 사용자가 넣은 값 — 안 덮음
|
pending.delete(wait); // 사용자가 넣은 값 — 안 덮음
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const flow = basins.find((entry) => Math.abs(entry.chainage_m - point.chainage_m) < 0.51);
|
const flow = basins.find(
|
||||||
|
(entry) => Math.abs(entry.chainage_m - point.chainage_m) < PIPE_MATCH_M,
|
||||||
|
);
|
||||||
const width = Number(point.options?.ford_width_m) || FORD_KINDS[point.facility];
|
const width = Number(point.options?.ford_width_m) || FORD_KINDS[point.facility];
|
||||||
const need = fordHeightM(flow?.design_flow_m3s ?? null, width);
|
const need = fordHeightM(flow?.design_flow_m3s ?? null, width);
|
||||||
if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸
|
if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다.
|
* 그리기·유역 목록·마커 편집기가 같은 값을 보던 구조를 그대로 두기 위함이다.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
|
||||||
import {
|
import {
|
||||||
computeMaxScale,
|
computeMaxScale,
|
||||||
lonLatToScreen,
|
lonLatToScreen,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||||
|
import { clampWheelScale } from "./B05_Profile_UI_Drainage_ZoomRange";
|
||||||
import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||||
import type { createMapContextMenu } from "@ui/ui_template_context_menu";
|
import type { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||||
|
|
||||||
@@ -33,6 +35,8 @@ export interface DrainageInteractParams {
|
|||||||
setScale: (value: number) => void;
|
setScale: (value: number) => void;
|
||||||
/** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */
|
/** 배율 상한을 도엽 실폭으로 계산하기 위한 메타 (2026-09-04). 없으면 종전 고정 상한. */
|
||||||
getMeta: () => VWorldMeta | null;
|
getMeta: () => VWorldMeta | null;
|
||||||
|
/** 줌아웃 한계 배율 — 노선 기준(계획서 281). */
|
||||||
|
getMinScale: () => number;
|
||||||
getOffset: () => { x: number; y: number };
|
getOffset: () => { x: number; y: number };
|
||||||
setOffset: (x: number, y: number) => void;
|
setOffset: (x: number, y: number) => void;
|
||||||
scheduleDraw: () => void;
|
scheduleDraw: () => void;
|
||||||
@@ -62,7 +66,13 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
|
|||||||
// 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시).
|
// 상한은 「화면 폭 20m」로 계산한다 — B04 지도와 같은 규칙(2026-09-04 사용자 지시).
|
||||||
const view = currentView();
|
const view = currentView();
|
||||||
const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16);
|
const maxScale = computeMaxScale(params.getMeta(), view.mapRect.width, view.width, 16);
|
||||||
const scale = Math.min(maxScale, Math.max(0.5, prevScale * (event.deltaY > 0 ? 1.15 : 0.87)));
|
// 하한은 노선 기준(계획서 281) — 도엽 전체까지 빠지지 않는다.
|
||||||
|
const scale = clampWheelScale(
|
||||||
|
prevScale * (wheelZoomsIn(event) ? 1.15 : 0.87),
|
||||||
|
prevScale,
|
||||||
|
params.getMinScale(),
|
||||||
|
maxScale,
|
||||||
|
);
|
||||||
params.setScale(scale);
|
params.setScale(scale);
|
||||||
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
|
// 커서 아래 지점을 고정한 채 확대/축소 (B04 지도와 동일 동작).
|
||||||
const ratio = scale / prevScale;
|
const ratio = scale / prevScale;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||||
|
import { viewOf } from "../common_util/common_util_view_window";
|
||||||
import {
|
import {
|
||||||
computeDetailBasins,
|
computeDetailBasins,
|
||||||
fetchDetailPipePoints,
|
fetchDetailPipePoints,
|
||||||
@@ -29,20 +30,24 @@ import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
|
|||||||
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
|
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
|
||||||
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
|
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
|
||||||
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||||
|
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||||
|
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||||
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||||
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||||
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
||||||
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
|
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
|
||||||
import { fillFordHeights, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
|
import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
|
||||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||||
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
||||||
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
||||||
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
|
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
|
||||||
|
import { computeZoomOutLimit } from "./B05_Profile_UI_Drainage_ZoomRange";
|
||||||
import {
|
import {
|
||||||
mountDrainageToggles,
|
mountDrainageToggles,
|
||||||
DRAINAGE_LAYERS,
|
DRAINAGE_LAYERS,
|
||||||
fetchDrainageLayers,
|
fetchDrainageLayers,
|
||||||
createProgressReporter,
|
createProgressReporter,
|
||||||
|
analyzeFailed,
|
||||||
fitCanvasToViewport,
|
fitCanvasToViewport,
|
||||||
fitViewToRoute,
|
fitViewToRoute,
|
||||||
observeViewportSize,
|
observeViewportSize,
|
||||||
@@ -172,10 +177,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
let offsetX = 0;
|
let offsetX = 0;
|
||||||
let offsetY = 0;
|
let offsetY = 0;
|
||||||
let frameHandle = 0;
|
let frameHandle = 0;
|
||||||
|
/** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */
|
||||||
|
let frameView: Window = window;
|
||||||
let loadSequence = 0;
|
let loadSequence = 0;
|
||||||
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
|
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
|
||||||
/** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */
|
/** 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1 · B06 [추가]도 298-1). */
|
||||||
const fordWaits = new Set<number>();
|
const fordWaits = () => fordWaitsOf(projectId);
|
||||||
let canvasWidth = 0;
|
let canvasWidth = 0;
|
||||||
let canvasHeight = 0;
|
let canvasHeight = 0;
|
||||||
let canvasDpr = 0;
|
let canvasDpr = 0;
|
||||||
@@ -284,24 +291,25 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage);
|
const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage);
|
||||||
|
|
||||||
/** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */
|
/** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */
|
||||||
function syncPipeSelection(): void {
|
function syncPipeSelection(notify = true): void {
|
||||||
const index = pipeEditor.selected();
|
const index = pipeEditor.selected();
|
||||||
deleteButton.disabled = index === null;
|
deleteButton.disabled = index === null;
|
||||||
const picked = index === null ? null : pipeEditor.pipes()[index];
|
const picked = index === null ? null : pipeEditor.pipes()[index];
|
||||||
selectBasin(basinIndexOfPipe(basins, picked ?? null));
|
selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);
|
||||||
// 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은
|
// 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은
|
||||||
// Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply)
|
// Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply)
|
||||||
// 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게.
|
// 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게.
|
||||||
const pickedChainage = picked ? picked.chainage_m : null;
|
const pickedChainage = picked ? picked.chainage_m : null;
|
||||||
if (pickedChainage !== lastNotifiedPipeChainage) {
|
if (pickedChainage !== lastNotifiedPipeChainage) {
|
||||||
lastNotifiedPipeChainage = pickedChainage;
|
lastNotifiedPipeChainage = pickedChainage;
|
||||||
callbacks.onPipeSelected?.(pickedChainage);
|
if (notify) callbacks.onPipeSelected?.(pickedChainage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleDraw(): void {
|
function scheduleDraw(): void {
|
||||||
if (frameHandle) return;
|
if (frameHandle) return;
|
||||||
frameHandle = window.requestAnimationFrame(() => {
|
frameView = viewOf(root);
|
||||||
|
frameHandle = frameView.requestAnimationFrame(() => {
|
||||||
frameHandle = 0;
|
frameHandle = 0;
|
||||||
draw();
|
draw();
|
||||||
});
|
});
|
||||||
@@ -351,7 +359,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
// 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음).
|
// 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음).
|
||||||
const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits);
|
const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits());
|
||||||
facilityStore.replaceFromResponse(response.pipe_points);
|
facilityStore.replaceFromResponse(response.pipe_points);
|
||||||
if (fordFilled) rememberPipes();
|
if (fordFilled) rememberPipes();
|
||||||
// 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직
|
// 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직
|
||||||
@@ -384,8 +392,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (seq !== requestSeq) return;
|
if (seq !== requestSeq) return;
|
||||||
status.hidden = false;
|
status.hidden = false;
|
||||||
status.textContent =
|
status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed"));
|
||||||
error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed");
|
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === requestSeq) {
|
if (seq === requestSeq) {
|
||||||
analyzeButton.disabled = false;
|
analyzeButton.disabled = false;
|
||||||
@@ -414,7 +421,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
const loadSaved = (): Promise<void> =>
|
const loadSaved = (): Promise<void> =>
|
||||||
run(() => {
|
run(() => {
|
||||||
const pending = readPendingPipes(projectId as string);
|
const pending = readPendingPipes(projectId as string);
|
||||||
return pending?.length
|
return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감
|
||||||
? computeDetailBasins(projectId as string, pending)
|
? computeDetailBasins(projectId as string, pending)
|
||||||
: fetchDetailPipePoints(projectId as string);
|
: fetchDetailPipePoints(projectId as string);
|
||||||
});
|
});
|
||||||
@@ -455,6 +462,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
offsetY = fitted.offsetY;
|
offsetY = fitted.offsetY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function zoomOutLimit(): number {
|
||||||
|
const view = currentView();
|
||||||
|
return computeZoomOutLimit(meta, routePoints, basins, view.width, view.height);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLayers(): Promise<void> {
|
async function loadLayers(): Promise<void> {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
const activeProjectId = projectId;
|
const activeProjectId = projectId;
|
||||||
@@ -511,6 +523,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
scale = value;
|
scale = value;
|
||||||
},
|
},
|
||||||
getMeta: () => meta,
|
getMeta: () => meta,
|
||||||
|
getMinScale: zoomOutLimit,
|
||||||
getOffset: () => ({ x: offsetX, y: offsetY }),
|
getOffset: () => ({ x: offsetX, y: offsetY }),
|
||||||
setOffset: (x, y) => {
|
setOffset: (x, y) => {
|
||||||
offsetX = x;
|
offsetX = x;
|
||||||
@@ -534,8 +547,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
offsetY = next.offsetY;
|
offsetY = next.offsetY;
|
||||||
},
|
},
|
||||||
redraw: () => {
|
redraw: () => {
|
||||||
// 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다.
|
// 배치가 잡히면 미뤄 둔 화면 맞춤을 그때 수행한다. 늘려서 줌아웃 한계 밖이면 노선 맞춤(281-2).
|
||||||
if (fitPending) fitToRoute();
|
if (fitPending || scale < zoomOutLimit() * 0.999) fitToRoute();
|
||||||
scheduleDraw();
|
scheduleDraw();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -567,15 +580,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
movePipe(fromChainage, toChainage) {
|
movePipe(fromChainage, toChainage) {
|
||||||
const index = pipeEditor
|
const index = pipeEditor
|
||||||
.pipes()
|
.pipes()
|
||||||
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < 0.51);
|
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainage) < PIPE_MATCH_M);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
pipeEditor.moveTo(index, toChainage);
|
const to = snapStructureStation(toChainage); // 정수 m(234-1) — 시설 속성도 새 자리로(지도 끌기와 같게)
|
||||||
|
facilityStore.move(pipeEditor.pipes()[index].chainage_m, to);
|
||||||
|
pipeEditor.moveTo(index, to);
|
||||||
},
|
},
|
||||||
selectBasinByChainage(chainageM) {
|
selectBasinByChainage(chainageM) {
|
||||||
const picked =
|
const picked =
|
||||||
chainageM === null
|
chainageM === null
|
||||||
? null
|
? null
|
||||||
: (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < 0.51) ?? null);
|
: (basins.find((basin) => Math.abs(basin.chainage_m - chainageM) < PIPE_MATCH_M) ?? null);
|
||||||
// 밖에서 온 요청이므로 되돌려 보내지 않는다(그래프 ↔ 유역 순환 차단).
|
// 밖에서 온 요청이므로 되돌려 보내지 않는다(그래프 ↔ 유역 순환 차단).
|
||||||
selectBasin(picked ? picked.index : null, false);
|
selectBasin(picked ? picked.index : null, false);
|
||||||
},
|
},
|
||||||
@@ -601,23 +616,27 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
scheduleDraw();
|
scheduleDraw();
|
||||||
},
|
},
|
||||||
addPipe(chainageM, attributes) {
|
addPipe(chainageM, attributes) {
|
||||||
|
chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1)
|
||||||
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
|
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
|
||||||
facilityStore.set(chainageM, attributes ?? null);
|
facilityStore.set(chainageM, attributes ?? null);
|
||||||
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits.add(chainageM);
|
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits().add(chainageM);
|
||||||
pipeEditor.addAtChainage(chainageM);
|
pipeEditor.addAtChainage(chainageM);
|
||||||
},
|
},
|
||||||
updatePipeFacility(fromChainageM, toChainageM, attributes) {
|
updatePipeFacility(fromChainageM, toChainageM, attributes) {
|
||||||
|
// 옮겼으면 정수 m · 속성 열쇠도 그 자리로(234-1) — 값만 고친 옛 소수 관은 제자리
|
||||||
|
if (Math.abs(toChainageM - fromChainageM) > 0.005)
|
||||||
|
toChainageM = snapStructureStation(toChainageM);
|
||||||
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
|
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
|
||||||
const previous = facilityStore.get(fromChainageM);
|
const previous = facilityStore.get(fromChainageM);
|
||||||
facilityStore.set(fromChainageM, null);
|
facilityStore.set(fromChainageM, null);
|
||||||
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
|
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
|
||||||
if (fordWaits.delete(fromChainageM)) fordWaits.add(toChainageM);
|
if (fordWaits().delete(fromChainageM)) fordWaits().add(toChainageM);
|
||||||
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
|
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
|
||||||
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
|
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
|
||||||
// attach가 새 위치로 시설 정보를 승계한다.
|
// attach가 새 위치로 시설 정보를 승계한다.
|
||||||
const index = pipeEditor
|
const index = pipeEditor
|
||||||
.pipes()
|
.pipes()
|
||||||
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < 0.51);
|
.findIndex((pipe) => Math.abs(pipe.chainage_m - fromChainageM) < PIPE_MATCH_M);
|
||||||
if (index >= 0) pipeEditor.moveTo(index, toChainageM);
|
if (index >= 0) pipeEditor.moveTo(index, toChainageM);
|
||||||
else void analyze();
|
else void analyze();
|
||||||
} else {
|
} else {
|
||||||
@@ -630,21 +649,23 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
removePipe(chainageM) {
|
removePipe(chainageM) {
|
||||||
const index = pipeEditor
|
const index = pipeEditor
|
||||||
.pipes()
|
.pipes()
|
||||||
.findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51);
|
.findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
pipeEditor.select(index);
|
pipeEditor.select(index);
|
||||||
pipeEditor.deleteSelected();
|
pipeEditor.deleteSelected();
|
||||||
},
|
},
|
||||||
selectPipeAtChainage(chainageM) {
|
selectPipeAtChainage(chainageM, notify = true) {
|
||||||
const index =
|
const index =
|
||||||
chainageM === null
|
chainageM === null
|
||||||
? null
|
? null
|
||||||
: pipeEditor.pipes().findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.51);
|
: pipeEditor
|
||||||
|
.pipes()
|
||||||
|
.findIndex((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M);
|
||||||
const next = index === -1 ? null : index;
|
const next = index === -1 ? null : index;
|
||||||
if (pipeEditor.selected() === next) return;
|
if (pipeEditor.selected() === next) return;
|
||||||
// select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다.
|
// select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다.
|
||||||
pipeEditor.select(next);
|
pipeEditor.select(next);
|
||||||
syncPipeSelection();
|
syncPipeSelection(notify);
|
||||||
scheduleDraw();
|
scheduleDraw();
|
||||||
},
|
},
|
||||||
async savePipes() {
|
async savePipes() {
|
||||||
@@ -667,7 +688,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
dispose() {
|
dispose() {
|
||||||
loadSequence += 1;
|
loadSequence += 1;
|
||||||
if (frameHandle) {
|
if (frameHandle) {
|
||||||
window.cancelAnimationFrame(frameHandle);
|
frameView.cancelAnimationFrame(frameHandle);
|
||||||
frameHandle = 0;
|
frameHandle = 0;
|
||||||
}
|
}
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export interface DrainagePanel {
|
|||||||
selectBasinByChainage: (chainageM: number | null) => void;
|
selectBasinByChainage: (chainageM: number | null) => void;
|
||||||
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
|
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
|
||||||
* 이미 같으면 무시(2026-08-17 전역 선택 동기화). */
|
* 이미 같으면 무시(2026-08-17 전역 선택 동기화). */
|
||||||
selectPipeAtChainage: (chainageM: number | null) => void;
|
/** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(밖으로 안 알림 · 재계산 뒤 다시 얹음 · 233-3). */
|
||||||
|
selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void;
|
||||||
/** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */
|
/** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */
|
||||||
markStation: (chainageM: number | null) => void;
|
markStation: (chainageM: number | null) => void;
|
||||||
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6).
|
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6).
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { stateKey } from "../A00_Common/b_page_state";
|
import { stateKey } from "../A00_Common/b_page_state";
|
||||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||||
import { themeColor } from "@ui/ui_template_palette";
|
import { themeColor } from "@ui/ui_template_palette";
|
||||||
|
import { showToast } from "@ui/ui_template_elements";
|
||||||
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||||
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_U
|
|||||||
import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu";
|
import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||||
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes";
|
import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes";
|
||||||
|
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];
|
||||||
@@ -153,7 +155,7 @@ export function renderBasinRows(
|
|||||||
.replace("{relief}", basin.relief_m.toFixed(1))
|
.replace("{relief}", basin.relief_m.toFixed(1))
|
||||||
.replace("{flow}", String(Math.round(basin.flow_length_m)))
|
.replace("{flow}", String(Math.round(basin.flow_length_m)))
|
||||||
.replace("{pipe}", pipe);
|
.replace("{pipe}", pipe);
|
||||||
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(1));
|
row.title = L("B05_Drainage_Basin_Chainage").replace("{chainage}", basin.chainage_m.toFixed(0)); // 정수 m(167)
|
||||||
// 산출 근거(도달시간·강우강도·설계유량)는 툴팁 둘째 줄로 붙인다 — 행이 길어지지 않게.
|
// 산출 근거(도달시간·강우강도·설계유량)는 툴팁 둘째 줄로 붙인다 — 행이 길어지지 않게.
|
||||||
if (basin.tc_minutes != null && basin.design_flow_m3s != null) {
|
if (basin.tc_minutes != null && basin.design_flow_m3s != null) {
|
||||||
row.title +=
|
row.title +=
|
||||||
@@ -352,7 +354,7 @@ export function reconcilePipes(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return next.map((chainage) => {
|
return next.map((chainage) => {
|
||||||
const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < 0.51);
|
const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < PIPE_MATCH_M);
|
||||||
return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" };
|
return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -514,9 +516,16 @@ export function createProgressReporter(progress: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 세부유역 요청 실패 — 지도 칸 문구를 돌려주고 토스트 한 줄(뒤에서 다시 나눔이 조용히 안 끝나게 · 298-1). */
|
||||||
|
export function analyzeFailed(error: unknown, fallback: string): string {
|
||||||
|
const text = error instanceof Error ? error.message : fallback;
|
||||||
|
showToast(text === fallback ? text : `${fallback} ${text}`, "error");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */
|
/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */
|
||||||
export function pipeMarkerColor(basins: ReadonlyArray<DetailBasin>, chainageM: number): string {
|
export function pipeMarkerColor(basins: ReadonlyArray<DetailBasin>, chainageM: number): string {
|
||||||
const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < 0.51);
|
const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < PIPE_MATCH_M);
|
||||||
if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb");
|
if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb");
|
||||||
return basinColor(basin.index).replace(/0\.45\)$/, "1)");
|
return basinColor(basin.index).replace(/0\.45\)$/, "1)");
|
||||||
}
|
}
|
||||||
@@ -527,7 +536,7 @@ export function basinIndexOfPipe(
|
|||||||
pipe: { chainage_m: number } | null,
|
pipe: { chainage_m: number } | null,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!pipe) return null;
|
if (!pipe) return null;
|
||||||
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < 0.51);
|
const basin = basins.find((item) => Math.abs(item.chainage_m - pipe.chainage_m) < PIPE_MATCH_M);
|
||||||
return basin ? basin.index : null;
|
return basin ? basin.index : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { themeColor } from "@ui/ui_template_palette";
|
import { themeColor } from "@ui/ui_template_palette";
|
||||||
|
import { pickPx } from "../A00_Common/touch_bridge";
|
||||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||||
|
import { snapStructureStation } from "../common_util/common_util_structure_station";
|
||||||
|
|
||||||
// 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제.
|
// 배관(관 매설) 지점 편집기 — 배수유역 패널의 계획선 위 마커 표시·추가·이동·삭제.
|
||||||
// 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선
|
// 마커 위치의 단일 소스는 누가거리(chainage)다. 화면 좌표는 매 프레임 노선
|
||||||
@@ -74,6 +76,9 @@ export function createPipeEditor(
|
|||||||
/** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다
|
/** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다
|
||||||
* (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */
|
* (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */
|
||||||
let dragDownAt: { x: number; y: number } | null = null;
|
let dragDownAt: { x: number; y: number } | null = null;
|
||||||
|
/** 노선 안 · 관 기준 측점 = 정수 m(일반 측점 1 m 안이면 그 측점 · 234-1). */
|
||||||
|
const onRoute = (chainageM: number): number =>
|
||||||
|
Math.min(snapStructureStation(Math.max(0, Math.min(totalChainage, chainageM))), totalChainage);
|
||||||
|
|
||||||
/** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */
|
/** 화면 → 사업지 좌표계 m (MapRender affine의 역변환). */
|
||||||
function screenToMetric(
|
function screenToMetric(
|
||||||
@@ -192,21 +197,21 @@ export function createPipeEditor(
|
|||||||
if (!xy) continue;
|
if (!xy) continue;
|
||||||
const screen = metricToScreen(view, xy.x, xy.y);
|
const screen = metricToScreen(view, xy.x, xy.y);
|
||||||
if (!screen) continue;
|
if (!screen) continue;
|
||||||
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) return i;
|
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= pickPx(HIT_RADIUS_PX)) return i;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
moveTo(index, chainageM) {
|
moveTo(index, chainageM) {
|
||||||
const pipe = pipeList[index];
|
const pipe = pipeList[index];
|
||||||
if (!pipe) return;
|
if (!pipe) return;
|
||||||
pipe.chainage_m = Math.max(0, Math.min(totalChainage, chainageM));
|
pipe.chainage_m = onRoute(chainageM);
|
||||||
pipe.reason = "confirmed";
|
pipe.reason = "confirmed";
|
||||||
sortPipes();
|
sortPipes();
|
||||||
onChange();
|
onChange();
|
||||||
onCommit();
|
onCommit();
|
||||||
},
|
},
|
||||||
addAtChainage(chainageM) {
|
addAtChainage(chainageM) {
|
||||||
const clamped = Math.max(0, Math.min(totalChainage, chainageM));
|
const clamped = onRoute(chainageM);
|
||||||
pipeList.push({ chainage_m: clamped, reason: "confirmed" });
|
pipeList.push({ chainage_m: clamped, reason: "confirmed" });
|
||||||
sortPipes();
|
sortPipes();
|
||||||
selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6);
|
selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - clamped) < 1e-6);
|
||||||
@@ -217,25 +222,24 @@ export function createPipeEditor(
|
|||||||
const metric = screenToMetric(view, screenX, screenY);
|
const metric = screenToMetric(view, screenX, screenY);
|
||||||
if (!metric) return false;
|
if (!metric) return false;
|
||||||
const nearest = nearestChainage(metric.x, metric.y);
|
const nearest = nearestChainage(metric.x, metric.y);
|
||||||
return !!nearest && nearest.distance * pxPerMeter(view) <= ADD_SNAP_PX;
|
return !!nearest && nearest.distance * pxPerMeter(view) <= pickPx(ADD_SNAP_PX);
|
||||||
},
|
},
|
||||||
chainageAt(view, screenX, screenY) {
|
chainageAt(view, screenX, screenY) {
|
||||||
const metric = screenToMetric(view, screenX, screenY);
|
const metric = screenToMetric(view, screenX, screenY);
|
||||||
if (!metric) return null;
|
if (!metric) return null;
|
||||||
const nearest = nearestChainage(metric.x, metric.y);
|
const nearest = nearestChainage(metric.x, metric.y);
|
||||||
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return null;
|
if (!nearest || nearest.distance * pxPerMeter(view) > pickPx(ADD_SNAP_PX)) return null;
|
||||||
return Number(nearest.chainage.toFixed(2));
|
return onRoute(nearest.chainage);
|
||||||
},
|
},
|
||||||
addAt(view, screenX, screenY) {
|
addAt(view, screenX, screenY) {
|
||||||
const metric = screenToMetric(view, screenX, screenY);
|
const metric = screenToMetric(view, screenX, screenY);
|
||||||
if (!metric) return false;
|
if (!metric) return false;
|
||||||
const nearest = nearestChainage(metric.x, metric.y);
|
const nearest = nearestChainage(metric.x, metric.y);
|
||||||
if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false;
|
if (!nearest || nearest.distance * pxPerMeter(view) > pickPx(ADD_SNAP_PX)) return false;
|
||||||
pipeList.push({ chainage_m: nearest.chainage, reason: "confirmed" });
|
const at = onRoute(nearest.chainage);
|
||||||
|
pipeList.push({ chainage_m: at, reason: "confirmed" });
|
||||||
sortPipes();
|
sortPipes();
|
||||||
selectedIndex = pipeList.findIndex(
|
selectedIndex = pipeList.findIndex((pipe) => Math.abs(pipe.chainage_m - at) < 1e-6);
|
||||||
(pipe) => Math.abs(pipe.chainage_m - nearest.chainage) < 1e-6,
|
|
||||||
);
|
|
||||||
onChange();
|
onChange();
|
||||||
onCommit();
|
onCommit();
|
||||||
return true;
|
return true;
|
||||||
@@ -250,7 +254,7 @@ export function createPipeEditor(
|
|||||||
if (!xy) continue;
|
if (!xy) continue;
|
||||||
const screen = metricToScreen(view, xy.x, xy.y);
|
const screen = metricToScreen(view, xy.x, xy.y);
|
||||||
if (!screen) continue;
|
if (!screen) continue;
|
||||||
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) {
|
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= pickPx(HIT_RADIUS_PX)) {
|
||||||
selectedIndex = i;
|
selectedIndex = i;
|
||||||
draggingIndex = i;
|
draggingIndex = i;
|
||||||
dragFrom = pipeList[i].chainage_m;
|
dragFrom = pipeList[i].chainage_m;
|
||||||
@@ -271,8 +275,7 @@ export function createPipeEditor(
|
|||||||
if (!metric) return true;
|
if (!metric) return true;
|
||||||
const nearest = nearestChainage(metric.x, metric.y);
|
const nearest = nearestChainage(metric.x, metric.y);
|
||||||
if (!nearest) return true;
|
if (!nearest) return true;
|
||||||
const clamped = Math.max(0, Math.min(totalChainage, nearest.chainage));
|
pipeList[draggingIndex].chainage_m = onRoute(nearest.chainage);
|
||||||
pipeList[draggingIndex].chainage_m = clamped;
|
|
||||||
pipeList[draggingIndex].reason = "confirmed";
|
pipeList[draggingIndex].reason = "confirmed";
|
||||||
dragMoved = true;
|
dragMoved = true;
|
||||||
onChange();
|
onChange();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||||
import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types";
|
import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types";
|
||||||
|
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||||
|
|
||||||
/** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
|
/** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
|
||||||
* 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
|
* 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
|
||||||
@@ -20,7 +21,7 @@ export function buildPipeReports(
|
|||||||
): DrainagePipeReport[] {
|
): DrainagePipeReport[] {
|
||||||
return pipes.map((pipe) => {
|
return pipes.map((pipe) => {
|
||||||
const chainage = Math.round(pipe.chainage_m * 100) / 100;
|
const chainage = Math.round(pipe.chainage_m * 100) / 100;
|
||||||
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
|
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M);
|
||||||
const attributes = facilityStore.get(chainage);
|
const attributes = facilityStore.get(chainage);
|
||||||
return {
|
return {
|
||||||
chainage_m: chainage,
|
chainage_m: chainage,
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
* · 관 자리 읽기 · 쓰기(되돌리기) = `pipeChainages()` · `setPipeChainages()`
|
* · 관 자리 읽기 · 쓰기(되돌리기) = `pipeChainages()` · `setPipeChainages()`
|
||||||
* · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems`
|
* · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems`
|
||||||
* · 구간 띠 · 노선 = `setIntervalSpans()` · `setRoute()`
|
* · 구간 띠 · 노선 = `setIntervalSpans()` · `setRoute()`
|
||||||
* 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역
|
* 두 보기(종단 · 횡단)에서 같은 판을 끼운다 — 판은 늘 보이고 접기 · 펼치기는 판 안 손잡이 하나로.
|
||||||
* 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다.
|
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import "./B05_Profile_UI_Style_Drainage.css"; // 홀로 서려면 판이 자기 모양을 들고 온다.
|
import "./B05_Profile_UI_Style_Drainage.css"; // 홀로 서려면 판이 자기 모양을 들고 온다.
|
||||||
@@ -23,71 +22,11 @@ import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
|
|||||||
/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`)에 projectId · view 를 더한 것. */
|
/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`)에 projectId · view 를 더한 것. */
|
||||||
export type DrainageSideHost = DesignSideHost;
|
export type DrainageSideHost = DesignSideHost;
|
||||||
|
|
||||||
/** 종단도 side 판 = 배수유역 판 창구 그대로 + 켜고 끔. */
|
/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. 접기 · 펼치기는 판 손잡이 몫. */
|
||||||
export type DrainageSideBoard = DesignSideBoard & {
|
export function createDrainageSide(host: DrainageSideHost): DesignSideBoard {
|
||||||
/** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */
|
|
||||||
setVisible: (visible: boolean) => void;
|
|
||||||
isVisible: () => boolean;
|
|
||||||
/** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */
|
|
||||||
onVisibleChange: (listener: (visible: boolean) => void) => () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DrainageSideOptions {
|
|
||||||
/** 처음 켤지(기본 켬). */
|
|
||||||
visible?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. */
|
|
||||||
export function createDrainageSide(
|
|
||||||
host: DrainageSideHost,
|
|
||||||
options: DrainageSideOptions = {},
|
|
||||||
): DrainageSideBoard {
|
|
||||||
const panel = createDrainagePanel(host);
|
|
||||||
const listeners = new Set<(visible: boolean) => void>();
|
|
||||||
let visible = true;
|
|
||||||
|
|
||||||
function setVisible(next: boolean): void {
|
|
||||||
if (next === visible) return;
|
|
||||||
visible = next;
|
|
||||||
panel.root.hidden = !visible;
|
|
||||||
listeners.forEach((listener) => listener(visible));
|
|
||||||
}
|
|
||||||
// 처음 상태는 알림 없이 맞춘다.
|
|
||||||
if (options.visible === false) {
|
|
||||||
visible = false;
|
|
||||||
panel.root.hidden = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...panel,
|
...createDrainagePanel(host),
|
||||||
// 보기가 바뀌어도 판은 다시 안 만든다 — 켬 · 끔은 끼우는 쪽이 `setVisible` 로 정한다.
|
// 보기가 바뀌어도 판은 다시 안 만든다.
|
||||||
setView: () => {},
|
setView: () => {},
|
||||||
setVisible,
|
|
||||||
isVisible: () => visible,
|
|
||||||
onVisibleChange(listener) {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => listeners.delete(listener);
|
|
||||||
},
|
|
||||||
dispose() {
|
|
||||||
listeners.clear();
|
|
||||||
panel.dispose();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 판을 뒤집는다. */
|
|
||||||
export function createDrainageToggleButton(side: DrainageSideBoard): HTMLButtonElement {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = "ui-btn ui-btn--ghost b05-drainage__toggle";
|
|
||||||
button.textContent = "배수유역도";
|
|
||||||
button.title = "배수유역도 판을 켜고 끕니다";
|
|
||||||
const sync = (visible: boolean): void => {
|
|
||||||
button.setAttribute("aria-pressed", String(visible));
|
|
||||||
button.classList.toggle("is-active", visible);
|
|
||||||
};
|
|
||||||
sync(side.isVisible());
|
|
||||||
side.onVisibleChange(sync);
|
|
||||||
button.addEventListener("click", () => side.setVisible(!side.isVisible()));
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B05_Profile_UI_Drainage_ZoomRange.ts
|
||||||
|
* 배수유역 지도의 줌아웃 한계(가장 작은 배율) — 도엽 전체가 아니라 **노선 기준**(계획서 281).
|
||||||
|
*
|
||||||
|
* 한계 = 노선 범위를 가운데 두고 ZOOM_OUT_ROUTE_FACTOR 배로 넓힌 범위가 화면에 들어오는 배율.
|
||||||
|
* 유역 폴리곤이 그 밖으로 나가면 유역까지 넓힌다. 처음 보기(노선 + 여유)보다 좁히지 않고,
|
||||||
|
* 도엽 전체(배율 1)보다 멀리는 안 간다. 노선이 없으면 종전 꼴(ZOOM_OUT_FALLBACK_SCALE).
|
||||||
|
* 화면 그리기 없는 순수 셈 — 시험이 Node 로 바로 돌린다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import type { DetailBasin, VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
|
import {
|
||||||
|
computeMapRect,
|
||||||
|
computeRouteView,
|
||||||
|
createNormalizer,
|
||||||
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||||
|
|
||||||
|
/** 노선 범위를 몇 배까지 멀리 볼지 — 키우면 더 멀리 빠진다(사용자가 바꿀 수 있는 값). */
|
||||||
|
export const ZOOM_OUT_ROUTE_FACTOR = 2;
|
||||||
|
|
||||||
|
/** 노선이 없을 때의 줌아웃 한계 — 도엽 전체의 절반까지(종전 값). */
|
||||||
|
export const ZOOM_OUT_FALLBACK_SCALE = 0.5;
|
||||||
|
|
||||||
|
/** 줌아웃 한계 배율. 지도 메타가 없거나 노선이 두 점 미만이면 종전 값. */
|
||||||
|
export function computeZoomOutLimit(
|
||||||
|
meta: VWorldMeta | null,
|
||||||
|
routePoints: ReadonlyArray<{ x: number; y: number }>,
|
||||||
|
basins: ReadonlyArray<Pick<DetailBasin, "polygon_lonlat">>,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
factor: number = ZOOM_OUT_ROUTE_FACTOR,
|
||||||
|
): number {
|
||||||
|
if (!meta || routePoints.length < 2 || width <= 0 || height <= 0) {
|
||||||
|
return ZOOM_OUT_FALLBACK_SCALE;
|
||||||
|
}
|
||||||
|
const route = {
|
||||||
|
x_min: Math.min(...routePoints.map((point) => point.x)),
|
||||||
|
x_max: Math.max(...routePoints.map((point) => point.x)),
|
||||||
|
y_min: Math.min(...routePoints.map((point) => point.y)),
|
||||||
|
y_max: Math.max(...routePoints.map((point) => point.y)),
|
||||||
|
};
|
||||||
|
// 도엽 정규 좌표(0~1, 위가 0)로 모아 잰다 — 노선(m)과 유역(경위도)을 한 자로 본다.
|
||||||
|
const spanX = meta.width_meters || 1;
|
||||||
|
const spanY = meta.height_meters || 1;
|
||||||
|
const centerX = ((route.x_min + route.x_max) / 2 - meta.x_min) / spanX;
|
||||||
|
const centerY = 1 - ((route.y_min + route.y_max) / 2 - meta.y_min) / spanY;
|
||||||
|
const halfW = ((route.x_max - route.x_min) / spanX) * (factor / 2);
|
||||||
|
const halfH = ((route.y_max - route.y_min) / spanY) * (factor / 2);
|
||||||
|
let x0 = centerX - halfW;
|
||||||
|
let x1 = centerX + halfW;
|
||||||
|
let y0 = centerY - halfH;
|
||||||
|
let y1 = centerY + halfH;
|
||||||
|
const normalizer = createNormalizer(meta);
|
||||||
|
basins.forEach((basin) =>
|
||||||
|
basin.polygon_lonlat.forEach(([lon, lat]) => {
|
||||||
|
const nx = (lon - normalizer.lonMin) / normalizer.lonRange;
|
||||||
|
const ny = 1 - (lat - normalizer.latMin) / normalizer.latRange;
|
||||||
|
x0 = Math.min(x0, nx);
|
||||||
|
x1 = Math.max(x1, nx);
|
||||||
|
y0 = Math.min(y0, ny);
|
||||||
|
y1 = Math.max(y1, ny);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// 배율 s 에서 화면에 드는 정규 폭 = 화면 폭 / (지도 사각형 폭 · s).
|
||||||
|
const mapRect = computeMapRect(meta, width, height);
|
||||||
|
const fit = Math.min(
|
||||||
|
width / (mapRect.width * Math.max(x1 - x0, 1e-9)),
|
||||||
|
height / (mapRect.height * Math.max(y1 - y0, 1e-9)),
|
||||||
|
);
|
||||||
|
// 처음 보기(노선 + 여유 200m)는 늘 한계 안 — 짧은 노선에서 처음 화면보다 좁아지지 않게.
|
||||||
|
const initial = computeRouteView(meta, route, width, height).scale;
|
||||||
|
return Math.max(1, Math.min(fit, initial));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 휠 한 번의 새 배율 — 한계 안에서 멈춘다. 이미 한계 밖이면 더 빠지지만 않게(튀지 않게) 둔다. */
|
||||||
|
export function clampWheelScale(
|
||||||
|
next: number,
|
||||||
|
previous: number,
|
||||||
|
minScale: number,
|
||||||
|
maxScale: number,
|
||||||
|
): number {
|
||||||
|
return Math.min(maxScale, Math.max(Math.min(minScale, previous), next));
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
pickPipeDiameter,
|
pickPipeDiameter,
|
||||||
type StructureType,
|
type StructureType,
|
||||||
} from "@config/config_frontend";
|
} from "@config/config_frontend";
|
||||||
|
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||||
|
|
||||||
export interface IrregularStation {
|
export interface IrregularStation {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -480,7 +481,9 @@ export function createIrregularStationsSection(
|
|||||||
// 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다.
|
// 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다.
|
||||||
const restoredGhost =
|
const restoredGhost =
|
||||||
entry.structureType === undefined &&
|
entry.structureType === undefined &&
|
||||||
incomingChainages.some((chainage) => Math.abs(entry.chainage_m - chainage) < 0.51);
|
incomingChainages.some(
|
||||||
|
(chainage) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M,
|
||||||
|
);
|
||||||
if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1);
|
if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1);
|
||||||
}
|
}
|
||||||
pipes.forEach((pipe) => {
|
pipes.forEach((pipe) => {
|
||||||
@@ -488,7 +491,7 @@ export function createIrregularStationsSection(
|
|||||||
const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null);
|
const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null);
|
||||||
const { station, remainder } = splitChainage(chainage);
|
const { station, remainder } = splitChainage(chainage);
|
||||||
const kept = previousPipes.find(
|
const kept = previousPipes.find(
|
||||||
(entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < 0.51,
|
(entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M,
|
||||||
);
|
);
|
||||||
const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE;
|
const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE;
|
||||||
const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective);
|
const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective);
|
||||||
@@ -530,7 +533,9 @@ export function createIrregularStationsSection(
|
|||||||
callbacks.onChange([...stations]);
|
callbacks.onChange([...stations]);
|
||||||
},
|
},
|
||||||
removeByChainage(chainageM) {
|
removeByChainage(chainageM) {
|
||||||
const index = stations.findIndex((entry) => Math.abs(entry.chainage_m - chainageM) < 0.51);
|
const index = stations.findIndex(
|
||||||
|
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||||
|
);
|
||||||
if (index < 0) return null;
|
if (index < 0) return null;
|
||||||
const [removed] = stations.splice(index, 1);
|
const [removed] = stations.splice(index, 1);
|
||||||
if (editingId === removed.id) loadForm(null);
|
if (editingId === removed.id) loadForm(null);
|
||||||
@@ -540,7 +545,7 @@ export function createIrregularStationsSection(
|
|||||||
},
|
},
|
||||||
moveByChainage(fromChainageM, toChainageM) {
|
moveByChainage(fromChainageM, toChainageM) {
|
||||||
const index = stations.findIndex(
|
const index = stations.findIndex(
|
||||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < 0.51,
|
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||||
);
|
);
|
||||||
if (index < 0) return false;
|
if (index < 0) return false;
|
||||||
const { station, remainder } = splitChainage(toChainageM);
|
const { station, remainder } = splitChainage(toChainageM);
|
||||||
|
|||||||
@@ -397,6 +397,8 @@ export function createRouteMarkers(
|
|||||||
sprite.position.copy(position);
|
sprite.position.copy(position);
|
||||||
sprite.position.y += 3.4;
|
sprite.position.y += 3.4;
|
||||||
sprite.renderOrder = 10;
|
sprite.renderOrder = 10;
|
||||||
|
// 라벨은 읽기용 — 띠 가운데 위에 떠 누름을 가로채지 않음(232-1)
|
||||||
|
sprite.raycast = () => undefined;
|
||||||
return sprite;
|
return sprite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +479,8 @@ export function createRouteMarkers(
|
|||||||
head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다.
|
head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다.
|
||||||
head.position.y = PIN_HEIGHT + 1.7;
|
head.position.y = PIN_HEIGHT + 1.7;
|
||||||
group.add(beam, head);
|
group.add(beam, head);
|
||||||
|
// 핀은 표시만 — 누름을 가로채지 않음(띠 가운데 누름이 핀에 먼저 맞았음 · 232-1)
|
||||||
|
beam.raycast = head.raycast = () => undefined;
|
||||||
group.renderOrder = 9;
|
group.renderOrder = 9;
|
||||||
group.visible = false;
|
group.visible = false;
|
||||||
interactionGroup.add(group);
|
interactionGroup.add(group);
|
||||||
@@ -490,7 +494,8 @@ export function createRouteMarkers(
|
|||||||
if (center) selectionPin.position.copy(center);
|
if (center) selectionPin.position.copy(center);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectStation(stationId: string | null): void {
|
/** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(알림 없음 · 233-2). */
|
||||||
|
function selectStation(stationId: string | null, notify = true): void {
|
||||||
selectedStationId = stationId;
|
selectedStationId = stationId;
|
||||||
stationGroup.children.forEach((object) => {
|
stationGroup.children.forEach((object) => {
|
||||||
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다.
|
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다.
|
||||||
@@ -499,7 +504,7 @@ export function createRouteMarkers(
|
|||||||
(object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15);
|
(object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15);
|
||||||
});
|
});
|
||||||
syncSelectionPin();
|
syncSelectionPin();
|
||||||
stationSelectionListener?.(selectedStationId);
|
if (notify) stationSelectionListener?.(selectedStationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -520,7 +525,9 @@ export function createRouteMarkers(
|
|||||||
deleteSelected,
|
deleteSelected,
|
||||||
selectObject(object: THREE.Object3D | undefined) {
|
selectObject(object: THREE.Object3D | undefined) {
|
||||||
if (typeof object?.userData.stationId === "string") {
|
if (typeof object?.userData.stationId === "string") {
|
||||||
selectStation(object.userData.stationId);
|
// 같은 띠 다시 누름 = 풂(232-1)
|
||||||
|
const id = object.userData.stationId as string;
|
||||||
|
selectStation(id === selectedStationId ? null : id);
|
||||||
selectionListener?.(null);
|
selectionListener?.(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* 종단 보기 본체(`design_view_profile`)가 700줄을 넘어 여기로 뗐다(계획서 24-3b).
|
* 종단 보기 본체(`design_view_profile`)가 700줄을 넘어 여기로 뗐다(계획서 24-3b).
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { dropStationsNear } from "./B05_Profile_Util_Station";
|
import { oneStationList } from "../common_util/common_util_structure_station";
|
||||||
import { interpolateIrregularStations } from "./B05_Profile_UI_Page_Helpers";
|
import { interpolateIrregularStations } from "./B05_Profile_UI_Page_Helpers";
|
||||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||||
@@ -24,7 +24,7 @@ export function stationLinesOf(input: StationLinesInput) {
|
|||||||
// 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고
|
// 확정 때 종단 정본에 병합해 둔 비정규 측점은 그리지 않는다. 화면의 정본은 사이드바 목록이고
|
||||||
// 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다
|
// 관을 옮기면 그 목록만 따라오므로, 둘을 겹쳐 그리면 옮기기 전 자리가 그대로 남는다
|
||||||
// (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다.
|
// (2026-08-02 사용자 보고). 저장분은 진입 시 `restoreSections`가 목록으로 되살린다.
|
||||||
const regular = detail.longitudinal.stations.filter((station) => station.kind !== "irregular");
|
const regular = oneStationList(detail.longitudinal.stations, []);
|
||||||
const injected = interpolateIrregularStations(
|
const injected = interpolateIrregularStations(
|
||||||
regular,
|
regular,
|
||||||
input.irregular,
|
input.irregular,
|
||||||
@@ -48,9 +48,8 @@ export function stationLinesOf(input: StationLinesInput) {
|
|||||||
return best.elevation_m;
|
return best.elevation_m;
|
||||||
};
|
};
|
||||||
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
|
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
|
||||||
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와
|
// 측점 한 벌(272) — 같은 m 면 구조물 측점 하나 · 종단 그래프 · 표와 같은 목록(`oneStationList`).
|
||||||
// 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06).
|
return oneStationList(detail.longitudinal.stations, injected).map((station) => {
|
||||||
return [...dropStationsNear(regular, injected), ...injected].map((station) => {
|
|
||||||
const design = designAt(station.chainage_m);
|
const design = designAt(station.chainage_m);
|
||||||
return {
|
return {
|
||||||
...station,
|
...station,
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ import {
|
|||||||
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
||||||
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||||
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
||||||
|
import { followedStation } from "./B05_Profile_UI_Profile_Alignment";
|
||||||
|
import { followSnapped } from "./B05_Profile_UI_Structures_Snap";
|
||||||
|
import { plantsStation } from "./B05_Profile_UI_Profile_Structures";
|
||||||
|
import {
|
||||||
|
pipeStationMarks,
|
||||||
|
SAME_STATION_M,
|
||||||
|
structureStationMarks,
|
||||||
|
type StationMark,
|
||||||
|
} from "../common_util/common_util_structure_station";
|
||||||
|
|
||||||
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
||||||
export interface PipeProjection {
|
export interface PipeProjection {
|
||||||
@@ -39,6 +48,9 @@ export interface PipeProjection {
|
|||||||
effective_diameter_mm: number | null;
|
effective_diameter_mm: number | null;
|
||||||
facility: PipeFacility;
|
facility: PipeFacility;
|
||||||
options?: Record<string, string | number>;
|
options?: Record<string, string | number>;
|
||||||
|
/** 독립 기슭막이 구간 — 시작 · 끝도 측점을 심는다(272 · 서버와 같은 규칙). */
|
||||||
|
start_m?: number | null;
|
||||||
|
end_m?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StructuresBridgeDeps {
|
export interface StructuresBridgeDeps {
|
||||||
@@ -56,17 +68,28 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
|||||||
/** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */
|
/** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */
|
||||||
let irregularStations: IrregularStation[] = [];
|
let irregularStations: IrregularStation[] = [];
|
||||||
|
|
||||||
function pipesToStations(
|
/** 측점 자리 → 측점번호 · 잔여거리(측점간격 기준). */
|
||||||
pipes: Array<{
|
function stationAt(
|
||||||
chainage_m: number;
|
chainage: number,
|
||||||
effective_diameter_mm: number | null;
|
): Pick<IrregularStation, "station" | "remainder" | "chainage_m"> {
|
||||||
facility: PipeFacility;
|
|
||||||
}>,
|
|
||||||
): IrregularStation[] {
|
|
||||||
const interval = deps.panel().values().stationInterval || 20;
|
const interval = deps.panel().values().stationInterval || 20;
|
||||||
return pipes.map((pipe) => {
|
const station = Math.floor(chainage / interval);
|
||||||
const station = Math.floor(pipe.chainage_m / interval);
|
return { station, remainder: chainage - station * interval, chainage_m: chainage };
|
||||||
const remainder = pipe.chainage_m - station * interval;
|
}
|
||||||
|
|
||||||
|
/** 구간형의 시작 · 끝 측점 — 기준 측점(`id`)과 짝 · 관 · 구조물 행이 아님(옮기기 · 지우기 · 관 대조에서
|
||||||
|
* 빠짐 · `origin` 없음) · id 에 자리 이름을 붙여 옮겨도 계획고 편집이 따라감(272). */
|
||||||
|
const spanStations = (id: string, label: string, marks: StationMark[]): IrregularStation[] =>
|
||||||
|
marks
|
||||||
|
.filter((mark) => mark.role !== "anchor")
|
||||||
|
.map((mark) => ({
|
||||||
|
id: `${id}:${mark.role}`,
|
||||||
|
...stationAt(mark.chainage_m),
|
||||||
|
structure: label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function pipesToStations(pipes: ReadonlyArray<PipeProjection>): IrregularStation[] {
|
||||||
|
return pipes.flatMap((pipe) => {
|
||||||
const label =
|
const label =
|
||||||
pipe.facility === "pipe"
|
pipe.facility === "pipe"
|
||||||
? structureLabel({
|
? structureLabel({
|
||||||
@@ -75,24 +98,20 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
|||||||
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
|
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
|
||||||
})
|
})
|
||||||
: FACILITY_NAMES[pipe.facility];
|
: FACILITY_NAMES[pipe.facility];
|
||||||
return {
|
const id = `pipe-${pipe.chainage_m.toFixed(2)}`;
|
||||||
id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
return [
|
||||||
station,
|
{
|
||||||
remainder,
|
id,
|
||||||
chainage_m: pipe.chainage_m,
|
...stationAt(pipe.chainage_m),
|
||||||
structure: label,
|
structure: label,
|
||||||
structureType: "배관",
|
structureType: "배관" as const,
|
||||||
origin: "pipe",
|
origin: "pipe" as const,
|
||||||
};
|
},
|
||||||
|
...spanStations(id, label, pipeStationMarks(pipe)),
|
||||||
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 물넘이포장은 비정규 측점이 아니다 — 횡단도로 표현할 것이 없어 종단 알약으로만
|
|
||||||
* 다룬다(2026-08-28 사용자 확정). 관 정본 대조(setPipeChainages)는 전체 목록을 써야
|
|
||||||
* 물넘이가 지워지지 않으므로, **표시·서버 전송 쪽에서만** 뺀다. */
|
|
||||||
const withoutFordPavement = (stations: IrregularStation[]): IrregularStation[] =>
|
|
||||||
stations.filter((station) => station.structure !== FACILITY_NAMES.ford_pavement);
|
|
||||||
|
|
||||||
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
|
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
|
||||||
function clearProjectedStations(): void {
|
function clearProjectedStations(): void {
|
||||||
pipeStations = [];
|
pipeStations = [];
|
||||||
@@ -103,23 +122,23 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
|||||||
|
|
||||||
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
|
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
|
||||||
function applyIrregularStations(stations: IrregularStation[]): void {
|
function applyIrregularStations(stations: IrregularStation[]): void {
|
||||||
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다.
|
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다.
|
||||||
// 서버가 관 측점을 0.01m 로 반올림해 돌려주므로 0.005m 안의 새 측점은 같은 측점 — 편집을 옮김.
|
// 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2)
|
||||||
|
// — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움.
|
||||||
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
|
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
|
||||||
const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3)));
|
const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3)));
|
||||||
irregularStations
|
const gone = irregularStations.filter(
|
||||||
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3)))
|
(station) => !nextKeys.has(station.chainage_m.toFixed(3)),
|
||||||
.forEach((station) => {
|
|
||||||
const same = stations.find(
|
|
||||||
(next) =>
|
|
||||||
Math.abs(next.chainage_m - station.chainage_m) < 0.006 &&
|
|
||||||
!oldKeys.has(next.chainage_m.toFixed(3)),
|
|
||||||
);
|
);
|
||||||
|
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
|
||||||
|
gone.forEach((station) => {
|
||||||
|
// 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272)
|
||||||
|
const same = followedStation(station, gone, born) ?? followSnapped(station, born);
|
||||||
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
||||||
});
|
});
|
||||||
irregularStations = stations;
|
irregularStations = stations;
|
||||||
deps.renderStationLines();
|
deps.renderStationLines();
|
||||||
deps.profilePanel().setIrregularStations(withoutFordPavement(stations));
|
deps.profilePanel().setIrregularStations(stations);
|
||||||
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
|
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
|
||||||
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
|
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
|
||||||
deps
|
deps
|
||||||
@@ -141,29 +160,40 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
|||||||
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
|
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
|
||||||
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
|
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
|
||||||
let pipeStations: IrregularStation[] = [];
|
let pipeStations: IrregularStation[] = [];
|
||||||
let structureTypeMap = new Map<string, { group: string; name: string }>();
|
let structureTypeMap = new Map<
|
||||||
|
string,
|
||||||
|
{ type_id: string; group: string; name: string; placement: string; managed_by: string | null }
|
||||||
|
>();
|
||||||
|
|
||||||
/** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
/** 측점을 심는 수동 구조물(A군 기준 · C · D군 구간형 시작 · 기준 · 끝 — 서버 `resolve_extra_stations`
|
||||||
|
* 와 같은 규칙 · 272)을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
||||||
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
|
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
|
||||||
const interval = deps.panel().values().stationInterval || 20;
|
return structures.flatMap((structure) => {
|
||||||
return structures
|
const type = structureTypeMap.get(structure.type_id);
|
||||||
.filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A")
|
const marks = structureStationMarks(structure, type);
|
||||||
.map((structure) => {
|
const anchor = marks.find((mark) => mark.role === "anchor");
|
||||||
const chainage = structure.chainage_m ?? structure.start_m ?? 0;
|
if (!anchor) return [];
|
||||||
const station = Math.floor(chainage / interval);
|
const id = structure.structure_id ?? `structure-${anchor.chainage_m.toFixed(2)}`;
|
||||||
return {
|
const label = type?.name ?? structure.type_id;
|
||||||
id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`,
|
return [
|
||||||
station,
|
{ id, ...stationAt(anchor.chainage_m), structure: label },
|
||||||
remainder: chainage - station * interval,
|
...spanStations(id, label, marks),
|
||||||
chainage_m: chainage,
|
];
|
||||||
structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */
|
/** 관 정본 + 측점 심는 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다 — 같은 m 면 측점 하나(관이
|
||||||
|
* 먼저 · 서버 측점 만들기와 같음). 관 기준 측점은 다 둠(관 정본 대조 `setPipeChainages` 가 관을
|
||||||
|
* 지우지 않게) · 물넘이포장도 측점을 심음(286-1). */
|
||||||
function syncCrossDrainStations(): void {
|
function syncCrossDrainStations(): void {
|
||||||
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]);
|
const kept: IrregularStation[] = [];
|
||||||
|
for (const station of [...pipeStations, ...crossDrainStationsOf(ownStructures)]) {
|
||||||
|
const taken =
|
||||||
|
station.origin !== "pipe" &&
|
||||||
|
kept.some((other) => Math.abs(other.chainage_m - station.chainage_m) < SAME_STATION_M);
|
||||||
|
if (!taken) kept.push(station);
|
||||||
|
}
|
||||||
|
applyIrregularStations(kept);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
|
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
|
||||||
@@ -324,7 +354,16 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
|||||||
deps.profilePanel().setStructureTypes(allTypes);
|
deps.profilePanel().setStructureTypes(allTypes);
|
||||||
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
||||||
structureTypeMap = new Map(
|
structureTypeMap = new Map(
|
||||||
allTypes.map((type) => [type.type_id, { group: type.group, name: type.name }]),
|
allTypes.map((type) => [
|
||||||
|
type.type_id,
|
||||||
|
{
|
||||||
|
type_id: type.type_id,
|
||||||
|
group: type.group,
|
||||||
|
name: type.name,
|
||||||
|
placement: type.placement,
|
||||||
|
managed_by: type.managed_by,
|
||||||
|
},
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
structureRevision = stored.revision;
|
structureRevision = stored.revision;
|
||||||
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
|
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
|
||||||
@@ -360,7 +399,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */
|
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */
|
||||||
irregularStations: () => withoutFordPavement(irregularStations),
|
irregularStations: () => irregularStations,
|
||||||
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
|
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
|
||||||
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
|
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
|
||||||
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
|
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
|
||||||
@@ -374,6 +413,11 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
|||||||
clearProjectedStations,
|
clearProjectedStations,
|
||||||
/** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */
|
/** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */
|
||||||
pipeMarkChainage,
|
pipeMarkChainage,
|
||||||
|
/** 측점 없는 관 알약(물넘이포장)인가 — 고름 값에 알약 id 를 남길지 가름(요구 178). */
|
||||||
|
stationlessPipeMark: (structureId: string | null): boolean => {
|
||||||
|
const mark = pipeMarks.find((entry) => entry.structure_id === structureId);
|
||||||
|
return !!mark && !plantsStation(mark, []);
|
||||||
|
},
|
||||||
/** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */
|
/** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */
|
||||||
markChainage,
|
markChainage,
|
||||||
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
|
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ interface PanelCallbacks {
|
|||||||
onPipeFacilityRemove: (chainageM: number) => void;
|
onPipeFacilityRemove: (chainageM: number) => void;
|
||||||
/** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */
|
/** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */
|
||||||
onPipeFacilitySelect: (chainageM: number | null) => void;
|
onPipeFacilitySelect: (chainageM: number | null) => void;
|
||||||
|
/** 목록 다시 누름 · 새로 놓기 = 풀기 한 길(233-3). */
|
||||||
|
onListRelease?: () => void;
|
||||||
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
|
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
|
||||||
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
|
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
|
||||||
}
|
}
|
||||||
@@ -104,6 +106,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
|
|||||||
onPipeUpdate: callbacks.onPipeFacilityUpdate,
|
onPipeUpdate: callbacks.onPipeFacilityUpdate,
|
||||||
onPipeRemove: callbacks.onPipeFacilityRemove,
|
onPipeRemove: callbacks.onPipeFacilityRemove,
|
||||||
onPipeSelect: callbacks.onPipeFacilitySelect,
|
onPipeSelect: callbacks.onPipeFacilitySelect,
|
||||||
|
onListRelease: callbacks.onListRelease,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ registerPanelSection({
|
|||||||
views: ["profile"],
|
views: ["profile"],
|
||||||
order: 30,
|
order: 30,
|
||||||
build: (ctx, view) => {
|
build: (ctx, view) => {
|
||||||
const frame = createSectionFrame(view, "계획노선");
|
const frame = createSectionFrame(view, "계획노선", true); // 311 기본 접힘
|
||||||
frame.body.append(part<RoutePart>(ctx, "route").plannedRouteButton);
|
frame.body.append(part<RoutePart>(ctx, "route").plannedRouteButton);
|
||||||
return frame.root;
|
return frame.root;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* 좌측 패널 칸이 아니라 뷰어에 붙는다 — `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b).
|
* 좌측 패널 칸이 아니라 뷰어에 붙는다 — `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b).
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||||
import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields";
|
import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields";
|
||||||
|
|
||||||
/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */
|
/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */
|
||||||
@@ -33,7 +34,8 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
|
|||||||
(["iso", "top", "front", "side"] as const).forEach((preset) =>
|
(["iso", "top", "front", "side"] as const).forEach((preset) =>
|
||||||
viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")),
|
viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")),
|
||||||
);
|
);
|
||||||
// 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음).
|
// 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 원근 · 고른 값은 화면 설정에 기억.
|
||||||
|
const initialProjection = readStateRaw("b05-3d-projection") === "ortho" ? "ortho" : "perspective";
|
||||||
const projectionButtons = (
|
const projectionButtons = (
|
||||||
[
|
[
|
||||||
["perspective", "원근"],
|
["perspective", "원근"],
|
||||||
@@ -47,15 +49,17 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
|
|||||||
el.classList.toggle("is-active", other === kind);
|
el.classList.toggle("is-active", other === kind);
|
||||||
el.setAttribute("aria-pressed", String(other === kind));
|
el.setAttribute("aria-pressed", String(other === kind));
|
||||||
});
|
});
|
||||||
|
writeStateRaw("b05-3d-projection", kind);
|
||||||
callbacks.onProjection(kind);
|
callbacks.onProjection(kind);
|
||||||
},
|
},
|
||||||
"glass",
|
"glass",
|
||||||
);
|
);
|
||||||
element.classList.toggle("is-active", kind === "ortho");
|
element.classList.toggle("is-active", kind === initialProjection);
|
||||||
element.setAttribute("aria-pressed", String(kind === "ortho"));
|
element.setAttribute("aria-pressed", String(kind === initialProjection));
|
||||||
return { kind, element };
|
return { kind, element };
|
||||||
});
|
});
|
||||||
viewButtons.append(...projectionButtons.map(({ element }) => element));
|
viewButtons.append(...projectionButtons.map(({ element }) => element));
|
||||||
|
if (initialProjection === "ortho") callbacks.onProjection("ortho");
|
||||||
const visibilityButtons = document.createElement("div");
|
const visibilityButtons = document.createElement("div");
|
||||||
visibilityButtons.className = "b05-route__view-group";
|
visibilityButtons.className = "b05-route__view-group";
|
||||||
visibilityButtons.append(
|
visibilityButtons.append(
|
||||||
|
|||||||
@@ -182,6 +182,22 @@ export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사라진 비정규 측점의 편집이 따라갈 새 측점(213-2) — 0.006m 안(반올림) · 같은 id · 사라진 것 하나와
|
||||||
|
* 새것 하나(옮긴 관)면 그것. 없으면 null(편집을 지움).
|
||||||
|
*/
|
||||||
|
export function followedStation<T extends { id: string; chainage_m: number }>(
|
||||||
|
station: T,
|
||||||
|
gone: ReadonlyArray<T>,
|
||||||
|
born: ReadonlyArray<T>,
|
||||||
|
): T | null {
|
||||||
|
return (
|
||||||
|
born.find((next) => Math.abs(next.chainage_m - station.chainage_m) < 0.006) ??
|
||||||
|
born.find((next) => next.id === station.id) ??
|
||||||
|
(gone.length === 1 && born.length === 1 ? born[0] : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */
|
/** 반올림만 다른 같은 측점(m) — 서버 `SAME_STATION_SNAP_M`(B05_Profile_Engine_Grade_Alignment.py) 와 짝. */
|
||||||
const SAME_STATION_SNAP_M = 0.006;
|
const SAME_STATION_SNAP_M = 0.006;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_stat
|
|||||||
import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights";
|
import { profilePanelMaxHeight } from "./B05_Profile_UI_Profile_Heights";
|
||||||
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
||||||
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
||||||
|
import { viewOf } from "../common_util/common_util_view_window";
|
||||||
|
|
||||||
const MIN_CHART_HEIGHT = 100;
|
const MIN_CHART_HEIGHT = 100;
|
||||||
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
||||||
@@ -123,9 +124,12 @@ export interface ProfileDrag {
|
|||||||
export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||||
const { root, body, massHaul, tableOverlay } = ctx;
|
const { root, body, massHaul, tableOverlay } = ctx;
|
||||||
let resizeTimer = 0;
|
let resizeTimer = 0;
|
||||||
|
/** 시계는 종단이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
|
||||||
|
let timerView: Window = window;
|
||||||
const scheduleDraw = (): void => {
|
const scheduleDraw = (): void => {
|
||||||
window.clearTimeout(resizeTimer);
|
timerView.clearTimeout(resizeTimer);
|
||||||
resizeTimer = window.setTimeout(ctx.draw, 120);
|
timerView = viewOf(root);
|
||||||
|
resizeTimer = timerView.setTimeout(ctx.draw, 120);
|
||||||
};
|
};
|
||||||
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
||||||
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
|
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
|
||||||
@@ -211,9 +215,9 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
|||||||
mainDragCooldown = false;
|
mainDragCooldown = false;
|
||||||
},
|
},
|
||||||
scheduleDraw,
|
scheduleDraw,
|
||||||
cancelDraw: () => window.clearTimeout(resizeTimer),
|
cancelDraw: () => timerView.clearTimeout(resizeTimer),
|
||||||
dispose() {
|
dispose() {
|
||||||
window.clearTimeout(resizeTimer);
|
timerView.clearTimeout(resizeTimer);
|
||||||
heightResizer.dispose();
|
heightResizer.dispose();
|
||||||
window.removeEventListener("pointerup", clearDragFlags);
|
window.removeEventListener("pointerup", clearDragFlags);
|
||||||
window.removeEventListener("pointercancel", clearDragFlags);
|
window.removeEventListener("pointercancel", clearDragFlags);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
useDesignStore,
|
useDesignStore,
|
||||||
} from "../A00_Common/design/design_store";
|
} from "../A00_Common/design/design_store";
|
||||||
import { clearState } from "../A00_Common/b_page_state";
|
import { clearState } from "../A00_Common/b_page_state";
|
||||||
|
import { viewOf } from "../common_util/common_util_view_window";
|
||||||
|
|
||||||
/** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */
|
/** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */
|
||||||
const BUTTON_CLEARANCE_PX = 23;
|
const BUTTON_CLEARANCE_PX = 23;
|
||||||
@@ -38,13 +39,18 @@ export const HOLD_INTERVAL_MS = 100;
|
|||||||
* 버튼이 아니라 이 컨트롤러가 window 이벤트로 들고 있어야 반복이 끊기거나
|
* 버튼이 아니라 이 컨트롤러가 window 이벤트로 들고 있어야 반복이 끊기거나
|
||||||
* 반대로 멈추지 않는 상황이 생기지 않는다.
|
* 반대로 멈추지 않는 상황이 생기지 않는다.
|
||||||
*/
|
*/
|
||||||
function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } {
|
function createHoldRepeater(node: Node): {
|
||||||
|
start: (action: () => void) => void;
|
||||||
|
stop: () => void;
|
||||||
|
} {
|
||||||
let delayTimer = 0;
|
let delayTimer = 0;
|
||||||
let repeatTimer = 0;
|
let repeatTimer = 0;
|
||||||
|
/** 시계는 버튼 층이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
|
||||||
|
let timerView: Window = window;
|
||||||
|
|
||||||
function stop(): void {
|
function stop(): void {
|
||||||
window.clearTimeout(delayTimer);
|
timerView.clearTimeout(delayTimer);
|
||||||
window.clearInterval(repeatTimer);
|
timerView.clearInterval(repeatTimer);
|
||||||
delayTimer = 0;
|
delayTimer = 0;
|
||||||
repeatTimer = 0;
|
repeatTimer = 0;
|
||||||
window.removeEventListener("pointerup", stop);
|
window.removeEventListener("pointerup", stop);
|
||||||
@@ -54,8 +60,9 @@ function createHoldRepeater(): { start: (action: () => void) => void; stop: () =
|
|||||||
|
|
||||||
function start(action: () => void): void {
|
function start(action: () => void): void {
|
||||||
stop();
|
stop();
|
||||||
delayTimer = window.setTimeout(() => {
|
timerView = viewOf(node);
|
||||||
repeatTimer = window.setInterval(action, HOLD_INTERVAL_MS);
|
delayTimer = timerView.setTimeout(() => {
|
||||||
|
repeatTimer = timerView.setInterval(action, HOLD_INTERVAL_MS);
|
||||||
}, HOLD_DELAY_MS);
|
}, HOLD_DELAY_MS);
|
||||||
window.addEventListener("pointerup", stop);
|
window.addEventListener("pointerup", stop);
|
||||||
window.addEventListener("pointercancel", stop);
|
window.addEventListener("pointercancel", stop);
|
||||||
@@ -329,7 +336,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
|||||||
layer.className = "b05-profile-edit";
|
layer.className = "b05-profile-edit";
|
||||||
layer.style.width = `${width}px`;
|
layer.style.width = `${width}px`;
|
||||||
|
|
||||||
const repeater = createHoldRepeater();
|
const repeater = createHoldRepeater(layer);
|
||||||
const edited = new Set(Object.keys(alignment.edits.station_offsets));
|
const edited = new Set(Object.keys(alignment.edits.station_offsets));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* B05_Profile_UI_Profile_Guard.ts
|
* B05_Profile_UI_Profile_Guard.ts
|
||||||
* 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고,
|
* 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고
|
||||||
* 구조물 측점을 종단 정본 측점으로 맞추기.
|
* (구조물 측점 맞추기는 측점 한 벌 `oneStationList` 로 갈음 · 272).
|
||||||
*
|
*
|
||||||
* 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다
|
* 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다
|
||||||
* (계획서 24-2c). 본문 로직과 수치는 그대로이고 화면 상태를 인자로 받는다.
|
* (계획서 24-2c). 본문 로직과 수치는 그대로이고 화면 상태를 인자로 받는다.
|
||||||
@@ -9,11 +9,6 @@
|
|||||||
|
|
||||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
|
||||||
|
|
||||||
/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는
|
|
||||||
* mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */
|
|
||||||
const STATION_SNAP_TOLERANCE_M = 0.1;
|
|
||||||
|
|
||||||
/** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */
|
/** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */
|
||||||
export function sampleAt(
|
export function sampleAt(
|
||||||
@@ -64,36 +59,3 @@ export function planCurveWarning(
|
|||||||
});
|
});
|
||||||
return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null;
|
return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04).
|
|
||||||
*
|
|
||||||
* 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`,
|
|
||||||
* 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든
|
|
||||||
* 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이
|
|
||||||
* 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
|
|
||||||
*
|
|
||||||
* 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다.
|
|
||||||
* 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다.
|
|
||||||
*/
|
|
||||||
export function snapToMasterStations(
|
|
||||||
alignment: ProfileAlignment | null,
|
|
||||||
stations: IrregularStation[],
|
|
||||||
): IrregularStation[] {
|
|
||||||
const master = alignment?.stations;
|
|
||||||
if (!master?.length) return stations;
|
|
||||||
return stations.map((station) => {
|
|
||||||
let best: number | null = null;
|
|
||||||
let gap = STATION_SNAP_TOLERANCE_M;
|
|
||||||
for (const node of master) {
|
|
||||||
const distance = Math.abs(node.chainage_m - station.chainage_m);
|
|
||||||
if (distance <= gap) {
|
|
||||||
gap = distance;
|
|
||||||
best = node.chainage_m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best === null || best === station.chainage_m
|
|
||||||
? station
|
|
||||||
: { ...station, chainage_m: best };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
||||||
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
||||||
import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
|
import { STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
|
||||||
|
import { frameOf } from "../common_util/common_util_view_window";
|
||||||
|
|
||||||
/** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */
|
/** 그래프가 이보다 낮아지면 종단선을 읽을 수 없다 — 어떤 배분에서도 지키는 하한(px). */
|
||||||
const MIN_CHART_HEIGHT = 100;
|
const MIN_CHART_HEIGHT = 100;
|
||||||
@@ -182,7 +183,7 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade {
|
|||||||
function scheduleLightSync(): void {
|
function scheduleLightSync(): void {
|
||||||
if (lightSyncPending) return;
|
if (lightSyncPending) return;
|
||||||
lightSyncPending = true;
|
lightSyncPending = true;
|
||||||
requestAnimationFrame(() => {
|
frameOf(deps.body, () => {
|
||||||
lightSyncPending = false;
|
lightSyncPending = false;
|
||||||
syncHeightsLight();
|
syncHeightsLight();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { createPanelResizer } from "@ui/ui_template_resizer";
|
|||||||
import "@util/common_util_mass_haul.css";
|
import "@util/common_util_mass_haul.css";
|
||||||
import "./B05_Profile_UI_Style_MassHaul.css";
|
import "./B05_Profile_UI_Style_MassHaul.css";
|
||||||
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
||||||
|
import { frameOf } from "../common_util/common_util_view_window";
|
||||||
|
|
||||||
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
|
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
|
||||||
/* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
/* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
||||||
@@ -134,8 +135,6 @@ export interface RouteMassHaulDrawParams {
|
|||||||
widthPx: number;
|
widthPx: number;
|
||||||
selectedStationId: string | null;
|
selectedStationId: string | null;
|
||||||
onSelectStation: (stationId: string) => void;
|
onSelectStation: (stationId: string) => void;
|
||||||
/** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */
|
|
||||||
onClearSelection?: () => void;
|
|
||||||
/**
|
/**
|
||||||
* 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛
|
* 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛
|
||||||
* 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정:
|
* 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정:
|
||||||
@@ -235,7 +234,7 @@ export function createRouteMassHaulPanel(
|
|||||||
syncHandlePosition();
|
syncHandlePosition();
|
||||||
if (resizeRedrawPending) return;
|
if (resizeRedrawPending) return;
|
||||||
resizeRedrawPending = true;
|
resizeRedrawPending = true;
|
||||||
requestAnimationFrame(() => {
|
frameOf(overlay, () => {
|
||||||
resizeRedrawPending = false;
|
resizeRedrawPending = false;
|
||||||
onChanged();
|
onChanged();
|
||||||
});
|
});
|
||||||
@@ -249,13 +248,6 @@ export function createRouteMassHaulPanel(
|
|||||||
pendingChip.hidden = true;
|
pendingChip.hidden = true;
|
||||||
overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip);
|
overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip);
|
||||||
|
|
||||||
// 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시).
|
|
||||||
let clearSelection: (() => void) | undefined;
|
|
||||||
scroll.addEventListener("click", (event) => {
|
|
||||||
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
|
|
||||||
clearSelection?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
|
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
|
||||||
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
|
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
|
||||||
attachWheelHorizontalScroll(scroll);
|
attachWheelHorizontalScroll(scroll);
|
||||||
@@ -367,7 +359,6 @@ export function createRouteMassHaulPanel(
|
|||||||
|
|
||||||
function draw(params: RouteMassHaulDrawParams): void {
|
function draw(params: RouteMassHaulDrawParams): void {
|
||||||
lastParams = params;
|
lastParams = params;
|
||||||
clearSelection = params.onClearSelection;
|
|
||||||
overlay.hidden = !open;
|
overlay.hidden = !open;
|
||||||
syncHandlePosition();
|
syncHandlePosition();
|
||||||
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
|
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
|
||||||
@@ -488,9 +479,13 @@ export function createRouteMassHaulPanel(
|
|||||||
);
|
);
|
||||||
drawnOnce = true;
|
drawnOnce = true;
|
||||||
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
|
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
|
||||||
scheduleMassHaulSettle(windowState, () => {
|
scheduleMassHaulSettle(
|
||||||
|
windowState,
|
||||||
|
() => {
|
||||||
if (lastParams) draw(lastParams);
|
if (lastParams) draw(lastParams);
|
||||||
});
|
},
|
||||||
|
overlay,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { isCtrlKey } from "../A00_Common/key_shortcut";
|
import { isCtrlKey } from "../A00_Common/key_shortcut";
|
||||||
|
import { viewOf } from "../common_util/common_util_view_window";
|
||||||
import type {
|
import type {
|
||||||
AlignmentBase,
|
AlignmentBase,
|
||||||
AlignmentEdits,
|
AlignmentEdits,
|
||||||
@@ -29,8 +30,10 @@ import {
|
|||||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||||
import { createFreeDraw } from "../A00_Common/design/design_profile_free";
|
import { createFreeDraw } from "../A00_Common/design/design_profile_free";
|
||||||
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
|
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
|
||||||
|
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
|
||||||
|
import { stepStructureStation } from "../common_util/common_util_structure_station";
|
||||||
|
|
||||||
/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */
|
/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */
|
||||||
const KEY_STEP_M = 0.1;
|
const KEY_STEP_M = 0.1;
|
||||||
|
|
||||||
/** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */
|
/** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */
|
||||||
@@ -222,6 +225,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
|||||||
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta));
|
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// 좌우 이동 = 마지막 누름이 종단도 안일 때만 — 횡단 카드 · 3D 를 누른 뒤 방향키가 관을 몰래 옮겼음(219-1)
|
||||||
|
if (!arrowsBelongToProfile()) return false;
|
||||||
// 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져
|
// 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져
|
||||||
// (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측).
|
// (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측).
|
||||||
const station = holdStation ?? selectedIrregular();
|
const station = holdStation ?? selectedIrregular();
|
||||||
@@ -230,8 +235,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
|||||||
// 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해
|
// 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해
|
||||||
// 0.1m 만 움직이고 멈춤(2026-09-04 실측).
|
// 0.1m 만 움직이고 멈춤(2026-09-04 실측).
|
||||||
const fromM = holdChainageM ?? station.chainage_m;
|
const fromM = holdChainageM ?? station.chainage_m;
|
||||||
const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3));
|
const next = stepStructureStation(fromM, key === "ArrowRight" ? 1 : -1);
|
||||||
if (next < 0) return false;
|
if (next === fromM) return false;
|
||||||
ctx.moveStation({ ...station, chainage_m: fromM }, next);
|
ctx.moveStation({ ...station, chainage_m: fromM }, next);
|
||||||
holdStation = station;
|
holdStation = station;
|
||||||
holdChainageM = next;
|
holdChainageM = next;
|
||||||
@@ -251,11 +256,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
|||||||
* 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */
|
* 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */
|
||||||
const HOLD_SETTLE_MS = 700;
|
const HOLD_SETTLE_MS = 700;
|
||||||
let settleTimer = 0;
|
let settleTimer = 0;
|
||||||
|
/** 시계는 패널이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
|
||||||
|
let timerView: Window = window;
|
||||||
|
|
||||||
/** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */
|
/** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */
|
||||||
function stopHold(): void {
|
function stopHold(): void {
|
||||||
window.clearTimeout(holdDelayTimer);
|
timerView.clearTimeout(holdDelayTimer);
|
||||||
window.clearInterval(holdRepeatTimer);
|
timerView.clearInterval(holdRepeatTimer);
|
||||||
holdDelayTimer = 0;
|
holdDelayTimer = 0;
|
||||||
holdRepeatTimer = 0;
|
holdRepeatTimer = 0;
|
||||||
if (heldKey === null) return;
|
if (heldKey === null) return;
|
||||||
@@ -264,8 +271,8 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
|||||||
holdStation = null;
|
holdStation = null;
|
||||||
window.removeEventListener("keyup", onKeyUp);
|
window.removeEventListener("keyup", onKeyUp);
|
||||||
window.removeEventListener("blur", stopHold);
|
window.removeEventListener("blur", stopHold);
|
||||||
window.clearTimeout(settleTimer);
|
timerView.clearTimeout(settleTimer);
|
||||||
settleTimer = window.setTimeout(() => {
|
settleTimer = timerView.setTimeout(() => {
|
||||||
settleTimer = 0;
|
settleTimer = 0;
|
||||||
history.resume();
|
history.resume();
|
||||||
}, HOLD_SETTLE_MS);
|
}, HOLD_SETTLE_MS);
|
||||||
@@ -275,7 +282,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
|||||||
function flushHold(): void {
|
function flushHold(): void {
|
||||||
stopHold();
|
stopHold();
|
||||||
if (!settleTimer) return;
|
if (!settleTimer) return;
|
||||||
window.clearTimeout(settleTimer);
|
timerView.clearTimeout(settleTimer);
|
||||||
settleTimer = 0;
|
settleTimer = 0;
|
||||||
history.resume();
|
history.resume();
|
||||||
}
|
}
|
||||||
@@ -289,8 +296,9 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
|||||||
heldKey = key;
|
heldKey = key;
|
||||||
window.addEventListener("keyup", onKeyUp);
|
window.addEventListener("keyup", onKeyUp);
|
||||||
window.addEventListener("blur", stopHold);
|
window.addEventListener("blur", stopHold);
|
||||||
holdDelayTimer = window.setTimeout(() => {
|
timerView = viewOf(ctx.root); // 앞선 덩어리는 flushHold 가 이미 닫음
|
||||||
holdRepeatTimer = window.setInterval(() => {
|
holdDelayTimer = timerView.setTimeout(() => {
|
||||||
|
holdRepeatTimer = timerView.setInterval(() => {
|
||||||
if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다.
|
if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다.
|
||||||
}, HOLD_INTERVAL_MS);
|
}, HOLD_INTERVAL_MS);
|
||||||
}, HOLD_DELAY_MS);
|
}, HOLD_DELAY_MS);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user