From 173d88be7f3c420b7c21001734f7de7f2a3ba4a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 21:55:14 +0900 Subject: [PATCH 1/6] 260718_7 --- config/config_system.py | 2 +- graphify-out/manifest.json | 946 ++++++++++++++++++++++++++++++++++--- main.py | 23 +- package.json | 10 +- scratch/wiki_linter.py | 251 +++++----- tsconfig.json | 18 +- vite.config.ts | 10 +- 7 files changed, 1022 insertions(+), 238 deletions(-) diff --git a/config/config_system.py b/config/config_system.py index a9c528dc..e870fa96 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -21,7 +21,7 @@ SERVER_PORT = int(os.getenv("SERVER_PORT", "8000")) DEBUG = os.getenv("DEBUG", "False").lower() == "true" # 정적 파일 서빙 (프론트엔드 빌드 결과) -STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "node_modules", ".build") +STATIC_DIR = os.path.join(os.path.dirname(__file__), "node_modules", ".build") STATIC_URL = "/static" # ───────────────────────────────────────────────────────────────────────── diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index 564dc9a3..9eb7cbbd 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -10,8 +10,8 @@ "semantic_hash": "" }, "docs/wiki/concepts/a00_app_shell_framework.md": { - "mtime": 1784259049.8622963, - "ast_hash": "56c3c5418a6a54f89afe3732cadaaaec", + "mtime": 1784369023.0, + "ast_hash": "1a73aa816dd5e4270ae6bf8e0ede0b97", "semantic_hash": "" }, "docs/wiki/concepts/a00_app_shell_framework_scaffold.md": { @@ -25,62 +25,62 @@ "semantic_hash": "" }, "docs/wiki/concepts/auth_rbac.md": { - "mtime": 1784259833.4644027, + "mtime": 1784259833.0, "ast_hash": "2c6ad199f866257eca3bec738fcf5f3d", "semantic_hash": "" }, "docs/wiki/concepts/common_util.md": { - "mtime": 1784259848.5701928, - "ast_hash": "941f7272498ffc25ba1deeb94c9c37b3", + "mtime": 1784365180.0, + "ast_hash": "c8a50d203fa7f0d12e562dc9dd077e95", "semantic_hash": "" }, "docs/wiki/concepts/db_schema/files_surface.md": { - "mtime": 1784267731.9194815, + "mtime": 1784267731.0, "ast_hash": "7025d41c372c169b7929ed6baca3b07f", "semantic_hash": "" }, "docs/wiki/concepts/db_schema/logs_monitoring.md": { - "mtime": 1784259355.6293466, - "ast_hash": "d394ba4d2cb09a6651f574352750eba0", + "mtime": 1784348499.0, + "ast_hash": "aaca07904f047b3e583e8cabc5187ecf", "semantic_hash": "" }, "docs/wiki/concepts/db_schema/overview.md": { - "mtime": 1784259347.9511518, + "mtime": 1784259347.0, "ast_hash": "8f35d8e4f659eee10ef9e01411cdc079", "semantic_hash": "" }, "docs/wiki/concepts/db_schema/projects.md": { - "mtime": 1784259359.3317523, + "mtime": 1784259359.0, "ast_hash": "f037e166e7b72002dfa3c7552a0eae43", "semantic_hash": "" }, "docs/wiki/concepts/db_schema/route_profile.md": { - "mtime": 1784259366.082059, + "mtime": 1784259366.0, "ast_hash": "9ba725f36208f26e1c8d0d9f6179325c", "semantic_hash": "" }, "docs/wiki/concepts/db_schema/structure_output.md": { - "mtime": 1784259369.8867612, + "mtime": 1784259369.0, "ast_hash": "92adeedeb94bb3b75b0ba4a42c75ea15", "semantic_hash": "" }, "docs/wiki/concepts/db_schema/unconfirmed/README.md": { - "mtime": 1784259342.919075, + "mtime": 1784259342.0, "ast_hash": "14b964e21ad8705d3fb2c6c818f71750", "semantic_hash": "" }, "docs/wiki/concepts/db_schema/users_auth.md": { - "mtime": 1784259859.5247, + "mtime": 1784259859.0, "ast_hash": "4252d7048a05615e6420fb2853567323", "semantic_hash": "" }, "docs/wiki/concepts/dependencies.md": { - "mtime": 1783850549.0, - "ast_hash": "93732554454116e3543ac2269b9d1f0d", + "mtime": 1784353789.0, + "ast_hash": "c9945009d6cf41f9b9bdc95ed6f31804", "semantic_hash": "" }, "docs/wiki/concepts/design.md": { - "mtime": 1784260030.4069157, + "mtime": 1784260030.0, "ast_hash": "85aec17d904c548e299a4e3810669cc0", "semantic_hash": "" }, @@ -90,28 +90,28 @@ "semantic_hash": "" }, "docs/wiki/concepts/storage_paths.md": { - "mtime": 1784276227.6785357, + "mtime": 1784276227.0, "ast_hash": "a0fe28424a580b6dce5ddbcb66f886e5", "semantic_hash": "" }, "docs/wiki/concepts/ui_templates.md": { - "mtime": 1784264934.0988722, + "mtime": 1784264934.0, "ast_hash": "6cb21cb237d90125d149b1db904a2d74", "semantic_hash": "" }, "docs/wiki/concepts/workflow_state.md": { - "mtime": 1784267747.180053, - "ast_hash": "c1fa859f4d19cea7059daf828b29a41e", + "mtime": 1784367329.0, + "ast_hash": "7c52330d044d3d1cf1bcd06404d64cd8", "semantic_hash": "" }, "docs/wiki/index.md": { - "mtime": 1784273610.479596, - "ast_hash": "635b063e8e9d1e504fa08e04f6e6673f", + "mtime": 1784376234.2042696, + "ast_hash": "fc1c8a3a02abfcf9821f1581b3fe7109", "semantic_hash": "" }, "docs/wiki/log.md": { - "mtime": 1784281064.938371, - "ast_hash": "d0a674d4bde81eea7e8bd2edf76d6287", + "mtime": 1784376236.3120687, + "ast_hash": "cf9f6ba825c065b8c23c481320439afe", "semantic_hash": "" }, "docs/wiki/pages/A01_Home/A01_components.md": { @@ -150,17 +150,17 @@ "semantic_hash": "" }, "docs/wiki/pages/A06_Login/A06_backend.md": { - "mtime": 1784259816.2009697, + "mtime": 1784259816.0, "ast_hash": "8ff447e9f595b09ab8706e6b2153c2e4", "semantic_hash": "" }, "docs/wiki/pages/A06_Login/A06_frontend.md": { - "mtime": 1784259825.8575397, + "mtime": 1784259825.0, "ast_hash": "b77699e691f04c97e2676b56a978d441", "semantic_hash": "" }, "docs/wiki/pages/A07_Register/A07_backend.md": { - "mtime": 1784259843.9720101, + "mtime": 1784259843.0, "ast_hash": "b2ba632dc7648b9925828c57fb94073f", "semantic_hash": "" }, @@ -190,12 +190,12 @@ "semantic_hash": "" }, "docs/wiki/pages/B01_Dashboard/B01_api.md": { - "mtime": 1784259230.2413194, + "mtime": 1784259230.0, "ast_hash": "cec23a7fa764aa9cb89ca556386a9d1e", "semantic_hash": "" }, "docs/wiki/pages/B01_Dashboard/B01_backend.md": { - "mtime": 1784259865.0189633, + "mtime": 1784259865.0, "ast_hash": "d3a676bc366985ef5ec949ae15a3d3cf", "semantic_hash": "" }, @@ -205,12 +205,12 @@ "semantic_hash": "" }, "docs/wiki/pages/B01_Dashboard/B01_dependencies.md": { - "mtime": 1784259237.7637663, + "mtime": 1784259237.0, "ast_hash": "b772aa70b5f90deb37c5324f93d79a80", "semantic_hash": "" }, "docs/wiki/pages/B01_Dashboard/B01_frontend.md": { - "mtime": 1784264944.284991, + "mtime": 1784264944.0, "ast_hash": "720d69c49dce55121af9846763aa3258", "semantic_hash": "" }, @@ -230,13 +230,13 @@ "semantic_hash": "" }, "docs/wiki/pages/B03_FileInput/B03_api.md": { - "mtime": 1784259243.9075582, + "mtime": 1784259243.0, "ast_hash": "96a8eb8ea278e761ddf0a3a0e2693728", "semantic_hash": "" }, "docs/wiki/pages/B03_FileInput/B03_backend.md": { - "mtime": 1784259247.156907, - "ast_hash": "2955910cfa45dad1e46c7a4141c779d6", + "mtime": 1784355935.0, + "ast_hash": "5b3c655277dabf98fcd8ab99ff2fccca", "semantic_hash": "" }, "docs/wiki/pages/B03_FileInput/B03_db.md": { @@ -245,88 +245,88 @@ "semantic_hash": "" }, "docs/wiki/pages/B03_FileInput/B03_dependencies.md": { - "mtime": 1784259157.4293892, + "mtime": 1784259157.0, "ast_hash": "8ba6d765120bd79067f87dad58605dba", "semantic_hash": "" }, "docs/wiki/pages/B03_FileInput/B03_frontend.md": { - "mtime": 1784264939.1710696, + "mtime": 1784264939.0, "ast_hash": "ff5b954b7136ca7f73c842965240400c", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_api.md": { - "mtime": 1784281082.294356, - "ast_hash": "688e57fa31f595d8a2eb50fe1534bae8", + "mtime": 1784348304.0, + "ast_hash": "0c432552116e17dabb23da60e6500c0c", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_backend.md": { - "mtime": 1784281075.9390342, - "ast_hash": "179006cfa5579947f12703de677b9178", + "mtime": 1784367346.0, + "ast_hash": "27103f91f214fc48dbbdc3402a66598d", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_db.md": { - "mtime": 1784267041.957495, - "ast_hash": "50541e6a765c96c40cb34856acc29900", + "mtime": 1784348536.0, + "ast_hash": "26728221e5e83fc1d551535e0fb33bb2", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_dependencies.md": { - "mtime": 1783849775.0, - "ast_hash": "15bd0faa0411264c14b0bbd89f7afd46", + "mtime": 1784353769.0, + "ast_hash": "9a6ce9a740a181df1c79d3f336664312", "semantic_hash": "" }, "docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": { - "mtime": 1784278834.8752944, + "mtime": 1784278834.0, "ast_hash": "bb89b16cc5a0d9763cb26c174a537f54", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_api.md": { - "mtime": 1784259165.6230073, - "ast_hash": "1eeb9b6e80f8a2122b61714669b85e1b", + "mtime": 1784367356.0, + "ast_hash": "e9160c380be1ae99eab6a0459a6e9c4e", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_backend.md": { - "mtime": 1784259169.115432, - "ast_hash": "50c0d1a2d67fbe66e3885cabae81d6cf", + "mtime": 1784367351.0, + "ast_hash": "601af33ffeac30c1eb81253e62978fe7", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_db.md": { - "mtime": 1783850658.0, - "ast_hash": "c45788b5db47a7a4f246b0714a2c11e3", + "mtime": 1784365771.0, + "ast_hash": "5e3577c18b306e2a0f1ff2ebf482ccf7", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_dependencies.md": { - "mtime": 1784259171.929299, - "ast_hash": "96757fcd55e66155d680aa611106b5ce", + "mtime": 1784353784.0, + "ast_hash": "203966b0f821d11a133db6954918b843", "semantic_hash": "" }, "docs/wiki/pages/B05_wf2_Route/B05_frontend.md": { - "mtime": 1784267722.8724308, - "ast_hash": "f72755ed7acf8a7450feac8323fad91c", + "mtime": 1784369027.0, + "ast_hash": "6f17f638d6829f29b66d451c985d6c74", "semantic_hash": "" }, "docs/wiki/pages/B06_wf3_ProfileCross/B06_api.md": { - "mtime": 1784259175.4055898, - "ast_hash": "a5989dca5de549ea7084f4291b554275", + "mtime": 1784376219.635943, + "ast_hash": "880164c5d0ff8fc2046706f6fe62f6f7", "semantic_hash": "" }, "docs/wiki/pages/B06_wf3_ProfileCross/B06_backend.md": { - "mtime": 1784259178.6757984, - "ast_hash": "5a0495ebd2ba0f22c5e87703572ad1ae", + "mtime": 1784376226.78404, + "ast_hash": "269f5d26ec2e7d08a595548ddef63009", "semantic_hash": "" }, "docs/wiki/pages/B06_wf3_ProfileCross/B06_db.md": { - "mtime": 1783850703.0, - "ast_hash": "63b1c8f0287b4b277633da8c4617b03f", + "mtime": 1784376231.4393904, + "ast_hash": "2f7dd1a4d9491782d9e87a62a8099d91", "semantic_hash": "" }, "docs/wiki/pages/B06_wf3_ProfileCross/B06_dependencies.md": { - "mtime": 1784259182.2659357, + "mtime": 1784259182.0, "ast_hash": "1033c52fc6823211db0690aafa706a83", "semantic_hash": "" }, "docs/wiki/pages/B06_wf3_ProfileCross/B06_frontend.md": { - "mtime": 1784264958.5574062, - "ast_hash": "edc082e6e035ebea9f7427393c8c863f", + "mtime": 1784376229.6672938, + "ast_hash": "7443a5902c1d4094d0b92d7639cd0120", "semantic_hash": "" }, "docs/wiki/pages/B07_wf4_DesignDetail/B07_frontend.md": { @@ -340,7 +340,7 @@ "semantic_hash": "" }, "docs/wiki/pages/B08_wf5_Quantity/B08_frontend.md": { - "mtime": 1784280350.6155877, + "mtime": 1784280350.0, "ast_hash": "82f0c3c8358a8f6b16395623d73f049f", "semantic_hash": "" }, @@ -350,13 +350,823 @@ "semantic_hash": "" }, "docs/wiki/pages/B10_Payment/B10_frontend.md": { - "mtime": 1784264923.3316023, + "mtime": 1784264923.0, "ast_hash": "922e565933ffcf90a3fa96307ae4dee3", "semantic_hash": "" }, "docs/wiki/pages/B11_Status/B11_frontend.md": { - "mtime": 1784264926.0308616, + "mtime": 1784264926.0, "ast_hash": "2cf11aa66d6346818c14c62fa8e2b12a", "semantic_hash": "" + }, + "A00_Common/.vite/deps/_metadata.json": { + "mtime": 1783416979.0, + "ast_hash": "48dd460763d1ba0111894f0f54fd2e63", + "semantic_hash": "" + }, + "A00_Common/.vite/deps/package.json": { + "mtime": 1783416979.0, + "ast_hash": "d0707362e90f00edd12435e9d3b9d71c", + "semantic_hash": "" + }, + "A00_Common/app_shell.ts": { + "mtime": 1784371862.691931, + "ast_hash": "4dacd9bd2c3c9552e5878c23df1069c5", + "semantic_hash": "" + }, + "A00_Common/b_page_scaffold.ts": { + "mtime": 1784371862.692441, + "ast_hash": "ba19266140129680b55b16659cdeca4b", + "semantic_hash": "" + }, + "A00_Common/b_workflow_nav.ts": { + "mtime": 1784371862.6932194, + "ast_hash": "15ffdd7d709c6f2b05782d3176347df0", + "semantic_hash": "" + }, + "A00_Common/main.ts": { + "mtime": 1784371862.6942656, + "ast_hash": "e70d2865f735eac9726efd78d7ed0c10", + "semantic_hash": "" + }, + "A00_Common/router.ts": { + "mtime": 1783505305.0, + "ast_hash": "3a07e5a021c346f6a511915f055922d7", + "semantic_hash": "" + }, + "A00_Common/vite-env.d.ts": { + "mtime": 1783234299.818669, + "ast_hash": "0352474ba2918efe13895edbc3780d94", + "semantic_hash": "" + }, + "A01_Home/A01_Home_UI_Page.ts": { + "mtime": 1783423100.0, + "ast_hash": "28b950204c23ad5d84d70136ea566b38", + "semantic_hash": "" + }, + "A02_ProgDetail/A02_ProgDetail_UI_Page.ts": { + "mtime": 1783237318.848724, + "ast_hash": "718c5537d87bebf0806019a11e815068", + "semantic_hash": "" + }, + "A03_CompDetail/A03_CompDetail_UI_Page.ts": { + "mtime": 1783237445.9769812, + "ast_hash": "85e6b5449bacfda98ae58026fa86882c", + "semantic_hash": "" + }, + "A04_NewsHistory/A04_NewsHistory_UI_Page.ts": { + "mtime": 1783237480.2490423, + "ast_hash": "4049d7e7ddcac64effcbbfa368fccfca", + "semantic_hash": "" + }, + "A05_EduDetail/A05_EduDetail_UI_Page.ts": { + "mtime": 1783237514.488196, + "ast_hash": "649868a7b8c71a64db80d1c398700a32", + "semantic_hash": "" + }, + "A06_Login/A06_Login_Api_Fetch.ts": { + "mtime": 1783421640.0, + "ast_hash": "85da5f81a01e70def7e211066ddec9c7", + "semantic_hash": "" + }, + "A06_Login/A06_Login_Router.py": { + "mtime": 1784371862.6952658, + "ast_hash": "25d3b98295ece2998add42d5b02d5da4", + "semantic_hash": "" + }, + "A06_Login/A06_Login_Schema.py": { + "mtime": 1783414472.0, + "ast_hash": "4001c066f5df2f7f90ce13167987f8bc", + "semantic_hash": "" + }, + "A06_Login/A06_Login_UI_Auth_Page.ts": { + "mtime": 1784371862.696359, + "ast_hash": "ecf3be6b6c74e4f49821eb39a44733ec", + "semantic_hash": "" + }, + "A07_Register/A07_Register_Api_Fetch.ts": { + "mtime": 1783421546.0, + "ast_hash": "1342286656a4bc7b26f3f3ab9fba2bbc", + "semantic_hash": "" + }, + "A07_Register/A07_Register_Router.py": { + "mtime": 1784371862.699004, + "ast_hash": "70a5256a2f78ad08a5a2f072bfc9e3f8", + "semantic_hash": "" + }, + "A07_Register/A07_Register_Schema.py": { + "mtime": 1783421495.0, + "ast_hash": "af7b0ddfaaf442781d754840b81ce59c", + "semantic_hash": "" + }, + "A07_Register/A07_Register_UI_Auth_Page.ts": { + "mtime": 1783421565.0, + "ast_hash": "daf482fe0f4146dc0f6403f5a99e4b82", + "semantic_hash": "" + }, + "A07_Register/A07_Register_UI_Page.ts": { + "mtime": 1783237673.7577379, + "ast_hash": "b911eae7a57cc1b24ae71a1fdf20bb98", + "semantic_hash": "" + }, + "A08_Support/A08_Support_Router.py": { + "mtime": 1783418906.0, + "ast_hash": "c490d0a41c3616db1f221e19d07017fb", + "semantic_hash": "" + }, + "A08_Support/A08_Support_Schema.py": { + "mtime": 1783418883.0, + "ast_hash": "e6ab097405511ec7585588e9b899d04d", + "semantic_hash": "" + }, + "A08_Support/A08_Support_UI_Page.ts": { + "mtime": 1783418988.0, + "ast_hash": "a4eb30df878f52e004fc7c03496ff57c", + "semantic_hash": "" + }, + "A09_Security/A09_Security_Api_Fetch.ts": { + "mtime": 1783415135.0, + "ast_hash": "4e3e8642ac23d07167094833b7762ae4", + "semantic_hash": "" + }, + "A09_Security/A09_Security_Router.py": { + "mtime": 1783415590.0, + "ast_hash": "188d62cf69fb770bb24423183cf7e4d8", + "semantic_hash": "" + }, + "A09_Security/A09_Security_Schema.py": { + "mtime": 1783415590.0, + "ast_hash": "27f6edbb97e74f9ec3615f34de2227b9", + "semantic_hash": "" + }, + "A09_Security/A09_Security_Terms.ts": { + "mtime": 1783414968.0, + "ast_hash": "d1ca33930803139d39382b8e02f6b5eb", + "semantic_hash": "" + }, + "A09_Security/A09_Security_UI_Page.ts": { + "mtime": 1783414968.0, + "ast_hash": "af0ada0cd6b3df7a3ebd2b00960b6216", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_Api_Fetch.ts": { + "mtime": 1784371862.7001457, + "ast_hash": "860ee6c6f184622e1d821949d00ff2cb", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_Repository.py": { + "mtime": 1784371862.7011478, + "ast_hash": "6b934081cb58e13bab33f6a6435e2c61", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_Router.py": { + "mtime": 1784371862.7022014, + "ast_hash": "bf9add5702a4cd8d3cb68b8c1f492774", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_Schema.py": { + "mtime": 1784371862.703229, + "ast_hash": "6d4cb5d264f6172e6c7d89493bc0ff6f", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_UI_Admin.ts": { + "mtime": 1784371862.703229, + "ast_hash": "97788f7b9050d62e411aab5ed2051858", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_UI_Common.ts": { + "mtime": 1784371862.703229, + "ast_hash": "0efca669a90f90a49fe0e2592c5bd162", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_UI_Company.ts": { + "mtime": 1784371862.7044973, + "ast_hash": "bf5db444b8531633ef2ad4c9e77eb931", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_UI_Helper.ts": { + "mtime": 1784371862.7056584, + "ast_hash": "2c6b3f845142f02687f401f86c901f8a", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_UI_Modals.ts": { + "mtime": 1784371862.706769, + "ast_hash": "393432ccf162ec6077541a03d727d7ae", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_UI_Page.ts": { + "mtime": 1784371862.707793, + "ast_hash": "62116ab40530d9f091c87fd16a452ddf", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_UI_Profile.ts": { + "mtime": 1784371862.707793, + "ast_hash": "a20c529b0c9835d6a7fcecc3cf4a2909", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_UI_Projects.ts": { + "mtime": 1784371862.7088647, + "ast_hash": "5cefe93aac7b392130bad296b7546647", + "semantic_hash": "" + }, + "B01_Dashboard/B01_Dashboard_UI_Resources.ts": { + "mtime": 1783587686.6291425, + "ast_hash": "3054f93fbf865e06edc7032d01bd9820", + "semantic_hash": "" + }, + "B02_ProjRegister/B02_ProjRegister_Repository.py": { + "mtime": 1783677088.0, + "ast_hash": "b5b9f982f6407cf25efdffe176c71502", + "semantic_hash": "" + }, + "B02_ProjRegister/B02_ProjRegister_Router.py": { + "mtime": 1783589237.4005332, + "ast_hash": "c91200b99e61930e8254943b7491ded5", + "semantic_hash": "" + }, + "B02_ProjRegister/B02_ProjRegister_Schema.py": { + "mtime": 1783589165.0666614, + "ast_hash": "cccc5f9402eb04efecba085056bb0d2a", + "semantic_hash": "" + }, + "B02_ProjRegister/B02_ProjRegister_UI_Page.ts": { + "mtime": 1783589195.3898335, + "ast_hash": "0e8ab1a73347615578d117e36bf00994", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_Api_Fetch.ts": { + "mtime": 1783672626.0, + "ast_hash": "511c359bd81f6f597d63c64a9a7a945d", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_Email.py": { + "mtime": 1783599110.3864007, + "ast_hash": "6d19915b85a7b3d2109b7c2dc1ea1edb", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_Engine.py": { + "mtime": 1783596209.3112216, + "ast_hash": "d0cf33b03272162dd4b688cf6ad72d73", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_Engine_Analyze.py": { + "mtime": 1784371862.7103398, + "ast_hash": "76f7d75cc2dc5d8041a432f4e1568d19", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_Repository.py": { + "mtime": 1783596239.7588317, + "ast_hash": "aaa288c15de5edb3719a9d7be9f5f973", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_Router.py": { + "mtime": 1784371862.7118456, + "ast_hash": "68c5829f62c0e08124712810d8c5bf1c", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_Schema.py": { + "mtime": 1783596181.187815, + "ast_hash": "64b068e7072e95395379b19caed40b8b", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_ServiceWorker.ts": { + "mtime": 1783596513.025032, + "ast_hash": "e42186f9d15ec1022bc00322882ea278", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_Service_WF1.py": { + "mtime": 1784371862.712557, + "ast_hash": "32e9a57eb4937a35e8acf00ad7005604", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_State.ts": { + "mtime": 1783674025.0, + "ast_hash": "4e43c9e52c0aabc140ed931e18478713", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_UI_Page.ts": { + "mtime": 1784371862.7130625, + "ast_hash": "c359a6ddd091e58c469c14deeac1ad5a", + "semantic_hash": "" + }, + "B03_FileInput/B03_FileInput_UI_Support.ts": { + "mtime": 1784371862.7130625, + "ast_hash": "64d2b28e0423f02c4e51ca787fd83db3", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts": { + "mtime": 1784371862.7145033, + "ast_hash": "5fb65b6de4a6b46751f6c12fc448a1bc", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine.py": { + "mtime": 1784371862.7155347, + "ast_hash": "0c3272f97684405915c78b1f9c7ae528", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py": { + "mtime": 1784371862.7165349, + "ast_hash": "abd2f8a4ed17156d736d0741bc10a89f", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_CSF.py": { + "mtime": 1783250545.7624342, + "ast_hash": "c3456ef16f6d99f5d0764d305d4c850c", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_Grid.py": { + "mtime": 1783248229.843674, + "ast_hash": "413f95f0cbef48fe62dd3cb782954a3d", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_PMF.py": { + "mtime": 1783250767.5307772, + "ast_hash": "3dd1f0fac27923be68cd2b01e2795e44", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_RANSAC.py": { + "mtime": 1783250896.4425025, + "ast_hash": "aae25c9f6cbad2d42e1d22849087c45d", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_GisVector.py": { + "mtime": 1783681631.0, + "ast_hash": "91bc7fda7ec2c74767932447baec3667", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_Ground.py": { + "mtime": 1783251572.58446, + "ast_hash": "ba1b1907e032c7fa0e6be6c90a4f73dd", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_ModelBuild.py": { + "mtime": 1783251412.31927, + "ast_hash": "d820a4619914343447b4b7de5d07ca55", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_ModelContext.py": { + "mtime": 1783251412.31927, + "ast_hash": "fb2e12ad33d430eadea2b654fbdea743", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_MvtHelper.py": { + "mtime": 1783681628.0, + "ast_hash": "5d69eca55395f9c145005e08861885c7", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py": { + "mtime": 1784371862.7170405, + "ast_hash": "9f1a6940a925f3d15b2e000b9848de0a", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_Smooth.py": { + "mtime": 1783251412.318266, + "ast_hash": "86ddc5c95cb5d0b87ff6eca3f8b221a6", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_Structurize.py": { + "mtime": 1783248152.672701, + "ast_hash": "5b8bc35b11bd89f08f84131a0e81fd15", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py": { + "mtime": 1783681604.0, + "ast_hash": "4100383b096bc40aea3e0721842b60bd", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Repository.py": { + "mtime": 1784371862.718252, + "ast_hash": "eed0e7887079837955600414134020b2", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Router.py": { + "mtime": 1784371862.7196527, + "ast_hash": "40deb5df719ce75a736e4c2f7aec6dfb", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py": { + "mtime": 1784371862.7208662, + "ast_hash": "43878b93ae7c45d097465fe9013c9ba3", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py": { + "mtime": 1784371862.7208662, + "ast_hash": "8f35831abd7534830b89b822e48d9572", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Schema.py": { + "mtime": 1784371862.7220292, + "ast_hash": "078bc37f1b87e46c0eba18de11e66059", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_Service.py": { + "mtime": 1784371862.7220292, + "ast_hash": "aff25d583a061a427be88b25bbde0a70", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts": { + "mtime": 1784371862.723084, + "ast_hash": "28e87065fe59f2917e7c4a165a1cb171", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts": { + "mtime": 1784371862.723084, + "ast_hash": "5dd35a1dbf882ec182f5497066be252c", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts": { + "mtime": 1784371862.7244713, + "ast_hash": "813afafa03a15a28d54bc2575efc32e4", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts": { + "mtime": 1784371862.7274556, + "ast_hash": "b81950f01aa877da8ff16bacb3696fc2", + "semantic_hash": "" + }, + "B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts": { + "mtime": 1784371862.7284577, + "ast_hash": "2b48d1b90147f675ec62ef7bd7dd5c6b", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts": { + "mtime": 1784371862.7289634, + "ast_hash": "cc6349770367b8ec4b9746aa875e2eea", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_Debug.py": { + "mtime": 1784371862.7299652, + "ast_hash": "07860ac23101ba802da621025bc374d5", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_Engine.py": { + "mtime": 1784371862.7299652, + "ast_hash": "4bdea485263a52587b62a4b190493214", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_Engine_Geometry.py": { + "mtime": 1783252313.986228, + "ast_hash": "37cd229216a5c5b9ea36d3413cbb04b1", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_Engine_RidgeValley.py": { + "mtime": 1783252726.219655, + "ast_hash": "3033d914817006ff557bb3a30e5129b3", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py": { + "mtime": 1784371862.731433, + "ast_hash": "2c54dd0920045a39956fe9574d27c8e9", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_Engine_Solver.py": { + "mtime": 1783252313.986228, + "ast_hash": "1a2b0e76bfb330dacfebabb72f1b5be6", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_Repository.py": { + "mtime": 1784375574.3473394, + "ast_hash": "f810f9ad3d45bab36443db8abb7218da", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_Router.py": { + "mtime": 1784371862.733941, + "ast_hash": "8480126cddd2ba075e56f9852c3060ab", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_Schema.py": { + "mtime": 1784371862.73506, + "ast_hash": "5f580db0bf758faab4fb7762fdd638e7", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_UI_Markers.ts": { + "mtime": 1784371862.73506, + "ast_hash": "6140bff561c64430d9b1d38b7be64d8c", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_UI_Page.ts": { + "mtime": 1784371862.7363892, + "ast_hash": "1f35be9f56d933f7f8cd7086464743ec", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_UI_Panel.ts": { + "mtime": 1784371862.7373946, + "ast_hash": "fa18f43da3ebef03f8b3236f0b579138", + "semantic_hash": "" + }, + "B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts": { + "mtime": 1784371862.7390225, + "ast_hash": "3ac599812e510168e1a493d27b1ec51a", + "semantic_hash": "" + }, + "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts": { + "mtime": 1784375574.3483717, + "ast_hash": "c963ea01b4e47fda7288da2466413faa", + "semantic_hash": "" + }, + "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py": { + "mtime": 1784375574.3494227, + "ast_hash": "c513b71a9cb135047ec58594d3d88067", + "semantic_hash": "" + }, + "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Sampler.py": { + "mtime": 1783252847.3035698, + "ast_hash": "09be25eece0a09f971bf66dcf9d83d8f", + "semantic_hash": "" + }, + "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Section.py": { + "mtime": 1783252893.2828002, + "ast_hash": "4b8b50006278faec701b3df20bfa9a85", + "semantic_hash": "" + }, + "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py": { + "mtime": 1784375574.350428, + "ast_hash": "f6ecffc9f54db3e1ea7fa682551c134b", + "semantic_hash": "" + }, + "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py": { + "mtime": 1784375574.3514285, + "ast_hash": "7d31dd3ca17998ee0d05d53fd16c8a7b", + "semantic_hash": "" + }, + "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py": { + "mtime": 1784375574.3534284, + "ast_hash": "be78faf37c307783ffc32cdaa3332a46", + "semantic_hash": "" + }, + "B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts": { + "mtime": 1784375574.3549345, + "ast_hash": "c6902c6082e97f2e794223b03f2dfba6", + "semantic_hash": "" + }, + "B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts": { + "mtime": 1784371862.7403774, + "ast_hash": "a66fe85891a43fc0ac1c9ae2fadea04e", + "semantic_hash": "" + }, + "B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts": { + "mtime": 1784371862.7418675, + "ast_hash": "33b2edca2fb2752de09d4cf42a6e1f48", + "semantic_hash": "" + }, + "B09_wf6_Estimation/B09_wf6_Estimation_UI_Page.ts": { + "mtime": 1784371862.7418675, + "ast_hash": "76f0a0873e539d3d2d5fb27c191888ed", + "semantic_hash": "" + }, + "B10_Payment/B10_Payment_UI_Page.ts": { + "mtime": 1784371862.7429183, + "ast_hash": "6466645325404f4703a88a93417d8dba", + "semantic_hash": "" + }, + "B11_Status/B11_Status_UI_Page.ts": { + "mtime": 1784371862.743941, + "ast_hash": "957b7ee41136212b91b60ed2d1fab72e", + "semantic_hash": "" + }, + "common_util/common_util_atomic.py": { + "mtime": 1783250994.605519, + "ast_hash": "8d9aa0a5c2d6ca864bd341267c6b0dfc", + "semantic_hash": "" + }, + "common_util/common_util_auth.py": { + "mtime": 1784371862.74606, + "ast_hash": "c4581646ca72b409715d1703627c4526", + "semantic_hash": "" + }, + "common_util/common_util_auth_repository.py": { + "mtime": 1784371862.7475655, + "ast_hash": "2898ea4436f14bbb938d28b2f14c5293", + "semantic_hash": "" + }, + "common_util/common_util_email.py": { + "mtime": 1783415605.0, + "ast_hash": "6c238daca6bf03c964a6cf2c02a1c9e4", + "semantic_hash": "" + }, + "common_util/common_util_email_templates.py": { + "mtime": 1783419082.0, + "ast_hash": "1aa788a52626e156e1619d4e3f0f93a9", + "semantic_hash": "" + }, + "common_util/common_util_json.py": { + "mtime": 1783244734.2867022, + "ast_hash": "91c303ff3382a3f0cca4596c872936a3", + "semantic_hash": "" + }, + "common_util/common_util_resource_monitor.py": { + "mtime": 1783504247.0, + "ast_hash": "e4689eb9141750dbcf22943a6ce7ea19", + "semantic_hash": "" + }, + "common_util/common_util_storage.py": { + "mtime": 1783673573.0, + "ast_hash": "c03810b60e5d5a47036838346346ce15", + "semantic_hash": "" + }, + "common_util/common_util_surface_confirmation.py": { + "mtime": 1784371862.7481408, + "ast_hash": "1e7d68a2270e9a6833bf17156c250ca1", + "semantic_hash": "" + }, + "common_util/common_util_validate.ts": { + "mtime": 1783237546.0309367, + "ast_hash": "6ad1316290c0476f952e0f81a81e03bd", + "semantic_hash": "" + }, + "common_util/common_util_workflow.py": { + "mtime": 1783244812.2994404, + "ast_hash": "be9ffbd894a166cd3f8763ae85dbe81f", + "semantic_hash": "" + }, + "common_util/common_util_workflow_state.py": { + "mtime": 1783677093.0, + "ast_hash": "4e05dc928abc17da0c2a12485ab11029", + "semantic_hash": "" + }, + "config/config_db.py": { + "mtime": 1783423949.0, + "ast_hash": "ce9915156c7b713b84eace38b68e099c", + "semantic_hash": "" + }, + "config/config_frontend.ts": { + "mtime": 1783673644.0, + "ast_hash": "e5b28cbb92bf2fbcebb36f6a51c91916", + "semantic_hash": "" + }, + "config/config_system.py": { + "mtime": 1784371862.7494447, + "ast_hash": "5aa2b09d55040942e73976a11ee8249a", + "semantic_hash": "" + }, + "db_management/001_create_schema.sql": { + "mtime": 1784371862.7504451, + "ast_hash": "b1d4c4c2e4bbfc412d51f3c84bfa4e36", + "semantic_hash": "" + }, + "db_management/002_auth_security.sql": { + "mtime": 1783418873.0, + "ast_hash": "741d633ee414e9722e5976f2e828a8fb", + "semantic_hash": "" + }, + "db_management/003_register_split.sql": { + "mtime": 1783502148.0, + "ast_hash": "74b3a17442f28d77687f581de27b1242", + "semantic_hash": "" + }, + "db_management/004_dashboard.sql": { + "mtime": 1784371862.751445, + "ast_hash": "386bb088c7ca7d678048d2a051ae69a8", + "semantic_hash": "" + }, + "db_management/005_b03_chunk_upload.sql": { + "mtime": 1783596524.266818, + "ast_hash": "57f83d7b98af96990d1bc69876897fbb", + "semantic_hash": "" + }, + "db_management/006_workflow_state.sql": { + "mtime": 1783676881.0, + "ast_hash": "5ecbe04b699371a577f853b1363b5128", + "semantic_hash": "" + }, + "db_management/007_trusted_device_token.sql": { + "mtime": 1784371862.751445, + "ast_hash": "cb6fca6d4e3b3183128b7282c22b1ec6", + "semantic_hash": "" + }, + "db_management/008_drop_project_automations.sql": { + "mtime": 1784371862.7525203, + "ast_hash": "59745e31a2a2801ebef77e0b03cdc59d", + "semantic_hash": "" + }, + "db_management/009_route_points_xyz.sql": { + "mtime": 1784371862.7525203, + "ast_hash": "9c73619108b4fcbd4b63721dbb8cb960", + "semantic_hash": "" + }, + "db_management/migrate_workflow_state.py": { + "mtime": 1783677101.0, + "ast_hash": "64137c7b017cc3e2c007818796e605fe", + "semantic_hash": "" + }, + "main.py": { + "mtime": 1784357162.0, + "ast_hash": "944ef36ad7b18e4180a1f49413a22cbd", + "semantic_hash": "" + }, + "migrations/001_create_upload_tables.sql": { + "mtime": 1783600548.0, + "ast_hash": "c0891f75d24a00ff3aaf666233be916c", + "semantic_hash": "" + }, + "package.json": { + "mtime": 1783417102.0, + "ast_hash": "21958685f75d3d89d497cf8da68ee15d", + "semantic_hash": "" + }, + "pyproject.toml": { + "mtime": 1783225255.0, + "ast_hash": "2cc68cbb844c44a41f3f035734750e69", + "semantic_hash": "" + }, + "scratch/test_crs_verification.py": { + "mtime": 1784371862.7568798, + "ast_hash": "8d266aebaa74e9a2bcef1eefe6e97184", + "semantic_hash": "" + }, + "scratch/wiki_linter.py": { + "mtime": 1784371862.7568798, + "ast_hash": "ef1b873d05b130e30dd461cced7d62c1", + "semantic_hash": "" + }, + "tsconfig.json": { + "mtime": 1783417261.0, + "ast_hash": "36f7ffbea3824d3bd1a9f8900244e806", + "semantic_hash": "" + }, + "ui_template/ui_template_elements.ts": { + "mtime": 1784371862.7589295, + "ast_hash": "584820cdbcdb5c360def7de1fcfaebf4", + "semantic_hash": "" + }, + "ui_template/ui_template_general_blocks.ts": { + "mtime": 1784371862.7594473, + "ast_hash": "8d5c82ae21fbea2da5d49783572f9c49", + "semantic_hash": "" + }, + "ui_template/ui_template_general_layout.ts": { + "mtime": 1784371862.7604806, + "ast_hash": "a491d4081e5d73c8ed9767324712ec65", + "semantic_hash": "" + }, + "ui_template/ui_template_locale.ts": { + "mtime": 1784375574.3572495, + "ast_hash": "e1934d1e709bc56259272166b9e2aca5", + "semantic_hash": "" + }, + "ui_template/ui_template_overlay.ts": { + "mtime": 1784371862.7630575, + "ast_hash": "76116f4d99e8beaa18f7cb8a433a5d2f", + "semantic_hash": "" + }, + "ui_template/ui_template_workflow_layout.ts": { + "mtime": 1784371862.7646809, + "ast_hash": "3bddec3560e443db6c99a852cbd43c7a", + "semantic_hash": "" + }, + "vite.config.ts": { + "mtime": 1783419145.0, + "ast_hash": "ca2c8f6b3896b7d4636fcf147005c153", + "semantic_hash": "" + }, + "A00_Common/index.html": { + "mtime": 1783335150.0, + "ast_hash": "370b5160117bf7b813b7f9439e5f9ac1", + "semantic_hash": "" + }, + "README.md": { + "mtime": 1784108913.0, + "ast_hash": "4619384b0b9fc2b1fecbc29ee925988d", + "semantic_hash": "" + }, + "requirements.txt": { + "mtime": 1784357162.0, + "ast_hash": "f75f25000ea734fc5e2dfa72cc019735", + "semantic_hash": "" + }, + "resources/legal/marketing_consent.txt": { + "mtime": 1783421488.0, + "ast_hash": "0b3c46af3ff5007be1bd0c2f643dc132", + "semantic_hash": "" + }, + "resources/legal/privacy_policy.txt": { + "mtime": 1783421482.0, + "ast_hash": "1e56e478d669bcec2f52e4e5ffba1465", + "semantic_hash": "" + }, + "resources/legal/terms_of_service.txt": { + "mtime": 1783421474.0, + "ast_hash": "683dafcbabe621641da485afab311c69", + "semantic_hash": "" + }, + "resources/prog_icon.jpg": { + "mtime": 1783331655.0, + "ast_hash": "18a3fd959eb07f8b0a18a86126c7bb6d", + "semantic_hash": "" + }, + "docs/wiki/graphify-out/memory/query_20260718_022949_compare_current_b04_wf1_surface_with_0_old_backend.md": { + "mtime": 1784341789.0, + "ast_hash": "0722820bbdb99abee95661d7a102fe7f", + "semantic_hash": "" + }, + "docs/wiki/graphify-out/memory/query_20260718_044714_\uc81c\uc2dc\ub41c_numpy_2_x_gis_\ud328\ud0a4\uc9c0_\ud540\uc744_\ud604\uc7ac_venv\uc5d0_\uc124\uce58\ud560_\ub54c_\ubb38\uc81c\uac00_\uc788\ub294\uc9c0_\ubd84\uc11d.md": { + "mtime": 1784350034.0, + "ast_hash": "d0623cc53601b82bf8993b6828bac357", + "semantic_hash": "" + }, + "docs/wiki/graphify-out/memory/query_20260718_053304_\ud2b8\ub77c\uc774\uba54\uc2dc_4_12_2\uc640_pyogrio_0_13_0\uc744_\ubc18\uc601\ud558\uace0_setuptools_whit.md": { + "mtime": 1784352784.0, + "ast_hash": "f6d427d6b59463bad1424aed217b987c", + "semantic_hash": "" } } \ No newline at end of file diff --git a/main.py b/main.py index 37e03222..6f024925 100644 --- a/main.py +++ b/main.py @@ -59,6 +59,16 @@ logger = logging.getLogger(__name__) # ───────────────────────────────────────────────────────────────────────── +def _frontend_process_env(root_dir: Path) -> dict[str, str]: + """config/node_modules를 사용하는 프론트엔드 프로세스 환경을 만든다.""" + env = os.environ.copy() + node_modules = root_dir / "config" / "node_modules" + bin_dir = node_modules / ".bin" + env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" + env["NODE_PATH"] = str(node_modules) + return env + + def build_frontend() -> bool: """프론트엔드 빌드 (npm run build from project root)""" root_dir = Path(__file__).resolve().parent @@ -69,6 +79,7 @@ def build_frontend() -> bool: "npm run build", shell=True, cwd=str(root_dir), + env=_frontend_process_env(root_dir), capture_output=True, text=True, encoding="utf-8", @@ -103,6 +114,7 @@ def serve_frontend_dev() -> None: "npm run dev", shell=True, cwd=str(root_dir), + env=_frontend_process_env(root_dir), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) @@ -203,11 +215,12 @@ app.add_middleware( # ───────────────────────────────────────────────────────────────────────── # 정적 파일 서빙 (프론트엔드) # ───────────────────────────────────────────────────────────────────────── -if os.path.isdir(STATIC_DIR): - app.mount(STATIC_URL, StaticFiles(directory=STATIC_DIR), name="static") - logger.info(f"✓ 정적 파일 서빙: {STATIC_URL} → {STATIC_DIR}") -else: - logger.warning(f"⚠ 정적 파일 디렉토리 없음: {STATIC_DIR}") +app.mount( + STATIC_URL, + StaticFiles(directory=STATIC_DIR, check_dir=False), + name="static", +) +logger.info(f"✓ 정적 파일 서빙 경로 등록: {STATIC_URL} → {STATIC_DIR}") # ───────────────────────────────────────────────────────────────────────── # 기본 엔드포인트 diff --git a/package.json b/package.json index 78ed25e4..7d17e182 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,11 @@ "description": "임도 설계 및 견적 자동화 프로그램 (프론트엔드)", "type": "module", "scripts": { - "dev": "vite", - "build": "tsc --noEmit && vite build", - "preview": "vite preview", - "typecheck": "tsc --noEmit", - "format": "prettier --write \"../**/*.{ts,css,html}\"" + "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", + "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner", + "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", + "typecheck": "node ./config/node_modules/typescript/bin/tsc --noEmit", + "format": "node ./config/node_modules/prettier/bin/prettier.cjs --write \"../**/*.{ts,css,html}\"" }, "dependencies": { "maplibre-gl": "^5.24.0", diff --git a/scratch/wiki_linter.py b/scratch/wiki_linter.py index 70449366..aee6382d 100644 --- a/scratch/wiki_linter.py +++ b/scratch/wiki_linter.py @@ -1,157 +1,120 @@ import os import re -from pathlib import Path -# 위키 루트 경로 설정 -wiki_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\wiki") -raw_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\raw") +WIKI_DIR = r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발\docs\wiki" +PAGES_DIR = os.path.join(WIKI_DIR, "pages") +CONCEPTS_DIR = os.path.join(WIKI_DIR, "concepts") +INDEX_PATH = os.path.join(WIKI_DIR, "index.md") -def load_frontmatter(file_path): - content = file_path.read_text(encoding="utf-8") - # Frontmatter regex - match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL) - if match: - fm_text = match.group(1) - fm = {} - # Simple YAML key-value parser for basic string/list properties - for line in fm_text.splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - if ":" in line: - key, val = line.split(":", 1) - key = key.strip() - val = val.strip().strip("'\"") - fm[key] = val - return fm, content[match.end():] - return None, content +errors = [] +warnings = [] +all_wiki_files = {} # relative_path -> absolute_path -def extract_wiki_links(text): - # Matches [[link]] or [[link|alias]] or [[link#section]] or [[link#section|alias]] - links = re.findall(r"\[\[(.*?)\]\]", text) - cleaned_links = [] - for l in links: - # Split alias - if "|" in l: - l = l.split("|")[0] - # Split section - if "#" in l: - l = l.split("#")[0] - l = l.strip() - if l: - cleaned_links.append(l) - return cleaned_links +# Collect all markdown files in wiki/ (ignoring graphify-out and memory directories) +for root, dirs, files in os.walk(WIKI_DIR): + # skip graphify-out or memory + if "graphify-out" in root or "memory" in root: + continue + for file in files: + if file.endswith(".md"): + abs_path = os.path.join(root, file) + rel_path = os.path.relpath(abs_path, WIKI_DIR).replace("\\", "/") + all_wiki_files[rel_path] = abs_path + simple_name = os.path.splitext(rel_path)[0] + all_wiki_files[simple_name] = abs_path -def run_lint(): - print("=== Start Wiki Linting (No PyYAML, Filtered) ===") - - all_files = list(wiki_root.glob("**/*.md")) - index_file = wiki_root / "index.md" - log_file = wiki_root / "log.md" - - # 1. 파일 목록화 및 Frontmatter 체크 - pages = {} - concepts = {} - errors = [] - warnings = [] - - for f in all_files: - rel_path = f.relative_to(wiki_root).as_posix() - # Skip output files generated by graphify in graphify-out - if rel_path.startswith("graphify-out/") or rel_path in ["index.md", "log.md", "AGENTS.md", "CLAUDE.md"]: - continue - - fm, body = load_frontmatter(f) +# Read index.md content +with open(INDEX_PATH, "r", encoding="utf-8") as f: + index_content = f.read() + +# Pattern for wikilinks: [[link]] or [[link|label]] +wikilink_pat = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]") + +for rel_path, abs_path in list(all_wiki_files.items()): + if "/" not in rel_path and rel_path != "index" and rel_path != "log": + continue + if rel_path in ["index", "log", "index.md", "log.md"]: + continue - # 기본 규칙 검사 - if not fm: - errors.append(f"Missing Frontmatter: {rel_path}") - continue - - # status 값 검사 - status = fm.get("status") + with open(abs_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + line_count = len(lines) + content = "".join(lines) + + # Rule 14: Max 100 lines + if line_count > 100: + warnings.append(f"[Line Count] `{rel_path}.md` exceeds 100 lines ({line_count} lines).") + + # Check YAML Frontmatter via simple regex + frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL) + if not frontmatter_match: + errors.append(f"[Frontmatter] `{rel_path}.md` has no valid YAML frontmatter.") + continue + + fm_text = frontmatter_match.group(1) + + # Simple regex parsing for status & page_id + status_match = re.search(r"^status:\s*(\w+)", fm_text, re.MULTILINE) + page_id_match = re.search(r"^page_id:\s*([^\n\r]+)", fm_text, re.MULTILINE) + + if not status_match: + errors.append(f"[Status] `{rel_path}.md` has no status field in frontmatter.") + else: + status = status_match.group(1).strip() if status not in ["draft", "stable", "stale"]: - errors.append(f"Invalid status '{status}' in {rel_path}. Must be draft, stable, or stale.") + errors.append(f"[Status] `{rel_path}.md` has invalid status '{status}'. Must be draft, stable, or stale.") + elif status == "stale": + warnings.append(f"[Stale Page] `{rel_path}.md` is marked as stale and needs updates from raw inputs.") - # 100줄 제한 검사 (10% 즉 110줄 허용) - lines_count = len(f.read_text(encoding="utf-8").splitlines()) - if lines_count > 110: - warnings.append(f"File exceeds 110 lines ({lines_count} lines): {rel_path}") + # Rule 5: page_id required for pages/ + if "pages/" in rel_path: + if not page_id_match: + errors.append(f"[page_id] `{rel_path}.md` is in pages/ but lacks a 'page_id' field.") - # 페이지와 컨셉 분류 - if "pages/" in rel_path: - pages[rel_path] = { - "fm": fm, - "body": body, - "path": f, - "links": extract_wiki_links(body) - } - # 파일명 형식 규칙 5 검사: {page_id}_{기능명}.md - filename = f.name - page_id = fm.get("page_id") - if not page_id: - errors.append(f"Missing page_id in page: {rel_path}") - else: - expected_prefix = page_id.split("_")[0] # e.g. A01 - if not filename.startswith(expected_prefix): - warnings.append(f"Filename does not match page_id format: {rel_path} (page_id: {page_id})") - else: - concepts[rel_path] = { - "fm": fm, - "body": body, - "path": f, - "links": extract_wiki_links(body) - } - - # 2. 위키 링크 정합성 검증 (Broken Links) - # 개념/페이지 맵 구성 - available_links = {} - for p_rel in pages: - name_no_ext = Path(p_rel).with_suffix("").as_posix() - available_links[name_no_ext] = p_rel - # Also map short form if distinct - short_name = Path(p_rel).name[:-3] - available_links[short_name] = p_rel + # Check for broken wikilinks in content + links = wikilink_pat.findall(content) + for link in links: + link_clean = link.strip().replace("\\", "/").split("#")[0] # ignore anchor for file existence check + if not link_clean: + continue + found = False - for c_rel in concepts: - name_no_ext = Path(c_rel).with_suffix("").as_posix() - available_links[name_no_ext] = c_rel - # Short form - short_name = Path(c_rel).name[:-3] - available_links[short_name] = c_rel - # Subdirectories for concepts like db_schema/* - if "concepts/" in name_no_ext: - available_links[name_no_ext.replace("concepts/", "")] = c_rel + # 1. Absolute link from vault root (e.g. concepts/storage_paths) + if link_clean in all_wiki_files: + found = True + # 2. Simple name match (e.g. storage_paths, B01_frontend) + elif link_clean.split("/")[-1] in all_wiki_files: + found = True + # 3. Handle subpages like [[B01_Dashboard/B01_frontend]] + elif f"pages/{link_clean}" in all_wiki_files: + found = True + elif f"concepts/{link_clean}" in all_wiki_files: + found = True + # 4. Handle nested directory patterns + elif link_clean.startswith("../"): + # Resolve relative link + curr_dir = os.path.dirname(rel_path) + resolved = os.path.normpath(os.path.join(curr_dir, link_clean)).replace("\\", "/") + if resolved in all_wiki_files or resolved.replace("pages/", "") in all_wiki_files: + found = True + + if not found: + errors.append(f"[Broken Link] `{rel_path}.md` contains broken wikilink: [[{link}]]") - # 링크 검사 - for rel_path, info in {**pages, **concepts}.items(): - for link in info["links"]: - if link.startswith("http://") or link.startswith("https://") or link.startswith("file:///"): - continue - normalized_link = link.replace("\\", "/") - if normalized_link not in available_links and f"concepts/{normalized_link}" not in available_links and f"pages/{normalized_link}" not in available_links: - warnings.append(f"Broken Link in {rel_path}: [[{link}]]") + # Check if this file is registered in index.md (avoid orphans) + file_base = os.path.basename(abs_path) + file_name_no_ext = os.path.splitext(file_base)[0] + if file_name_no_ext not in index_content: + # check if relative path is in index + rel_path_no_ext = rel_path.replace(".md", "") + if rel_path_no_ext not in index_content and rel_path_no_ext.split("/")[-1] not in index_content: + warnings.append(f"[Orphan Page] `{rel_path}.md` is not linked or mentioned in index.md.") - # 3. index.md 등록 상태 확인 - index_content = index_file.read_text(encoding="utf-8") if index_file.exists() else "" - for rel_path in pages: - short_name = Path(rel_path).name[:-3] - dir_name = Path(rel_path).parent.name - expected_ref_dir = f"{dir_name}/{short_name}" - if expected_ref_dir not in index_content and f"[[{short_name}]]" not in index_content and short_name not in index_content: - warnings.append(f"Page not indexed in index.md: {rel_path} (Expected link to [[{expected_ref_dir}]] or [[{short_name}]])") - - # 4. 결과 출력 - print(f"\nScanning completed: {len(all_files)} total markdown files.") - print(f"Detected {len(pages)} pages and {len(concepts)} concept documents.") - - print(f"\n--- Errors ({len(errors)}) ---") - for e in errors: - print(f"[ERROR] {e}") - - print(f"\n--- Warnings ({len(warnings)}) ---") - for w in warnings: - print(f"[WARN] {w}") - -if __name__ == "__main__": - run_lint() +print("=== LINT ERRORS ===") +for e in sorted(list(set(errors))): + print(e) +print("\n=== LINT WARNINGS ===") +for w in sorted(list(set(warnings))): + print(w) +print("\nLint completed.") diff --git a/tsconfig.json b/tsconfig.json index ae7d3c7e..44c4d72f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,8 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "types": ["node"], + "types": ["node", "vite/client"], + "typeRoots": ["./config/node_modules/@types", "./config/node_modules"], "ignoreDeprecations": "6.0", "baseUrl": ".", @@ -25,17 +26,12 @@ "paths": { "@ui/*": ["ui_template/*"], "@config/*": ["config/*"], - "@util/*": ["common_util/*"] + "@util/*": ["common_util/*"], + "three": ["config/node_modules/@types/three"], + "three/*": ["config/node_modules/@types/three/*"], + "maplibre-gl": ["config/node_modules/maplibre-gl"] } }, - "include": [ - "A00_Common", - "A0*", - "B0*", - "B1*", - "ui_template", - "config/**/*", - "common_util" - ], + "include": ["A00_Common", "A0*", "B0*", "B1*", "ui_template", "config/**/*", "common_util"], "exclude": ["0_old", "node_modules", "venv", "dist"] } diff --git a/vite.config.ts b/vite.config.ts index 35d60a50..5d04666f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,3 @@ -import { defineConfig } from "vite"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,14 +10,17 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url)); * - 페이지 폴더(A01_Home 등)는 ../를 통해 접근 * - 0_old(구형 코드), venv(파이썬)는 빌드에서 제외 */ -export default defineConfig({ +export default { root: "A00_Common", publicDir: false, + cacheDir: "config/node_modules/.vite", resolve: { alias: { "@ui": resolve(__dirname, "./ui_template"), "@config": resolve(__dirname, "./config"), "@util": resolve(__dirname, "./common_util"), + three: resolve(__dirname, "./config/node_modules/three"), + "maplibre-gl": resolve(__dirname, "./config/node_modules/maplibre-gl"), }, }, server: { @@ -33,11 +35,11 @@ export default defineConfig({ }, }, build: { - outDir: "../node_modules/.build", + outDir: "../config/node_modules/.build", emptyOutDir: true, target: "es2022", }, optimizeDeps: { exclude: ["0_old"], }, -}); +}; From 05890780543f4a6312e4101be4bb9d99b77f491a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 22:04:20 +0900 Subject: [PATCH 2/6] 260718_7 --- .../B06_wf3_ProfileCross_Api_Fetch.ts | 27 +++ .../B06_wf3_ProfileCross_Repository.py | 45 +++- .../B06_wf3_ProfileCross_Router.py | 46 ++++- .../B06_wf3_ProfileCross_Schema.py | 23 +++ .../B06_wf3_ProfileCross_UI_Page.ts | 192 ++++++++++++------ .../B06_wf3_ProfileCross_UI_Style.css | 15 +- ui_template/ui_template_locale.ts | 12 +- 7 files changed, 285 insertions(+), 75 deletions(-) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index a4a16473..83588539 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -4,6 +4,7 @@ * * 백엔드 계약 (B06_wf3_ProfileCross_Router.py): * POST /api/projects/{project_id}/sections/generate → 종횡단 생성 + DB 기록 + * GET /api/projects/{project_id}/sections/context → 확정 경로 + 기본 옵션 * GET /api/projects/{project_id}/sections/{route_id} → 종단 요약 조회 * POST /api/projects/{project_id}/sections/{route_id}/confirm → 종횡단 확정 * @@ -38,12 +39,31 @@ export interface SectionGenerateResponse { longitudinal_file_path: string; } +export interface SectionOptionDefaults { + station_interval_m: number; + cross_half_width_m: number; + cross_sample_interval_m: number; + long_sample_interval_m: number; +} + +export interface SectionContextResponse { + project_id: string; + route_id: number | null; + filter_key: string | null; + method: string | null; + smooth: boolean | null; + crs_epsg: number | null; + defaults: SectionOptionDefaults; +} + /** 종단 요약 조회 결과 (SectionSummaryResponse) */ export interface SectionSummaryResponse { status: string; project_id: string; route_id: number; longitudinal: Record | null; + length_m: number | null; + cross_section_count: number; } /** 종횡단 확정 결과 (SectionConfirmResponse) */ @@ -89,6 +109,13 @@ export async function generateSections( }); } +/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */ +export async function fetchSectionContext(projectId: string): Promise { + return requestJson(`/projects/${projectId}/sections/context`, { + method: "GET", + }); +} + /** 경로의 종단면 요약을 조회한다. */ export async function getSections( projectId: string, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index afa9c890..c72e0ed6 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -24,6 +24,31 @@ def _validate_stage_path(relative_path: str) -> str: return normalized.as_posix() +async def get_confirmed_route_context( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT r.id AS route_id, sm.crs_epsg + FROM routes r + LEFT JOIN surface_models sm ON sm.id = r.surface_model_id + WHERE r.project_id = %s AND r.status = 'CONFIRMED' + ORDER BY r.computed_at DESC, r.id DESC + LIMIT 1 + """, + (str(project_id),), + ) + row = await cursor.fetchone() + if not row: + return None + return { + "route_id": int(row["route_id"]), + "crs_epsg": int(row["crs_epsg"]) if row["crs_epsg"] is not None else None, + } + + async def delete_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None: """경로 재생성 전에 기존 종횡단 레코드를 삭제한다 (멱등 재실행).""" async with connection.cursor() as cursor: @@ -113,7 +138,7 @@ async def get_longitudinal_section( async with connection.cursor() as cursor: await cursor.execute( """ - SELECT id, longitudinal_file_path, status, computed_at + SELECT id, data, longitudinal_file_path, status, computed_at FROM longitudinal_sections WHERE project_id = %s AND route_id = %s ORDER BY id DESC @@ -124,14 +149,26 @@ async def get_longitudinal_section( row = await cursor.fetchone() if not row: return None + data = row[1] + if isinstance(data, str): + data = json.loads(data) return { "id": int(row[0]), - "longitudinal_file_path": row[1], - "status": row[2], - "computed_at": row[3].isoformat() if row[3] else None, + "data": data if isinstance(data, dict) else None, + "longitudinal_file_path": row[2], + "status": row[3], + "computed_at": row[4].isoformat() if row[4] else None, } +async def count_cross_sections(connection: aiomysql.Connection, route_id: int) -> int: + """경로에 저장된 횡단면 개수를 반환한다.""" + async with connection.cursor() as cursor: + await cursor.execute("SELECT COUNT(*) FROM cross_sections WHERE route_id = %s", (route_id,)) + row = await cursor.fetchone() + return int(row[0]) if row else 0 + + async def confirm_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None: """경로의 종횡단면 상태를 CONFIRMED로 변경한다.""" async with connection.cursor() as cursor: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 4f07375b..81bbb387 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -14,18 +14,23 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine import run_section_generat from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import SectionGenerationOptions from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( confirm_sections_for_route, + count_cross_sections, create_longitudinal_section, delete_sections_for_route, + get_confirmed_route_context, get_longitudinal_section, insert_cross_sections, ) from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( SectionConfirmResponse, + SectionContextResponse, SectionGenerateRequest, SectionGenerateResponse, + SectionOptionDefaults, SectionSummaryResponse, ) from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage from config.config_db import get_db_pool @@ -148,6 +153,38 @@ async def generate_sections( ) +@router.get("/{project_id}/sections/context", response_model=SectionContextResponse) +async def get_section_context(project_id: UUID) -> SectionContextResponse | JSONResponse: + """최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + route_context = await get_confirmed_route_context(connection, project_id) + surface_params = await get_surface_confirmation_params(connection, str(project_id)) + + defaults = SectionGenerationOptions() + return SectionContextResponse( + project_id=str(project_id), + route_id=route_context["route_id"] if route_context else None, + filter_key=surface_params["source_filter"] if route_context else None, + method=surface_params["method"] if route_context else None, + smooth=bool(surface_params["smooth"]) if route_context else None, + crs_epsg=route_context["crs_epsg"] if route_context else None, + defaults=SectionOptionDefaults( + station_interval_m=defaults.station_interval_m, + cross_half_width_m=defaults.cross_half_width_m, + cross_sample_interval_m=defaults.cross_sample_interval_m, + long_sample_interval_m=defaults.long_sample_interval_m, + ), + ) + except Exception: + logger.exception("B06 종횡단 컨텍스트 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡단 컨텍스트 조회 중 오류가 발생했습니다."}, + ) + + @router.get("/{project_id}/sections/{route_id}", response_model=SectionSummaryResponse) async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryResponse | JSONResponse: """경로의 종단면 요약을 조회한다.""" @@ -155,8 +192,15 @@ async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryRespons try: async with pool.acquire() as connection: longitudinal = await get_longitudinal_section(connection, project_id, route_id) + cross_section_count = await count_cross_sections(connection, route_id) + data = longitudinal.get("data") if longitudinal else None + length_m = data.get("length_m") if isinstance(data, dict) else None return SectionSummaryResponse( - project_id=str(project_id), route_id=route_id, longitudinal=longitudinal + project_id=str(project_id), + route_id=route_id, + longitudinal=longitudinal, + length_m=length_m, + cross_section_count=cross_section_count, ) except Exception: logger.exception("B06 종횡단 조회 실패: project_id=%s", project_id) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 85e088e9..425c42ea 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -44,6 +44,27 @@ class SectionConfirmResponse(BaseModel): confirmed: bool = True +class SectionOptionDefaults(BaseModel): + """config에서 읽은 종횡단 생성 기본 옵션.""" + + station_interval_m: float + cross_half_width_m: float + cross_sample_interval_m: float + long_sample_interval_m: float + + +class SectionContextResponse(BaseModel): + """B06 진입 시 필요한 확정 경로 컨텍스트와 기본 옵션.""" + + project_id: str + route_id: int | None = None + filter_key: str | None = None + method: str | None = None + smooth: bool | None = None + crs_epsg: int | None = None + defaults: SectionOptionDefaults + + class SectionSummaryResponse(BaseModel): """종횡단 요약 조회 결과.""" @@ -51,3 +72,5 @@ class SectionSummaryResponse(BaseModel): project_id: str route_id: int longitudinal: dict[str, Any] | None = None + length_m: float | None = None + cross_section_count: int = 0 diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index e4a54374..3202b0ae 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -30,8 +30,13 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmSections, + fetchSectionContext, generateSections, + getSections, + type SectionContextResponse, + type SectionGenerateRequest, type SectionGenerateResponse, + type SectionSummaryResponse, } from "./B06_wf3_ProfileCross_Api_Fetch"; import "./B06_wf3_ProfileCross_UI_Style.css"; @@ -51,6 +56,18 @@ function buildGroup(legend: string): HTMLElement { return group; } +/** 읽기 전용 경로 컨텍스트 표시 행. */ +function buildInfoLine(label: string): { root: HTMLElement; value: HTMLElement } { + const root = document.createElement("div"); + root.className = "b06-profile__info-line"; + const key = document.createElement("span"); + key.textContent = label; + const value = document.createElement("strong"); + value.textContent = "-"; + root.append(key, value); + return { root, value }; +} + /** 숫자 입력값을 파싱. 빈 값이면 null. */ function parseNumber(value: string): number | null { const trimmed = value.trim(); @@ -61,26 +78,22 @@ function parseNumber(value: string): number | null { export async function renderB06ProfileCross(root: HTMLElement): Promise { let currentRouteId: number | null = null; + let sectionContext: SectionContextResponse | null = null; /* ---- 좌측: 대상 경로 ---- */ const routeGroup = buildGroup(L("B06_Profile_Group_Route")); - const routeIdField = createInputField({ - label: L("B06_Profile_Field_RouteId"), - type: "number", - min: 1, - }); - const filterField = createInputField({ label: L("B06_Profile_Field_Filter"), type: "text" }); - const methodField = createInputField({ - label: L("B06_Profile_Field_Method"), - type: "text", - value: "dtm", - }); - const crsField = createInputField({ - label: L("B06_Profile_Field_Crs"), - type: "text", - placeholder: "EPSG:5178", - }); - routeGroup.append(routeIdField.root, filterField.root, methodField.root, crsField.root); + const routeIdInfo = buildInfoLine(L("B06_Profile_Field_RouteId")); + const filterInfo = buildInfoLine(L("B06_Profile_Field_Filter")); + const methodInfo = buildInfoLine(L("B06_Profile_Field_Method")); + const smoothInfo = buildInfoLine(L("B06_Profile_Field_Smooth")); + const crsInfo = buildInfoLine(L("B06_Profile_Field_Crs")); + routeGroup.append( + routeIdInfo.root, + filterInfo.root, + methodInfo.root, + smoothInfo.root, + crsInfo.root, + ); /* ---- 좌측: 측점/횡단 옵션 ---- */ const optionGroup = buildGroup(L("B06_Profile_Group_Options")); @@ -101,20 +114,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { type: "number", }); - const smoothLabel = document.createElement("label"); - smoothLabel.className = "b06-profile__check"; - const smoothBox = document.createElement("input"); - smoothBox.type = "checkbox"; - const smoothText = document.createElement("span"); - smoothText.textContent = L("B06_Profile_Field_Smooth"); - smoothLabel.append(smoothBox, smoothText); - optionGroup.append( stationField.root, halfWidthField.root, crossSampleField.root, longSampleField.root, - smoothLabel, ); const generateButton = createButton({ @@ -150,6 +154,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { resultBody.append(empty); } + function renderResultMessage(message: string): void { + resultBody.replaceChildren(); + const text = document.createElement("p"); + text.className = "b06-profile__empty"; + text.textContent = message; + resultBody.append(text); + } + function metricRow(label: string, value: string): HTMLElement { const row = document.createElement("div"); row.className = "b06-profile__metric"; @@ -172,6 +184,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ); } + function renderSummary(result: SectionSummaryResponse): void { + const path = result.longitudinal?.longitudinal_file_path; + resultBody.replaceChildren(); + resultBody.append( + metricRow( + L("B06_Profile_Result_Length"), + result.length_m === null ? "-" : result.length_m.toFixed(2), + ), + metricRow(L("B06_Profile_Result_CrossCount"), String(result.cross_section_count)), + metricRow(L("B06_Profile_Result_Path"), typeof path === "string" ? path : "-"), + ); + } + const resultCard = document.createElement("div"); resultCard.className = "b06-profile__result"; resultCard.append(resultTitle, resultBody); @@ -183,43 +208,45 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return projectId; } - async function onB06_Profile_Generate_Click(): Promise { + function buildGenerateRequest(): SectionGenerateRequest | null { + if ( + sectionContext?.route_id === null || + !sectionContext?.route_id || + !sectionContext.filter_key || + !sectionContext.method + ) { + return null; + } + return { + route_id: sectionContext.route_id, + filter_key: sectionContext.filter_key, + method: sectionContext.method, + smooth: sectionContext.smooth ?? false, + crs: sectionContext.crs_epsg === null ? null : `EPSG:${sectionContext.crs_epsg}`, + station_interval_m: parseNumber(stationField.input.value), + cross_half_width_m: parseNumber(halfWidthField.input.value), + cross_sample_interval_m: parseNumber(crossSampleField.input.value), + long_sample_interval_m: parseNumber(longSampleField.input.value), + }; + } + + async function onB06_Profile_Generate_Click(): Promise { const projectId = getProjectId(); - if (!projectId) return; - - const routeId = parseNumber(routeIdField.input.value); - if (routeId === null || !Number.isInteger(routeId) || routeId <= 0) { - routeIdField.setError(L("B06_Profile_Error_RouteId")); - return; - } - routeIdField.setError(); - - const filterKey = filterField.input.value.trim(); - if (!filterKey) { - filterField.setError(L("B06_Profile_Error_Filter")); - return; - } - filterField.setError(); + if (!projectId) return false; + const request = buildGenerateRequest(); + if (!request) return false; showLoadingOverlay(); try { - const result = await generateSections(projectId, { - route_id: routeId, - filter_key: filterKey, - method: methodField.input.value.trim() || "dtm", - smooth: smoothBox.checked, - crs: crsField.input.value.trim() || null, - station_interval_m: parseNumber(stationField.input.value), - cross_half_width_m: parseNumber(halfWidthField.input.value), - cross_sample_interval_m: parseNumber(crossSampleField.input.value), - long_sample_interval_m: parseNumber(longSampleField.input.value), - }); + const result = await generateSections(projectId, request); currentRouteId = result.route_id; renderResult(result); showToast(L("B06_Profile_Generate_Success"), "success"); + return true; } catch (error) { const detail = error instanceof Error ? error.message : L("B06_Profile_Generate_Failed"); showToast(`${L("B06_Profile_Generate_Failed")} ${detail}`, "error"); + return false; } finally { hideLoadingOverlay(); } @@ -228,11 +255,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { async function onB06_Profile_Confirm_Click(): Promise { const projectId = getProjectId(); if (!projectId) return; - const routeId = currentRouteId ?? parseNumber(routeIdField.input.value); - if (routeId === null || !Number.isInteger(routeId) || routeId <= 0) { - routeIdField.setError(L("B06_Profile_Error_RouteId")); - return; - } + const routeId = currentRouteId; + if (routeId === null) return; showLoadingOverlay(); try { await confirmSections(projectId, routeId); @@ -250,11 +274,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { let workflowState: WorkflowState | undefined; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (projectId) { - try { - workflowState = await fetchWorkflowState(projectId); - } catch { - /* 조회 실패 시 stages 미전달 → 전체 이동 허용 */ - } + const [contextResult, workflowResult] = await Promise.allSettled([ + fetchSectionContext(projectId), + fetchWorkflowState(projectId), + ]); + if (contextResult.status === "fulfilled") sectionContext = contextResult.value; + if (workflowResult.status === "fulfilled") workflowState = workflowResult.value; } const layout = createWorkflowLayout({ @@ -273,4 +298,45 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }, }); root.replaceChildren(layout.root); + + if (!projectId || !sectionContext) { + generateButton.disabled = true; + confirmButton.disabled = true; + return; + } + + const context = sectionContext; + routeIdInfo.value.textContent = context.route_id === null ? "-" : String(context.route_id); + filterInfo.value.textContent = context.filter_key ?? "-"; + methodInfo.value.textContent = context.method ?? "-"; + smoothInfo.value.textContent = context.smooth === null ? "-" : context.smooth ? "ON" : "OFF"; + crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`; + stationField.input.value = String(context.defaults.station_interval_m); + halfWidthField.input.value = String(context.defaults.cross_half_width_m); + crossSampleField.input.value = String(context.defaults.cross_sample_interval_m); + longSampleField.input.value = String(context.defaults.long_sample_interval_m); + + if (context.route_id === null) { + generateButton.disabled = true; + confirmButton.disabled = true; + renderResultMessage(L("B06_Profile_No_Confirmed_Route")); + return; + } + + currentRouteId = context.route_id; + confirmButton.disabled = true; + try { + const existing = await getSections(projectId, context.route_id); + if (existing.longitudinal) { + renderSummary(existing); + confirmButton.disabled = false; + return; + } + renderResultMessage(L("B06_Profile_Auto_Generating")); + const generated = await onB06_Profile_Generate_Click(); + confirmButton.disabled = !generated; + } catch (error) { + const detail = error instanceof Error ? error.message : L("B06_Profile_Generate_Failed"); + showToast(`${L("B06_Profile_Generate_Failed")} ${detail}`, "error"); + } } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index db059be9..ec205fa6 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -32,18 +32,23 @@ color: var(--color-text-secondary); } -/* --- 체크박스 --- */ -.b06-profile__check { +/* --- 읽기 전용 경로 컨텍스트 --- */ +.b06-profile__info-line { display: flex; align-items: center; + justify-content: space-between; gap: var(--spacing-8); font-size: var(--text-body-sm); color: var(--color-text-body); - cursor: pointer; } -.b06-profile__check input { - accent-color: var(--color-primary); +.b06-profile__info-line span { + color: var(--color-text-secondary); +} + +.b06-profile__info-line strong { + font-family: var(--font-mono); + text-align: right; } /* --- 액션 버튼 행 --- */ diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index d2de37e4..b2553bb6 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -732,17 +732,25 @@ export const ui_locales = { B06_Profile_Field_RouteId: ["경로 ID (routes.id)", "Route ID"], B06_Profile_Field_Filter: ["지면 필터", "Ground filter"], B06_Profile_Field_Method: ["지표면 표현", "Surface method"], - B06_Profile_Field_Crs: ["좌표계 (선택)", "CRS (optional)"], + B06_Profile_Field_Crs: ["좌표계", "CRS"], B06_Profile_Group_Options: ["측점·횡단 옵션", "Station & Cross Options"], B06_Profile_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"], B06_Profile_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"], B06_Profile_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"], B06_Profile_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"], B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"], - B06_Profile_Btn_Generate: ["종·횡단 생성", "Generate Sections"], + B06_Profile_Btn_Generate: ["종·횡단 재생성", "Regenerate Sections"], B06_Profile_Btn_Confirm: ["종·횡단 확정", "Confirm Sections"], B06_Profile_Result_Title: ["종·횡단 생성 결과", "Section Result"], B06_Profile_Result_Empty: ["아직 생성된 종·횡단이 없습니다.", "No sections generated yet."], + B06_Profile_No_Confirmed_Route: [ + "확정된 경로가 없습니다. 먼저 경로를 확정하세요.", + "No confirmed route. Confirm a route first.", + ], + B06_Profile_Auto_Generating: [ + "기본 옵션으로 종·횡단을 자동 생성하고 있습니다.", + "Generating sections automatically with the default options.", + ], B06_Profile_Result_Length: ["종단 연장(m)", "Longitudinal length (m)"], B06_Profile_Result_CrossCount: ["횡단 개수", "Cross-section count"], B06_Profile_Result_Path: ["종단 파일", "Longitudinal file"], From 7e51cf4f14321213a73d4301af6530f05f3b8d98 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 22:51:44 +0900 Subject: [PATCH 3/6] =?UTF-8?q?B06=5F=ED=9A=A1=EB=8B=A8=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=B4=88=EC=95=88=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../B06_wf3_ProfileCross_Api_Fetch.ts | 43 ++ .../B06_wf3_ProfileCross_Repository.py | 16 +- .../B06_wf3_ProfileCross_Router.py | 79 +++ .../B06_wf3_ProfileCross_Schema.py | 8 + .../B06_wf3_ProfileCross_UI_Page.ts | 56 +- .../B06_wf3_ProfileCross_UI_Section_View.ts | 481 ++++++++++++++++++ .../B06_wf3_ProfileCross_UI_Style.css | 193 +++++++ config/config_system.py | 1 + graphify-out/manifest.json | 48 +- ui_template/ui_template_locale.ts | 34 ++ 10 files changed, 930 insertions(+), 29 deletions(-) create mode 100644 B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 83588539..dced1bc8 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -6,6 +6,7 @@ * POST /api/projects/{project_id}/sections/generate → 종횡단 생성 + DB 기록 * GET /api/projects/{project_id}/sections/context → 확정 경로 + 기본 옵션 * GET /api/projects/{project_id}/sections/{route_id} → 종단 요약 조회 + * GET /api/projects/{project_id}/sections/{route_id}/detail → 종횡단 원시 샘플 조회 * POST /api/projects/{project_id}/sections/{route_id}/confirm → 종횡단 확정 * * 규칙: @@ -44,6 +45,7 @@ export interface SectionOptionDefaults { cross_half_width_m: number; cross_sample_interval_m: number; long_sample_interval_m: number; + vertical_exaggeration: number; } export interface SectionContextResponse { @@ -66,6 +68,37 @@ export interface SectionSummaryResponse { cross_section_count: number; } +export interface SectionSample { + chainage_m?: number; + offset_m?: number; + elevation_m: number | null; + valid: boolean; +} + +export interface SectionStation { + station_id: string; + chainage_m: number; + label: string; + kind: "bp" | "ep" | "regular"; + center_z: number | null; + azimuth_deg: number | null; +} + +export interface LongitudinalSection { + length_m: number; + samples: SectionSample[]; + stations: SectionStation[]; +} + +export interface CrossSection extends SectionStation { + samples: SectionSample[]; +} + +export interface SectionDetailResponse { + longitudinal: LongitudinalSection; + cross_sections: CrossSection[]; +} + /** 종횡단 확정 결과 (SectionConfirmResponse) */ export interface SectionConfirmResponse { status: string; @@ -126,6 +159,16 @@ export async function getSections( }); } +/** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */ +export async function fetchSectionDetail( + projectId: string, + routeId: number, +): Promise { + return requestJson(`/projects/${projectId}/sections/${routeId}/detail`, { + method: "GET", + }); +} + /** 경로의 종·횡단면을 확정한다. */ export async function confirmSections( projectId: string, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index c72e0ed6..587799a4 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -27,11 +27,23 @@ def _validate_stage_path(relative_path: str) -> str: async def get_confirmed_route_context( connection: aiomysql.Connection, project_id: UUID ) -> dict[str, Any] | None: - """프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다.""" + """프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다. + + surface_models.crs_epsg가 NULL이면(분석에 사용한 입력 파일에 좌표계가 + 없던 경우) 같은 프로젝트 input_files의 감지된 좌표계로 폴백한다. + """ async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """ - SELECT r.id AS route_id, sm.crs_epsg + SELECT r.id AS route_id, + COALESCE( + sm.crs_epsg, + (SELECT f.crs_epsg + FROM input_files f + WHERE f.project_id = r.project_id AND f.crs_epsg IS NOT NULL + ORDER BY f.id DESC + LIMIT 1) + ) AS crs_epsg FROM routes r LEFT JOIN surface_models sm ON sm.id = r.surface_model_id WHERE r.project_id = %s AND r.status = 'CONFIRMED' diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 81bbb387..137043e8 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -1,6 +1,7 @@ """B06 종횡단 생성 FastAPI 라우터.""" import asyncio +import json import logging from pathlib import Path from uuid import UUID @@ -24,6 +25,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( SectionConfirmResponse, SectionContextResponse, + SectionDetailResponse, SectionGenerateRequest, SectionGenerateResponse, SectionOptionDefaults, @@ -33,6 +35,7 @@ from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage from config.config_db import get_db_pool +from config.config_system import SECTION_VERTICAL_EXAGGERATION logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) @@ -175,6 +178,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON cross_half_width_m=defaults.cross_half_width_m, cross_sample_interval_m=defaults.cross_sample_interval_m, long_sample_interval_m=defaults.long_sample_interval_m, + vertical_exaggeration=SECTION_VERTICAL_EXAGGERATION, ), ) except Exception: @@ -210,6 +214,81 @@ async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryRespons ) +def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dict: + """검증된 프로젝트 루트 안의 종단 및 횡단 JSON을 읽는다.""" + root = project_root.resolve() + longitudinal_path = (root / longitudinal_file_path).resolve() + if root not in longitudinal_path.parents: + raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.") + if not longitudinal_path.is_file(): + raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.") + + stage_root = longitudinal_path.parent.parent + cross_dir = stage_root / "cross_sections" + if not cross_dir.is_dir(): + raise FileNotFoundError("횡단면 상세 파일을 찾을 수 없습니다.") + + longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) + cross_sections = [ + json.loads(path.read_text(encoding="utf-8")) + for path in sorted(cross_dir.glob("cross_*.json")) + ] + if not isinstance(longitudinal, dict) or not all( + isinstance(section, dict) for section in cross_sections + ): + raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.") + return {"longitudinal": longitudinal, "cross_sections": cross_sections} + + +@router.get( + "/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse +) +async def get_section_detail( + project_id: UUID, route_id: int +) -> SectionDetailResponse | JSONResponse: + """경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + longitudinal = await get_longitudinal_section(connection, project_id, route_id) + if not longitudinal: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, + ) + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + detail = await asyncio.to_thread( + _read_section_detail, + project_root, + str(longitudinal["longitudinal_file_path"]), + ) + return SectionDetailResponse(**detail) + except FileNotFoundError as exc: + return JSONResponse( + status_code=404, content={"status": "error", "message": str(exc)} + ) + except (OSError, ValueError, json.JSONDecodeError) as exc: + logger.warning( + "B06 종횡단 상세 파일 조회 실패: project_id=%s route_id=%s error=%s", + project_id, + route_id, + exc, + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡단 상세 파일을 읽지 못했습니다."}, + ) + except Exception: + logger.exception( + "B06 종횡단 상세 조회 실패: project_id=%s route_id=%s", project_id, route_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡단 상세 조회 중 오류가 발생했습니다."}, + ) + + @router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse) async def confirm_sections( project_id: UUID, route_id: int diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 425c42ea..5b8ea25d 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -51,6 +51,7 @@ class SectionOptionDefaults(BaseModel): cross_half_width_m: float cross_sample_interval_m: float long_sample_interval_m: float + vertical_exaggeration: float class SectionContextResponse(BaseModel): @@ -74,3 +75,10 @@ class SectionSummaryResponse(BaseModel): longitudinal: dict[str, Any] | None = None length_m: float | None = None cross_section_count: int = 0 + + +class SectionDetailResponse(BaseModel): + """종단·횡단 SVG 렌더링에 필요한 원시 샘플 데이터.""" + + longitudinal: dict[str, Any] + cross_sections: list[dict[str, Any]] diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 3202b0ae..059ca52b 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -30,14 +30,17 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmSections, + fetchSectionDetail, fetchSectionContext, generateSections, getSections, type SectionContextResponse, + type SectionDetailResponse, type SectionGenerateRequest, type SectionGenerateResponse, type SectionSummaryResponse, } from "./B06_wf3_ProfileCross_Api_Fetch"; +import { createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View"; import "./B06_wf3_ProfileCross_UI_Style.css"; /** locale 헬퍼 */ @@ -79,6 +82,7 @@ function parseNumber(value: string): number | null { export async function renderB06ProfileCross(root: HTMLElement): Promise { let currentRouteId: number | null = null; let sectionContext: SectionContextResponse | null = null; + let sectionDetail: SectionDetailResponse | null = null; /* ---- 좌측: 대상 경로 ---- */ const routeGroup = buildGroup(L("B06_Profile_Group_Route")); @@ -113,12 +117,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { label: L("B06_Profile_Field_LongSample"), type: "number", }); + const verticalExaggerationField = createInputField({ + label: L("B06_Profile_Field_VerticalExaggeration"), + type: "number", + }); + verticalExaggerationField.input.min = "0.1"; + verticalExaggerationField.input.step = "0.1"; optionGroup.append( stationField.root, halfWidthField.root, crossSampleField.root, longSampleField.root, + verticalExaggerationField.root, ); const generateButton = createButton({ @@ -200,6 +211,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const resultCard = document.createElement("div"); resultCard.className = "b06-profile__result"; resultCard.append(resultTitle, resultBody); + const sectionView = createSectionView(); + const mainContent = document.createElement("div"); + mainContent.className = "b06-profile__main"; + mainContent.append(resultCard, sectionView.root); + + verticalExaggerationField.input.addEventListener("input", () => { + const exaggeration = parseNumber(verticalExaggerationField.input.value); + if (sectionDetail && exaggeration !== null && exaggeration >= 0.1) { + sectionView.render(sectionDetail, exaggeration); + } + }); /* ---- 이벤트 핸들러 ---- */ function getProjectId(): string | null { @@ -208,6 +230,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return projectId; } + async function loadSectionDetail(projectId: string, routeId: number): Promise { + try { + sectionDetail = await fetchSectionDetail(projectId, routeId); + sectionView.render(sectionDetail, parseNumber(verticalExaggerationField.input.value) ?? 1); + } catch (error) { + sectionDetail = null; + sectionView.clear(); + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${L("B06_Profile_Detail_Failed")}${detail}`, "error"); + } + } + function buildGenerateRequest(): SectionGenerateRequest | null { if ( sectionContext?.route_id === null || @@ -241,6 +275,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const result = await generateSections(projectId, request); currentRouteId = result.route_id; renderResult(result); + await loadSectionDetail(projectId, result.route_id); + confirmButton.disabled = false; showToast(L("B06_Profile_Generate_Success"), "success"); return true; } catch (error) { @@ -278,7 +314,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { fetchSectionContext(projectId), fetchWorkflowState(projectId), ]); - if (contextResult.status === "fulfilled") sectionContext = contextResult.value; + if (contextResult.status === "fulfilled") { + sectionContext = contextResult.value; + } else { + const reason = contextResult.reason; + const detail = reason instanceof Error ? ` ${reason.message}` : ""; + showToast(`${L("B06_Profile_Context_Failed")}${detail}`, "error"); + } if (workflowResult.status === "fulfilled") workflowState = workflowResult.value; } @@ -287,7 +329,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { steps: workflowSteps(), activeStep: 3, leftPanel: leftForm, - mainContent: resultCard, + mainContent, stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, @@ -302,6 +344,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (!projectId || !sectionContext) { generateButton.disabled = true; confirmButton.disabled = true; + if (projectId) renderResultMessage(L("B06_Profile_Context_Failed")); return; } @@ -309,12 +352,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { routeIdInfo.value.textContent = context.route_id === null ? "-" : String(context.route_id); filterInfo.value.textContent = context.filter_key ?? "-"; methodInfo.value.textContent = context.method ?? "-"; - smoothInfo.value.textContent = context.smooth === null ? "-" : context.smooth ? "ON" : "OFF"; + smoothInfo.value.textContent = + context.smooth === null + ? "-" + : context.smooth + ? L("B06_Profile_Smooth_On") + : L("B06_Profile_Smooth_Off"); crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`; stationField.input.value = String(context.defaults.station_interval_m); halfWidthField.input.value = String(context.defaults.cross_half_width_m); crossSampleField.input.value = String(context.defaults.cross_sample_interval_m); longSampleField.input.value = String(context.defaults.long_sample_interval_m); + verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration); if (context.route_id === null) { generateButton.disabled = true; @@ -329,6 +378,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const existing = await getSections(projectId, context.route_id); if (existing.longitudinal) { renderSummary(existing); + await loadSectionDetail(projectId, context.route_id); confirmButton.disabled = false; return; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts new file mode 100644 index 00000000..3d25c301 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -0,0 +1,481 @@ +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import type { + CrossSection, + LongitudinalSection, + SectionDetailResponse, + SectionSample, +} from "./B06_wf3_ProfileCross_Api_Fetch"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const LONG_WIDTH = 1200; +const LONG_HEIGHT = 310; +const CROSS_WIDTH = 560; +const CROSS_HEIGHT = 260; +const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 }; +const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 }; + +interface YScaleOptions { + pixelsPerMeter: number; + globalMinElevation: number; + globalMaxElevation: number; +} + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +function svgElement( + tag: K, + attributes: Record = {}, +): SVGElementTagNameMap[K] { + const element = document.createElementNS(SVG_NS, tag); + Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); + return element; +} + +function svgText(value: string, attributes: Record): SVGTextElement { + const text = svgElement("text", attributes); + text.textContent = value; + return text; +} + +function validElevation(sample: SectionSample): sample is SectionSample & { elevation_m: number } { + return ( + sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m) + ); +} + +function calculateYScale(detail: SectionDetailResponse): YScaleOptions | undefined { + const elevations = [ + ...detail.longitudinal.samples.map((sample) => sample.elevation_m), + ...detail.cross_sections.flatMap((section) => + section.samples.map((sample) => sample.elevation_m), + ), + ].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + if (!elevations.length) return undefined; + const globalMinElevation = Math.min(...elevations); + const globalMaxElevation = Math.max(...elevations); + const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; + return { + pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1), + globalMinElevation, + globalMaxElevation, + }; +} + +function emptyView(message: string): HTMLElement { + const empty = document.createElement("div"); + empty.className = "b06-section__empty"; + empty.textContent = message; + return empty; +} + +export function createLongitudinalProfile( + data: LongitudinalSection, + selectedStationId: string | null, + verticalExaggeration: number, + yScaleOptions: YScaleOptions | undefined, + onSelectStation: (stationId: string) => void, +): HTMLElement { + const samples = data.samples.filter(validElevation); + if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); + + const wrapper = document.createElement("div"); + wrapper.className = "b06-section__chart-wrap"; + const svg = svgElement("svg", { + class: "b06-section__chart", + viewBox: `0 0 ${LONG_WIDTH} ${LONG_HEIGHT}`, + role: "img", + "aria-label": L("B06_Profile_View_Longitudinal"), + }); + svg.append( + svgElement("rect", { width: LONG_WIDTH, height: LONG_HEIGHT, class: "b06-chart__bg" }), + ); + + const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); + const elevations = samples.map((sample) => sample.elevation_m); + const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations); + const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); + const elevationMid = (rawMin + rawMax) / 2; + const exaggeration = Math.max(verticalExaggeration, 0.1); + const plotWidth = LONG_WIDTH - LONG_PAD.left - LONG_PAD.right; + const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; + const elevationSpan = yScaleOptions + ? plotHeight / yScaleOptions.pixelsPerMeter + : Math.max(rawMax - rawMin, 1); + const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; + const y = (elevation: number) => + LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; + + for (const ratio of [0, 0.25, 0.5, 0.75, 1]) { + const gridY = LONG_PAD.top + ratio * plotHeight; + const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan; + const rawValue = elevationMid + (displayed - elevationMid) / exaggeration; + svg.append( + svgElement("line", { + x1: LONG_PAD.left, + y1: gridY, + x2: LONG_WIDTH - LONG_PAD.right, + y2: gridY, + class: "b06-chart__grid", + }), + svgText(`${rawValue.toFixed(1)}m`, { + x: LONG_PAD.left - 9, + y: gridY + 4, + "text-anchor": "end", + class: "b06-chart__tick", + }), + ); + } + + for (const station of data.stations) { + const stationX = x(station.chainage_m); + const selected = station.station_id === selectedStationId; + const marker = svgElement("g", { + class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`, + tabindex: "0", + role: "button", + "aria-label": `${station.label} ${station.chainage_m.toFixed(1)}m`, + }); + marker.addEventListener("click", () => onSelectStation(station.station_id)); + marker.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id); + }); + marker.append( + svgElement("line", { + x1: stationX, + y1: LONG_PAD.top, + x2: stationX, + y2: LONG_HEIGHT - LONG_PAD.bottom + 8, + class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`, + }), + svgText(station.label, { + x: stationX, + y: LONG_HEIGHT - 23, + "text-anchor": "middle", + class: "b06-chart__station-label", + }), + ); + svg.append(marker); + } + + const points = samples + .map((sample) => { + const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; + return `${x(sample.chainage_m ?? 0)},${y(elevated)}`; + }) + .join(" "); + svg.append( + svgElement("polyline", { points, class: "b06-chart__profile" }), + svgElement("line", { + x1: LONG_PAD.left, + y1: LONG_HEIGHT - LONG_PAD.bottom, + x2: LONG_WIDTH - LONG_PAD.right, + y2: LONG_HEIGHT - LONG_PAD.bottom, + class: "b06-chart__axis", + }), + svgElement("line", { + x1: LONG_PAD.left, + y1: LONG_PAD.top, + x2: LONG_PAD.left, + y2: LONG_HEIGHT - LONG_PAD.bottom, + class: "b06-chart__axis", + }), + svgText(L("B06_Profile_View_LongitudinalXAxis"), { + x: LONG_WIDTH / 2, + y: LONG_HEIGHT - 4, + "text-anchor": "middle", + class: "b06-chart__axis-label", + }), + svgText(L("B06_Profile_View_ElevationAxis"), { + x: 15, + y: LONG_HEIGHT / 2, + "text-anchor": "middle", + transform: `rotate(-90 15 ${LONG_HEIGHT / 2})`, + class: "b06-chart__axis-label", + }), + ); + wrapper.append(svg); + return wrapper; +} + +export function createCrossSectionCard( + section: CrossSection, + selected: boolean, + verticalExaggeration: number, + yScaleOptions: YScaleOptions | undefined, + onSelect: (stationId: string) => void, +): HTMLElement { + const card = document.createElement("article"); + card.id = `cross-${section.station_id}`; + card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`; + card.tabIndex = 0; + card.addEventListener("click", () => onSelect(section.station_id)); + card.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") onSelect(section.station_id); + }); + + const header = document.createElement("header"); + const title = document.createElement("div"); + const label = document.createElement("strong"); + label.textContent = section.label; + const chainage = document.createElement("span"); + chainage.textContent = `${section.chainage_m.toFixed(1)}m`; + title.append(label, chainage); + const kind = document.createElement("span"); + kind.textContent = + section.kind === "ep" + ? L("B06_Profile_View_Kind_EP") + : section.kind === "bp" + ? L("B06_Profile_View_Kind_BP") + : L("B06_Profile_View_Kind_Station"); + header.append(title, kind); + card.append(header); + + const valid = section.samples.filter(validElevation); + if (!valid.length) { + card.append(emptyView(L("B06_Profile_View_NoCross"))); + } else { + const offsets = section.samples.map((sample) => sample.offset_m ?? 0); + const minOffset = Math.min(...offsets, -1); + const maxOffset = Math.max(...offsets, 1); + const elevations = valid.map((sample) => sample.elevation_m); + const rawMin = Math.min(...elevations); + const rawMax = Math.max(...elevations); + const elevationMid = (rawMin + rawMax) / 2; + const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5; + const exaggeration = Math.max(verticalExaggeration, 0.1); + const plotWidth = CROSS_WIDTH - CROSS_PAD.left - CROSS_PAD.right; + const plotHeight = CROSS_HEIGHT - CROSS_PAD.top - CROSS_PAD.bottom; + const displaySpan = yScaleOptions + ? plotHeight / yScaleOptions.pixelsPerMeter + : Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1); + const displayMin = elevationMid - displaySpan / 2; + const displayMax = elevationMid + displaySpan / 2; + const x = (offset: number) => + CROSS_PAD.left + ((offset - minOffset) / Math.max(maxOffset - minOffset, 1)) * plotWidth; + const y = (elevation: number) => + CROSS_PAD.top + + ((displayMax - elevation) / Math.max(displayMax - displayMin, 1)) * plotHeight; + const svg = svgElement("svg", { + class: "b06-section__chart", + viewBox: `0 0 ${CROSS_WIDTH} ${CROSS_HEIGHT}`, + role: "img", + "aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`, + }); + svg.append( + svgElement("rect", { width: CROSS_WIDTH, height: CROSS_HEIGHT, class: "b06-chart__bg" }), + ); + + const xTicks = Array.from( + { length: 7 }, + (_, index) => minOffset + ((maxOffset - minOffset) * index) / 6, + ); + for (const tick of xTicks) { + svg.append( + svgElement("line", { + x1: x(tick), + y1: CROSS_PAD.top, + x2: x(tick), + y2: CROSS_HEIGHT - CROSS_PAD.bottom, + class: "b06-chart__grid", + }), + svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), { + x: x(tick), + y: CROSS_HEIGHT - CROSS_PAD.bottom + 16, + "text-anchor": "middle", + class: "b06-chart__tick", + }), + ); + } + const rawDisplaySpan = displaySpan / exaggeration; + for (let index = 0; index < 5; index += 1) { + const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4; + const displayTick = elevationMid + (tick - elevationMid) * exaggeration; + svg.append( + svgElement("line", { + x1: CROSS_PAD.left, + y1: y(displayTick), + x2: CROSS_WIDTH - CROSS_PAD.right, + y2: y(displayTick), + class: "b06-chart__grid", + }), + svgText(tick.toFixed(1), { + x: CROSS_PAD.left - 7, + y: y(displayTick) + 3, + "text-anchor": "end", + class: "b06-chart__tick", + }), + ); + } + + const segments: string[] = []; + let current: string[] = []; + for (const sample of section.samples) { + if (!validElevation(sample)) { + if (current.length > 1) segments.push(current.join(" ")); + current = []; + continue; + } + const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; + current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`); + } + if (current.length > 1) segments.push(current.join(" ")); + segments.forEach((points) => + svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), + ); + + const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => { + if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0)) + return sample; + return nearest; + }, null); + const centerX = x(0); + const centerY = centerSample + ? y(elevationMid + (centerSample.elevation_m - elevationMid) * exaggeration) + : CROSS_HEIGHT / 2; + svg.append( + svgElement("line", { + x1: CROSS_PAD.left, + y1: CROSS_HEIGHT - CROSS_PAD.bottom, + x2: CROSS_WIDTH - CROSS_PAD.right, + y2: CROSS_HEIGHT - CROSS_PAD.bottom, + class: "b06-chart__axis", + }), + svgElement("line", { + x1: CROSS_PAD.left, + y1: CROSS_PAD.top, + x2: CROSS_PAD.left, + y2: CROSS_HEIGHT - CROSS_PAD.bottom, + class: "b06-chart__axis", + }), + svgElement("line", { + x1: centerX, + y1: centerY - 18, + x2: centerX, + y2: centerY + 18, + class: "b06-chart__center-marker", + }), + svgElement("line", { + x1: centerX - 18, + y1: centerY, + x2: centerX + 18, + y2: centerY, + class: "b06-chart__center-marker", + }), + svgText(L("B06_Profile_View_CrossXAxis"), { + x: CROSS_WIDTH / 2, + y: CROSS_HEIGHT - 8, + "text-anchor": "middle", + class: "b06-chart__axis-label", + }), + svgText(L("B06_Profile_View_ElevationAxis"), { + x: 13, + y: CROSS_HEIGHT / 2, + "text-anchor": "middle", + transform: `rotate(-90 13 ${CROSS_HEIGHT / 2})`, + class: "b06-chart__axis-label", + }), + ); + card.append(svg); + } + + const footer = document.createElement("footer"); + const center = document.createElement("span"); + center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; + const azimuth = document.createElement("span"); + azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`; + footer.append(center, azimuth); + card.append(footer); + return card; +} + +export interface SectionViewController { + root: HTMLElement; + render: (detail: SectionDetailResponse, verticalExaggeration: number) => void; + clear: () => void; +} + +export function createSectionView(): SectionViewController { + const root = document.createElement("div"); + root.className = "b06-section"; + let currentDetail: SectionDetailResponse | null = null; + let selectedStationId: string | null = null; + let currentExaggeration = 1; + + const draw = (): void => { + root.replaceChildren(); + if (!currentDetail) return; + const detail = currentDetail; + const yScale = calculateYScale(detail); + const selectStation = (stationId: string, scroll: boolean): void => { + selectedStationId = stationId; + draw(); + if (scroll) { + document + .getElementById(`cross-${stationId}`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }; + + const longitudinalPanel = document.createElement("section"); + longitudinalPanel.className = "b06-section__panel"; + const longitudinalHeader = document.createElement("header"); + const longitudinalTitle = document.createElement("h3"); + longitudinalTitle.textContent = L("B06_Profile_View_Longitudinal"); + const stationCount = document.createElement("span"); + stationCount.textContent = `${L("B06_Profile_View_StationCount")} ${detail.longitudinal.stations.length}`; + longitudinalHeader.append(longitudinalTitle, stationCount); + longitudinalPanel.append( + longitudinalHeader, + createLongitudinalProfile( + detail.longitudinal, + selectedStationId, + currentExaggeration, + yScale, + (stationId) => selectStation(stationId, true), + ), + ); + + const crossHeading = document.createElement("div"); + crossHeading.className = "b06-section__heading"; + const crossTitle = document.createElement("h3"); + crossTitle.textContent = L("B06_Profile_View_Cross"); + const crossCount = document.createElement("span"); + crossCount.textContent = `${detail.cross_sections.length}${L("B06_Profile_View_CrossCountSuffix")}`; + crossHeading.append(crossTitle, crossCount); + const grid = document.createElement("div"); + grid.className = "b06-section__grid"; + if (detail.cross_sections.length) { + detail.cross_sections.forEach((section) => + grid.append( + createCrossSectionCard( + section, + section.station_id === selectedStationId, + currentExaggeration, + yScale, + (stationId) => selectStation(stationId, false), + ), + ), + ); + } else { + grid.append(emptyView(L("B06_Profile_View_NoCross"))); + } + root.append(longitudinalPanel, crossHeading, grid); + }; + + return { + root, + render(detail, verticalExaggeration) { + currentDetail = detail; + currentExaggeration = Math.max(verticalExaggeration, 0.1); + selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null; + draw(); + }, + clear() { + currentDetail = null; + selectedStationId = null; + root.replaceChildren(); + }, + }; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index ec205fa6..2deee323 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -104,3 +104,196 @@ word-break: break-all; text-align: right; } + +/* --- 종·횡단 도면 --- */ +.b06-profile__main, +.b06-section { + display: flex; + flex-direction: column; + gap: var(--spacing-24); + min-width: 0; +} + +.b06-section__panel, +.b06-cross-card { + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.b06-section__panel > header, +.b06-cross-card > header, +.b06-cross-card > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b06-section__panel > header { + border-bottom: 1px solid var(--color-border); +} + +.b06-section__panel > header h3, +.b06-section__heading h3 { + font-size: var(--text-body); +} + +.b06-section__chart-wrap { + width: 100%; + overflow-x: auto; +} + +.b06-section__chart { + display: block; + width: 100%; + min-width: 520px; + height: auto; +} + +.b06-section__heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: var(--spacing-16); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b06-section__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-16); +} + +.b06-cross-card { + cursor: pointer; + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); +} + +.b06-cross-card:hover { + border-color: var(--color-text-muted); + box-shadow: var(--shadow-sm); +} + +.b06-cross-card--selected { + border-color: var(--color-danger); + box-shadow: 0 0 0 2px var(--color-danger); +} + +.b06-cross-card > header { + border-bottom: 1px solid var(--color-border); +} + +.b06-cross-card > header div { + display: flex; + align-items: baseline; + gap: var(--spacing-8); +} + +.b06-cross-card > header strong { + color: var(--color-text); + font-size: var(--text-body-sm); +} + +.b06-cross-card > footer { + border-top: 1px solid var(--color-border); +} + +.b06-section__empty { + display: flex; + min-height: 180px; + align-items: center; + justify-content: center; + padding: var(--spacing-24); + border: 1px dashed var(--color-text-muted); + color: var(--color-text-muted); + background: var(--color-surface); +} + +/* SVG 차트 색상은 테마 토큰만 사용한다. */ +.b06-chart__bg { + fill: var(--color-surface-raised); +} + +.b06-chart__grid { + stroke: var(--color-border); + stroke-width: 1; +} + +.b06-chart__axis { + stroke: var(--color-text-secondary); + stroke-width: 1; +} + +.b06-chart__tick, +.b06-chart__station-label, +.b06-chart__axis-label { + fill: var(--color-text-secondary); + font-family: var(--font-body); +} + +.b06-chart__tick, +.b06-chart__station-label { + font-size: 10px; +} + +.b06-chart__axis-label { + fill: var(--color-text-body); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); +} + +.b06-chart__profile { + fill: none; + stroke: var(--color-chart-0); + stroke-width: 2.5; +} + +.b06-chart__cross-profile { + fill: none; + stroke: var(--color-chart-1); + stroke-width: 2.4; +} + +.b06-chart__station { + cursor: pointer; +} + +.b06-chart__station-line { + stroke-width: 1.2; +} + +.b06-chart__station-line--bp, +.b06-chart__station-line--regular { + stroke: var(--color-warning); +} + +.b06-chart__station-line--ep { + stroke: var(--color-accent); + stroke-dasharray: 4 3; +} + +.b06-chart__station-line--selected, +.b06-chart__center-marker { + stroke: var(--color-danger); + stroke-width: 2.5; +} + +.b06-chart__station--selected .b06-chart__station-label { + fill: var(--color-danger); + font-weight: var(--font-weight-bold); +} + +@media (max-width: 900px) { + .b06-section__grid { + grid-template-columns: 1fr; + } +} diff --git a/config/config_system.py b/config/config_system.py index e870fa96..d8cd3f55 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -239,6 +239,7 @@ SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0 SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "15.0")) SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5")) SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0")) +SECTION_VERTICAL_EXAGGERATION = float(os.getenv("SECTION_VERTICAL_EXAGGERATION", "1.0")) SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true" diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index 9eb7cbbd..255689d0 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -860,8 +860,8 @@ "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts": { - "mtime": 1784375574.3483717, - "ast_hash": "c963ea01b4e47fda7288da2466413faa", + "mtime": 1784379630.3386846, + "ast_hash": "52c3a9ee9585954231e15ae041b8f06b", "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py": { @@ -880,23 +880,23 @@ "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py": { - "mtime": 1784375574.350428, - "ast_hash": "f6ecffc9f54db3e1ea7fa682551c134b", + "mtime": 1784380743.010188, + "ast_hash": "54e86ecb1802a13476f93b41085fe3d9", "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py": { - "mtime": 1784375574.3514285, - "ast_hash": "7d31dd3ca17998ee0d05d53fd16c8a7b", + "mtime": 1784379682.786804, + "ast_hash": "e05a792607a34e36ad14deaadfcef175", "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py": { - "mtime": 1784375574.3534284, - "ast_hash": "be78faf37c307783ffc32cdaa3332a46", + "mtime": 1784379656.886007, + "ast_hash": "12de528aa1281f841c583435305bee92", "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts": { - "mtime": 1784375574.3549345, - "ast_hash": "c6902c6082e97f2e794223b03f2dfba6", + "mtime": 1784380760.2568946, + "ast_hash": "0bd7cd13aa999ac1b74481359b3d8da9", "semantic_hash": "" }, "B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts": { @@ -995,8 +995,8 @@ "semantic_hash": "" }, "config/config_system.py": { - "mtime": 1784371862.7494447, - "ast_hash": "5aa2b09d55040942e73976a11ee8249a", + "mtime": 1784377093.9173894, + "ast_hash": "f4a83c9879f6d2bf6d9b3a84a05b5fd2", "semantic_hash": "" }, "db_management/001_create_schema.sql": { @@ -1050,8 +1050,8 @@ "semantic_hash": "" }, "main.py": { - "mtime": 1784357162.0, - "ast_hash": "944ef36ad7b18e4180a1f49413a22cbd", + "mtime": 1784377236.4697044, + "ast_hash": "4852786a0f34ac197fc45b009a5ef34f", "semantic_hash": "" }, "migrations/001_create_upload_tables.sql": { @@ -1060,8 +1060,8 @@ "semantic_hash": "" }, "package.json": { - "mtime": 1783417102.0, - "ast_hash": "21958685f75d3d89d497cf8da68ee15d", + "mtime": 1784377181.9267156, + "ast_hash": "5143268e06d1faeaa7e9e1acb7ef4b20", "semantic_hash": "" }, "pyproject.toml": { @@ -1075,13 +1075,13 @@ "semantic_hash": "" }, "scratch/wiki_linter.py": { - "mtime": 1784371862.7568798, - "ast_hash": "ef1b873d05b130e30dd461cced7d62c1", + "mtime": 1784376278.8101668, + "ast_hash": "76c8805b5ade82b52388c8e58af69a7b", "semantic_hash": "" }, "tsconfig.json": { - "mtime": 1783417261.0, - "ast_hash": "36f7ffbea3824d3bd1a9f8900244e806", + "mtime": 1784377132.7051294, + "ast_hash": "b7eaa646b12fd1e7f38bfd68d0befe96", "semantic_hash": "" }, "ui_template/ui_template_elements.ts": { @@ -1100,8 +1100,8 @@ "semantic_hash": "" }, "ui_template/ui_template_locale.ts": { - "mtime": 1784375574.3572495, - "ast_hash": "e1934d1e709bc56259272166b9e2aca5", + "mtime": 1784380749.5154142, + "ast_hash": "3559a748646ef02c8fbf3fe2002ff99d", "semantic_hash": "" }, "ui_template/ui_template_overlay.ts": { @@ -1115,8 +1115,8 @@ "semantic_hash": "" }, "vite.config.ts": { - "mtime": 1783419145.0, - "ast_hash": "ca2c8f6b3896b7d4636fcf147005c153", + "mtime": 1784377182.994571, + "ast_hash": "50c1973601cae4e83c36c6cbd9e67203", "semantic_hash": "" }, "A00_Common/index.html": { diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index b2553bb6..f428cb33 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -738,11 +738,18 @@ export const ui_locales = { B06_Profile_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"], B06_Profile_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"], B06_Profile_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"], + B06_Profile_Field_VerticalExaggeration: ["높이 배율", "Vertical exaggeration"], B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"], + B06_Profile_Smooth_On: ["사용", "On"], + B06_Profile_Smooth_Off: ["미사용", "Off"], B06_Profile_Btn_Generate: ["종·횡단 재생성", "Regenerate Sections"], B06_Profile_Btn_Confirm: ["종·횡단 확정", "Confirm Sections"], B06_Profile_Result_Title: ["종·횡단 생성 결과", "Section Result"], B06_Profile_Result_Empty: ["아직 생성된 종·횡단이 없습니다.", "No sections generated yet."], + B06_Profile_Context_Failed: [ + "경로 정보를 불러오지 못했습니다. 서버 상태를 확인하세요.", + "Failed to load route context. Check the server status.", + ], B06_Profile_No_Confirmed_Route: [ "확정된 경로가 없습니다. 먼저 경로를 확정하세요.", "No confirmed route. Confirm a route first.", @@ -761,6 +768,33 @@ export const ui_locales = { B06_Profile_Generate_Failed: ["종·횡단 생성에 실패했습니다.", "Section generation failed."], B06_Profile_Confirm_Success: ["종·횡단을 확정했습니다.", "Sections confirmed."], B06_Profile_Confirm_Failed: ["종·횡단 확정에 실패했습니다.", "Section confirm failed."], + B06_Profile_Detail_Failed: [ + "종·횡단 도면 데이터를 불러오지 못했습니다.", + "Failed to load section drawing data.", + ], + B06_Profile_View_Longitudinal: ["종단면도", "Longitudinal profile"], + B06_Profile_View_Cross: ["횡단면도", "Cross sections"], + B06_Profile_View_StationCount: ["횡단 측점", "Cross stations"], + B06_Profile_View_CrossCountSuffix: ["개 도면", " drawings"], + B06_Profile_View_LongitudinalXAxis: [ + "BP 기준 누적거리 (횡단 측점)", + "Chainage from BP (cross stations)", + ], + B06_Profile_View_CrossXAxis: ["중심선 기준 편거리 (m)", "Offset from centerline (m)"], + B06_Profile_View_ElevationAxis: ["지반고 (m)", "Elevation (m)"], + B06_Profile_View_CenterElevation: ["중심고", "Center elevation"], + B06_Profile_View_Azimuth: ["방위각", "Azimuth"], + B06_Profile_View_Kind_BP: ["BP", "BP"], + B06_Profile_View_Kind_EP: ["EP", "EP"], + B06_Profile_View_Kind_Station: ["일반 측점", "Station"], + B06_Profile_View_NoLongitudinal: [ + "표시할 종단면 데이터가 없습니다.", + "No longitudinal profile data to display.", + ], + B06_Profile_View_NoCross: [ + "표시할 횡단면 데이터가 없습니다.", + "No cross-section data to display.", + ], /* --- B07_wf4_DesignDetail 상세 설계 --- */ B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"], From eed809748bc3e9917772f1acf02f0b49f69e2b15 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 18 Jul 2026 23:59:28 +0900 Subject: [PATCH 4/6] 260718_9 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 10 + .../B05_wf2_Route_Engine_Sections.py | 10 +- .../B05_wf2_Route_Engine_Sections_Core.py | 4 +- .../B05_wf2_Route_Engine_Sections_Sampler.py | 6 +- B05_wf2_Route/B05_wf2_Route_Router.py | 56 + B05_wf2_Route/B05_wf2_Route_Schema.py | 6 + B05_wf2_Route/B05_wf2_Route_UI_Markers.ts | 45 +- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 48 +- B05_wf2_Route/B05_wf2_Route_UI_Panel.ts | 42 + .../B05_wf2_Route_UI_Profile_Panel.ts | 65 + B05_wf2_Route/B05_wf2_Route_UI_Style.css | 64 +- B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts | 5 + .../B06_wf3_ProfileCross_Api_Fetch.ts | 42 +- .../B06_wf3_ProfileCross_Router.py | 134 +- .../B06_wf3_ProfileCross_Schema.py | 34 +- .../B06_wf3_ProfileCross_UI_Page.ts | 298 +---- .../B06_wf3_ProfileCross_UI_Section_View.ts | 2 +- graphify-out/manifest.json | 1172 ----------------- ui_template/ui_template_locale.ts | 28 +- 19 files changed, 431 insertions(+), 1640 deletions(-) rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py => B05_wf2_Route/B05_wf2_Route_Engine_Sections.py (92%) rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Section.py => B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py (98%) rename B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Sampler.py => B05_wf2_Route/B05_wf2_Route_Engine_Sections_Sampler.py (98%) create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts delete mode 100644 graphify-out/manifest.json diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index 8f23d88e..b4d13aff 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -45,6 +45,10 @@ export interface RouteSolveRequest { min_uphill_grade?: number | null; min_downhill_grade?: number | null; allow_avoid_pass_through?: boolean; + station_interval_m?: number | null; + cross_half_width_m?: number | null; + cross_sample_interval_m?: number | null; + long_sample_interval_m?: number | null; } /** 경로 탐색 실행 결과 (RouteSolveResponse) */ @@ -56,6 +60,8 @@ export interface RouteSolveResponse { metrics: Record; required_points_ok: boolean; route_data_path: string; + longitudinal_length_m: number; + cross_section_count: number; } /** 경로 확정 결과 (RouteConfirmResponse) */ @@ -97,6 +103,10 @@ export interface RouteLatestResponse { }; options?: Record; algorithm?: string; + station_interval_m?: number | null; + cross_half_width_m?: number | null; + cross_sample_interval_m?: number | null; + long_sample_interval_m?: number | null; } | null; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py similarity index 92% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py rename to B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index 904ee71d..6c53a932 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -1,4 +1,4 @@ -"""B06 종횡단 생성 엔진 오케스트레이터. +"""B05 경로 계산 후 종횡단을 생성하는 엔진 오케스트레이터. 확정 경로 GeoJSON과 확정 지표면 모델 sampler로 종단·횡단을 생성하고, 종단은 longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB 기록용 @@ -10,11 +10,11 @@ import json from pathlib import Path from typing import Any -from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import build_surface_sampler -from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import ( +from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import ( SectionGenerationOptions, generate_sections, ) +from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import build_surface_sampler from common_util.common_util_json import atomic_write_json _STAGE_SUBDIR = Path("B06_wf3_ProfileCross") @@ -36,7 +36,9 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]: """횡단면 상세에서 DB data 컬럼에 저장할 요약을 만든다.""" samples = cross_section.get("samples", []) - valid_z = [s["elevation_m"] for s in samples if s.get("valid") and s.get("elevation_m") is not None] + valid_z = [ + s["elevation_m"] for s in samples if s.get("valid") and s.get("elevation_m") is not None + ] return { "chainage_m": cross_section.get("chainage_m"), "center_z": cross_section.get("center_z"), diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Section.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py similarity index 98% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Section.py rename to B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py index 4b72c1cf..5fcb956c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Section.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py @@ -1,4 +1,4 @@ -"""B06 종단·횡단 원시 데이터 생성. +"""B05 경로 기반 종단·횡단 원시 데이터 생성. 확정 경로 폴리라인과 표고 sampler로 CAD 인계 가능한 종단(longitudinal)·횡단 (cross) 데이터를 생성한다. BP(0m)부터 station_interval 간격 측점을 만들고 @@ -11,7 +11,7 @@ from typing import Any import numpy as np -from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import SurfaceElevationSampler +from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import SurfaceElevationSampler from config.config_system import ( SECTION_CROSS_HALF_WIDTH_M, SECTION_CROSS_SAMPLE_INTERVAL_M, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Sampler.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Sampler.py similarity index 98% rename from B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Sampler.py rename to B05_wf2_Route/B05_wf2_Route_Engine_Sections_Sampler.py index b6d8ac96..8d43a97e 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Sampler.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Sampler.py @@ -1,4 +1,4 @@ -"""B06 지표면 표고 sampler. +"""B05 종횡단 계산용 지표면 표고 sampler. 종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스와, 확정된 지표면 모델 (B04_wf1_Surface/models)을 일괄 XY 표고 sampler로 여는 팩토리를 제공한다. @@ -69,9 +69,7 @@ class DtmGridSampler: if not len(xy): return np.empty(0, dtype=np.float64), np.empty(0, dtype=bool) - z = np.asarray( - self._interpolator(np.column_stack([xy[:, 1], xy[:, 0]])), dtype=np.float64 - ) + z = np.asarray(self._interpolator(np.column_stack([xy[:, 1], xy[:, 0]])), dtype=np.float64) ix = np.searchsorted(self.x, xy[:, 0], side="right") - 1 iy = np.searchsorted(self.y, xy[:, 1], side="right") - 1 inside = (ix >= 0) & (iy >= 0) & (ix < len(self.x) - 1) & (iy < len(self.y) - 1) diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index 755ff91a..adf483b9 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -12,6 +12,8 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design +from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation +from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions from B05_wf2_Route.B05_wf2_Route_Repository import ( confirm_route, create_route, @@ -26,6 +28,11 @@ from B05_wf2_Route.B05_wf2_Route_Schema import ( RouteSolveRequest, RouteSolveResponse, ) +from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( + create_longitudinal_section, + delete_sections_for_route, + insert_cross_sections, +) from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import ( @@ -40,6 +47,19 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"]) +def _section_options(request: RouteSolveRequest) -> SectionGenerationOptions: + defaults = SectionGenerationOptions() + return SectionGenerationOptions( + station_interval_m=request.station_interval_m or defaults.station_interval_m, + cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, + cross_sample_interval_m=( + request.cross_sample_interval_m or defaults.cross_sample_interval_m + ), + long_sample_interval_m=request.long_sample_interval_m or defaults.long_sample_interval_m, + include_endpoint=defaults.include_endpoint, + ) + + @router.post("/{project_id}/route/solve", response_model=RouteSolveResponse) async def solve_route( project_id: UUID, request: RouteSolveRequest @@ -55,6 +75,10 @@ async def solve_route( "options": request.options(), "algorithm": request.algorithm, "surface_model_id": request.surface_model_id, + "station_interval_m": request.station_interval_m, + "cross_half_width_m": request.cross_half_width_m, + "cross_sample_interval_m": request.cross_sample_interval_m, + "long_sample_interval_m": request.long_sample_interval_m, } log_b05_debug( logger, @@ -174,6 +198,36 @@ async def solve_route( ) raise + sections = await asyncio.to_thread( + run_section_generation, + project_root, + design["route_data_path"], + request.filter_key, + request.method, + request.smooth, + options=_section_options(request), + ) + await connection.begin() + try: + await delete_sections_for_route(connection, route_id) + await create_longitudinal_section( + connection, + project_id=project_id, + route_id=route_id, + data=sections["longitudinal"]["data"], + longitudinal_file_path=sections["longitudinal"]["file_path"], + ) + await insert_cross_sections( + connection, + project_id=project_id, + route_id=route_id, + sections=sections["cross_sections"], + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + return RouteSolveResponse( project_id=str(project_id), route_id=route_id, @@ -181,6 +235,8 @@ async def solve_route( metrics=metrics, required_points_ok=solver["required_points_ok"], route_data_path=design["route_data_path"], + longitudinal_length_m=sections["longitudinal"]["data"]["length_m"], + cross_section_count=len(sections["cross_sections"]), ) except LookupError as exc: async with pool.acquire() as connection, connection.cursor() as cursor: diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index bd4ec272..eb24e675 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -54,6 +54,10 @@ class RouteSolveRequest(BaseModel): min_downhill_grade: float | None = None weights: dict[str, float] | None = None allow_avoid_pass_through: bool = Field(default=False) + station_interval_m: float | None = Field(default=None, gt=0) + cross_half_width_m: float | None = Field(default=None, gt=0) + cross_sample_interval_m: float | None = Field(default=None, gt=0) + long_sample_interval_m: float | None = Field(default=None, gt=0) @model_validator(mode="after") def validate_choices(self) -> "RouteSolveRequest": @@ -96,6 +100,8 @@ class RouteSolveResponse(BaseModel): metrics: dict[str, Any] required_points_ok: bool route_data_path: str + longitudinal_length_m: float + cross_section_count: int class RouteConfirmResponse(BaseModel): diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts index a8d5750a..85198f34 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts @@ -25,6 +25,13 @@ export interface ModelBounds { z: [number, number]; } +export interface SectionStationMarker { + center_x: number; + center_y: number; + center_z: number | null; + frame: { left_xy: [number, number] }; +} + const COLORS: Record = { bp: 0x10b981, ep: 0xef4444, @@ -65,7 +72,8 @@ export function sceneToModel(point: THREE.Vector3, bounds: ModelBounds) { export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBounds | null) { const markerGroup = new THREE.Group(); const routeGroup = new THREE.Group(); - scene.add(markerGroup, routeGroup); + const stationGroup = new THREE.Group(); + scene.add(markerGroup, routeGroup, stationGroup); let points = emptyPoints(); let selectedId: string | null = null; let changeListener: ((points: RouteDesignPoints) => void) | undefined; @@ -216,6 +224,34 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou ); } + function renderStationLines(stations: SectionStationMarker[], halfWidth: number): void { + disposeGroup(stationGroup); + const bounds = getBounds(); + if (!bounds || halfWidth <= 0) return; + const points: THREE.Vector3[] = []; + stations.forEach((station) => { + if (station.center_z === null) return; + const [leftX, leftY] = station.frame.left_xy; + const center = { x: station.center_x, y: station.center_y, z: station.center_z + 0.45 }; + points.push( + modelToScene( + { x: center.x + leftX * halfWidth, y: center.y + leftY * halfWidth, z: center.z }, + bounds, + ), + modelToScene( + { x: center.x - leftX * halfWidth, y: center.y - leftY * halfWidth, z: center.z }, + bounds, + ), + ); + }); + stationGroup.add( + new THREE.LineSegments( + new THREE.BufferGeometry().setFromPoints(points), + new THREE.LineBasicMaterial({ color: 0xa855f7 }), + ), + ); + } + return { group: markerGroup, getPoints: () => points, @@ -239,6 +275,10 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou }, renderMarkers, renderRoute, + renderStationLines, + setStationLinesVisible(visible: boolean) { + stationGroup.visible = visible; + }, onChange(listener: (next: RouteDesignPoints) => void) { changeListener = listener; }, @@ -248,7 +288,8 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou dispose() { disposeGroup(markerGroup); disposeGroup(routeGroup); - scene.remove(markerGroup, routeGroup); + disposeGroup(stationGroup); + scene.remove(markerGroup, routeGroup, stationGroup); }, }; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index b54b1eee..c146f1d9 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -27,7 +27,13 @@ import { type RoutePointKind, } from "./B05_wf2_Route_UI_Markers"; import { createRoutePanel, type RoutePanelValues } from "./B05_wf2_Route_UI_Panel"; +import { createRouteProfilePanel } from "./B05_wf2_Route_UI_Profile_Panel"; import { createRouteViewer } from "./B05_wf2_Route_UI_Viewer"; +import { + fetchSectionContext, + fetchSectionDetail, + type SectionDetailResponse, +} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; import "./B05_wf2_Route_UI_Style.css"; function toBounds(bounds: { @@ -88,6 +94,7 @@ export async function renderB05Route(root: HTMLElement): Promise { const activeProjectId: string = projectId; const viewer = createRouteViewer(); + const profilePanel = createRouteProfilePanel(); let confirmedSurface: SurfaceModelSummary | null = null; let latest: RouteLatestResponse | null = null; let routeReady = false; @@ -101,6 +108,7 @@ export async function renderB05Route(root: HTMLElement): Promise { onSurfaceVisible: viewer.setSurfaceVisible, onContoursVisible: viewer.setContoursVisible, onAxesVisible: viewer.setAxesVisible, + onStationLinesVisible: viewer.setStationLinesVisible, onView: viewer.setView, onResetView: () => viewer.setView("top"), onMovePoint: viewer.beginMoveSelected, @@ -136,10 +144,28 @@ export async function renderB05Route(root: HTMLElement): Promise { minUphillGrade: options.min_uphill_grade as number | undefined, minDownhillGrade: options.min_downhill_grade as number | undefined, allowAvoidPassThrough: options.allow_avoid_pass_through as boolean | undefined, + stationInterval: next.route_params?.station_interval_m ?? undefined, + crossHalfWidth: next.route_params?.cross_half_width_m ?? undefined, + crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined, + longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined, }); viewer.markers.setPoints(restorePoints(next)); } + function renderSections(detail: SectionDetailResponse): void { + profilePanel.render(detail); + viewer.renderStationLines(detail.longitudinal.stations, panel.values().crossHalfWidth); + } + + async function restoreSections(routeId: number): Promise { + try { + renderSections(await fetchSectionDetail(activeProjectId, routeId)); + } catch { + profilePanel.clear(); + viewer.renderStationLines([], 0); + } + } + function renderLatest(next: RouteLatestResponse): void { latest = next; routeReady = Boolean(next.route && next.route_points.length > 1); @@ -188,7 +214,7 @@ export async function renderB05Route(root: HTMLElement): Promise { const values = panel.values(); showLoadingOverlay(); try { - await solveRoute(activeProjectId, { + const solved = await solveRoute(activeProjectId, { filter_key: latest.surface_params.source_filter, method: latest.surface_params.method, smooth: latest.surface_params.smooth, @@ -207,8 +233,13 @@ export async function renderB05Route(root: HTMLElement): Promise { min_uphill_grade: values.minUphillGrade, min_downhill_grade: values.minDownhillGrade, allow_avoid_pass_through: values.allowAvoidPassThrough, + station_interval_m: values.stationInterval, + cross_half_width_m: values.crossHalfWidth, + cross_sample_interval_m: values.crossSampleInterval, + long_sample_interval_m: values.longSampleInterval, }); renderLatest(await fetchLatestRoute(activeProjectId)); + renderSections(await fetchSectionDetail(activeProjectId, solved.route_id)); showToast("최적 경로 계산이 완료되었습니다.", "success"); } catch (error) { showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error"); @@ -232,10 +263,11 @@ export async function renderB05Route(root: HTMLElement): Promise { } } - const [workflowState, models, latestResponse] = await Promise.all([ + const [workflowState, models, latestResponse, sectionContext] = await Promise.all([ fetchWorkflowState(activeProjectId), listSurfaceModels(activeProjectId), fetchLatestRoute(activeProjectId), + fetchSectionContext(activeProjectId), ]); confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; if (!confirmedSurface) { @@ -245,6 +277,12 @@ export async function renderB05Route(root: HTMLElement): Promise { activeProjectId, latestResponse.surface_params.source_filter, ); + panel.restore({ + stationInterval: sectionContext.defaults.station_interval_m, + crossHalfWidth: sectionContext.defaults.cross_half_width_m, + crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, + longSampleInterval: sectionContext.defaults.long_sample_interval_m, + }); restorePanel(latestResponse); await viewer.loadSurface( activeProjectId, @@ -255,16 +293,20 @@ export async function renderB05Route(root: HTMLElement): Promise { toBounds(cloud.bounds), ); renderLatest(latestResponse); + if (latestResponse.route) await restoreSections(latestResponse.route.id); } latest = latestResponse; restoring = false; + const mainContent = document.createElement("div"); + mainContent.className = "b05-route__main"; + mainContent.append(viewer.root, profilePanel.root); const layout = createWorkflowLayout({ title: "노선 설계", steps: workflowSteps(), activeStep: 2, leftPanel: panel.root, - mainContent: viewer.root, + mainContent, stages: workflowState.stages, currentStage: workflowState.current_stage, routes: WORKFLOW_STEP_ROUTES, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index 73eae3c6..3b45391f 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -1,4 +1,5 @@ import type { PlacedRoutePoint, RoutePointKind } from "./B05_wf2_Route_UI_Markers"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; export interface RoutePanelValues { contourInterval: number; @@ -11,6 +12,10 @@ export interface RoutePanelValues { minUphillGrade: number | null; minDownhillGrade: number | null; allowAvoidPassThrough: boolean; + stationInterval: number; + crossHalfWidth: number; + crossSampleInterval: number; + longSampleInterval: number; } interface PanelCallbacks { @@ -20,6 +25,7 @@ interface PanelCallbacks { onSurfaceVisible: (visible: boolean) => void; onContoursVisible: (visible: boolean) => void; onAxesVisible: (visible: boolean) => void; + onStationLinesVisible: (visible: boolean) => void; onView: (view: "iso" | "top" | "front" | "side") => void; onResetView: () => void; onMovePoint: () => void; @@ -30,6 +36,10 @@ interface PanelCallbacks { type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement }; +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + function section(title: string): { root: HTMLElement; body: HTMLElement } { const root = document.createElement("section"); root.className = "b05-route__panel-section"; @@ -92,6 +102,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { const surfaceVisible = checkbox("지표면", true); const contoursVisible = checkbox("등고선", true); const axesVisible = checkbox("축 표시", false); + const stationLinesVisible = checkbox(L("B05_Route_Field_StationLines"), true); surfaceVisible.addEventListener("change", () => callbacks.onSurfaceVisible(surfaceVisible.checked), ); @@ -99,11 +110,15 @@ export function createRoutePanel(callbacks: PanelCallbacks) { callbacks.onContoursVisible(contoursVisible.checked), ); axesVisible.addEventListener("change", () => callbacks.onAxesVisible(axesVisible.checked)); + stationLinesVisible.addEventListener("change", () => + callbacks.onStationLinesVisible(stationLinesVisible.checked), + ); view.body.append( viewButtons, surfaceVisible.wrapper, contoursVisible.wrapper, axesVisible.wrapper, + stationLinesVisible.wrapper, button("뷰 초기화", callbacks.onResetView), ); @@ -190,6 +205,18 @@ export function createRoutePanel(callbacks: PanelCallbacks) { help, ); + const sectionOptions = section(L("B05_Route_Group_SectionOptions")); + const stationInterval = numberField(L("B05_Route_Field_StationInterval")); + const crossHalfWidth = numberField(L("B05_Route_Field_CrossHalfWidth")); + const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample")); + const longSampleInterval = numberField(L("B05_Route_Field_LongSample")); + sectionOptions.body.append( + stationInterval.wrapper, + crossHalfWidth.wrapper, + crossSampleInterval.wrapper, + longSampleInterval.wrapper, + ); + const result = section("경로 설계 산출 결과"); const stale = document.createElement("span"); stale.className = "b05-route__stale"; @@ -217,6 +244,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) { maxDownhillGrade, minUphillGrade, minDownhillGrade, + stationInterval, + crossHalfWidth, + crossSampleInterval, + longSampleInterval, ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); root.append( @@ -225,6 +256,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { palette.root, selected.root, conditions.root, + sectionOptions.root, result.root, actionRow, ); @@ -243,6 +275,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minUphillGrade: parseOptional(minUphillGrade), minDownhillGrade: parseOptional(minDownhillGrade), allowAvoidPassThrough: avoidPass.checked, + stationInterval: Number(stationInterval.value), + crossHalfWidth: Number(crossHalfWidth.value), + crossSampleInterval: Number(crossSampleInterval.value), + longSampleInterval: Number(longSampleInterval.value), }; }, restore(values: Partial) { @@ -256,6 +292,12 @@ export function createRoutePanel(callbacks: PanelCallbacks) { if (values.minUphillGrade != null) minUphillGrade.value = String(values.minUphillGrade); if (values.minDownhillGrade != null) minDownhillGrade.value = String(values.minDownhillGrade); if (values.allowAvoidPassThrough != null) avoidPass.checked = values.allowAvoidPassThrough; + if (values.stationInterval != null) stationInterval.value = String(values.stationInterval); + if (values.crossHalfWidth != null) crossHalfWidth.value = String(values.crossHalfWidth); + if (values.crossSampleInterval != null) + crossSampleInterval.value = String(values.crossSampleInterval); + if (values.longSampleInterval != null) + longSampleInterval.value = String(values.longSampleInterval); }, setSelected(point: PlacedRoutePoint | null) { selected.root.hidden = !point; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts new file mode 100644 index 00000000..5985bd4f --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -0,0 +1,65 @@ +import type { + LongitudinalSection, + SectionDetailResponse, +} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; +import { createLongitudinalProfile } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View"; +import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; + +const COLLAPSED_KEY = "b05-route-profile-collapsed"; + +function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection { + return { + ...data, + samples: data.samples.map((sample) => ({ + ...sample, + elevation_m: sample.elevation_m ?? sample.z ?? null, + })), + }; +} + +export function createRouteProfilePanel() { + const root = document.createElement("section"); + root.className = "b05-route-profile"; + const header = document.createElement("header"); + const title = document.createElement("strong"); + title.textContent = "종단면도"; + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "b05-route-profile__toggle"; + const body = document.createElement("div"); + body.className = "b05-route-profile__body"; + const empty = document.createElement("p"); + empty.className = "b05-route-profile__empty"; + empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다."; + body.append(empty); + header.append(title, toggle); + root.append(header, body); + + function setCollapsed(collapsed: boolean): void { + root.classList.toggle("is-collapsed", collapsed); + toggle.textContent = collapsed ? "펼치기" : "접기"; + toggle.setAttribute("aria-expanded", String(!collapsed)); + sessionStorage.setItem(COLLAPSED_KEY, String(collapsed)); + } + + toggle.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed"))); + setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true"); + + return { + root, + render(detail: SectionDetailResponse) { + body.replaceChildren( + createLongitudinalProfile( + normalizedLongitudinal(detail.longitudinal), + null, + 1, + undefined, + () => undefined, + ), + ); + }, + clear() { + body.replaceChildren(empty); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index a88f8da6..7e60ffa1 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -15,15 +15,75 @@ overflow: hidden; } -.b05-route__viewport { - position: relative; +.b05-route__main { + display: flex; + flex-direction: column; width: 100%; height: 100%; min-height: 0; overflow: hidden; +} + +.b05-route__viewport { + position: relative; + width: 100%; + flex: 1 1 auto; + height: auto; + min-height: 0; + overflow: hidden; background: var(--color-surface); } +.b05-route-profile { + flex: 0 0 250px; + min-height: 0; + overflow: hidden; + border-top: 1px solid var(--color-border); + background: var(--color-surface-raised); + transition: flex-basis var(--transition-fast); +} + +.b05-route-profile.is-collapsed { + flex-basis: 42px; +} + +.b05-route-profile > header { + display: flex; + height: 42px; + align-items: center; + justify-content: space-between; + padding: 0 var(--spacing-16); + border-bottom: 1px solid var(--color-border); + color: var(--color-text); +} + +.b05-route-profile__toggle { + border: 0; + background: transparent; + color: var(--color-primary); + cursor: pointer; +} + +.b05-route-profile__body { + height: calc(100% - 42px); + overflow: auto; +} + +.b05-route-profile.is-collapsed .b05-route-profile__body { + display: none; +} + +.b05-route-profile__empty { + margin: 0; + padding: var(--spacing-24); + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.b05-route-profile .b06-section__chart { + max-height: 205px; +} + .b05-route__viewport canvas { display: block; width: 100%; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts index 6fce9bc3..64809eb7 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts @@ -9,6 +9,7 @@ import { type ModelBounds, type RouteMarkers, type RoutePointKind, + type SectionStationMarker, } from "./B05_wf2_Route_UI_Markers"; function disposeObject(object: THREE.Object3D | null): void { @@ -40,6 +41,8 @@ export interface RouteViewer { setSurfaceVisible: (visible: boolean) => void; setContoursVisible: (visible: boolean) => void; setAxesVisible: (visible: boolean) => void; + setStationLinesVisible: (visible: boolean) => void; + renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void; setView: (view: "iso" | "top" | "front" | "side") => void; beginMoveSelected: () => void; dispose: () => void; @@ -243,6 +246,8 @@ export function createRouteViewer(): RouteViewer { setAxesVisible(visible) { axes.visible = visible; }, + setStationLinesVisible: markers.setStationLinesVisible, + renderStationLines: markers.renderStationLines, setView: fit, beginMoveSelected() { movingSelected = true; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index dced1bc8..2c749862 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -3,7 +3,6 @@ * 3차 워크플로우(종·횡단 생성) API 클라이언트 * * 백엔드 계약 (B06_wf3_ProfileCross_Router.py): - * POST /api/projects/{project_id}/sections/generate → 종횡단 생성 + DB 기록 * GET /api/projects/{project_id}/sections/context → 확정 경로 + 기본 옵션 * GET /api/projects/{project_id}/sections/{route_id} → 종단 요약 조회 * GET /api/projects/{project_id}/sections/{route_id}/detail → 종횡단 원시 샘플 조회 @@ -16,30 +15,6 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; -/** 종횡단 생성 실행 요청 (SectionGenerateRequest) */ -export interface SectionGenerateRequest { - route_id: number; - filter_key: string; - method?: string; - smooth?: boolean; - crs?: string | null; - station_interval_m?: number | null; - cross_half_width_m?: number | null; - cross_sample_interval_m?: number | null; - long_sample_interval_m?: number | null; -} - -/** 종횡단 생성 결과 (SectionGenerateResponse) */ -export interface SectionGenerateResponse { - status: string; - project_id: string; - route_id: number; - longitudinal_id: number; - cross_section_count: number; - length_m: number; - longitudinal_file_path: string; -} - export interface SectionOptionDefaults { station_interval_m: number; cross_half_width_m: number; @@ -71,7 +46,8 @@ export interface SectionSummaryResponse { export interface SectionSample { chainage_m?: number; offset_m?: number; - elevation_m: number | null; + elevation_m?: number | null; + z?: number | null; valid: boolean; } @@ -82,6 +58,9 @@ export interface SectionStation { kind: "bp" | "ep" | "regular"; center_z: number | null; azimuth_deg: number | null; + center_x: number; + center_y: number; + frame: { left_xy: [number, number] }; } export interface LongitudinalSection { @@ -131,17 +110,6 @@ async function requestJson(path: string, init: RequestInit): Promise { } } -/** 확정 경로에서 종·횡단을 생성한다 (측점 배열 → 종단 프로필 → 횡단 샘플). */ -export async function generateSections( - projectId: string, - request: SectionGenerateRequest, -): Promise { - return requestJson(`/projects/${projectId}/sections/generate`, { - method: "POST", - body: JSON.stringify(request), - }); -} - /** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */ export async function fetchSectionContext(projectId: string): Promise { return requestJson(`/projects/${projectId}/sections/context`, { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 137043e8..4732ec62 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -10,30 +10,23 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_wf2_Route.B05_wf2_Route_Repository import get_latest_route -from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine import run_section_generation -from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import SectionGenerationOptions +from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( confirm_sections_for_route, count_cross_sections, - create_longitudinal_section, - delete_sections_for_route, get_confirmed_route_context, get_longitudinal_section, - insert_cross_sections, ) from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( SectionConfirmResponse, SectionContextResponse, SectionDetailResponse, - SectionGenerateRequest, - SectionGenerateResponse, SectionOptionDefaults, SectionSummaryResponse, ) from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params -from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage +from common_util.common_util_workflow_state import complete_stage from config.config_db import get_db_pool from config.config_system import SECTION_VERTICAL_EXAGGERATION @@ -41,121 +34,6 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) -def _build_options(request: SectionGenerateRequest) -> SectionGenerationOptions: - """요청의 옵션(미지정은 config 기본값)으로 SectionGenerationOptions를 만든다.""" - defaults = SectionGenerationOptions() - return SectionGenerationOptions( - station_interval_m=request.station_interval_m or defaults.station_interval_m, - cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, - cross_sample_interval_m=request.cross_sample_interval_m or defaults.cross_sample_interval_m, - long_sample_interval_m=request.long_sample_interval_m or defaults.long_sample_interval_m, - include_endpoint=defaults.include_endpoint, - ) - - -@router.post("/{project_id}/sections/generate", response_model=SectionGenerateResponse) -async def generate_sections( - project_id: UUID, request: SectionGenerateRequest -) -> SectionGenerateResponse | JSONResponse: - """확정 경로에서 종횡단을 생성·저장하고 DB에 기록한다.""" - pool = get_db_pool() - try: - params = { - "route_id": request.route_id, - "filter_key": request.filter_key, - "method": request.method, - "smooth": request.smooth, - "station_interval_m": request.station_interval_m, - "cross_half_width_m": request.cross_half_width_m, - "cross_sample_interval_m": request.cross_sample_interval_m, - "long_sample_interval_m": request.long_sample_interval_m, - "crs": request.crs, - } - async with pool.acquire() as connection: - async with connection.cursor() as cursor: - await start_stage(cursor, str(project_id), 3, params) - await connection.commit() - - stored_path = await get_project_storage_relative_path(connection, project_id) - project_root = Path(resolve_stored_project_path(stored_path)) - - latest = await get_latest_route(connection, project_id) - if not latest or latest["id"] != request.route_id: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "대상 경로를 찾을 수 없습니다."}, - ) - route_data_path = latest["route_data_path"] - - options = _build_options(request) - design = await asyncio.to_thread( - run_section_generation, - project_root, - route_data_path, - request.filter_key, - request.method, - request.smooth, - options=options, - crs=request.crs, - ) - - await connection.begin() - try: - await delete_sections_for_route(connection, request.route_id) - longitudinal_id = await create_longitudinal_section( - connection, - project_id=project_id, - route_id=request.route_id, - data=design["longitudinal"]["data"], - longitudinal_file_path=design["longitudinal"]["file_path"], - ) - await insert_cross_sections( - connection, - project_id=project_id, - route_id=request.route_id, - sections=design["cross_sections"], - ) - async with connection.cursor() as cursor: - await complete_stage(cursor, str(project_id), 3) - await connection.commit() - except Exception: - await connection.rollback() - raise - - return SectionGenerateResponse( - project_id=str(project_id), - route_id=request.route_id, - longitudinal_id=longitudinal_id, - cross_section_count=len(design["cross_sections"]), - length_m=design["longitudinal"]["data"]["length_m"], - longitudinal_file_path=design["longitudinal"]["file_path"], - ) - except LookupError as exc: - async with pool.acquire() as connection, connection.cursor() as cursor: - await fail_stage(cursor, str(project_id), 3, str(exc)) - await connection.commit() - return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) - except FileNotFoundError as exc: - async with pool.acquire() as connection, connection.cursor() as cursor: - await fail_stage(cursor, str(project_id), 3, str(exc)) - await connection.commit() - return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) - except (OSError, ValueError) as exc: - async with pool.acquire() as connection, connection.cursor() as cursor: - await fail_stage(cursor, str(project_id), 3, str(exc)) - await connection.commit() - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception as exc: - logger.exception("B06 종횡단 생성 실패: project_id=%s", project_id) - async with pool.acquire() as connection, connection.cursor() as cursor: - await fail_stage(cursor, str(project_id), 3, str(exc)) - await connection.commit() - return JSONResponse( - status_code=500, - content={"status": "error", "message": "종횡단 생성 처리 중 오류가 발생했습니다."}, - ) - - @router.get("/{project_id}/sections/context", response_model=SectionContextResponse) async def get_section_context(project_id: UUID) -> SectionContextResponse | JSONResponse: """최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 반환한다.""" @@ -240,9 +118,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic return {"longitudinal": longitudinal, "cross_sections": cross_sections} -@router.get( - "/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse -) +@router.get("/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse) async def get_section_detail( project_id: UUID, route_id: int ) -> SectionDetailResponse | JSONResponse: @@ -265,9 +141,7 @@ async def get_section_detail( ) return SectionDetailResponse(**detail) 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)}) except (OSError, ValueError, json.JSONDecodeError) as exc: logger.warning( "B06 종횡단 상세 파일 조회 실패: project_id=%s route_id=%s error=%s", diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 5b8ea25d..2da30548 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -1,38 +1,8 @@ -"""B06 종횡단 생성 요청·응답 검증 모델.""" +"""B06 종횡단 조회·확정 응답 검증 모델.""" from typing import Any -from pydantic import BaseModel, ConfigDict, Field - - -class SectionGenerateRequest(BaseModel): - """종횡단 생성 실행 요청.""" - - model_config = ConfigDict(extra="forbid") - - route_id: int = Field(gt=0, description="종횡단을 생성할 확정 경로 routes.id") - filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)") - method: str = Field(default="dtm", description="지표면 표현") - smooth: bool = Field(default=False) - crs: str | None = Field(default=None, description="좌표계 (예: EPSG:5178)") - - # 측점/횡단 옵션 (미지정 시 config 기본값) - station_interval_m: float | None = Field(default=None, gt=0) - cross_half_width_m: float | None = Field(default=None, gt=0) - cross_sample_interval_m: float | None = Field(default=None, gt=0) - long_sample_interval_m: float | None = Field(default=None, gt=0) - - -class SectionGenerateResponse(BaseModel): - """종횡단 생성 결과.""" - - status: str = "success" - project_id: str - route_id: int - longitudinal_id: int - cross_section_count: int - length_m: float - longitudinal_file_path: str +from pydantic import BaseModel class SectionConfirmResponse(BaseModel): diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 059ca52b..0ac748fe 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -1,16 +1,3 @@ -/* ============================================================================= - * B06_wf3_ProfileCross_UI_Page.ts - * 로그인 후 06: 3차 워크플로우 (종·횡단 생성) - * - * 3단 레이아웃 (frontend.md §2): - * 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowLayout) - * 좌측: 대상 경로 ID + 지표면 참조 + 측점/횡단 옵션 폼 - * 우측: 종·횡단 생성 결과(연장·횡단 개수·파일) 카드 - * - * 이벤트 핸들러 명명 (frontend.md §4): onB06_Profile_[기능]_[액션] - * 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3). - * ========================================================================== */ - import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { @@ -30,36 +17,30 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmSections, - fetchSectionDetail, fetchSectionContext, - generateSections, + fetchSectionDetail, getSections, type SectionContextResponse, type SectionDetailResponse, - type SectionGenerateRequest, - type SectionGenerateResponse, type SectionSummaryResponse, } from "./B06_wf3_ProfileCross_Api_Fetch"; import { createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View"; import "./B06_wf3_ProfileCross_UI_Style.css"; -/** locale 헬퍼 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 라벨 + fieldset 그룹 컨테이너 생성. */ function buildGroup(legend: string): HTMLElement { const group = document.createElement("fieldset"); group.className = "b06-profile__group"; - const legendEl = document.createElement("legend"); - legendEl.className = "b06-profile__group-legend"; - legendEl.textContent = legend; - group.append(legendEl); + const legendElement = document.createElement("legend"); + legendElement.className = "b06-profile__group-legend"; + legendElement.textContent = legend; + group.append(legendElement); return group; } -/** 읽기 전용 경로 컨텍스트 표시 행. */ function buildInfoLine(label: string): { root: HTMLElement; value: HTMLElement } { const root = document.createElement("div"); root.className = "b06-profile__info-line"; @@ -71,20 +52,24 @@ function buildInfoLine(label: string): { root: HTMLElement; value: HTMLElement } return { root, value }; } -/** 숫자 입력값을 파싱. 빈 값이면 null. */ -function parseNumber(value: string): number | null { - const trimmed = value.trim(); - if (!trimmed) return null; - const parsed = Number(trimmed); - return Number.isFinite(parsed) ? parsed : null; +function metricRow(label: string, value: string): HTMLElement { + const row = document.createElement("div"); + row.className = "b06-profile__metric"; + const key = document.createElement("span"); + key.className = "b06-profile__metric-key"; + key.textContent = label; + const metricValue = document.createElement("span"); + metricValue.className = "b06-profile__metric-val"; + metricValue.textContent = value; + row.append(key, metricValue); + return row; } export async function renderB06ProfileCross(root: HTMLElement): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); let currentRouteId: number | null = null; - let sectionContext: SectionContextResponse | null = null; let sectionDetail: SectionDetailResponse | null = null; - /* ---- 좌측: 대상 경로 ---- */ const routeGroup = buildGroup(L("B06_Profile_Group_Route")); const routeIdInfo = buildInfoLine(L("B06_Profile_Field_RouteId")); const filterInfo = buildInfoLine(L("B06_Profile_Field_Filter")); @@ -99,106 +84,45 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { crsInfo.root, ); - /* ---- 좌측: 측점/횡단 옵션 ---- */ - const optionGroup = buildGroup(L("B06_Profile_Group_Options")); - const stationField = createInputField({ - label: L("B06_Profile_Field_StationInterval"), - type: "number", - }); - const halfWidthField = createInputField({ - label: L("B06_Profile_Field_CrossHalfWidth"), - type: "number", - }); - const crossSampleField = createInputField({ - label: L("B06_Profile_Field_CrossSample"), - type: "number", - }); - const longSampleField = createInputField({ - label: L("B06_Profile_Field_LongSample"), - type: "number", - }); + const resultGroup = buildGroup(L("B06_Profile_Result_Title")); + const resultBody = document.createElement("div"); + resultBody.className = "b06-profile__result-body"; + resultGroup.append(resultBody); + + const displayGroup = buildGroup(L("B06_Profile_Group_Display")); const verticalExaggerationField = createInputField({ label: L("B06_Profile_Field_VerticalExaggeration"), type: "number", }); verticalExaggerationField.input.min = "0.1"; verticalExaggerationField.input.step = "0.1"; + displayGroup.append(verticalExaggerationField.root); - optionGroup.append( - stationField.root, - halfWidthField.root, - crossSampleField.root, - longSampleField.root, - verticalExaggerationField.root, - ); - - const generateButton = createButton({ - label: L("B06_Profile_Btn_Generate"), - variant: "filled", - onClick: () => void onB06_Profile_Generate_Click(), - }); const confirmButton = createButton({ label: L("B06_Profile_Btn_Confirm"), - variant: "ghost", - onClick: () => void onB06_Profile_Confirm_Click(), + variant: "filled", + onClick: () => void confirmCurrentSections(), }); + confirmButton.disabled = true; const actionRow = document.createElement("div"); actionRow.className = "b06-profile__actions"; - actionRow.append(generateButton, confirmButton); + actionRow.append(confirmButton); const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; - leftForm.append(routeGroup, optionGroup, actionRow); + leftForm.append(routeGroup, resultGroup, displayGroup, actionRow); + const sectionView = createSectionView(); - /* ---- 우측: 결과 ---- */ - const resultTitle = document.createElement("h3"); - resultTitle.className = "b06-profile__result-title"; - resultTitle.textContent = L("B06_Profile_Result_Title"); - const resultBody = document.createElement("div"); - resultBody.className = "b06-profile__result-body"; - - function renderEmptyResult(): void { - resultBody.replaceChildren(); - const empty = document.createElement("p"); - empty.className = "b06-profile__empty"; - empty.textContent = L("B06_Profile_Result_Empty"); - resultBody.append(empty); - } - - function renderResultMessage(message: string): void { - resultBody.replaceChildren(); + function renderMessage(message: string): void { const text = document.createElement("p"); text.className = "b06-profile__empty"; text.textContent = message; - resultBody.append(text); - } - - function metricRow(label: string, value: string): HTMLElement { - const row = document.createElement("div"); - row.className = "b06-profile__metric"; - const key = document.createElement("span"); - key.className = "b06-profile__metric-key"; - key.textContent = label; - const val = document.createElement("span"); - val.className = "b06-profile__metric-val"; - val.textContent = value; - row.append(key, val); - return row; - } - - function renderResult(result: SectionGenerateResponse): void { - resultBody.replaceChildren(); - resultBody.append( - metricRow(L("B06_Profile_Result_Length"), result.length_m.toFixed(2)), - metricRow(L("B06_Profile_Result_CrossCount"), String(result.cross_section_count)), - metricRow(L("B06_Profile_Result_Path"), result.longitudinal_file_path), - ); + resultBody.replaceChildren(text); } function renderSummary(result: SectionSummaryResponse): void { const path = result.longitudinal?.longitudinal_file_path; - resultBody.replaceChildren(); - resultBody.append( + resultBody.replaceChildren( metricRow( L("B06_Profile_Result_Length"), result.length_m === null ? "-" : result.length_m.toFixed(2), @@ -208,94 +132,20 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ); } - const resultCard = document.createElement("div"); - resultCard.className = "b06-profile__result"; - resultCard.append(resultTitle, resultBody); - const sectionView = createSectionView(); - const mainContent = document.createElement("div"); - mainContent.className = "b06-profile__main"; - mainContent.append(resultCard, sectionView.root); + function verticalExaggeration(): number { + const parsed = Number(verticalExaggerationField.input.value); + return Number.isFinite(parsed) && parsed >= 0.1 ? parsed : 1; + } verticalExaggerationField.input.addEventListener("input", () => { - const exaggeration = parseNumber(verticalExaggerationField.input.value); - if (sectionDetail && exaggeration !== null && exaggeration >= 0.1) { - sectionView.render(sectionDetail, exaggeration); - } + if (sectionDetail) sectionView.render(sectionDetail, verticalExaggeration()); }); - /* ---- 이벤트 핸들러 ---- */ - function getProjectId(): string | null { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - if (!projectId) showToast(L("B06_Profile_Error_Project"), "error"); - return projectId; - } - - async function loadSectionDetail(projectId: string, routeId: number): Promise { - try { - sectionDetail = await fetchSectionDetail(projectId, routeId); - sectionView.render(sectionDetail, parseNumber(verticalExaggerationField.input.value) ?? 1); - } catch (error) { - sectionDetail = null; - sectionView.clear(); - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${L("B06_Profile_Detail_Failed")}${detail}`, "error"); - } - } - - function buildGenerateRequest(): SectionGenerateRequest | null { - if ( - sectionContext?.route_id === null || - !sectionContext?.route_id || - !sectionContext.filter_key || - !sectionContext.method - ) { - return null; - } - return { - route_id: sectionContext.route_id, - filter_key: sectionContext.filter_key, - method: sectionContext.method, - smooth: sectionContext.smooth ?? false, - crs: sectionContext.crs_epsg === null ? null : `EPSG:${sectionContext.crs_epsg}`, - station_interval_m: parseNumber(stationField.input.value), - cross_half_width_m: parseNumber(halfWidthField.input.value), - cross_sample_interval_m: parseNumber(crossSampleField.input.value), - long_sample_interval_m: parseNumber(longSampleField.input.value), - }; - } - - async function onB06_Profile_Generate_Click(): Promise { - const projectId = getProjectId(); - if (!projectId) return false; - const request = buildGenerateRequest(); - if (!request) return false; - + async function confirmCurrentSections(): Promise { + if (!projectId || currentRouteId === null) return; showLoadingOverlay(); try { - const result = await generateSections(projectId, request); - currentRouteId = result.route_id; - renderResult(result); - await loadSectionDetail(projectId, result.route_id); - confirmButton.disabled = false; - showToast(L("B06_Profile_Generate_Success"), "success"); - return true; - } catch (error) { - const detail = error instanceof Error ? error.message : L("B06_Profile_Generate_Failed"); - showToast(`${L("B06_Profile_Generate_Failed")} ${detail}`, "error"); - return false; - } finally { - hideLoadingOverlay(); - } - } - - async function onB06_Profile_Confirm_Click(): Promise { - const projectId = getProjectId(); - if (!projectId) return; - const routeId = currentRouteId; - if (routeId === null) return; - showLoadingOverlay(); - try { - await confirmSections(projectId, routeId); + await confirmSections(projectId, currentRouteId); showToast(L("B06_Profile_Confirm_Success"), "success"); } catch (error) { const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed"); @@ -305,22 +155,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } - renderEmptyResult(); - let workflowState: WorkflowState | undefined; - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + let context: SectionContextResponse | null = null; if (projectId) { const [contextResult, workflowResult] = await Promise.allSettled([ fetchSectionContext(projectId), fetchWorkflowState(projectId), ]); - if (contextResult.status === "fulfilled") { - sectionContext = contextResult.value; - } else { - const reason = contextResult.reason; - const detail = reason instanceof Error ? ` ${reason.message}` : ""; - showToast(`${L("B06_Profile_Context_Failed")}${detail}`, "error"); - } + if (contextResult.status === "fulfilled") context = contextResult.value; + else showToast(L("B06_Profile_Context_Failed"), "error"); if (workflowResult.status === "fulfilled") workflowState = workflowResult.value; } @@ -329,64 +172,53 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { steps: workflowSteps(), activeStep: 3, leftPanel: leftForm, - mainContent, + mainContent: sectionView.root, stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => { - if (projectId) { - goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); - } + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); root.replaceChildren(layout.root); - if (!projectId || !sectionContext) { - generateButton.disabled = true; - confirmButton.disabled = true; - if (projectId) renderResultMessage(L("B06_Profile_Context_Failed")); + if (!projectId) { + renderMessage(L("B06_Profile_Error_Project")); + return; + } + if (!context) { + renderMessage(L("B06_Profile_Context_Failed")); return; } - const context = sectionContext; routeIdInfo.value.textContent = context.route_id === null ? "-" : String(context.route_id); filterInfo.value.textContent = context.filter_key ?? "-"; methodInfo.value.textContent = context.method ?? "-"; - smoothInfo.value.textContent = - context.smooth === null - ? "-" - : context.smooth - ? L("B06_Profile_Smooth_On") - : L("B06_Profile_Smooth_Off"); + smoothInfo.value.textContent = context.smooth + ? L("B06_Profile_Smooth_On") + : L("B06_Profile_Smooth_Off"); crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`; - stationField.input.value = String(context.defaults.station_interval_m); - halfWidthField.input.value = String(context.defaults.cross_half_width_m); - crossSampleField.input.value = String(context.defaults.cross_sample_interval_m); - longSampleField.input.value = String(context.defaults.long_sample_interval_m); verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration); if (context.route_id === null) { - generateButton.disabled = true; - confirmButton.disabled = true; - renderResultMessage(L("B06_Profile_No_Confirmed_Route")); + renderMessage(L("B06_Profile_Calculate_In_B05")); return; } currentRouteId = context.route_id; - confirmButton.disabled = true; try { const existing = await getSections(projectId, context.route_id); - if (existing.longitudinal) { - renderSummary(existing); - await loadSectionDetail(projectId, context.route_id); - confirmButton.disabled = false; + if (!existing.longitudinal) { + renderMessage(L("B06_Profile_Calculate_In_B05")); return; } - renderResultMessage(L("B06_Profile_Auto_Generating")); - const generated = await onB06_Profile_Generate_Click(); - confirmButton.disabled = !generated; + renderSummary(existing); + sectionDetail = await fetchSectionDetail(projectId, context.route_id); + sectionView.render(sectionDetail, verticalExaggeration()); + confirmButton.disabled = false; } catch (error) { - const detail = error instanceof Error ? error.message : L("B06_Profile_Generate_Failed"); - showToast(`${L("B06_Profile_Generate_Failed")} ${detail}`, "error"); + const detail = error instanceof Error ? ` ${error.message}` : ""; + renderMessage(L("B06_Profile_Calculate_In_B05")); + showToast(`${L("B06_Profile_Detail_Failed")}${detail}`, "error"); } } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 3d25c301..d34a520e 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -8,7 +8,7 @@ import type { const SVG_NS = "http://www.w3.org/2000/svg"; const LONG_WIDTH = 1200; -const LONG_HEIGHT = 310; +const LONG_HEIGHT = 220; const CROSS_WIDTH = 560; const CROSS_HEIGHT = 260; const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 }; diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json deleted file mode 100644 index 255689d0..00000000 --- a/graphify-out/manifest.json +++ /dev/null @@ -1,1172 +0,0 @@ -{ - "docs/wiki/AGENTS.md": { - "mtime": 1783834983.0, - "ast_hash": "74d04caee1f70d588da20af0f2224049", - "semantic_hash": "" - }, - "docs/wiki/CLAUDE.md": { - "mtime": 1783834983.0, - "ast_hash": "74d04caee1f70d588da20af0f2224049", - "semantic_hash": "" - }, - "docs/wiki/concepts/a00_app_shell_framework.md": { - "mtime": 1784369023.0, - "ast_hash": "1a73aa816dd5e4270ae6bf8e0ede0b97", - "semantic_hash": "" - }, - "docs/wiki/concepts/a00_app_shell_framework_scaffold.md": { - "mtime": 1783844389.0, - "ast_hash": "b08d53673fa62ab2ae17105341f6beda", - "semantic_hash": "" - }, - "docs/wiki/concepts/api_common.md": { - "mtime": 1783849575.0, - "ast_hash": "aa36d7b826c4ac0349cd64a64cde5fe4", - "semantic_hash": "" - }, - "docs/wiki/concepts/auth_rbac.md": { - "mtime": 1784259833.0, - "ast_hash": "2c6ad199f866257eca3bec738fcf5f3d", - "semantic_hash": "" - }, - "docs/wiki/concepts/common_util.md": { - "mtime": 1784365180.0, - "ast_hash": "c8a50d203fa7f0d12e562dc9dd077e95", - "semantic_hash": "" - }, - "docs/wiki/concepts/db_schema/files_surface.md": { - "mtime": 1784267731.0, - "ast_hash": "7025d41c372c169b7929ed6baca3b07f", - "semantic_hash": "" - }, - "docs/wiki/concepts/db_schema/logs_monitoring.md": { - "mtime": 1784348499.0, - "ast_hash": "aaca07904f047b3e583e8cabc5187ecf", - "semantic_hash": "" - }, - "docs/wiki/concepts/db_schema/overview.md": { - "mtime": 1784259347.0, - "ast_hash": "8f35d8e4f659eee10ef9e01411cdc079", - "semantic_hash": "" - }, - "docs/wiki/concepts/db_schema/projects.md": { - "mtime": 1784259359.0, - "ast_hash": "f037e166e7b72002dfa3c7552a0eae43", - "semantic_hash": "" - }, - "docs/wiki/concepts/db_schema/route_profile.md": { - "mtime": 1784259366.0, - "ast_hash": "9ba725f36208f26e1c8d0d9f6179325c", - "semantic_hash": "" - }, - "docs/wiki/concepts/db_schema/structure_output.md": { - "mtime": 1784259369.0, - "ast_hash": "92adeedeb94bb3b75b0ba4a42c75ea15", - "semantic_hash": "" - }, - "docs/wiki/concepts/db_schema/unconfirmed/README.md": { - "mtime": 1784259342.0, - "ast_hash": "14b964e21ad8705d3fb2c6c818f71750", - "semantic_hash": "" - }, - "docs/wiki/concepts/db_schema/users_auth.md": { - "mtime": 1784259859.0, - "ast_hash": "4252d7048a05615e6420fb2853567323", - "semantic_hash": "" - }, - "docs/wiki/concepts/dependencies.md": { - "mtime": 1784353789.0, - "ast_hash": "c9945009d6cf41f9b9bdc95ed6f31804", - "semantic_hash": "" - }, - "docs/wiki/concepts/design.md": { - "mtime": 1784260030.0, - "ast_hash": "85aec17d904c548e299a4e3810669cc0", - "semantic_hash": "" - }, - "docs/wiki/concepts/schema_common.md": { - "mtime": 1783844389.0, - "ast_hash": "ebde162a912c33c17bd42e3edb469bc6", - "semantic_hash": "" - }, - "docs/wiki/concepts/storage_paths.md": { - "mtime": 1784276227.0, - "ast_hash": "a0fe28424a580b6dce5ddbcb66f886e5", - "semantic_hash": "" - }, - "docs/wiki/concepts/ui_templates.md": { - "mtime": 1784264934.0, - "ast_hash": "6cb21cb237d90125d149b1db904a2d74", - "semantic_hash": "" - }, - "docs/wiki/concepts/workflow_state.md": { - "mtime": 1784367329.0, - "ast_hash": "7c52330d044d3d1cf1bcd06404d64cd8", - "semantic_hash": "" - }, - "docs/wiki/index.md": { - "mtime": 1784376234.2042696, - "ast_hash": "fc1c8a3a02abfcf9821f1581b3fe7109", - "semantic_hash": "" - }, - "docs/wiki/log.md": { - "mtime": 1784376236.3120687, - "ast_hash": "cf9f6ba825c065b8c23c481320439afe", - "semantic_hash": "" - }, - "docs/wiki/pages/A01_Home/A01_components.md": { - "mtime": 1783849656.0, - "ast_hash": "af5724435a2da0845f42aed322971f7a", - "semantic_hash": "" - }, - "docs/wiki/pages/A01_Home/A01_frontend.md": { - "mtime": 1783849659.0, - "ast_hash": "59d5bcdf4d88a0e576c55c5752579a23", - "semantic_hash": "" - }, - "docs/wiki/pages/A02_ProgDetail/A02_components.md": { - "mtime": 1783849775.0, - "ast_hash": "d663c36e0202f099a2602d631c2f962d", - "semantic_hash": "" - }, - "docs/wiki/pages/A02_ProgDetail/A02_frontend.md": { - "mtime": 1783849775.0, - "ast_hash": "b590aa6c5a13b2478592e23803f5398d", - "semantic_hash": "" - }, - "docs/wiki/pages/A03_CompDetail/A03_frontend.md": { - "mtime": 1783844088.0, - "ast_hash": "833dd9355fb96b46b0f94271a642c216", - "semantic_hash": "" - }, - "docs/wiki/pages/A04_NewsHistory/A04_frontend.md": { - "mtime": 1783844098.0, - "ast_hash": "5bb83dca8f9f11f0122f5911b295e279", - "semantic_hash": "" - }, - "docs/wiki/pages/A05_EduDetail/A05_frontend.md": { - "mtime": 1783844108.0, - "ast_hash": "fadf785b9ebad689862cb8cfa9827fbd", - "semantic_hash": "" - }, - "docs/wiki/pages/A06_Login/A06_backend.md": { - "mtime": 1784259816.0, - "ast_hash": "8ff447e9f595b09ab8706e6b2153c2e4", - "semantic_hash": "" - }, - "docs/wiki/pages/A06_Login/A06_frontend.md": { - "mtime": 1784259825.0, - "ast_hash": "b77699e691f04c97e2676b56a978d441", - "semantic_hash": "" - }, - "docs/wiki/pages/A07_Register/A07_backend.md": { - "mtime": 1784259843.0, - "ast_hash": "b2ba632dc7648b9925828c57fb94073f", - "semantic_hash": "" - }, - "docs/wiki/pages/A07_Register/A07_frontend.md": { - "mtime": 1783849775.0, - "ast_hash": "0c30b980601414cefa02c05ba50a9008", - "semantic_hash": "" - }, - "docs/wiki/pages/A08_Support/A08_backend.md": { - "mtime": 1783849900.0, - "ast_hash": "d2dd1028a837d8c17429179c4ffd7135", - "semantic_hash": "" - }, - "docs/wiki/pages/A08_Support/A08_frontend.md": { - "mtime": 1783849775.0, - "ast_hash": "f2ebae60bb02320db4ac39e0a66b9d69", - "semantic_hash": "" - }, - "docs/wiki/pages/A09_Security/A09_backend.md": { - "mtime": 1783849775.0, - "ast_hash": "f96ba36247f269672317bc352c705a7c", - "semantic_hash": "" - }, - "docs/wiki/pages/A09_Security/A09_frontend.md": { - "mtime": 1783849775.0, - "ast_hash": "d90b0617abec1376bd32e140b0eb1f63", - "semantic_hash": "" - }, - "docs/wiki/pages/B01_Dashboard/B01_api.md": { - "mtime": 1784259230.0, - "ast_hash": "cec23a7fa764aa9cb89ca556386a9d1e", - "semantic_hash": "" - }, - "docs/wiki/pages/B01_Dashboard/B01_backend.md": { - "mtime": 1784259865.0, - "ast_hash": "d3a676bc366985ef5ec949ae15a3d3cf", - "semantic_hash": "" - }, - "docs/wiki/pages/B01_Dashboard/B01_db.md": { - "mtime": 1783850631.0, - "ast_hash": "e7467e7ab25de8a576244c8595fefca4", - "semantic_hash": "" - }, - "docs/wiki/pages/B01_Dashboard/B01_dependencies.md": { - "mtime": 1784259237.0, - "ast_hash": "b772aa70b5f90deb37c5324f93d79a80", - "semantic_hash": "" - }, - "docs/wiki/pages/B01_Dashboard/B01_frontend.md": { - "mtime": 1784264944.0, - "ast_hash": "720d69c49dce55121af9846763aa3258", - "semantic_hash": "" - }, - "docs/wiki/pages/B02_ProjRegister/B02_backend.md": { - "mtime": 1783859694.0, - "ast_hash": "ec7d0edddc708060b31ca0058630cd1c", - "semantic_hash": "" - }, - "docs/wiki/pages/B02_ProjRegister/B02_db.md": { - "mtime": 1783849775.0, - "ast_hash": "ca17bc76462201e2ce50e754fbe7c816", - "semantic_hash": "" - }, - "docs/wiki/pages/B02_ProjRegister/B02_frontend.md": { - "mtime": 1783849775.0, - "ast_hash": "407638de4f1ed56a3ba72c5c244d8ebd", - "semantic_hash": "" - }, - "docs/wiki/pages/B03_FileInput/B03_api.md": { - "mtime": 1784259243.0, - "ast_hash": "96a8eb8ea278e761ddf0a3a0e2693728", - "semantic_hash": "" - }, - "docs/wiki/pages/B03_FileInput/B03_backend.md": { - "mtime": 1784355935.0, - "ast_hash": "5b3c655277dabf98fcd8ab99ff2fccca", - "semantic_hash": "" - }, - "docs/wiki/pages/B03_FileInput/B03_db.md": { - "mtime": 1783850637.0, - "ast_hash": "229bc7bb89bc7fb3a745fa7a1eb56640", - "semantic_hash": "" - }, - "docs/wiki/pages/B03_FileInput/B03_dependencies.md": { - "mtime": 1784259157.0, - "ast_hash": "8ba6d765120bd79067f87dad58605dba", - "semantic_hash": "" - }, - "docs/wiki/pages/B03_FileInput/B03_frontend.md": { - "mtime": 1784264939.0, - "ast_hash": "ff5b954b7136ca7f73c842965240400c", - "semantic_hash": "" - }, - "docs/wiki/pages/B04_wf1_Surface/B04_api.md": { - "mtime": 1784348304.0, - "ast_hash": "0c432552116e17dabb23da60e6500c0c", - "semantic_hash": "" - }, - "docs/wiki/pages/B04_wf1_Surface/B04_backend.md": { - "mtime": 1784367346.0, - "ast_hash": "27103f91f214fc48dbbdc3402a66598d", - "semantic_hash": "" - }, - "docs/wiki/pages/B04_wf1_Surface/B04_db.md": { - "mtime": 1784348536.0, - "ast_hash": "26728221e5e83fc1d551535e0fb33bb2", - "semantic_hash": "" - }, - "docs/wiki/pages/B04_wf1_Surface/B04_dependencies.md": { - "mtime": 1784353769.0, - "ast_hash": "9a6ce9a740a181df1c79d3f336664312", - "semantic_hash": "" - }, - "docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": { - "mtime": 1784278834.0, - "ast_hash": "bb89b16cc5a0d9763cb26c174a537f54", - "semantic_hash": "" - }, - "docs/wiki/pages/B05_wf2_Route/B05_api.md": { - "mtime": 1784367356.0, - "ast_hash": "e9160c380be1ae99eab6a0459a6e9c4e", - "semantic_hash": "" - }, - "docs/wiki/pages/B05_wf2_Route/B05_backend.md": { - "mtime": 1784367351.0, - "ast_hash": "601af33ffeac30c1eb81253e62978fe7", - "semantic_hash": "" - }, - "docs/wiki/pages/B05_wf2_Route/B05_db.md": { - "mtime": 1784365771.0, - "ast_hash": "5e3577c18b306e2a0f1ff2ebf482ccf7", - "semantic_hash": "" - }, - "docs/wiki/pages/B05_wf2_Route/B05_dependencies.md": { - "mtime": 1784353784.0, - "ast_hash": "203966b0f821d11a133db6954918b843", - "semantic_hash": "" - }, - "docs/wiki/pages/B05_wf2_Route/B05_frontend.md": { - "mtime": 1784369027.0, - "ast_hash": "6f17f638d6829f29b66d451c985d6c74", - "semantic_hash": "" - }, - "docs/wiki/pages/B06_wf3_ProfileCross/B06_api.md": { - "mtime": 1784376219.635943, - "ast_hash": "880164c5d0ff8fc2046706f6fe62f6f7", - "semantic_hash": "" - }, - "docs/wiki/pages/B06_wf3_ProfileCross/B06_backend.md": { - "mtime": 1784376226.78404, - "ast_hash": "269f5d26ec2e7d08a595548ddef63009", - "semantic_hash": "" - }, - "docs/wiki/pages/B06_wf3_ProfileCross/B06_db.md": { - "mtime": 1784376231.4393904, - "ast_hash": "2f7dd1a4d9491782d9e87a62a8099d91", - "semantic_hash": "" - }, - "docs/wiki/pages/B06_wf3_ProfileCross/B06_dependencies.md": { - "mtime": 1784259182.0, - "ast_hash": "1033c52fc6823211db0690aafa706a83", - "semantic_hash": "" - }, - "docs/wiki/pages/B06_wf3_ProfileCross/B06_frontend.md": { - "mtime": 1784376229.6672938, - "ast_hash": "7443a5902c1d4094d0b92d7639cd0120", - "semantic_hash": "" - }, - "docs/wiki/pages/B07_wf4_DesignDetail/B07_frontend.md": { - "mtime": 1783844389.0, - "ast_hash": "d4bd4b2f000f33a76bfd5d2f9b1a2850", - "semantic_hash": "" - }, - "docs/wiki/pages/B08_wf5_Quantity/B08_db.md": { - "mtime": 1783850708.0, - "ast_hash": "00ca815731f29da6f062f6e5884430de", - "semantic_hash": "" - }, - "docs/wiki/pages/B08_wf5_Quantity/B08_frontend.md": { - "mtime": 1784280350.0, - "ast_hash": "82f0c3c8358a8f6b16395623d73f049f", - "semantic_hash": "" - }, - "docs/wiki/pages/B09_wf6_Estimation/B09_frontend.md": { - "mtime": 1783844389.0, - "ast_hash": "c17bf17a317f2190131be87d2e23e9bb", - "semantic_hash": "" - }, - "docs/wiki/pages/B10_Payment/B10_frontend.md": { - "mtime": 1784264923.0, - "ast_hash": "922e565933ffcf90a3fa96307ae4dee3", - "semantic_hash": "" - }, - "docs/wiki/pages/B11_Status/B11_frontend.md": { - "mtime": 1784264926.0, - "ast_hash": "2cf11aa66d6346818c14c62fa8e2b12a", - "semantic_hash": "" - }, - "A00_Common/.vite/deps/_metadata.json": { - "mtime": 1783416979.0, - "ast_hash": "48dd460763d1ba0111894f0f54fd2e63", - "semantic_hash": "" - }, - "A00_Common/.vite/deps/package.json": { - "mtime": 1783416979.0, - "ast_hash": "d0707362e90f00edd12435e9d3b9d71c", - "semantic_hash": "" - }, - "A00_Common/app_shell.ts": { - "mtime": 1784371862.691931, - "ast_hash": "4dacd9bd2c3c9552e5878c23df1069c5", - "semantic_hash": "" - }, - "A00_Common/b_page_scaffold.ts": { - "mtime": 1784371862.692441, - "ast_hash": "ba19266140129680b55b16659cdeca4b", - "semantic_hash": "" - }, - "A00_Common/b_workflow_nav.ts": { - "mtime": 1784371862.6932194, - "ast_hash": "15ffdd7d709c6f2b05782d3176347df0", - "semantic_hash": "" - }, - "A00_Common/main.ts": { - "mtime": 1784371862.6942656, - "ast_hash": "e70d2865f735eac9726efd78d7ed0c10", - "semantic_hash": "" - }, - "A00_Common/router.ts": { - "mtime": 1783505305.0, - "ast_hash": "3a07e5a021c346f6a511915f055922d7", - "semantic_hash": "" - }, - "A00_Common/vite-env.d.ts": { - "mtime": 1783234299.818669, - "ast_hash": "0352474ba2918efe13895edbc3780d94", - "semantic_hash": "" - }, - "A01_Home/A01_Home_UI_Page.ts": { - "mtime": 1783423100.0, - "ast_hash": "28b950204c23ad5d84d70136ea566b38", - "semantic_hash": "" - }, - "A02_ProgDetail/A02_ProgDetail_UI_Page.ts": { - "mtime": 1783237318.848724, - "ast_hash": "718c5537d87bebf0806019a11e815068", - "semantic_hash": "" - }, - "A03_CompDetail/A03_CompDetail_UI_Page.ts": { - "mtime": 1783237445.9769812, - "ast_hash": "85e6b5449bacfda98ae58026fa86882c", - "semantic_hash": "" - }, - "A04_NewsHistory/A04_NewsHistory_UI_Page.ts": { - "mtime": 1783237480.2490423, - "ast_hash": "4049d7e7ddcac64effcbbfa368fccfca", - "semantic_hash": "" - }, - "A05_EduDetail/A05_EduDetail_UI_Page.ts": { - "mtime": 1783237514.488196, - "ast_hash": "649868a7b8c71a64db80d1c398700a32", - "semantic_hash": "" - }, - "A06_Login/A06_Login_Api_Fetch.ts": { - "mtime": 1783421640.0, - "ast_hash": "85da5f81a01e70def7e211066ddec9c7", - "semantic_hash": "" - }, - "A06_Login/A06_Login_Router.py": { - "mtime": 1784371862.6952658, - "ast_hash": "25d3b98295ece2998add42d5b02d5da4", - "semantic_hash": "" - }, - "A06_Login/A06_Login_Schema.py": { - "mtime": 1783414472.0, - "ast_hash": "4001c066f5df2f7f90ce13167987f8bc", - "semantic_hash": "" - }, - "A06_Login/A06_Login_UI_Auth_Page.ts": { - "mtime": 1784371862.696359, - "ast_hash": "ecf3be6b6c74e4f49821eb39a44733ec", - "semantic_hash": "" - }, - "A07_Register/A07_Register_Api_Fetch.ts": { - "mtime": 1783421546.0, - "ast_hash": "1342286656a4bc7b26f3f3ab9fba2bbc", - "semantic_hash": "" - }, - "A07_Register/A07_Register_Router.py": { - "mtime": 1784371862.699004, - "ast_hash": "70a5256a2f78ad08a5a2f072bfc9e3f8", - "semantic_hash": "" - }, - "A07_Register/A07_Register_Schema.py": { - "mtime": 1783421495.0, - "ast_hash": "af7b0ddfaaf442781d754840b81ce59c", - "semantic_hash": "" - }, - "A07_Register/A07_Register_UI_Auth_Page.ts": { - "mtime": 1783421565.0, - "ast_hash": "daf482fe0f4146dc0f6403f5a99e4b82", - "semantic_hash": "" - }, - "A07_Register/A07_Register_UI_Page.ts": { - "mtime": 1783237673.7577379, - "ast_hash": "b911eae7a57cc1b24ae71a1fdf20bb98", - "semantic_hash": "" - }, - "A08_Support/A08_Support_Router.py": { - "mtime": 1783418906.0, - "ast_hash": "c490d0a41c3616db1f221e19d07017fb", - "semantic_hash": "" - }, - "A08_Support/A08_Support_Schema.py": { - "mtime": 1783418883.0, - "ast_hash": "e6ab097405511ec7585588e9b899d04d", - "semantic_hash": "" - }, - "A08_Support/A08_Support_UI_Page.ts": { - "mtime": 1783418988.0, - "ast_hash": "a4eb30df878f52e004fc7c03496ff57c", - "semantic_hash": "" - }, - "A09_Security/A09_Security_Api_Fetch.ts": { - "mtime": 1783415135.0, - "ast_hash": "4e3e8642ac23d07167094833b7762ae4", - "semantic_hash": "" - }, - "A09_Security/A09_Security_Router.py": { - "mtime": 1783415590.0, - "ast_hash": "188d62cf69fb770bb24423183cf7e4d8", - "semantic_hash": "" - }, - "A09_Security/A09_Security_Schema.py": { - "mtime": 1783415590.0, - "ast_hash": "27f6edbb97e74f9ec3615f34de2227b9", - "semantic_hash": "" - }, - "A09_Security/A09_Security_Terms.ts": { - "mtime": 1783414968.0, - "ast_hash": "d1ca33930803139d39382b8e02f6b5eb", - "semantic_hash": "" - }, - "A09_Security/A09_Security_UI_Page.ts": { - "mtime": 1783414968.0, - "ast_hash": "af0ada0cd6b3df7a3ebd2b00960b6216", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_Api_Fetch.ts": { - "mtime": 1784371862.7001457, - "ast_hash": "860ee6c6f184622e1d821949d00ff2cb", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_Repository.py": { - "mtime": 1784371862.7011478, - "ast_hash": "6b934081cb58e13bab33f6a6435e2c61", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_Router.py": { - "mtime": 1784371862.7022014, - "ast_hash": "bf9add5702a4cd8d3cb68b8c1f492774", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_Schema.py": { - "mtime": 1784371862.703229, - "ast_hash": "6d4cb5d264f6172e6c7d89493bc0ff6f", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_UI_Admin.ts": { - "mtime": 1784371862.703229, - "ast_hash": "97788f7b9050d62e411aab5ed2051858", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_UI_Common.ts": { - "mtime": 1784371862.703229, - "ast_hash": "0efca669a90f90a49fe0e2592c5bd162", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_UI_Company.ts": { - "mtime": 1784371862.7044973, - "ast_hash": "bf5db444b8531633ef2ad4c9e77eb931", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_UI_Helper.ts": { - "mtime": 1784371862.7056584, - "ast_hash": "2c6b3f845142f02687f401f86c901f8a", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_UI_Modals.ts": { - "mtime": 1784371862.706769, - "ast_hash": "393432ccf162ec6077541a03d727d7ae", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_UI_Page.ts": { - "mtime": 1784371862.707793, - "ast_hash": "62116ab40530d9f091c87fd16a452ddf", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_UI_Profile.ts": { - "mtime": 1784371862.707793, - "ast_hash": "a20c529b0c9835d6a7fcecc3cf4a2909", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_UI_Projects.ts": { - "mtime": 1784371862.7088647, - "ast_hash": "5cefe93aac7b392130bad296b7546647", - "semantic_hash": "" - }, - "B01_Dashboard/B01_Dashboard_UI_Resources.ts": { - "mtime": 1783587686.6291425, - "ast_hash": "3054f93fbf865e06edc7032d01bd9820", - "semantic_hash": "" - }, - "B02_ProjRegister/B02_ProjRegister_Repository.py": { - "mtime": 1783677088.0, - "ast_hash": "b5b9f982f6407cf25efdffe176c71502", - "semantic_hash": "" - }, - "B02_ProjRegister/B02_ProjRegister_Router.py": { - "mtime": 1783589237.4005332, - "ast_hash": "c91200b99e61930e8254943b7491ded5", - "semantic_hash": "" - }, - "B02_ProjRegister/B02_ProjRegister_Schema.py": { - "mtime": 1783589165.0666614, - "ast_hash": "cccc5f9402eb04efecba085056bb0d2a", - "semantic_hash": "" - }, - "B02_ProjRegister/B02_ProjRegister_UI_Page.ts": { - "mtime": 1783589195.3898335, - "ast_hash": "0e8ab1a73347615578d117e36bf00994", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_Api_Fetch.ts": { - "mtime": 1783672626.0, - "ast_hash": "511c359bd81f6f597d63c64a9a7a945d", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_Email.py": { - "mtime": 1783599110.3864007, - "ast_hash": "6d19915b85a7b3d2109b7c2dc1ea1edb", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_Engine.py": { - "mtime": 1783596209.3112216, - "ast_hash": "d0cf33b03272162dd4b688cf6ad72d73", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_Engine_Analyze.py": { - "mtime": 1784371862.7103398, - "ast_hash": "76f7d75cc2dc5d8041a432f4e1568d19", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_Repository.py": { - "mtime": 1783596239.7588317, - "ast_hash": "aaa288c15de5edb3719a9d7be9f5f973", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_Router.py": { - "mtime": 1784371862.7118456, - "ast_hash": "68c5829f62c0e08124712810d8c5bf1c", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_Schema.py": { - "mtime": 1783596181.187815, - "ast_hash": "64b068e7072e95395379b19caed40b8b", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_ServiceWorker.ts": { - "mtime": 1783596513.025032, - "ast_hash": "e42186f9d15ec1022bc00322882ea278", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_Service_WF1.py": { - "mtime": 1784371862.712557, - "ast_hash": "32e9a57eb4937a35e8acf00ad7005604", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_State.ts": { - "mtime": 1783674025.0, - "ast_hash": "4e43c9e52c0aabc140ed931e18478713", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_UI_Page.ts": { - "mtime": 1784371862.7130625, - "ast_hash": "c359a6ddd091e58c469c14deeac1ad5a", - "semantic_hash": "" - }, - "B03_FileInput/B03_FileInput_UI_Support.ts": { - "mtime": 1784371862.7130625, - "ast_hash": "64d2b28e0423f02c4e51ca787fd83db3", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts": { - "mtime": 1784371862.7145033, - "ast_hash": "5fb65b6de4a6b46751f6c12fc448a1bc", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine.py": { - "mtime": 1784371862.7155347, - "ast_hash": "0c3272f97684405915c78b1f9c7ae528", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_Contour.py": { - "mtime": 1784371862.7165349, - "ast_hash": "abd2f8a4ed17156d736d0741bc10a89f", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_CSF.py": { - "mtime": 1783250545.7624342, - "ast_hash": "c3456ef16f6d99f5d0764d305d4c850c", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_Grid.py": { - "mtime": 1783248229.843674, - "ast_hash": "413f95f0cbef48fe62dd3cb782954a3d", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_PMF.py": { - "mtime": 1783250767.5307772, - "ast_hash": "3dd1f0fac27923be68cd2b01e2795e44", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_Filter_RANSAC.py": { - "mtime": 1783250896.4425025, - "ast_hash": "aae25c9f6cbad2d42e1d22849087c45d", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_GisVector.py": { - "mtime": 1783681631.0, - "ast_hash": "91bc7fda7ec2c74767932447baec3667", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_Ground.py": { - "mtime": 1783251572.58446, - "ast_hash": "ba1b1907e032c7fa0e6be6c90a4f73dd", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_ModelBuild.py": { - "mtime": 1783251412.31927, - "ast_hash": "d820a4619914343447b4b7de5d07ca55", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_ModelContext.py": { - "mtime": 1783251412.31927, - "ast_hash": "fb2e12ad33d430eadea2b654fbdea743", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_MvtHelper.py": { - "mtime": 1783681628.0, - "ast_hash": "5d69eca55395f9c145005e08861885c7", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_Pipeline.py": { - "mtime": 1784371862.7170405, - "ast_hash": "9f1a6940a925f3d15b2e000b9848de0a", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_Smooth.py": { - "mtime": 1783251412.318266, - "ast_hash": "86ddc5c95cb5d0b87ff6eca3f8b221a6", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_Structurize.py": { - "mtime": 1783248152.672701, - "ast_hash": "5b8bc35b11bd89f08f84131a0e81fd15", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Engine_VWorld.py": { - "mtime": 1783681604.0, - "ast_hash": "4100383b096bc40aea3e0721842b60bd", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Repository.py": { - "mtime": 1784371862.718252, - "ast_hash": "eed0e7887079837955600414134020b2", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Router.py": { - "mtime": 1784371862.7196527, - "ast_hash": "40deb5df719ce75a736e4c2f7aec6dfb", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Router_Contour.py": { - "mtime": 1784371862.7208662, - "ast_hash": "43878b93ae7c45d097465fe9013c9ba3", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Router_GIS.py": { - "mtime": 1784371862.7208662, - "ast_hash": "8f35831abd7534830b89b822e48d9572", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Schema.py": { - "mtime": 1784371862.7220292, - "ast_hash": "078bc37f1b87e46c0eba18de11e66059", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_Service.py": { - "mtime": 1784371862.7220292, - "ast_hash": "aff25d583a061a427be88b25bbde0a70", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts": { - "mtime": 1784371862.723084, - "ast_hash": "28e87065fe59f2917e7c4a165a1cb171", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts": { - "mtime": 1784371862.723084, - "ast_hash": "5dd35a1dbf882ec182f5497066be252c", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts": { - "mtime": 1784371862.7244713, - "ast_hash": "813afafa03a15a28d54bc2575efc32e4", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts": { - "mtime": 1784371862.7274556, - "ast_hash": "b81950f01aa877da8ff16bacb3696fc2", - "semantic_hash": "" - }, - "B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts": { - "mtime": 1784371862.7284577, - "ast_hash": "2b48d1b90147f675ec62ef7bd7dd5c6b", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts": { - "mtime": 1784371862.7289634, - "ast_hash": "cc6349770367b8ec4b9746aa875e2eea", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_Debug.py": { - "mtime": 1784371862.7299652, - "ast_hash": "07860ac23101ba802da621025bc374d5", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_Engine.py": { - "mtime": 1784371862.7299652, - "ast_hash": "4bdea485263a52587b62a4b190493214", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_Engine_Geometry.py": { - "mtime": 1783252313.986228, - "ast_hash": "37cd229216a5c5b9ea36d3413cbb04b1", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_Engine_RidgeValley.py": { - "mtime": 1783252726.219655, - "ast_hash": "3033d914817006ff557bb3a30e5129b3", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_Engine_Skeleton.py": { - "mtime": 1784371862.731433, - "ast_hash": "2c54dd0920045a39956fe9574d27c8e9", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_Engine_Solver.py": { - "mtime": 1783252313.986228, - "ast_hash": "1a2b0e76bfb330dacfebabb72f1b5be6", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_Repository.py": { - "mtime": 1784375574.3473394, - "ast_hash": "f810f9ad3d45bab36443db8abb7218da", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_Router.py": { - "mtime": 1784371862.733941, - "ast_hash": "8480126cddd2ba075e56f9852c3060ab", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_Schema.py": { - "mtime": 1784371862.73506, - "ast_hash": "5f580db0bf758faab4fb7762fdd638e7", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_UI_Markers.ts": { - "mtime": 1784371862.73506, - "ast_hash": "6140bff561c64430d9b1d38b7be64d8c", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_UI_Page.ts": { - "mtime": 1784371862.7363892, - "ast_hash": "1f35be9f56d933f7f8cd7086464743ec", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_UI_Panel.ts": { - "mtime": 1784371862.7373946, - "ast_hash": "fa18f43da3ebef03f8b3236f0b579138", - "semantic_hash": "" - }, - "B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts": { - "mtime": 1784371862.7390225, - "ast_hash": "3ac599812e510168e1a493d27b1ec51a", - "semantic_hash": "" - }, - "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts": { - "mtime": 1784379630.3386846, - "ast_hash": "52c3a9ee9585954231e15ae041b8f06b", - "semantic_hash": "" - }, - "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py": { - "mtime": 1784375574.3494227, - "ast_hash": "c513b71a9cb135047ec58594d3d88067", - "semantic_hash": "" - }, - "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Sampler.py": { - "mtime": 1783252847.3035698, - "ast_hash": "09be25eece0a09f971bf66dcf9d83d8f", - "semantic_hash": "" - }, - "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Section.py": { - "mtime": 1783252893.2828002, - "ast_hash": "4b8b50006278faec701b3df20bfa9a85", - "semantic_hash": "" - }, - "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py": { - "mtime": 1784380743.010188, - "ast_hash": "54e86ecb1802a13476f93b41085fe3d9", - "semantic_hash": "" - }, - "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py": { - "mtime": 1784379682.786804, - "ast_hash": "e05a792607a34e36ad14deaadfcef175", - "semantic_hash": "" - }, - "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py": { - "mtime": 1784379656.886007, - "ast_hash": "12de528aa1281f841c583435305bee92", - "semantic_hash": "" - }, - "B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts": { - "mtime": 1784380760.2568946, - "ast_hash": "0bd7cd13aa999ac1b74481359b3d8da9", - "semantic_hash": "" - }, - "B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts": { - "mtime": 1784371862.7403774, - "ast_hash": "a66fe85891a43fc0ac1c9ae2fadea04e", - "semantic_hash": "" - }, - "B08_wf5_Quantity/B08_wf5_Quantity_UI_Page.ts": { - "mtime": 1784371862.7418675, - "ast_hash": "33b2edca2fb2752de09d4cf42a6e1f48", - "semantic_hash": "" - }, - "B09_wf6_Estimation/B09_wf6_Estimation_UI_Page.ts": { - "mtime": 1784371862.7418675, - "ast_hash": "76f0a0873e539d3d2d5fb27c191888ed", - "semantic_hash": "" - }, - "B10_Payment/B10_Payment_UI_Page.ts": { - "mtime": 1784371862.7429183, - "ast_hash": "6466645325404f4703a88a93417d8dba", - "semantic_hash": "" - }, - "B11_Status/B11_Status_UI_Page.ts": { - "mtime": 1784371862.743941, - "ast_hash": "957b7ee41136212b91b60ed2d1fab72e", - "semantic_hash": "" - }, - "common_util/common_util_atomic.py": { - "mtime": 1783250994.605519, - "ast_hash": "8d9aa0a5c2d6ca864bd341267c6b0dfc", - "semantic_hash": "" - }, - "common_util/common_util_auth.py": { - "mtime": 1784371862.74606, - "ast_hash": "c4581646ca72b409715d1703627c4526", - "semantic_hash": "" - }, - "common_util/common_util_auth_repository.py": { - "mtime": 1784371862.7475655, - "ast_hash": "2898ea4436f14bbb938d28b2f14c5293", - "semantic_hash": "" - }, - "common_util/common_util_email.py": { - "mtime": 1783415605.0, - "ast_hash": "6c238daca6bf03c964a6cf2c02a1c9e4", - "semantic_hash": "" - }, - "common_util/common_util_email_templates.py": { - "mtime": 1783419082.0, - "ast_hash": "1aa788a52626e156e1619d4e3f0f93a9", - "semantic_hash": "" - }, - "common_util/common_util_json.py": { - "mtime": 1783244734.2867022, - "ast_hash": "91c303ff3382a3f0cca4596c872936a3", - "semantic_hash": "" - }, - "common_util/common_util_resource_monitor.py": { - "mtime": 1783504247.0, - "ast_hash": "e4689eb9141750dbcf22943a6ce7ea19", - "semantic_hash": "" - }, - "common_util/common_util_storage.py": { - "mtime": 1783673573.0, - "ast_hash": "c03810b60e5d5a47036838346346ce15", - "semantic_hash": "" - }, - "common_util/common_util_surface_confirmation.py": { - "mtime": 1784371862.7481408, - "ast_hash": "1e7d68a2270e9a6833bf17156c250ca1", - "semantic_hash": "" - }, - "common_util/common_util_validate.ts": { - "mtime": 1783237546.0309367, - "ast_hash": "6ad1316290c0476f952e0f81a81e03bd", - "semantic_hash": "" - }, - "common_util/common_util_workflow.py": { - "mtime": 1783244812.2994404, - "ast_hash": "be9ffbd894a166cd3f8763ae85dbe81f", - "semantic_hash": "" - }, - "common_util/common_util_workflow_state.py": { - "mtime": 1783677093.0, - "ast_hash": "4e05dc928abc17da0c2a12485ab11029", - "semantic_hash": "" - }, - "config/config_db.py": { - "mtime": 1783423949.0, - "ast_hash": "ce9915156c7b713b84eace38b68e099c", - "semantic_hash": "" - }, - "config/config_frontend.ts": { - "mtime": 1783673644.0, - "ast_hash": "e5b28cbb92bf2fbcebb36f6a51c91916", - "semantic_hash": "" - }, - "config/config_system.py": { - "mtime": 1784377093.9173894, - "ast_hash": "f4a83c9879f6d2bf6d9b3a84a05b5fd2", - "semantic_hash": "" - }, - "db_management/001_create_schema.sql": { - "mtime": 1784371862.7504451, - "ast_hash": "b1d4c4c2e4bbfc412d51f3c84bfa4e36", - "semantic_hash": "" - }, - "db_management/002_auth_security.sql": { - "mtime": 1783418873.0, - "ast_hash": "741d633ee414e9722e5976f2e828a8fb", - "semantic_hash": "" - }, - "db_management/003_register_split.sql": { - "mtime": 1783502148.0, - "ast_hash": "74b3a17442f28d77687f581de27b1242", - "semantic_hash": "" - }, - "db_management/004_dashboard.sql": { - "mtime": 1784371862.751445, - "ast_hash": "386bb088c7ca7d678048d2a051ae69a8", - "semantic_hash": "" - }, - "db_management/005_b03_chunk_upload.sql": { - "mtime": 1783596524.266818, - "ast_hash": "57f83d7b98af96990d1bc69876897fbb", - "semantic_hash": "" - }, - "db_management/006_workflow_state.sql": { - "mtime": 1783676881.0, - "ast_hash": "5ecbe04b699371a577f853b1363b5128", - "semantic_hash": "" - }, - "db_management/007_trusted_device_token.sql": { - "mtime": 1784371862.751445, - "ast_hash": "cb6fca6d4e3b3183128b7282c22b1ec6", - "semantic_hash": "" - }, - "db_management/008_drop_project_automations.sql": { - "mtime": 1784371862.7525203, - "ast_hash": "59745e31a2a2801ebef77e0b03cdc59d", - "semantic_hash": "" - }, - "db_management/009_route_points_xyz.sql": { - "mtime": 1784371862.7525203, - "ast_hash": "9c73619108b4fcbd4b63721dbb8cb960", - "semantic_hash": "" - }, - "db_management/migrate_workflow_state.py": { - "mtime": 1783677101.0, - "ast_hash": "64137c7b017cc3e2c007818796e605fe", - "semantic_hash": "" - }, - "main.py": { - "mtime": 1784377236.4697044, - "ast_hash": "4852786a0f34ac197fc45b009a5ef34f", - "semantic_hash": "" - }, - "migrations/001_create_upload_tables.sql": { - "mtime": 1783600548.0, - "ast_hash": "c0891f75d24a00ff3aaf666233be916c", - "semantic_hash": "" - }, - "package.json": { - "mtime": 1784377181.9267156, - "ast_hash": "5143268e06d1faeaa7e9e1acb7ef4b20", - "semantic_hash": "" - }, - "pyproject.toml": { - "mtime": 1783225255.0, - "ast_hash": "2cc68cbb844c44a41f3f035734750e69", - "semantic_hash": "" - }, - "scratch/test_crs_verification.py": { - "mtime": 1784371862.7568798, - "ast_hash": "8d266aebaa74e9a2bcef1eefe6e97184", - "semantic_hash": "" - }, - "scratch/wiki_linter.py": { - "mtime": 1784376278.8101668, - "ast_hash": "76c8805b5ade82b52388c8e58af69a7b", - "semantic_hash": "" - }, - "tsconfig.json": { - "mtime": 1784377132.7051294, - "ast_hash": "b7eaa646b12fd1e7f38bfd68d0befe96", - "semantic_hash": "" - }, - "ui_template/ui_template_elements.ts": { - "mtime": 1784371862.7589295, - "ast_hash": "584820cdbcdb5c360def7de1fcfaebf4", - "semantic_hash": "" - }, - "ui_template/ui_template_general_blocks.ts": { - "mtime": 1784371862.7594473, - "ast_hash": "8d5c82ae21fbea2da5d49783572f9c49", - "semantic_hash": "" - }, - "ui_template/ui_template_general_layout.ts": { - "mtime": 1784371862.7604806, - "ast_hash": "a491d4081e5d73c8ed9767324712ec65", - "semantic_hash": "" - }, - "ui_template/ui_template_locale.ts": { - "mtime": 1784380749.5154142, - "ast_hash": "3559a748646ef02c8fbf3fe2002ff99d", - "semantic_hash": "" - }, - "ui_template/ui_template_overlay.ts": { - "mtime": 1784371862.7630575, - "ast_hash": "76116f4d99e8beaa18f7cb8a433a5d2f", - "semantic_hash": "" - }, - "ui_template/ui_template_workflow_layout.ts": { - "mtime": 1784371862.7646809, - "ast_hash": "3bddec3560e443db6c99a852cbd43c7a", - "semantic_hash": "" - }, - "vite.config.ts": { - "mtime": 1784377182.994571, - "ast_hash": "50c1973601cae4e83c36c6cbd9e67203", - "semantic_hash": "" - }, - "A00_Common/index.html": { - "mtime": 1783335150.0, - "ast_hash": "370b5160117bf7b813b7f9439e5f9ac1", - "semantic_hash": "" - }, - "README.md": { - "mtime": 1784108913.0, - "ast_hash": "4619384b0b9fc2b1fecbc29ee925988d", - "semantic_hash": "" - }, - "requirements.txt": { - "mtime": 1784357162.0, - "ast_hash": "f75f25000ea734fc5e2dfa72cc019735", - "semantic_hash": "" - }, - "resources/legal/marketing_consent.txt": { - "mtime": 1783421488.0, - "ast_hash": "0b3c46af3ff5007be1bd0c2f643dc132", - "semantic_hash": "" - }, - "resources/legal/privacy_policy.txt": { - "mtime": 1783421482.0, - "ast_hash": "1e56e478d669bcec2f52e4e5ffba1465", - "semantic_hash": "" - }, - "resources/legal/terms_of_service.txt": { - "mtime": 1783421474.0, - "ast_hash": "683dafcbabe621641da485afab311c69", - "semantic_hash": "" - }, - "resources/prog_icon.jpg": { - "mtime": 1783331655.0, - "ast_hash": "18a3fd959eb07f8b0a18a86126c7bb6d", - "semantic_hash": "" - }, - "docs/wiki/graphify-out/memory/query_20260718_022949_compare_current_b04_wf1_surface_with_0_old_backend.md": { - "mtime": 1784341789.0, - "ast_hash": "0722820bbdb99abee95661d7a102fe7f", - "semantic_hash": "" - }, - "docs/wiki/graphify-out/memory/query_20260718_044714_\uc81c\uc2dc\ub41c_numpy_2_x_gis_\ud328\ud0a4\uc9c0_\ud540\uc744_\ud604\uc7ac_venv\uc5d0_\uc124\uce58\ud560_\ub54c_\ubb38\uc81c\uac00_\uc788\ub294\uc9c0_\ubd84\uc11d.md": { - "mtime": 1784350034.0, - "ast_hash": "d0623cc53601b82bf8993b6828bac357", - "semantic_hash": "" - }, - "docs/wiki/graphify-out/memory/query_20260718_053304_\ud2b8\ub77c\uc774\uba54\uc2dc_4_12_2\uc640_pyogrio_0_13_0\uc744_\ubc18\uc601\ud558\uace0_setuptools_whit.md": { - "mtime": 1784352784.0, - "ast_hash": "f6d427d6b59463bad1424aed217b987c", - "semantic_hash": "" - } -} \ No newline at end of file diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index f428cb33..782de85b 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -725,6 +725,12 @@ export const ui_locales = { B05_Route_Solve_Failed: ["경로 탐색에 실패했습니다.", "Route solve failed."], B05_Route_Confirm_Success: ["경로를 확정했습니다.", "Route confirmed."], B05_Route_Confirm_Failed: ["경로 확정에 실패했습니다.", "Route confirm failed."], + B05_Route_Group_SectionOptions: ["측점·횡단 옵션", "Station & Cross Options"], + B05_Route_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"], + B05_Route_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"], + B05_Route_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"], + B05_Route_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"], + B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"], /* --- B06_wf3_ProfileCross 종·횡단 생성 --- */ B06_Profile_Title: ["3차 · 종·횡단 생성", "Step 3 · Profile & Cross-section"], @@ -733,39 +739,25 @@ export const ui_locales = { B06_Profile_Field_Filter: ["지면 필터", "Ground filter"], B06_Profile_Field_Method: ["지표면 표현", "Surface method"], B06_Profile_Field_Crs: ["좌표계", "CRS"], - B06_Profile_Group_Options: ["측점·횡단 옵션", "Station & Cross Options"], - B06_Profile_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"], - B06_Profile_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"], - B06_Profile_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"], - B06_Profile_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"], + B06_Profile_Group_Display: ["표시 옵션", "Display Options"], B06_Profile_Field_VerticalExaggeration: ["높이 배율", "Vertical exaggeration"], B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"], B06_Profile_Smooth_On: ["사용", "On"], B06_Profile_Smooth_Off: ["미사용", "Off"], - B06_Profile_Btn_Generate: ["종·횡단 재생성", "Regenerate Sections"], B06_Profile_Btn_Confirm: ["종·횡단 확정", "Confirm Sections"], B06_Profile_Result_Title: ["종·횡단 생성 결과", "Section Result"], - B06_Profile_Result_Empty: ["아직 생성된 종·횡단이 없습니다.", "No sections generated yet."], B06_Profile_Context_Failed: [ "경로 정보를 불러오지 못했습니다. 서버 상태를 확인하세요.", "Failed to load route context. Check the server status.", ], - B06_Profile_No_Confirmed_Route: [ - "확정된 경로가 없습니다. 먼저 경로를 확정하세요.", - "No confirmed route. Confirm a route first.", - ], - B06_Profile_Auto_Generating: [ - "기본 옵션으로 종·횡단을 자동 생성하고 있습니다.", - "Generating sections automatically with the default options.", + B06_Profile_Calculate_In_B05: [ + "저장된 종·횡단이 없습니다. B05에서 경로를 계산하세요.", + "No saved sections. Calculate the route in B05.", ], B06_Profile_Result_Length: ["종단 연장(m)", "Longitudinal length (m)"], B06_Profile_Result_CrossCount: ["횡단 개수", "Cross-section count"], B06_Profile_Result_Path: ["종단 파일", "Longitudinal file"], B06_Profile_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."], - B06_Profile_Error_RouteId: ["유효한 경로 ID를 입력하세요.", "Enter a valid route ID."], - B06_Profile_Error_Filter: ["지면 필터 키를 입력하세요.", "Enter a ground filter key."], - B06_Profile_Generate_Success: ["종·횡단을 생성했습니다.", "Sections generated."], - B06_Profile_Generate_Failed: ["종·횡단 생성에 실패했습니다.", "Section generation failed."], B06_Profile_Confirm_Success: ["종·횡단을 확정했습니다.", "Sections confirmed."], B06_Profile_Confirm_Failed: ["종·횡단 확정에 실패했습니다.", "Section confirm failed."], B06_Profile_Detail_Failed: [ From 14ba5f96b77db1ec5f88d2aab90e5d74103e10aa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 19 Jul 2026 00:19:59 +0900 Subject: [PATCH 5/6] 260719_0 --- B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 4 +- B05_wf2_Route/B05_wf2_Route_Repository.py | 26 +++++++++ B05_wf2_Route/B05_wf2_Route_Router.py | 71 ++++++++++++++--------- B05_wf2_Route/B05_wf2_Route_Schema.py | 5 +- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 15 ++++- B05_wf2_Route/B05_wf2_Route_UI_Panel.ts | 16 ++--- 6 files changed, 95 insertions(+), 42 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index b4d13aff..b33fda70 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -60,8 +60,8 @@ export interface RouteSolveResponse { metrics: Record; required_points_ok: boolean; route_data_path: string; - longitudinal_length_m: number; - cross_section_count: number; + longitudinal_length_m: number | null; + cross_section_count: number | null; } /** 경로 확정 결과 (RouteConfirmResponse) */ diff --git a/B05_wf2_Route/B05_wf2_Route_Repository.py b/B05_wf2_Route/B05_wf2_Route_Repository.py index 44f5ce45..e9692695 100644 --- a/B05_wf2_Route/B05_wf2_Route_Repository.py +++ b/B05_wf2_Route/B05_wf2_Route_Repository.py @@ -206,3 +206,29 @@ async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None: "UPDATE routes SET status = 'CONFIRMED' WHERE id = %s", (route_id,), ) + + +async def get_surface_crs_epsg( + connection: aiomysql.Connection, project_id: UUID, surface_model_id: int +) -> int | None: + """종횡단 메타데이터용 좌표계를 조회한다. + + 지표면 모델 crs_epsg가 NULL이면 같은 프로젝트 input_files의 감지된 + 좌표계로 폴백한다 (B06 get_confirmed_route_context와 동일 규칙). + """ + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT COALESCE( + (SELECT sm.crs_epsg FROM surface_models sm WHERE sm.id = %s), + (SELECT f.crs_epsg + FROM input_files f + WHERE f.project_id = %s AND f.crs_epsg IS NOT NULL + ORDER BY f.id DESC + LIMIT 1) + ) AS crs_epsg + """, + (surface_model_id, str(project_id)), + ) + row = await cursor.fetchone() + return int(row["crs_epsg"]) if row and row["crs_epsg"] is not None else None diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index adf483b9..2da7ea5f 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -20,6 +20,7 @@ from B05_wf2_Route.B05_wf2_Route_Repository import ( create_route_statistics, get_latest_route, get_route_points, + get_surface_crs_epsg, insert_route_points, ) from B05_wf2_Route.B05_wf2_Route_Schema import ( @@ -198,35 +199,51 @@ async def solve_route( ) raise - sections = await asyncio.to_thread( - run_section_generation, - project_root, - design["route_data_path"], - request.filter_key, - request.method, - request.smooth, - options=_section_options(request), - ) - await connection.begin() + # 종횡단 생성 실패는 저장된 경로를 무효화하지 않으므로 비치명적으로 처리한다. + longitudinal_length_m: float | None = None + cross_section_count: int | None = None try: - await delete_sections_for_route(connection, route_id) - await create_longitudinal_section( - connection, - project_id=project_id, - route_id=route_id, - data=sections["longitudinal"]["data"], - longitudinal_file_path=sections["longitudinal"]["file_path"], + crs_epsg = await get_surface_crs_epsg( + connection, project_id, request.surface_model_id ) - await insert_cross_sections( - connection, - project_id=project_id, - route_id=route_id, - sections=sections["cross_sections"], + sections = await asyncio.to_thread( + run_section_generation, + project_root, + design["route_data_path"], + request.filter_key, + request.method, + request.smooth, + options=_section_options(request), + crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, ) - await connection.commit() + await connection.begin() + try: + await delete_sections_for_route(connection, route_id) + await create_longitudinal_section( + connection, + project_id=project_id, + route_id=route_id, + data=sections["longitudinal"]["data"], + longitudinal_file_path=sections["longitudinal"]["file_path"], + ) + await insert_cross_sections( + connection, + project_id=project_id, + route_id=route_id, + sections=sections["cross_sections"], + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + longitudinal_length_m = sections["longitudinal"]["data"]["length_m"] + cross_section_count = len(sections["cross_sections"]) except Exception: - await connection.rollback() - raise + logger.exception( + "B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s", + project_id, + route_id, + ) return RouteSolveResponse( project_id=str(project_id), @@ -235,8 +252,8 @@ async def solve_route( metrics=metrics, required_points_ok=solver["required_points_ok"], route_data_path=design["route_data_path"], - longitudinal_length_m=sections["longitudinal"]["data"]["length_m"], - cross_section_count=len(sections["cross_sections"]), + longitudinal_length_m=longitudinal_length_m, + cross_section_count=cross_section_count, ) except LookupError as exc: async with pool.acquire() as connection, connection.cursor() as cursor: diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index eb24e675..985594de 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -100,8 +100,9 @@ class RouteSolveResponse(BaseModel): metrics: dict[str, Any] required_points_ok: bool route_data_path: str - longitudinal_length_m: float - cross_section_count: int + # 종횡단 생성 실패 시 None (경로 자체는 저장됨) + longitudinal_length_m: float | None = None + cross_section_count: int | None = None class RouteConfirmResponse(BaseModel): diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index c146f1d9..509c2d4a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -97,6 +97,7 @@ export async function renderB05Route(root: HTMLElement): Promise { const profilePanel = createRouteProfilePanel(); let confirmedSurface: SurfaceModelSummary | null = null; let latest: RouteLatestResponse | null = null; + let defaultCrossHalfWidth = 0; let routeReady = false; let stale = false; let restoring = true; @@ -154,7 +155,10 @@ export async function renderB05Route(root: HTMLElement): Promise { function renderSections(detail: SectionDetailResponse): void { profilePanel.render(detail); - viewer.renderStationLines(detail.longitudinal.stations, panel.values().crossHalfWidth); + viewer.renderStationLines( + detail.longitudinal.stations, + panel.values().crossHalfWidth ?? defaultCrossHalfWidth, + ); } async function restoreSections(routeId: number): Promise { @@ -239,8 +243,12 @@ export async function renderB05Route(root: HTMLElement): Promise { long_sample_interval_m: values.longSampleInterval, }); renderLatest(await fetchLatestRoute(activeProjectId)); - renderSections(await fetchSectionDetail(activeProjectId, solved.route_id)); - showToast("최적 경로 계산이 완료되었습니다.", "success"); + await restoreSections(solved.route_id); + if (solved.cross_section_count === null) { + showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error"); + } else { + showToast("최적 경로 계산이 완료되었습니다.", "success"); + } } catch (error) { showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error"); } finally { @@ -270,6 +278,7 @@ export async function renderB05Route(root: HTMLElement): Promise { fetchSectionContext(activeProjectId), ]); confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; + defaultCrossHalfWidth = sectionContext.defaults.cross_half_width_m; if (!confirmedSurface) { showToast("확정된 지표면 모델이 없습니다.", "error"); } else { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index 3b45391f..d88435e5 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -12,10 +12,10 @@ export interface RoutePanelValues { minUphillGrade: number | null; minDownhillGrade: number | null; allowAvoidPassThrough: boolean; - stationInterval: number; - crossHalfWidth: number; - crossSampleInterval: number; - longSampleInterval: number; + stationInterval: number | null; + crossHalfWidth: number | null; + crossSampleInterval: number | null; + longSampleInterval: number | null; } interface PanelCallbacks { @@ -275,10 +275,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minUphillGrade: parseOptional(minUphillGrade), minDownhillGrade: parseOptional(minDownhillGrade), allowAvoidPassThrough: avoidPass.checked, - stationInterval: Number(stationInterval.value), - crossHalfWidth: Number(crossHalfWidth.value), - crossSampleInterval: Number(crossSampleInterval.value), - longSampleInterval: Number(longSampleInterval.value), + stationInterval: parseOptional(stationInterval), + crossHalfWidth: parseOptional(crossHalfWidth), + crossSampleInterval: parseOptional(crossSampleInterval), + longSampleInterval: parseOptional(longSampleInterval), }; }, restore(values: Partial) { From fe007bd2e487ade45fd2a151654af33119f0e4cf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 19 Jul 2026 00:27:34 +0900 Subject: [PATCH 6/6] test --- B10_Payment/B10_Payment_UI_Page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/B10_Payment/B10_Payment_UI_Page.ts b/B10_Payment/B10_Payment_UI_Page.ts index 3a6ddb7e..957ead6f 100644 --- a/B10_Payment/B10_Payment_UI_Page.ts +++ b/B10_Payment/B10_Payment_UI_Page.ts @@ -18,7 +18,7 @@ export function renderB10Payment(root: HTMLElement): void { const invoiceSection = section(L("B10_Payment_Invoice_Title"), invoiceBody, true, [ createButton({ label: L("B10_Payment_Invoice_Request"), disabled: true }), ]); - + const depositNote = document.createElement("p"); depositNote.className = "b10-payment__note"; depositNote.textContent = L("B10_Payment_Deposit_Note");