Compare commits
855 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 082a1ed010 | |||
| dabb3706fe | |||
| 69df2f8190 | |||
| c780b6fd96 | |||
| 8db8171b97 | |||
| 6e33be3de1 | |||
| 0b1925fd3b | |||
| 978448d99b | |||
| 02ab886bee | |||
| 0f3e12426a | |||
| 351251d652 | |||
| 34afdafcb1 | |||
| 225e252e3c | |||
| 4b5c8077d3 | |||
| 6743dfcbce | |||
| b6f854fc77 | |||
| 69e219ae8c | |||
| 222005c0ba | |||
| 4be3fbde50 | |||
| d8717d0fbd | |||
| 9bd40c5d1c | |||
| f26b522207 | |||
| 5c01a5c7ed | |||
| cbb6edf372 | |||
| 1bee332ae1 | |||
| a13c0b77fa | |||
| 014c96db1e | |||
| 572d479f12 | |||
| e51b57d9c7 | |||
| 4dd92f83a0 | |||
| eca68e1a28 | |||
| 51fcb6e4b7 | |||
| 2067e6efb1 | |||
| d1d52d806d | |||
| c4ca8bcae7 | |||
| c0442d6803 | |||
| 15282c50b3 | |||
| 28db2de74f | |||
| 57eae767bf | |||
| 1c20bafd05 | |||
| ad7265d553 | |||
| cd9f2d5efa | |||
| 39aa283daf | |||
| 9dd3522869 | |||
| 21cea72874 | |||
| ccfb151eca | |||
| 9d622454d6 | |||
| 107ca2220c | |||
| 09c6c2b21d | |||
| 646e93cf46 | |||
| 5a57812dab | |||
| 95fee1d8c5 | |||
| dd5dfee5c9 | |||
| 840bb823b9 | |||
| 5d3db90b5d | |||
| ddc260e114 | |||
| 88651d85ab | |||
| 4e8c0841db | |||
| c75e331c02 | |||
| 6fcdafed50 | |||
| d6faf6b22c | |||
| db2fccef56 | |||
| e1fbe4086c | |||
| b9f70ff88b | |||
| 900fdb893d | |||
| 9cfb7d1c3b | |||
| 5417083f88 | |||
| 1ed9dbcacf | |||
| 4c0fcc88f0 | |||
| 98c3775c9e | |||
| 442c748e81 | |||
| c99731a6b2 | |||
| 3760238e05 | |||
| a6ca3f0327 | |||
| ac618b3fb1 | |||
| cac352b355 | |||
| 7db337eccd | |||
| 6880e1a55a | |||
| 152291aec8 | |||
| 7bf1da94e4 | |||
| 8c961cd082 | |||
| 8a7091ddec | |||
| 13d91714d4 | |||
| b9d63b0776 | |||
| 6e0a00fd8b | |||
| f1f79335ec | |||
| 78a9eca9c0 | |||
| f748e074fd | |||
| 4172569ff7 | |||
| 0e08e5775d | |||
| d1be2c1a62 | |||
| aac1240658 | |||
| e2bff24b5b | |||
| 78300845ed | |||
| 961504b256 | |||
| 3ecf488e83 | |||
| 4224a22092 | |||
| 0119ea0f15 | |||
| 2252bbd666 | |||
| dc073e2114 | |||
| 479c621e2e | |||
| 638b684f77 | |||
| 9dbc0443af | |||
| e1cde834d0 | |||
| c33295e975 | |||
| 2f3fd7475b | |||
| aff07647ec | |||
| 9baaca7f68 | |||
| 3f8009c59d | |||
| 26c0ac0e58 | |||
| 423c1001e4 | |||
| ed50cb49e5 | |||
| bdc8075c3d | |||
| 53e996e2e0 | |||
| 55c8c5fa51 | |||
| ac1857c931 | |||
| 0be62d5156 | |||
| aee8597499 | |||
| 8e8f54b41b | |||
| 7a2a07c96e | |||
| 7b653d92c2 | |||
| 785f85e1ef | |||
| f7357adf1e | |||
| e793b4ec09 | |||
| d3a64ac682 | |||
| 43fe90d601 | |||
| 61e30bedf9 | |||
| 9c95dc8bff | |||
| 2c7e97406a | |||
| fd3e5c47e8 | |||
| fcb8ef77bd | |||
| 116876d8ec | |||
| 44ab5e045e | |||
| 6be8a505eb | |||
| 7e640e4207 | |||
| 2f47edbc72 | |||
| b678b2e226 | |||
| ef59023546 | |||
| 71d94f45f1 | |||
| ecce4b013a | |||
| 49f01fd23c | |||
| c9f3eed8ed | |||
| d05bb386a7 | |||
| a88b69797f | |||
| a096f3bcd9 | |||
| e210410526 | |||
| c7cfd72e7f | |||
| b4a5b1abc2 | |||
| b9b86a3656 | |||
| 6b148127b6 | |||
| bdc8bef857 | |||
| 32c2c44b76 | |||
| 7cdb2e2af2 | |||
| 5aa2dd1a4b | |||
| a6ff965d80 | |||
| a250d15f9a | |||
| 8b495f1508 | |||
| b95b639e75 | |||
| 0b0c113181 | |||
| 0a24a66a2e | |||
| 86935c43b0 | |||
| 6bd1532735 | |||
| 7d474b40c0 | |||
| 1b78f675f8 | |||
| b0e385b2c6 | |||
| 9ac2a612e0 | |||
| e37432d812 | |||
| 34c7886a41 | |||
| 78a870ab70 | |||
| d9a89296de | |||
| 3a59f56fb1 | |||
| b702b04ed2 | |||
| 11ec350dfa | |||
| 6a874a341d | |||
| 8467d7202a | |||
| d1f24736c3 | |||
| 9b7585ac7b | |||
| 2c7200fbad | |||
| 4549b4e819 | |||
| 9a145e5d62 | |||
| 16d0f91622 | |||
| 596e8d8b30 | |||
| 67a70c672d | |||
| 1bbddc00c8 | |||
| edb98895df | |||
| 5a2a1be089 | |||
| 06e9846cc3 | |||
| 12a08e7d42 | |||
| 6e64339e8a | |||
| a4a0ae1a77 | |||
| 9ed89ab0c8 | |||
| 5eb9fe3f1c | |||
| bcc6c7e79b | |||
| c020a2c948 | |||
| 21c18ce477 | |||
| 51db000545 | |||
| 302b062649 | |||
| 97966ba2df | |||
| 555f701b57 | |||
| f4ece6f5b1 | |||
| 8edab2196f | |||
| 3f5333588c | |||
| 1fc1672acd | |||
| 61de12e2de | |||
| 6b734957e9 | |||
| 85c516e811 | |||
| fe378371bd | |||
| e3f1fe7eb5 | |||
| a4ac33c014 | |||
| 0bb48d3f04 | |||
| 21853bdc27 | |||
| 203807ada8 | |||
| a7835659d5 | |||
| c5a7803e34 | |||
| 670ae80124 | |||
| 09c61d8eed | |||
| ba847db060 | |||
| 4b949f7ce2 | |||
| 826e7b04f2 | |||
| dd0d63d25a | |||
| 653d3564df | |||
| 1d95f72d45 | |||
| c900a0332e | |||
| 751d88048c | |||
| 51e5dc29e1 | |||
| da5e95bdaf | |||
| 008f38c0d2 | |||
| 3d627ce782 | |||
| 3015a66fab | |||
| 24f02f8a0e | |||
| 696c9f23a0 | |||
| 5e6531176e | |||
| 21b45fa6d5 | |||
| 7fcf9a9615 | |||
| 188bf94a12 | |||
| 276b13a35f | |||
| 5d5190711e | |||
| 8d231860af | |||
| cf21c5797c | |||
| 26524f9278 | |||
| 21b7b4d9c9 | |||
| 44e262b025 | |||
| f2b0db4d9a | |||
| fe122b7681 | |||
| 30626e0928 | |||
| 7df33e533e | |||
| 3807c424c9 | |||
| 3ac72dde12 | |||
| a8eb4849c0 | |||
| 1a6d4a76c3 | |||
| c6835cf30c | |||
| 03ed4bb387 | |||
| 2b012f247c | |||
| 6ae7e1877e | |||
| e88cd431ca | |||
| 8641bb6954 | |||
| de205a598d | |||
| 2d83896a9a | |||
| 7d478c1c1b | |||
| 57ffbc8ae6 | |||
| b22a1fad3c | |||
| 5b103ab606 | |||
| ed8323cbb9 | |||
| 927b39b0d6 | |||
| ec2a207fb8 | |||
| 63ceeaabc2 | |||
| 7d8e2220ff | |||
| 86a08348e0 | |||
| 0b2e7c8880 | |||
| 536261ceb5 | |||
| 9512e33783 | |||
| dc3f2a8100 | |||
| 06b23c36dd | |||
| 0d2ddee874 | |||
| a34137c41c | |||
| 6551990e8e | |||
| 4fed35fec8 | |||
| eaba6b7389 | |||
| 2addb8ec02 | |||
| e6120c6fc8 | |||
| 3ffe4ff560 | |||
| edfa799d9c | |||
| 5a93751ccc | |||
| 5c611166f3 | |||
| c6662b3056 | |||
| 667054fa58 | |||
| ca5dc3a4f3 | |||
| 8629616a04 | |||
| 29301ff87d | |||
| 1dcc4cbf6e | |||
| d4ab7993c5 | |||
| d85da0198c | |||
| 400a229959 | |||
| 358b761eb2 | |||
| b7db2fc229 | |||
| dca1fd54ce | |||
| 0676e6e12d | |||
| 2354353e93 | |||
| 8c44115d32 | |||
| 192055dc0f | |||
| ec8403e26c | |||
| cff9973dcf | |||
| b67fac6407 | |||
| 8b5d9238b5 | |||
| 7c32501e18 | |||
| 5f481f5d11 | |||
| ac6552b44f | |||
| 639f985e6f | |||
| 33f968bff9 | |||
| e8cb95be55 | |||
| 41985a93eb | |||
| f620562124 | |||
| c1ef1ecee9 | |||
| a33f622a35 | |||
| 26eaee5c57 | |||
| 7574d16678 | |||
| 1635bc6051 | |||
| 0af08bcc55 | |||
| 2c80a52d6f | |||
| 0317b0b109 | |||
| e949cb18a5 | |||
| a6567d0938 | |||
| 6a934ca6c6 | |||
| 13cbbacc1f | |||
| bc50a0d9f1 | |||
| 4a424505a8 | |||
| f7d27ecb91 | |||
| 185ce2b640 | |||
| c33b4ab4f6 | |||
| 5381679c68 | |||
| 50ecb6463a | |||
| 6c1e003340 | |||
| e843a701a6 | |||
| b29b395a96 | |||
| 5a7724bdbb | |||
| fb6175e4a2 | |||
| e2ff28a482 | |||
| 6b0d556347 | |||
| 7d86c155c8 | |||
| 8edab4638b | |||
| 0500a4a37c | |||
| 0568c400e4 | |||
| f6698b086b | |||
| 04a93c833b | |||
| 15c74f5aa8 | |||
| 941a25b836 | |||
| 57e4a6ae95 | |||
| 96fd5eba25 | |||
| 7bf15d449a | |||
| dead984d8a | |||
| 92fb7227f4 | |||
| 72bd3ff72c | |||
| bcd49b2405 | |||
| 9ebd86e220 | |||
| 24970a94ac | |||
| 7aa6707d66 | |||
| ddd8d5924e | |||
| 83c589cbe5 | |||
| fdf0cfeb8c | |||
| 9754773324 | |||
| f8c68f940d | |||
| c1c5bafaff | |||
| b5ebaf28d5 | |||
| 8ce4cec798 | |||
| 045eb2646e | |||
| 0ed6d5fa55 | |||
| 35a3b2406f | |||
| c1d532aaad | |||
| 84feca94d7 | |||
| 8a09816061 | |||
| 106a4d4323 | |||
| d8508baf8d | |||
| 787092674a | |||
| d3c336566a | |||
| b9d30f5252 | |||
| a9cf8c049d | |||
| 813d5ef5e6 | |||
| 437be55a88 | |||
| 1f3fe79abd | |||
| 201e94ea81 | |||
| bbfde0db51 | |||
| 1fd7fcc3c8 | |||
| 809d0316c3 | |||
| fc1139f51d | |||
| 39515af6bf | |||
| a587cf3b1e | |||
| 6ea140af54 | |||
| d6036fbb8e | |||
| f8b4667e86 | |||
| 3f99d1b62f | |||
| 67b95234d0 | |||
| 3b637d154f | |||
| b6dedfe516 | |||
| 177a5b94d7 | |||
| 2c8103aea4 | |||
| ebb2a9b37b | |||
| 81d4c15442 | |||
| 4b7939aba8 | |||
| 6a3d1e04d0 | |||
| 8a9ff304f2 | |||
| a07c945cfd | |||
| af25a845c9 | |||
| a97896d293 | |||
| 016786ac50 | |||
| cc7332c7ce | |||
| d46966c24d | |||
| 9a64bebb77 | |||
| 4173ae1bff | |||
| bb58141c76 | |||
| 6d95c3da6c | |||
| 0888a707cc | |||
| dfce94fbf7 | |||
| 410eb8a862 | |||
| eb5593333c | |||
| 1674df0ddc | |||
| 3b6481b1df | |||
| 76df3b4594 | |||
| 5dc9164ee3 | |||
| 1c7d8e9d95 | |||
| 353a6cb8a9 | |||
| 832efc0907 | |||
| 471171b77c | |||
| 98f955a85e | |||
| a60349d339 | |||
| e4050fcaed | |||
| d63f6eec67 | |||
| 2bf7ff7ef1 | |||
| 8786cf5e20 | |||
| 3801d0cfa8 | |||
| f471463911 | |||
| ccfb6116c0 | |||
| c7345a71cf | |||
| b46c761373 | |||
| dbb6a6fa11 | |||
| 799f651777 | |||
| c802fe552a | |||
| 7c598d6430 | |||
| 96a2097e70 | |||
| 8af85961b5 | |||
| ed97b6d90b | |||
| 903bc5cf42 | |||
| 83aad34e8b | |||
| 65c2e7dac1 | |||
| e76485cadc | |||
| 29a2bae7d9 | |||
| 382dff3879 | |||
| aa2e869b93 | |||
| ca67ae6e0d | |||
| e316d39264 | |||
| 0d9226f6d5 | |||
| 4d53919e9a | |||
| da6dd96aac | |||
| 1f82a980de | |||
| 1de2aed05d | |||
| d190fd2de9 | |||
| d701d824ba | |||
| e85f7135ff | |||
| bc64828b22 | |||
| cd14e1326f | |||
| 4bcc47e5be | |||
| eaee79dc8a | |||
| 8f1fba25f9 | |||
| a3f7e9976e | |||
| a6a9fb858c | |||
| 5b075cde86 | |||
| 77e4dffb43 | |||
| 70aad6a423 | |||
| 9d5a2959e1 | |||
| d2ce0d70b2 | |||
| 29ae219025 | |||
| 88365a6f26 | |||
| 09cfaa3bd2 | |||
| bf788c1c3a | |||
| 8a480c8ead | |||
| 15fbd73847 | |||
| ce9f29fcd0 | |||
| 75165d900b | |||
| 239e54540e | |||
| b6ea1ae398 | |||
| 839f9f65dd | |||
| 8142fc814f | |||
| c34fd27c6a | |||
| 1f17fb40dc | |||
| fe0bfa62c6 | |||
| 932bef237c | |||
| 66bd7ac1f4 | |||
| c26423b7d4 | |||
| 16d9dfa029 | |||
| a6bc034bdb | |||
| dcdcde5b4e | |||
| 0c6618fb38 | |||
| 3727417810 | |||
| 007c9211cd | |||
| 24d7f2b1d9 | |||
| a1b57d8936 | |||
| 31e03923ca | |||
| 073cc3c06d | |||
| 9a123be71c | |||
| 17a4a1b751 | |||
| 053c2ebfdd | |||
| ed6fea460c | |||
| 2dac331aa3 | |||
| cac9b8acbe | |||
| 0c0eea7a6b | |||
| d8141087cd | |||
| 2ae70dd48f | |||
| d52ad9b06f | |||
| 244a063363 | |||
| 33a91900a8 | |||
| 8e8988ec23 | |||
| 2e30878f00 | |||
| 095265f482 | |||
| b3ea35049f | |||
| 96b5e46660 | |||
| f08a81263d | |||
| 79992d23c5 | |||
| f6fbe922a9 | |||
| d838c94df4 | |||
| 23075dddb1 | |||
| d2ca0d61cc | |||
| 1b04384770 | |||
| 98ec1ed478 | |||
| 41ca41d69c | |||
| dbeee44fc7 | |||
| 268ea31bb8 | |||
| b005226e2c | |||
| 90c8464356 | |||
| 660e7e2747 | |||
| 3e7e6e5b9b | |||
| cef226c53e | |||
| a6d86f4374 | |||
| b5c224d7f5 | |||
| 9df4fd5e24 | |||
| 06db392f6a | |||
| 7a703bd184 | |||
| 27a67d0866 | |||
| a5ffc624cf | |||
| e4801dcc2f | |||
| bed085ac98 | |||
| e192feefcc | |||
| d93d8b782e | |||
| 8c3e7ce7aa | |||
| a09616450f | |||
| 7aa681b503 | |||
| 6087c814b3 | |||
| fb01e5aafb | |||
| 2b13976610 | |||
| f2a1c6e24d | |||
| f999ad550e | |||
| 22b95ed072 | |||
| 774b54afc8 | |||
| a24c334730 | |||
| dfc17ae717 | |||
| dc7ad11e52 | |||
| de1dbea8aa | |||
| 1611e4b461 | |||
| 07adcbd108 | |||
| f2398dd078 | |||
| e1a694ed90 | |||
| 5e9bafb20c | |||
| c3d6af8757 | |||
| 4154e0b791 | |||
| 5356096349 | |||
| 2b653c4655 | |||
| b0c024be76 | |||
| 7acc606cc2 | |||
| 3116f9d815 | |||
| d387018ee5 | |||
| 573de62963 | |||
| 663459a675 | |||
| d5bb907adc | |||
| 09a292eca6 | |||
| 4533ef14ed | |||
| 3869cebe95 | |||
| c2a2497e49 | |||
| 22675fd48e | |||
| 7f045737d3 | |||
| cf4507a4d6 | |||
| f2933a6186 | |||
| e6a1a697bd | |||
| 169a5130ba | |||
| 87a057f5b9 | |||
| eb19ce3cf9 | |||
| 4423a72635 | |||
| 46d373752c | |||
| 8e39993bb0 | |||
| 788d612716 | |||
| 3cc52e21b0 | |||
| 441321c598 | |||
| 0e52fedc2d | |||
| bf794f76a6 | |||
| bfa7c46ef5 | |||
| fb12196cfa | |||
| 451f6a66ea | |||
| 0284611263 | |||
| 3df79d081c | |||
| 4814d5edeb | |||
| 19ce8728e5 | |||
| 717ad3d0f5 | |||
| 15de0d914f | |||
| dd0a54d8ca | |||
| c2b5d73913 | |||
| 6acdb72b39 | |||
| b6bb1d9f48 | |||
| b771c3d497 | |||
| 0cca1754e8 | |||
| e2f0bb61af | |||
| c8385205b4 | |||
| 2a987f01d0 | |||
| aee927a3b1 | |||
| c931eeacd6 | |||
| c0efd1029e | |||
| 4606d79e44 | |||
| 6bdfa48578 | |||
| 3483223f42 | |||
| d0e249613c | |||
| 2f4109cb7c | |||
| 566197df48 | |||
| 94490ba239 | |||
| 068d6c2afe | |||
| 21c5ae2d91 | |||
| 54b8d06c61 | |||
| fe7d44aa83 | |||
| 90cda5129c | |||
| 4747229b09 | |||
| 5e37707b11 | |||
| 77bfdb4331 | |||
| 18fadcba9f | |||
| 0903ef640a | |||
| c590c32b41 | |||
| a4be2ecba0 | |||
| 294b3622b5 | |||
| 2fda4ee7f6 | |||
| e63c05cc34 | |||
| cfcb233b6c | |||
| b07da5ee6d | |||
| 52824d8fc9 | |||
| f3cff6ad03 | |||
| 8b8616e1de | |||
| 4652f9a73d | |||
| 0d1474f0f5 | |||
| da14b9cb68 | |||
| 5f8fcbd964 | |||
| ba4c9b071d | |||
| a4c933b62c | |||
| 55006e691e | |||
| 69a5707cb6 | |||
| 92a698e307 | |||
| 7c33d4ce11 | |||
| 1b6865a491 | |||
| c494ec92fb | |||
| b8be0b879a | |||
| cbeb198be3 | |||
| 1b79965fce | |||
| 8dcd54d206 | |||
| 8cb461827c | |||
| 948b831273 | |||
| b76315573c | |||
| cd11b2aec6 | |||
| 4b63f7fbf3 | |||
| 74793b0616 | |||
| 58a73365fd | |||
| 7534a79842 | |||
| ccedd61f92 | |||
| 24abf261e2 | |||
| 79aaf27b7f | |||
| 7546fe0553 | |||
| 12d2b13618 | |||
| 2f843b6661 | |||
| f5bc39fbbf | |||
| 7fbbfad0fe | |||
| 1c93eb668e | |||
| e1d4a1e38a | |||
| 2b6ddef5c9 | |||
| b66c688340 | |||
| 00bd7cada7 | |||
| 5ed21e4d2e | |||
| eb565081f6 | |||
| 5c3ca4c1b6 | |||
| d0cfff38c1 | |||
| 8ecb8409eb | |||
| 1b704b98e5 | |||
| cf88cb88dc | |||
| 41f6561357 | |||
| c4b4312b9a | |||
| cf5662087c | |||
| 34dd197390 | |||
| 70c5641452 | |||
| bb40776fa8 | |||
| 31b40b0e99 | |||
| a61b1e3c20 | |||
| 724c8a5817 | |||
| cccbb64159 | |||
| 100834e9b1 | |||
| 140f711e3c | |||
| a07e631e8e | |||
| 2ffe376b2d | |||
| 995661158b | |||
| ff78851310 | |||
| 790c2e9445 | |||
| e1c05da294 | |||
| 83f807fbad | |||
| 8c8c180eea | |||
| 84d4ac5bd6 | |||
| 6d6bed53d4 | |||
| 641b6332a4 | |||
| 641d62ac5f | |||
| c23375dc5f | |||
| df8b5ff18b | |||
| e6b2d7f321 | |||
| e8767ed30d | |||
| 5df801daf8 | |||
| 7cea060179 | |||
| e8bd098427 | |||
| ac10ebdd21 | |||
| 9b6a9adaf9 | |||
| b400366f06 | |||
| cf3ffb4a46 | |||
| 497a4e92a0 | |||
| 8b2dca16ad | |||
| 121713b5d8 | |||
| c36043c80e | |||
| dac075b886 | |||
| caef49f387 | |||
| 892024f6a3 | |||
| ca6b93fb57 | |||
| 1d6f97e636 | |||
| 2a6c214cd5 | |||
| 0bcb9e5f2e | |||
| cc7551755b | |||
| 17e42990ad | |||
| 219e488d7f | |||
| a7ca9a7463 | |||
| 5ecae8a078 | |||
| e0d36b45c0 | |||
| ad5435dace | |||
| d7d74b1553 | |||
| 70c6fe0054 | |||
| 8d4eab659c | |||
| f9f6a04c88 | |||
| e0e3280404 | |||
| 4803f970c1 | |||
| ac3aaeadb2 | |||
| a7c5f10fd4 | |||
| 4b160a46e8 | |||
| 1d39a1c7ea | |||
| e424bc231c | |||
| aa4c219d5a | |||
| e2fc78d1f1 | |||
| 8dee3e9829 | |||
| faf0fc5b41 | |||
| 6d7eafceb5 | |||
| 427874ee54 | |||
| cb1559439c | |||
| d20f0f933e | |||
| e22405516b | |||
| 640ca245ee | |||
| 2e37360dac | |||
| 22bd60cf0f | |||
| ecda85e8ef | |||
| 76eff24732 | |||
| 99d7bf3d03 | |||
| 03d567e953 | |||
| 08d259bfa2 | |||
| 908e7f3f1c | |||
| 1b07f086b4 | |||
| dd7daa7d7a | |||
| 1e1c0e95f7 | |||
| 5e49fd5835 | |||
| edeb442846 | |||
| 43f5edbbc3 | |||
| dfe26a4771 | |||
| 25c0bb2a79 | |||
| 89ddc4f68f | |||
| dacc0eb4ac | |||
| dad34dc1d6 | |||
| 1a347650f4 | |||
| c806a5137a | |||
| 699fdcc7fb | |||
| 033c941b02 | |||
| c2ef4f4898 | |||
| b540c1b3a0 | |||
| a508b6a4da | |||
| dc201f28ff | |||
| 66166f6294 | |||
| 64bd44088d | |||
| 9199427dfd | |||
| 58998a59c0 | |||
| e21b12a7ce | |||
| 0cd187b693 | |||
| 75792c93aa | |||
| 7a85007777 | |||
| 26510ff712 | |||
| c2f66b1e97 | |||
| cfca88c3e0 | |||
| 90d0c41fd0 | |||
| 2c8eb84c65 | |||
| 31fb5d7ab0 | |||
| beebdadca0 | |||
| aed820c2d1 | |||
| aebdc47e4f | |||
| 6864db5b94 | |||
| 718772a2aa | |||
| 10ba4978cf | |||
| deffa3c714 | |||
| d0484f9e55 | |||
| 62d50e00ae | |||
| ed93b696bd | |||
| 0248e3db61 | |||
| 42408ee301 | |||
| 898629a5b6 | |||
| 1ee16a3a38 | |||
| 0417f51427 | |||
| 8c0506ba23 | |||
| 055599bb01 | |||
| 5e7098a610 | |||
| c335f33e25 | |||
| 0927605bd0 | |||
| 8838f963a3 | |||
| e43f14b5e1 | |||
| ea753cf5b0 | |||
| 87e78714b7 | |||
| 6a65e223b8 | |||
| fff3ddc45e | |||
| dd62486074 | |||
| 92a0a364ea | |||
| a217a6f1f7 | |||
| 1940c57f81 | |||
| af46290ca3 | |||
| 218baef4ad | |||
| 021ee79219 | |||
| 2db3abcf64 | |||
| 1528d4e46e | |||
| 970276915c | |||
| ce54d3576d | |||
| 46d80c0bdf | |||
| be1e558be9 | |||
| 4dce6d0d8f | |||
| 2552c8a90e | |||
| 9aa8c76932 | |||
| 9ef9443096 | |||
| 7a323f8fe0 | |||
| e46548d06b | |||
| 7ffed45974 | |||
| ea2526dc73 | |||
| add17b1bb4 | |||
| 2a8011d68e | |||
| 02009a8c94 | |||
| 6afe928c0d | |||
| 8b3159b529 | |||
| 8f30a8cef6 | |||
| 7f75c96acd | |||
| 085b7322cf | |||
| 5d5f51acfe | |||
| 5fa2844451 |
@@ -0,0 +1,78 @@
|
|||||||
|
# LearnSpace Project Memory
|
||||||
|
|
||||||
|
- [project_status.md](project_status.md) — Полный список реализованных фич: все страницы, API, таблицы БД, инструменты доски, стек, деплой (апрель 2026)
|
||||||
|
- [project_classroom_module.md](project_classroom_module.md) — Оригинальный план classroom-модуля (4 фазы, все реализованы)
|
||||||
|
- [project_whiteboard_roadmap.md](project_whiteboard_roadmap.md) — Roadmap улучшений доски (7 фаз, утверждён 2026-04-11)
|
||||||
|
- [project_pet_assistant.md](project_pet_assistant.md) — «Квантик-ассистент» РЕАЛИЗОВАН (commit 3f8009c): Ф0/Ф1/«Спроси-FAQ», правиловый движок, reuse 'pet', assistant_seen, на учебнике тоже. НЕ сделано: Ф2 тур, реальная LLM, activeLesson
|
||||||
|
- [feedback_no_emoji.md](feedback_no_emoji.md) — Запрет эмоджи в коде, только inline SVG `.ic`
|
||||||
|
- [feedback_sims_admin_sync.md](feedback_sims_admin_sync.md) — При добавлении симуляции в lab.html → сразу обновить ADMIN_SIMS в admin.html
|
||||||
|
- [project_ct_seeded.md](project_ct_seeded.md) — Список перенесённых сборников ЦТ/ЦЭ (физика 2024 + матем 2024); правило: 1 вариант из сборника, нет повторов
|
||||||
|
- [project_hardening_2026.md](project_hardening_2026.md) — 8-task security/architecture hardening plan (started 2026-05-06), executed by Sonnet sessions one task at a time
|
||||||
|
- [reference_textbook_sources.md](reference_textbook_sources.md) — Расположение PDF учебников Беларуси (физика/алгебра/геометрия 7-11) в `G:\Dev\Тесты\Методички\тест_6 класс\Книги\` + структура §-канвы Исаченковой
|
||||||
|
- [project_stereo3d_improvements.md](project_stereo3d_improvements.md) — Стереометрия 3D: апгрейд 5 фаз (май 2026) + deep-link фигур `openSim('stereo:<figure>')` / `?stereofig=`
|
||||||
|
- [reference_sqlite_node.md](reference_sqlite_node.md) — БД на встроенном node:sqlite (НЕ better-sqlite3); живая БД backend/data/learnspace.db; Bash ломает кириллический путь
|
||||||
|
- [reference_textbook_latex_escaping.md](reference_textbook_latex_escaping.md) — Баг формул = ЛИШНИЕ слэши (over-escape), правило чётности, фикс fix_overescaped_latex.js; БД чиста
|
||||||
|
- [project_content_access.md](project_content_access.md) — Доступ к учебникам/экзаменам/симуляциям/курсам по классам и ученикам (allowlist, ученик > класс), миграции 040/051/052, /api/access; ревью+переработка done, Фаза 3 (HTML-гейт) отложена
|
||||||
|
- [project_permissions_rework.md](project_permissions_rework.md) — Ролевые права (registry/role_permissions/user_permissions): Phase A+B+C ВСЕ в master (2026-06-03): зависимости, история, группы, массово по классу, пресеты, временные права, произвольные кастомные роли (конструктор). План plans/permissions-rework/
|
||||||
|
- [project_optics_constructor.md](project_optics_constructor.md) — Конструктор оптических систем (BenchSim) в режиме «Конструктор» оптической скамьи: общий 2D-трассировщик, элементы/призма/дисперсия
|
||||||
|
- [project_lab_content_engine.md](project_lab_content_engine.md) — Рефактор лаборатории «симуляции как данные» (LabRegistry); фазы 0-3 done, ветка feature/lab-content-engine
|
||||||
|
- [project_chemistry7_textbook.md](project_chemistry7_textbook.md) — Новый учебник «Химия 7» (4 гл, 26§): план + статус (Phase 0 done), переиспользует движок Химии 8
|
||||||
|
- [project_concurrent_sessions_branch.md](project_concurrent_sessions_branch.md) — Несколько сессий коммитят в одну ветку → fetch перед работой, не force-push вслепую, add поимённо
|
||||||
|
- [feedback_verify_edits_applied.md](feedback_verify_edits_applied.md) — После каждого Edit проверять grep -c маркера; не пушить пакет без поштучной верификации (дважды коммитил сломанное)
|
||||||
|
- [project_dashboard_rebuild.md](project_dashboard_rebuild.md) — План пересборки dashboard.html по скрину (hero: чтение+лаба+питомец, синхрон питомца); редизайн утерян (был некоммичен)
|
||||||
|
- [project_phys7_status.md](project_phys7_status.md) — Физика 7: контент ВСЕХ 5 глав готов (рендер из phys7_chN_widgets.js); Шпаргалки наполнены (47 шт, commit c6835cf); учебник функционально полный
|
||||||
|
- [reference_vex_search.md](reference_vex_search.md) — vex установлен+проиндексирован (semantic); когда vex (semantic/pattern/similar/duplicates), когда ast-index (символы/usages); гочи модели/HEAD
|
||||||
|
- [project_math6_textbook.md](project_math6_textbook.md) — Учебник «Математика 6» (Герасимов 2022): движок math6_engine.js + Math6 svg (numberLine/plane/pie/venn). ВСЕ 6 глав + курсовой финал ГОТОВЫ на master (тесты 17/17, +полировка 20/20). Осталось только: выдать доступ ученикам (/api/access)
|
||||||
|
- [project_math5_textbook.md](project_math5_textbook.md) — Учебник «Математика 5» (Герасимов 2020) переиспользует движок math6. НАПОЛНЕН ЦЕЛИКОМ: 3 главы, 44 § (Гл.1 Opus-эталон, Гл.2–3 Sonnet), хаб+курсовой финал, тест 12/12, всё на master (последний 5a2a1be). Осталось только: выдать доступ ученикам (/api/access). План: plans/textbooks-5/
|
||||||
|
- [reference_exam_textbook_links.md](reference_exam_textbook_links.md) — Привязка задач экзамена math9 к § учебников: per-task колонки в exam_tasks + классификатор tag-exam-textbook.js (таксономия gen-exam-textbook-sections.js) + починенный deep-link (textbook-deeplink.js). 98% размечено. Готчи: geometry-8 поглавная нумерация, math5/6 движковые
|
||||||
|
- [reference_svg_drawer.md](reference_svg_drawer.md) — Векторная SVG-рисовалка: виджет js/svg-draw.js (SvgDraw.mount) + санитайзер js/svg-sanitize.js (UMD, клиент+сервер) + блок урока svg-draw (редактор/превью/lesson.html). Переиспользуемо для флешкарт/фигур генератора
|
||||||
|
- [reference_quick_lesson.md](reference_quick_lesson.md) — «Быстрый урок»: одиночный урок без курса через скрытый личный курс-контейнер (courses.is_personal, POST /api/lessons/quick, кнопка в theory.html). Каталог скрывает контейнеры от всех кроме владельца
|
||||||
|
- [reference_student_materials.md](reference_student_materials.md) — «Мои материалы»: ученик сохраняет к себе доску(PNG)/заметку из онлайн-урока (миграция 060 student_materials, /api/materials, Whiteboard.exportBlob, страница /my-materials, кнопки в my-lessons.html). Копия переживает удаление сессии
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Node.js/Express backend, SQLite (встроенный **node:sqlite** `DatabaseSync`, НЕ better-sqlite3 — см. [[reference_sqlite_node]])
|
||||||
|
- Frontend: vanilla JS, `window.LS.*` namespace via /js/api.js
|
||||||
|
- No bundler — plain HTML/CSS/JS served by Express static
|
||||||
|
- Репо: https://git.dolgolyov-family.by/maxim.dolgolyov/Learn_System (master)
|
||||||
|
|
||||||
|
## Key Paths
|
||||||
|
- Backend: `backend/src/`
|
||||||
|
- Frontend pages: `frontend/*.html`
|
||||||
|
- Shared CSS: `frontend/css/ls.css` (design system)
|
||||||
|
- JS API: `js/api.js` → `window.LS.*`
|
||||||
|
- Whiteboard engine: `frontend/js/whiteboard.js` (~3200 строк)
|
||||||
|
- Server entry: `backend/src/server.js`
|
||||||
|
|
||||||
|
## UI Architecture
|
||||||
|
- Sidebar nav: `.app-layout > .sidebar + .sb-content`
|
||||||
|
- login.html: split layout `.login-layout > .login-left + .login-right`
|
||||||
|
- Page transitions: CSS `@view-transition { navigation: auto }`
|
||||||
|
- Mobile: `.mob-bar` (56px fixed top), sidebar drawer на ≤768px, `/js/mobile.js`
|
||||||
|
- Notifications dropdown: `left` ставится через `r.right + 8` динамически
|
||||||
|
|
||||||
|
## Whiteboard (classroom.html)
|
||||||
|
- Chalkboard theme: зелёный фон (#2d5a2d), деревянная рамка, chalk-grain
|
||||||
|
- Tools: pencil (Catmull-Rom), highlighter, laser, eraser, 11 shapes, connector, sticky, text, image, formula (KaTeX), table, coordinate system, number line, compass
|
||||||
|
- Select tool: move/resize/rotate всех объектов, lasso multi-select, snap guides, copy/paste
|
||||||
|
- Zoom/Pan: wheel zoom, Space+drag, minimap overlay (bottom-right, при zoom>1)
|
||||||
|
- Ruler/Protractor: rotation + resize handles, floating props panel
|
||||||
|
- SSE real-time sync + HTTP polling (since_seq параметр)
|
||||||
|
- Two-layer canvas: static (_ctx) + dynamic (_dynCtx)
|
||||||
|
- Multi-page + thumbnail sidebar
|
||||||
|
|
||||||
|
## User Roles
|
||||||
|
- admin: full access
|
||||||
|
- teacher: classes + board + library + classroom
|
||||||
|
- student: dashboard + board (только если в классе)
|
||||||
|
|
||||||
|
## Icons — КРИТИЧНО
|
||||||
|
- **⛔ ЗАПРЕТ на эмоджи** — никогда не использовать эмоджи в коде.
|
||||||
|
- Только inline SVG с классом `.ic` (определён в ls.css).
|
||||||
|
- На некоторых страницах также Lucide CDN `lucide@0.469.0`.
|
||||||
|
|
||||||
|
## Workflow Preferences — КРИТИЧНО
|
||||||
|
- **⛔ АБСОЛЮТНЫЙ ЗАПРЕТ на Grep tool** — пользователь запретил КАТЕГОРИЧЕСКИ.
|
||||||
|
- Поиск по коду: `ast-index` (дефолт: символы/usages/callers/outline) + `vex` (semantic/pattern/similar/duplicates) — см. [[reference_vex_search]] / `.claude/rules/search-tools.md`. usages по JS — только ast-index.
|
||||||
|
- Чтение файлов: ТОЛЬКО `Read` с offset/limit
|
||||||
|
- Поиск файлов: `Glob` или `ast-index search`
|
||||||
|
- НЕТ ИСКЛЮЧЕНИЙ. Даже для "быстрой проверки". Даже для верификации.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Файлы памяти Claude (перенос между машинами)
|
||||||
|
|
||||||
|
Здесь собраны файлы автопамяти Claude Code для этого проекта, чтобы их можно было
|
||||||
|
переносить через git и работать на другой машине.
|
||||||
|
|
||||||
|
`MEMORY.md` — индекс (загружается в контекст каждой сессии). Остальные `.md` —
|
||||||
|
по одному факту на файл (см. frontmatter `metadata.type`: user / feedback / project / reference).
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
Claude Code хранит память не в репозитории, а в пользовательской папке, привязанной
|
||||||
|
к пути проекта:
|
||||||
|
|
||||||
|
```
|
||||||
|
<домашняя папка>/.claude/projects/<хэш-пути-проекта>/memory/
|
||||||
|
```
|
||||||
|
|
||||||
|
На этой машине это:
|
||||||
|
`C:\Users\Home\.claude\projects\g--Dev-------BQ-System\memory\`
|
||||||
|
|
||||||
|
Хэш `g--Dev-------BQ-System` получается из абсолютного пути проекта
|
||||||
|
(`g:\Dev\Тесты\BQ-System`), где не-буквенно-цифровые символы заменены на дефис.
|
||||||
|
|
||||||
|
## Восстановление на другой машине
|
||||||
|
|
||||||
|
1. Склонируй репозиторий (память приедет в `.claude/memory/`).
|
||||||
|
2. Определи целевую папку памяти:
|
||||||
|
- **Если путь проекта тот же** (`g:\Dev\Тесты\BQ-System`) — папка та же:
|
||||||
|
`~/.claude/projects/g--Dev-------BQ-System/memory/`.
|
||||||
|
- **Если путь другой** — открой проект в Claude Code один раз (он создаст папку
|
||||||
|
`~/.claude/projects/<новый-хэш>/memory/`), либо вычисли хэш из своего пути по
|
||||||
|
правилу выше.
|
||||||
|
3. Скопируй туда все `.md` из `.claude/memory/` (включая `MEMORY.md`).
|
||||||
|
|
||||||
|
PowerShell-пример (путь проекта тот же):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$dst = "$env:USERPROFILE\.claude\projects\g--Dev-------BQ-System\memory"
|
||||||
|
New-Item -ItemType Directory -Force -Path $dst | Out-Null
|
||||||
|
Copy-Item ".\.claude\memory\*.md" $dst -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
bash-пример (Linux/macOS, путь проекта тот же):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dst="$HOME/.claude/projects/g--Dev-------BQ-System/memory"
|
||||||
|
mkdir -p "$dst" && cp .claude/memory/*.md "$dst"/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поддержание в актуальном состоянии
|
||||||
|
|
||||||
|
Это **снимок**. Когда Claude обновляет память во время работы, меняются файлы в
|
||||||
|
пользовательской папке, а не здесь. Чтобы снова синхронизировать в репозиторий —
|
||||||
|
скопируй из пользовательской папки обратно в `.claude/memory/` и закоммить
|
||||||
|
(или попроси Claude «обнови снимок памяти в репозитории»).
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: no_emoji_use_svg
|
||||||
|
description: Never use emoji in code — always use inline SVG icons instead
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
Никогда не использовать эмоджи в коде. Вместо эмоджи всегда использовать inline SVG иконки с классом `.ic` (определён в bq.css).
|
||||||
|
|
||||||
|
Примеры замен:
|
||||||
|
- ✓ → `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`
|
||||||
|
- ⚠ → `<svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/>...</svg>`
|
||||||
|
- Любая другая иконка → соответствующий Lucide SVG path
|
||||||
|
|
||||||
|
CSS класс `.ic` в bq.css: `display:inline-block; width:1em; height:1em; fill:none; stroke:currentColor; stroke-width:2.5; stroke-linecap:round; stroke-linejoin:round`
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: Симуляции — синхронизация с панелью администратора
|
||||||
|
description: При добавлении новой симуляции в lab.html нужно сразу же обновить ADMIN_SIMS в admin.html
|
||||||
|
type: feedback
|
||||||
|
originSessionId: 1959f491-c6c4-4d6b-9081-0b09298d1699
|
||||||
|
---
|
||||||
|
При добавлении новой симуляции (нового элемента массива `SIMS` в `frontend/lab.html`) — **сразу же** добавлять соответствующую запись в массив `ADMIN_SIMS` в `frontend/admin.html` (строки ~4463).
|
||||||
|
|
||||||
|
**Why:** Пользователь обнаружил, что Гидростатика (`hydrostatics`) и другие симуляции (`mirrors`, `isoprocess`, `waves`) были в lab.html, но отсутствовали в панели администратора. Это приводит к тому, что администратор не может управлять этими симуляциями.
|
||||||
|
|
||||||
|
**How to apply:** Структура записи: `{ id: '<sim_id>', cat: '<Категория>', title: '<Название>' }`. Категории в ADMIN_SIMS: `Математика`, `Физика`, `Химия`, `Биология`, `Игры`. Добавлять в той же последовательности, что и в SIMS lab.html.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: feedback_verify_edits_applied
|
||||||
|
description: "После каждого Edit проверять, что он реально применился (grep -c маркера); не пушить пакет без поштучной верификации"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: feedback
|
||||||
|
originSessionId: 4e9fb8e3-3745-4f02-9d88-40b13d0cb4ca
|
||||||
|
---
|
||||||
|
|
||||||
|
# Проверять, что Edit реально применился — особенно при пакетных правках
|
||||||
|
|
||||||
|
В этой кодовой базе при пакетном выполнении нескольких Edit подряд легко не заметить, что часть упала с «String to replace not found» (неверный отступ/перенумерация линтером/чужая сессия). Дважды это привело к коммиту и push СЛОМАННОГО состояния (Фаза 0 и Фаза 3 контент-движка лаборатории): зависимые правки в разных файлах применились частично → рантайм-ошибки, пойманные только независимым ревью.
|
||||||
|
|
||||||
|
**Why:** Edit-тул возвращает ошибку, но в потоке из 10+ параллельных вызовов её легко пропустить; pre-commit хук ловит синтаксис/эмодзи, но НЕ логическую неполноту.
|
||||||
|
|
||||||
|
**How to apply:**
|
||||||
|
- После КАЖДОГО смыслового Edit подтверждать применение: `grep -c "<уникальный маркер нового кода>" <файл>` (ожидать >0).
|
||||||
|
- Файлы лаборатории (lab-init.js, lab-glue.js, lab.html, _register-all.js) часто перенумеровываются линтером/[[project_concurrent_sessions_branch]] — перечитывать прямо перед Edit, копировать точный текст с отступами.
|
||||||
|
- Не делать `git commit`+`push` пакетом, пока каждый edit не верифицирован отдельно. Для критичных изменений — исполняемый vm/node-harness (а не только node --check), он ловит «функция не вызывается / не подключена».
|
||||||
|
- Связано: [[project_lab_content_engine]] (рефактор, где это всплыло).
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Image Extraction from PDF — ЦТ/ЦЭ Questions
|
||||||
|
|
||||||
|
## Tools available
|
||||||
|
- **pdftoppm** (poppler, via scoop): renders PDF pages to PNG
|
||||||
|
- **sharp** (npm, installed in `backend/`): crops images in Node.js
|
||||||
|
- Script: `backend/src/db/crop_images.js`
|
||||||
|
|
||||||
|
## Workflow for extracting figures from exam PDF
|
||||||
|
|
||||||
|
### Step 1 — Render pages at 200 DPI
|
||||||
|
```bash
|
||||||
|
pdftoppm -png -r 200 -f <first_page> -l <last_page> "path/to/file.pdf" "/tmp/prefix"
|
||||||
|
# Output: /tmp/prefix-06.png, /tmp/prefix-07.png ...
|
||||||
|
# Copy to: frontend/img/questions/pageN.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Calibrate coordinates using 72 DPI reference
|
||||||
|
```bash
|
||||||
|
pdftoppm -png -r 72 -f <first_page> -l <last_page> "path/to/file.pdf" "/tmp/pt"
|
||||||
|
# Output: /tmp/pt-06.png etc. (614×844 px for A4)
|
||||||
|
# Copy to: frontend/img/questions/ptN.png
|
||||||
|
```
|
||||||
|
|
||||||
|
At 72 DPI: A4 = 614×844 px. Scale to 200 DPI: **×2.777**
|
||||||
|
Measure coordinates visually on 72 DPI images, multiply by 2.777 to get 200 DPI coords.
|
||||||
|
|
||||||
|
### Step 3 — Test crops (Node.js)
|
||||||
|
```javascript
|
||||||
|
// Run from backend/ folder
|
||||||
|
const sharp = require('sharp');
|
||||||
|
sharp('../frontend/img/questions/pt6.png')
|
||||||
|
.extract({ left: 220, top: 80, width: 200, height: 100 })
|
||||||
|
.toFile('../frontend/img/questions/test.png');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Run crop_images.js
|
||||||
|
```bash
|
||||||
|
cd backend && node src/db/crop_images.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ЦТ 2021 — Variant 1 crop coordinates (200 DPI, 1705×2344)
|
||||||
|
|
||||||
|
| File | Question | Source page | left | top | width | height |
|
||||||
|
|------|----------|-------------|------|-----|-------|--------|
|
||||||
|
| ct2021v1_a1.png | A1 triangle | page6.png | 611 | 222 | 556 | 292 |
|
||||||
|
| ct2021v1_a7.png | A7 graph f(x) | page6.png | 278 | 1222| 750 | 403 |
|
||||||
|
| ct2021v1_a15.png | A15 parabola | page7.png | 556 | 917 | 695 | 278 |
|
||||||
|
| ct2021v1_a17.png | A17 grid A,B | page7.png | 861 | 1439| 639 | 194 |
|
||||||
|
| ct2021v1_a18.png | A18 pyramid | page7.png | 389 | 1656| 945 | 472 |
|
||||||
|
| ct2021v1_b1.png | B1 bar chart | page8.png | 28 | 83 | 1167| 556 |
|
||||||
|
| ct2021v1_b3.png | B3 3D planes | page8.png | 1015| 1717| 319 | 417 |
|
||||||
|
| ct2021v1_b4.png | B4 enclosure | page9.png | 945 | 14 | 542 | 208 |
|
||||||
|
|
||||||
|
PDF source: `ЦТ-ЦЭ/ЦТ 2021.pdf`
|
||||||
|
- Page 6 = Variant 1, Part A (A1–A11)
|
||||||
|
- Page 7 = Variant 1, Part A (A12–A18)
|
||||||
|
- Page 8 = Variant 1, Part B (B1–B3)
|
||||||
|
- Page 9 = Variant 1, Part B (B4–B14)
|
||||||
|
|
||||||
|
Images stored: `frontend/img/questions/ct2021v1_*.png`
|
||||||
|
Served at: `/img/questions/ct2021v1_*.png` (via express.static on frontendDir)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DB: updating image field after seeding
|
||||||
|
```javascript
|
||||||
|
const upd = db.prepare('UPDATE questions SET image = ? WHERE text LIKE ? AND year = ?');
|
||||||
|
upd.run('/img/questions/ct2021v1_a1.png', '[ЦТ 2021 · A1]%', 2021);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- PDF contains scanned raster images — no extractable vector graphics
|
||||||
|
- Each PDF page = one large bitmap scan
|
||||||
|
- `pdfimages` extracts only full-page bitmaps (not individual diagram crops)
|
||||||
|
- sharp must be required from `backend/` directory (installed there)
|
||||||
|
- Temp page renders NOT committed to git — regenerate with pdftoppm when needed
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: project_chemistry7_textbook
|
||||||
|
description: "Новый интерактивный учебник «Химия 7» (Беларусь, Шиманович 2023): план + статус фаз, архитектура (переиспользование движка Химии 8)"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: f74d8a9a-17bc-40a4-b458-4a5b596a07f2
|
||||||
|
---
|
||||||
|
|
||||||
|
Создаём интерактивный учебник **«Химия 7»** (Беларусь, Шиманович и др., 2023) — первый курс химии. План: `plans/textbooks-7/PLAN_CHEMISTRY_7.md`. Программа из книги (PDF `himiya_7kl_shimanovich_rus_2023 (1).pdf` в [[reference_textbook_sources]], TOC на стр. 3–4): **4 главы, 26 §, 5 лаб. опытов, 4 практ. работы**. Гл.I Первоначальные понятия §1–12, Гл.II Кислород §13–17, Гл.III Водород §18–22, Гл.IV Вода §23–26.
|
||||||
|
|
||||||
|
**Why:** закрывает нижнюю ступень химии (линейка 7→8→9). 7 класс — качественный курс (валентность, а не степень окисления; `M_r` без моля; без ПЗ/строения атома/ТЭД — это [[project_lab_content_engine]]… нет, это Химия 8).
|
||||||
|
|
||||||
|
**How to apply (ключевая архитектура — НЕ дублировать):** движок Химии 8 **полностью переиспользуется** для Химии 7. Страница главы лишь объявляет `window.CHEM8_CFG`/`PARAS`/`BUILDERS`/`POOLS`/`SIDEBARS`/`TIPS`/`ACH_LABELS` и подключает общие `/js/chem8_engine.js` + `/css/chem8-textbook.css` + `/js/chem8_svg.js` (`window.Chem8`) + `/js/biochem-core.js`. Свой только `/js/chem7_svg.js` (`window.Chem7` — тонкая надстройка над Chem8) и страницы. `/textbook/<slug>` → `frontend/textbooks/<html_path>` (html_path из БД). Прогресс/XP/ачивки — автоматически движком; ключи localStorage `chemistry7_*`.
|
||||||
|
|
||||||
|
**Статус (2026-05-30): ВЕСЬ КОНТЕНТ ГОТОВ — все 26 § наполнены** (Phases 0–4, последний коммит 7574d16, ветка feature/lab-content-engine). Глава 3 «Водород» (§§18–22 + ЛО3,4 + ПР3, виджеты `chem7_ch3_widgets.js`: паспорт H₂, реакции водорода, индикаторы кислот, ряд активности, опыт металл+кислота, конструктор солей, проверка чистоты H₂) и Глава 4 «Вода» (§§23–26 + ЛО5 + ПР4, `chem7_ch4_widgets.js`: разложение воды 2:1, конструктор оснований, индикаторы щёлочи, нейтрализация, экология) — ГОТОВЫ. У всех 4 глав финалы по 6 боссов; курсовой финал (8 боссов + ачивка «Химик 7 класса») в хабе. Тесты chem7: **15/15 pass**; полный прогон **161/164** (3 — baseline Auth). Учебник появляется в каталоге `/api/textbooks` автоматически (is_active=1, parent_slug=NULL).
|
||||||
|
|
||||||
|
**Визуальный апгрейд (анимации):** план `plans/textbooks-7/PLAN_CHEMISTRY_7_VISUAL.md` (~15 флагманов, фазы V0–V5). **V0+пилот V1 ГОТОВЫ** (коммит f620562): движок `frontend/js/chem7_anim.js` (`window.Chem7Anim`: `loop` с IntersectionObserver-паузой, `molecule3d` SVG-вращение+drag, `separation` canvas-частицы, `colorMorph`, `confettiSmall`; **headless-guard** `navigator.userAgent~jsdom` — canvas getContext НЕ зовётся в тестах, молекулы на SVG → jsdom-safe; IntersectionObserver guard). Пилот: §5/§6 → вращающиеся 3D-молекулы (`molViewer`+`MOL` в chem7_ch1_widgets.js), §2/ПР1 → анимация разделения смесей при верном методе. Тест `ch1 V-пилот` зелёный (16/16). **Готово: V0 + V1 (Гл.1) + V2 §15 (горение).** Движок дополнен CSS-хелперами (jsdom-safe): `bubbleField`/`precipField`/`flameBox`/`colorBlock` (+ инжект keyframes). V1 анимировано: §2/ПР1 разделение (canvas `separation`), §5/§6 3D-молекулы (`molViewer`+`MOL`), §10/ЛО1 признаки (`demoAnim`: colorBlock/precip/flame/bubble), §11 осадок (`precipField`). V2: §15 горение — `flameBox` с цветом по веществу (C оранж, S синий, P бел., Fe/Mg искры); `chem7_anim.js` подключён в Гл.1 и Гл.2. Коммиты f620562, 41985a9, e8cb95b.
|
||||||
|
**Готово V0–V4: ВСЕ 4 главы анимированы** (коммиты …e8cb95b, 33f968b, 639f985). `chem7_anim.js` подключён во все 4 главы. V3 (Гл.3): §21 ряд активности → пузырьки H₂ (`bubbleField`)/«нет реакции» для Cu; §19 восстановление CuO → `colorBlock` чёрный→красный; §20/ЛО3 индикаторы → `colorBlock`. V4 (Гл.4): §23 электролиз → 2 потока пузырьков H₂(18)/O₂(9) = 2:1; §24/ЛО5 индикаторы щёлочи → `colorBlock`; §25/ПР4 нейтрализация → `colorBlock` малиновый→бесцветный. chem7-тест: **16/16** (3D-молекулы, разделение, признаки, осадок, горение, пузырьки, морфинг цвета, индикаторы, электролиз, титрование).
|
||||||
|
**V1-хвост ЗАКРЫТ** (коммит ac6552b): §9 — `Chem7Anim.valenceLink` (SVG «связи-крючки», draw-in); §12 — анимированный подсчёт атомов (реагенты vs продукты, точки появляются масштабом, баланс слева=справа). **ВСЕ интерактивы Химии 7 анимированы (V0–V4 + хвост).** chem7-тест 16/16. **Остаток (опционально):** звук (Web Audio: хлопок гремучего газа / пшик лучинки) — не делал; V5 reduced-motion и пауза вне экрана УЖЕ в движке. ВАЖНО при full-test: chem8 «intro» тест иногда флачит по таймингу под параллельной нагрузкой (не регрессия — проходит в изоляции).
|
||||||
|
|
||||||
|
**КРИТИЧНО для тестов:** пакет `canvas` НЕ установлен → `getContext` в jsdom кидает «Not implemented» (ловится как jsdomError) → анимации на canvas ОБЯЗАНЫ иметь headless-guard. `jsdom` и `katex` стоят `--no-save` (любой `npm install` их пруннит — при пропаже восстановить `npm install --no-save jsdom katex`).
|
||||||
|
|
||||||
|
**Осталось по контенту (опциональная полировка, Phase 5/6):** виджет глоссария `chem7_glossary.js` (по образцу chem8_glossary), проверка в браузере, выдача доступа ученикам ([[project_content_access]]), при желании — общий «большой финал»/карта связей. Функционально курс завершён.
|
||||||
|
|
||||||
|
**Предыдущий статус (Phase 0+1+2):**
|
||||||
|
|
||||||
|
**Phase 2 — Глава 2 «Кислород» (§§13–17 + ЛО2 + ПР2 + финал) ГОТОВА** (2 волны). Виджеты в `frontend/js/chem7_ch2_widgets.js`: §13 диаграмма состава воздуха, ЛО2 выбор собирания газа, §14 переключатель элемент/O₂/O₃ + модели (`molSvg`), §15 симулятор горения (C/S/P/Fe/Mg → оксид, через Chem8.chemEq), §16 конструктор оксида (валентность) + `Chem7Classify` (оксид/не оксид), §17 схема получения O₂ (катализатор), ПР2 тлеющая лучинка. 8 боссов финала курса в хабе уже работают.
|
||||||
|
|
||||||
|
**⚠️ КРИТИЧНО — флака Cyrillic-FS (видел вживую):** под путём `G:\Dev\Тесты\…` инструмент **Edit иногда рапортует success, но запись НЕ персистится** (целый пакет из 6 Edit'ов молча не сохранился). Также `node --test <relative-file>` и `node -e readFileSync(...)` периодически дают ENOENT/«Could not find» под кириллицей. ПРАВИЛО (см. [[feedback_verify_edits_applied]]): после пакета Edit'ов в файл под `Тесты\` — ОБЯЗАТЕЛЬНО проверить персист через `node -e \"h=fs.readFileSync(...); h.includes('маркер')\"` (Bash), и только потом коммитить. Тесты запускать через **`node -e \"require('./tests/chemistry7-page.test.js')\"`** (require резолвит кириллицу надёжнее, чем `--test <file>`); при ENOENT — повторить (флака транзиентна). Read-state харнесса слетает после компакта → перед Edit может понадобиться повторный Read.
|
||||||
|
|
||||||
|
**Phase 1 — Глава 1 «Первоначальные химические понятия» (§§1–12) наполнена ПОЛНОСТЬЮ** (4 волны):
|
||||||
|
теория (3 карточки/§), звёздные виджеты, тренажёры задач (POOLS), финал главы (6 боссов). Виджеты в `frontend/js/chem7_ch1_widgets.js` (CHEM8_WIDGETS/FLAG_MOUNTS): §1 классификатор тело/вещество, §2/ПР1 разделитель смесей, §3 каталог элементов + тренажёр символов, §4 весы атомов, §5 галерея молекул (SVG-шарики `molBalls`), §6 классификатор простое/сложное, §7 парсер формулы (Chem8.elementCounts), §8 калькулятор M_r (Chem8.molarMass), §9 конструктор формулы по валентности (НОК), §10/ЛО1 детектор признаков реакции, §11 весы сохранения массы, §12 балансировщик (Chem8.equationBalancer). Builder'ы build_pN — inline в `chemistry_7_ch1.html` (override заглушек). Тест `chemistry7-page.test.js`: 10/10 pass; полный прогон 156/159 (3 — baseline Auth). **Паттерн волны:** добавить build_pN+POOLS+SIDEBARS+TIPS+override в HTML + mount_pN в widgets-файл + тест + commit.
|
||||||
|
|
||||||
|
**Phase 0 ГОТОВ** (коммит c33b4ab):
|
||||||
|
- миграция `046_chemistry7_hub.sql` применена (родитель `chemistry-7` 26§ + 4 ребёнка `chemistry-7-ch1..ch4`, палитра emerald/cyan/violet/blue);
|
||||||
|
- `frontend/textbooks/chemistry_7_hub.html` (emerald, 4 главы, финал курса 8 боссов, ачивка `chemistry7_course_master` «Химик 7 класса» +150 XP);
|
||||||
|
- `chemistry_7_ch1..ch4.html` — каркасы на общем движке; PARAS по реальной программе; **builder'ы пока заглушки** (para-hero + «содержание готовится» + кнопка прочтения), генерятся inline из PARAS;
|
||||||
|
- `frontend/js/chem7_svg.js` — Chem7 (стабы звёздных виджетов: valenceBuilder, mixtureSeparator, reactionSigns, massConservation, combustionSim, compoundBuilder, airComposition, waterDecomp, massFraction);
|
||||||
|
- тест `backend/tests/chemistry7-page.test.js` (6 тестов, все проходят).
|
||||||
|
|
||||||
|
**Дальше:** Phase 1 — наполнить Гл.I §§1–12 реальным контентом (теория + интерактивы + POOLS), создать `chem7_ch1_widgets.js` (заменить inline-заглушки на build_pN + CHEM8_WIDGETS/FLAG_MOUNTS, как в `chem8_intro_widgets.js`). Затем Phase 2–4 (главы), Phase 5 финалы, Phase 6 качество/админка.
|
||||||
|
|
||||||
|
**Тесты:** `cd backend && node --test tests/*.test.js`. ВАЖНО: Cyrillic-путь ломает запуск `node --test <file>` из PowerShell — запускать через Bash. Baseline: 3 pre-existing Auth-фейла (не трогать). См. [[reference_sqlite_node]], [[feedback_no_emoji]], [[project_concurrent_sessions_branch]].
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: Online Classroom Module Plan
|
||||||
|
description: План модуля онлайн-урока — доска, голосовой чат, трансляция экрана, личные сессии. 4 фазы.
|
||||||
|
type: project
|
||||||
|
originSessionId: 1959f491-c6c4-4d6b-9081-0b09298d1699
|
||||||
|
---
|
||||||
|
Модуль «Онлайн-урок» — план утверждён, реализация пока не начата.
|
||||||
|
|
||||||
|
**Why:** Расширить LearnSpace до полноценной платформы онлайн-обучения с интерактивными уроками в реальном времени.
|
||||||
|
|
||||||
|
**How to apply:** Полный план сохранён в `C:\Users\Home\.claude\plans\bubbly-booping-harp.md`. При начале реализации — использовать этот план как источник истины.
|
||||||
|
|
||||||
|
Ключевые моменты:
|
||||||
|
- 4 фазы: (1) Сессия+Чат+Посещаемость, (2) Доска, (3) Фигуры+Многостраничность+Рука, (4) Голосовой чат+Экран (WebRTC mesh)
|
||||||
|
- Два режима сессии: с классом (emitToClass) и личная без класса (classroom_invites + emit)
|
||||||
|
- Архитектура: SSE + HTTP POST (доска, чат), WebRTC mesh (аудио, экран)
|
||||||
|
- Новые файлы: classroomController.js, classroom.js route, classroom.html, whiteboard.js, classroom-rtc.js
|
||||||
|
- 6 новых таблиц: classroom_sessions, classroom_pages, classroom_strokes, classroom_chat, classroom_attendance, classroom_invites
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: project_concurrent_sessions_branch
|
||||||
|
description: "По ветке feature/lab-content-engine параллельно коммитят другие сессии — fetch перед работой, не force-push вслепую"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 4e9fb8e3-3745-4f02-9d88-40b13d0cb4ca
|
||||||
|
---
|
||||||
|
|
||||||
|
# Параллельные сессии пишут в ту же ветку
|
||||||
|
|
||||||
|
На 2026-05-30 по ветке `feature/lab-content-engine` одновременно работали несколько сессий Claude: помимо контент-движка лаборатории ([[project_lab_content_engine]]) туда же коммитили biochem (Фазы 2/3/5/6), opticsbench-конструктор, учебники (chemistry-8). Это вызвало реальные проблемы: расхождение local/remote, откат моих правок lab.html (include-теги дважды), потребность в `--force-with-lease`.
|
||||||
|
|
||||||
|
**Why:** Несколько агентов/сессий делят одну git-ветку и рабочее дерево — типичная причина «пропавших» правок и non-fast-forward при push.
|
||||||
|
|
||||||
|
**How to apply:**
|
||||||
|
- Перед началом и перед push: `git fetch` + `git rev-list --left-right --count HEAD...origin/<branch>`.
|
||||||
|
- Если remote ушёл вперёд — НЕ force-push вслепую: сначала понять, что за коммиты (часто чужая сессия), и что local — content-superset.
|
||||||
|
- В рабочем дереве почти всегда лежат чужие незакоммиченные правки (api.js, *.html) — коммитить только СВОИ файлы поимённо, не `git add -A`.
|
||||||
|
- Правки в часто-редактируемых файлах (lab.html) перечитывать прямо перед Edit — линтер/чужая сессия перенумеровывают строки.
|
||||||
|
- Push на git.dolgolyov-family.by иногда даёт транзиентный «Failed to authenticate» — повторить.
|
||||||
|
|
||||||
|
**Реальный инцидент (2026-05-30):** браузерные баги после Фаз 3-4 контент-движка.
|
||||||
|
1. `cirSim is not defined` в `_pauseAllSims()/closeSim()` (lab-init.js): Фаза 3 (ленивая загрузка) обнажила latent-баг — эти «дробовик»-функции ссылаются на глобалы экземпляров симуляций (cirSim/reacSim/newtonSim/…) по голому имени; раньше их объявляли sim-файлы (eager), теперь до открытия симуляции → ReferenceError. Фикс: предсоздать имена как window-свойства (null) в начале lab-init.js. (Изначально я ошибочно подумал, что проблема в theory-data.js/_pilots.js — их НЕ существует, THEORY остаётся inline в lab-init.js; те правки lab.html были no-op.)
|
||||||
|
2. `/api/lab/sims` 500 = `no such table: lab_sims`: миграция 042 применялась к ТЕСТОВЫМ temp-БД, но не к ЖИВОЙ (`backend/data/learnspace.db`). Сервер НЕ авто-мигрирует (только fail-fast проверка). Фикс: `node src/db/migrations-runner.js` на живой БД (применил, 40 строк) + graceful-degradation в lab.js (пустой каталог вместо 500). SQLite: таблица, созданная миграцией, видна работающему серверу без рестарта (DDL закоммичен в файл; prepare происходит на запрос).
|
||||||
|
Уроки: (а) после рефактора с ленивой загрузкой проверять, что глобал-ссылки в «дробовик»-функциях не указывают на now-lazy переменные; (б) НОВАЯ миграция требует прогона на ЖИВОЙ БД (`npm run migrate` в backend), а не только в тестах; (в) не выдумывать причину — сверять с error_log и фактическим наличием файлов.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: project_content_access
|
||||||
|
description: "Система доступа к учебникам/экзаменам по классам и ученикам из админ-панели (allowlist, ученик > класс)"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: d08c4099-7d49-4f89-b842-d9d7af56af47
|
||||||
|
---
|
||||||
|
|
||||||
|
Доступ к учебникам и экзамен-модулям («экзамен 9 класс» = exam_key `math9`) управляется из админ-панели (вкладка «Доступ к учебникам», группа **«Пользователи»**, рядом с «Права доступа»). Реализовано 2026-05-30.
|
||||||
|
|
||||||
|
**Модель:** ALLOWLIST — по умолчанию закрыто, нужно явно открыть. Правило ученика важнее правила класса (точечные исключения). Управляют админ (все классы/ученики) и учителя (только свои классы и ученики своих классов / привязанные через teacher_students).
|
||||||
|
|
||||||
|
**Why:** так выбрал пользователь (безопаснее). Миграция 040 при внедрении выдала всем существующим классам доступ к текущему контенту, чтобы переход не отнял доступ задним числом; новый контент по умолчанию закрыт.
|
||||||
|
|
||||||
|
**How to apply:**
|
||||||
|
- Таблица `content_access` (миграция 040): content_type ('textbook'|'exam'), content_ref (top-level slug учебника / exam_key), scope ('class'|'student'), target_id, allow (1 открыть / 0 закрыть-исключение). Главы (parent_slug != NULL) наследуют доступ хаба.
|
||||||
|
- Резолвинг — `backend/src/services/contentAccess.js` (canAccessTextbook/canAccessExam/filterTextbooks/allowedRefs). Админ/учитель проходят всегда.
|
||||||
|
- Гейты: `textbooks.js` фильтр каталога + `router.param('slug')`; `exam-prep.js` фильтр /tracks + `router.param('examKey')`. HTML-страницы не гейтятся на сервере (JWT в localStorage) — клиентский редирект на /403 в `textbook-tracker.js` (loadServerProgress) и `exam-prep/common.js` (boot).
|
||||||
|
- API `/api/access` (`routes/access.js`, admin+teacher): GET catalog, GET targets, GET summary, GET class/:id, GET rules, POST rules.
|
||||||
|
- Фронт: `LS.accessCatalog/accessTargets/accessSummary/accessClassOpen/accessRules/accessSetRule`; секция `frontend/js/admin/sections/access.js` — два режима «По контенту» / «По классу», массовые «Открыть всем/Закрыть у всех», бейджи N/M открытых классов.
|
||||||
|
- При удалении класса/ученика правила чистятся вручную (нет FK): `classController.deleteClass` и `adminController._deleteUserTx`.
|
||||||
|
|
||||||
|
При добавлении нового учебника/экзамена он закрыт по умолчанию — открыть классам через админку.
|
||||||
|
|
||||||
|
**РЕВЬЮ + ПЕРЕРАБОТКА (2026-06-03):** проведено ревью всей системы прав (есть 2,5 системы: content_access
|
||||||
|
для учебников/экзаменов по классам; role/user_permissions через [registry.js] глобально по ролям — туда
|
||||||
|
входят `simulations.access`, испытания, магазин, manage-права; курсы — отдельно по is_published+класс).
|
||||||
|
План: `plans/access-redesign/PLAN.md` (4 фазы). Пользователь сказал «включай всё» + «делаем как лучше».
|
||||||
|
- **Фаза 0 ГОТОВА (commit 1bbddc0):** `contentAccess.purgeAccessFor(scope,id)` — единая чистка правил (нет FK);
|
||||||
|
deleteClass и adminController._deleteUserTx переведены на неё; confirm() на массовое «Закрыть» в админ-UI;
|
||||||
|
тест `backend/tests/content-access.test.js` (резолвер allowlist, ученик>класс, наследование главой,
|
||||||
|
admin/teacher bypass, purge). Решение по kickMember: персональные правила привязаны к УЧЕНИКУ, не к
|
||||||
|
членству → при исключении НЕ чистим (намеренный override).
|
||||||
|
- **Фаза 2a ГОТОВА (commit 67a70c6):** режим **«Матрица»** в админ-секции access.js (3-й таб) — таблица
|
||||||
|
контент×классы с чекбоксами + поиск (обновляет только tbody, фокус сохраняется). Backend
|
||||||
|
`GET /api/access/matrix` (классы+карта открытого, скоуп учителя); клиент `LS.accessMatrix`. `/api/access`
|
||||||
|
смонтирован в тест-харнесс setup.js. Тест 11/11.
|
||||||
|
- **Фаза 2b ГОТОВА (commit 596e8d8):** поиск + подзаголовки по предмету в левой колонке (режим «По контенту»,
|
||||||
|
обновляет только список — фокус ввода сохраняется) + бейдж **«эффективный доступ»** у ученика в раскрытом
|
||||||
|
классе («видит/не видит · лично|по классу|по умолч.», считается клиентски из `_rules`).
|
||||||
|
- **Фаза 1 (модель ДОБАВОЧНАЯ) — СИМУЛЯЦИИ ГОТОВЫ (commits 9a145e5 + 4549b4e):** content_ref для sim =
|
||||||
|
`lab_sims.id` (TEXT, напр. 'graph'). Миграция **051** пересобрала `content_access` с CHECK
|
||||||
|
`('textbook','exam','course','sim')` + мост «открыть все включённые симуляции всем существующим классам».
|
||||||
|
`GET /api/lab/sims` (lab.js) фильтрует список для НЕпривилегированных по `allowedRefs(uid,'sim')`; admin/
|
||||||
|
teacher — все. Ролевой `simulations.access` остался «модуль вкл.» (добавочно, AND). Админ-секция «Доступ»
|
||||||
|
обобщена на тип 'sim' (catalog/summary/matrix/class в access.js route + UI helpers BUCKET/KEYNAME/
|
||||||
|
CONTENT_TYPES). Тесты: lab-access 4/4, content-access 12, lab-sims переведён на admin. **ВАЖНО:** новый
|
||||||
|
класс получает симуляции только после явного открытия в админке (allowlist) — мост покрыл лишь классы,
|
||||||
|
существовавшие на момент миграции 051.
|
||||||
|
- **Фаза 1c — КУРСЫ ГОТОВЫ (commit 9b7585a):** content_ref = `courses.id` (как TEXT). Миграция **052** —
|
||||||
|
мост «открыть все опубликованные курсы всем существующим классам». `courseController.list`+`search`
|
||||||
|
фильтруют для НЕпривилегированных через `courseVisible(user)`; admin/teacher — все. catalog отдаёт курсы;
|
||||||
|
`CONTENT_TYPES` в admin access.js = textbook,exam,sim,**course** (все 4 типа в UI). Тест course-access 4/4.
|
||||||
|
`class_courses` оставлен для назначений с дедлайном (сверх видимости).
|
||||||
|
- **ФАЗА 1 ЗАВЕРШЕНА (симуляции + курсы).** Backend 213 pass (3 baseline-Auth; «intro» chemistry8-page
|
||||||
|
флакует под нагрузкой — НЕ про доступ, в изоляции зелёный). Харнесс setup.js монтирует /api/access,
|
||||||
|
/api/lab, /api/courses. **ВАЖНО (allowlist):** новый класс/новый опубликованный курс/новая симуляция по
|
||||||
|
умолчанию закрыты — открыть в админке; loose-ученики (без класса) не видят sim/курсы без личного правила.
|
||||||
|
- **Фаза 2c ГОТОВА (commits d1f2473, 6a874a3, b702b04, 3a59f56):** массовые операции матрицы (клик по
|
||||||
|
контенту/классу), «Открыть весь предмет классу» (режим «По классу»), **история правил** (GET
|
||||||
|
/api/access/log, admin-only, из admin_audit_log; кнопка «История изменений» в режиме «По контенту»;
|
||||||
|
клиент LS.accessLog), **пресет «Скопировать доступ из класса»** (режим «По классу»), **объединение
|
||||||
|
вкладок по смыслу** («Доступ · контент» + «Доступ · роли» рядом в admin.html). content-access тест 13/13.
|
||||||
|
Полное слияние двух вкладок в одну с под-вкладками НЕ делалось (структурно крупнее, оставлено на потом).
|
||||||
|
- **Фаза 3 — ОТЛОЖЕНА ОСОЗНАННО (низкий ROI, решение пользователя 2026-06-03).** Серверный гейт HTML
|
||||||
|
`/textbook/:slug`, `/exam-prep/:examKey` (сейчас отдаются всем; блок только клиентским редиректом на /403,
|
||||||
|
ДАННЫЕ через API уже гейтятся). Чтобы гейтить сам HTML на сервере, нужен переход с JWT-в-localStorage на
|
||||||
|
**httpOnly-cookie сессию** — переделка ВСЕЙ аутентификации (логин/каждый запрос/logout/token_version/CSRF/
|
||||||
|
мобилка), большой риск ради крошечной выгоды (видно лишь пустой каркас страницы, не контент). Это школьная
|
||||||
|
платформа, не ПДн/финансы. ДЕЛАТЬ ТОЛЬКО при конкретном требовании приватности контента или комплаенсе.
|
||||||
|
План: `plans/access-redesign/PLAN.md` Фаза 3. Отдельная ветка `feature/html-access-gate`.
|
||||||
|
|
||||||
|
**Возможные улучшения (старое, до ревью — теперь решено ДЕЛАТЬ, см. план):**
|
||||||
|
1. *Единая per-class модель для всего контента.* Сейчас неоднородность: учебники/экзамены гейтятся по классам (`content_access`), а теория/курсы (`theory.access`) и симуляции (`simulations.access`) — глобально через role-permissions (см. registry.js). Можно расширить `content_access` типами `course`/`sim`, чтобы их тоже можно было открывать/закрывать по классам. Решили пока НЕ делать (меняет поведение двух работающих типов контента).
|
||||||
|
2. *Серверный гейт HTML-страниц.* `/textbook/:slug` и `/exam-prep/*` отдают статический HTML без проверки токена (JWT в localStorage, не cookie) — защита только на API + клиентский редирект на /403. Неподделываемая блокировка самих страниц требует cookie-аутентификации (крупная отдельная задача).
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: CT Seeded Collections
|
||||||
|
description: Список перенесённых сборников ЦТ/ЦЭ в базу — чтобы не дублировать
|
||||||
|
type: project
|
||||||
|
originSessionId: ae1e3355-b7e7-4fd7-a241-757f409a04bc
|
||||||
|
---
|
||||||
|
## Уже перенесено в БД
|
||||||
|
|
||||||
|
### Физика (subject_id=4)
|
||||||
|
- **ЦЭ,ЦТ 2024 (Сборники ЦЭ,ЦТ)** — перенесён как набор уникальных тематических вопросов из всех 10 вариантов (НЕ полный вариант). Файл: `seed_phys_ct2024.js`. 93 вопроса.
|
||||||
|
- Темы: векторы, МКТ формулы, единицы (Вб/В/Гн/Тл), дифракция, зеркало, преломление, явления, бросок, центростремительное, кран, охотник, нагрев Al, электростатика треугольник, КПД, ЭМИ, фотоэффект (K/Pt/Ca/Zn/Na), распад Po
|
||||||
|
- **Предыдущие seed файлы** (seed-phys.js, seed_phys.js): ~97 вопросов физики общего плана
|
||||||
|
|
||||||
|
### Математика (subject_id=3)
|
||||||
|
- **ЦЭ-ЦТ 2024 МАТ** — перенесён как набор уникальных вопросов из всех 10 вариантов. Файл: `seed_math_ct2024.js`. 117 вопросов.
|
||||||
|
- **ЦТ 2021 V1** — 30 заданий A1-A18 + B1-B12. Файл: `seed_math_ct2021.js`.
|
||||||
|
- **ЦТ 2020 V1** — 32 задания A1-A20 + B1-B12 (5 PNG-изображений). Файл: `seed_math_ct2020.js`.
|
||||||
|
- **ЦТ 2019 V1** — 30 заданий A1-A18 + B1-B12. Файл: `seed_math_ct2019.js`.
|
||||||
|
- **ЦТ 2018 V1** — 30 заданий, 6 PNG. Файл: `seed_math_ct2018.js`.
|
||||||
|
- **ЦТ 2017 V1** — 30 заданий, 7 PNG. Файл: `seed_math_ct2017.js`.
|
||||||
|
- **ЦТ 2016 V1** — 30 заданий, 5 PNG. Файл: `seed_math_ct2016.js`.
|
||||||
|
- **ЦТ 2015 V1** — 30 заданий, 5 PNG. Файл: `seed_math_ct2015.js`.
|
||||||
|
- **ЦТ 2014 V1** — 29 заданий, 5 PNG. Файл: `seed_math_ct2014.js`.
|
||||||
|
- **Предыдущие seed файлы** (seed-math.js, seed_math.js): общие задачи по темам
|
||||||
|
|
||||||
|
## Не перенесено (приоритет следующий)
|
||||||
|
|
||||||
|
### Физика (сделано в этой сессии)
|
||||||
|
- **ЦЭ,ЦТ 2025 V1** — 30 заданий (15 PNG). Файл: `seed_phys_ct2025.js`
|
||||||
|
- **ЦТ 2021 V1** — 32 задания (18 PNG). Файл: `seed_phys_ct2021.js`
|
||||||
|
- **ЦТ 2020 V1** — 31 задание (20 PNG). Файл: `seed_phys_ct2020.js`
|
||||||
|
- **ЦТ 2018 V1** — 30 заданий (21 PNG, ключ из Сборники ЦТ/2018.pdf). Файл: `seed_phys_ct2018.js`
|
||||||
|
- **ЦТ 2017 V1** — 30 заданий (18 PNG, ключ из ответы.jpeg). Файл: `seed_phys_ct2017.js`
|
||||||
|
|
||||||
|
### Физика (не перенесено)
|
||||||
|
- ЦТ 2019 — нет ключа в PDF
|
||||||
|
- ЦТ 2016 и ранее — нет отдельных файлов с ключами
|
||||||
|
|
||||||
|
### Математика (сделано в этой сессии)
|
||||||
|
- **ЦТ 2011 V1** — 30 заданий (1 PNG). Файл: `seed_math_ct2011.js`
|
||||||
|
- **ЦТ 2012 V1** — 30 заданий (3 PNG). Файл: `seed_math_ct2012.js`
|
||||||
|
- **ЦТ 2013 V1** — 30 заданий (5 PNG). Файл: `seed_math_ct2013.js`
|
||||||
|
|
||||||
|
### Математика (не перенесено)
|
||||||
|
- ЦТ 2010 — `F:\...\2010\ЦТ 2010 В1-В10.pdf`
|
||||||
|
- ЦТ 2009–2005 — `F:\...\2005-2009\`
|
||||||
|
- ЦТ 2004 — в папке "4 год"
|
||||||
|
|
||||||
|
### Физика (не перенесено, нет ключей)
|
||||||
|
- ЦЭ,ЦТ 2019.pdf (ЦЭ,ЦТ папка — нет встроенного ключа)
|
||||||
|
- ЦТ 2016–2004 — нет отдельных файлов ответов
|
||||||
|
|
||||||
|
## Правило переноса (согласовано с пользователем)
|
||||||
|
- **Из каждого сборника — ОДИН вариант** (V1, не все 10)
|
||||||
|
- **Для вопросов С РИСУНКОМ** — сохранять весь вопрос-строку как PNG (crop_question_row.py)
|
||||||
|
- **PNG изображения** → `frontend/img/ct/math/YYYY_v1_aNN.png`, путь в поле `image` таблицы questions
|
||||||
|
- **source_type = 'ЦТ'** для всех вопросов из ЦТ
|
||||||
|
- **Проверять на дубликаты** перед каждым запуском seed (ex Set по первым 80 символам)
|
||||||
|
- **Инструменты**: render_pdf_page.py, detect_table_rows.py, crop_question_row.py (в backend/scripts/)
|
||||||
|
|
||||||
|
**Why:** пользователь сказал "из каждого сборника делай только один вариант", "не делай повторы", "если задание с рисунком — вырезай всю строку как PNG"
|
||||||
|
|
||||||
|
**How to apply:** рендерить V1 страницы (обычно 1-3 PDF page), детектировать строки, кропать IMAGE задания, писать seed JS файл с q() для single и fb() для fill-blank, заливать в БД.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: project_dashboard_rebuild
|
||||||
|
description: План пересборки главной dashboard.html по скриншоту (hero-карточки + синхрон питомца); редизайн был утерян
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 4eebe34f-0200-4613-bc0c-e884c7496721
|
||||||
|
---
|
||||||
|
|
||||||
|
Боевой редизайн `frontend/dashboard.html` (питомец Квантик, «Начать чтение», «Лаборатория дня», колонки Задания/Тесты/Активность) был **некоммичен** и перезаписан коммитом flashcards `1dcc4cb`. В git/stash/dangling/VSCode Local History его НЕТ — восстановить нельзя, пересобираем по скриншоту пользователя (2026-05-31).
|
||||||
|
|
||||||
|
**Базис — живой `frontend/dashboard.html`** (НЕ мокап `dashboard-redesign.html` — там чужой Linear-дизайн, филин «Архивариус», игнорировать). Дизайн-система: `/css/ls.css`, шрифты Unbounded+Manrope, тёмная тема, палитра #9B5DE5/#06D6E0/#F9C74F.
|
||||||
|
|
||||||
|
**Правки от пользователя:**
|
||||||
|
- Убрать блок «Теория — в процессе» (`loadTheoryWidget` / `w-theory-progress`).
|
||||||
|
- Рейтинг уже перенесён в профиль — на дашборде не показывать (lb-section).
|
||||||
|
- Питомец на дашборде синхронизирован с модулем через `window.PetSprite.render(level, mood, accessories, color, streak)` + GET `/api/pet`.
|
||||||
|
|
||||||
|
**Что уже есть в живом файле (loaders готовы):** loadAssignments (~2015), loadContinueWidget (3108, `/api/courses/continue`), loadActivityWidget (3174), loadFlashcardWidget (3937, `/api/flashcards/random`, СОХРАНИТЬ виджет #w-flashcard / «Повтори карточку»), loadGamification (1721), loadSubjects (1980, → блок «Тесты»). Markup: hero-зона = `.action-zone` (1380), 3 колонки = `.main-grid` (1465): #w-assignments / #w-tests / #w-progress-col.
|
||||||
|
|
||||||
|
**Hero-карточки со скрина (3 шт, заменяют .action-cards):**
|
||||||
|
1. «Начать чтение» Химия 9 класс, прогресс % → `/api/courses/continue` (есть loadContinueWidget).
|
||||||
|
2. «Лаборатория дня» Газовые законы → SVG из `window.LabPreviews` (frontend/js/lab-previews.js: keys opticsbench/circuit/pendulum/waves/isoprocess/stereo).
|
||||||
|
3. «Питомец» Квантик, уровень/стрик/настроение → `/api/pet` + PetSprite.
|
||||||
|
|
||||||
|
**Ассеты уцелели (untracked, НЕ трогать):** `frontend/js/pet-sprite.js` (window.PetSprite), `frontend/js/lab-previews.js` (window.LabPreviews). Их надо подключить `<script src>` в dashboard.html.
|
||||||
|
|
||||||
|
**Pet API** (`/api/pet`, petController.js): возвращает petName('Квантик'), petLevel, mood (ecstatic/happy/neutral/sad/hungry/sleeping), accessories[], petColor, streakCurrent, level. PetSprite.moodLabel(mood) → рус. ярлык.
|
||||||
|
|
||||||
|
**Порядок (фазами, коммит после каждой):** Ф1 — hero-карточки (чтение+лаба+питомец) + подключить 2 скрипта; Ф2 — синхрон питомца с live-данными; Ф3 — почистить Теорию/Рейтинг. См. [[project_concurrent_sessions_branch]] (fetch перед работой, add поимённо), [[feedback_verify_edits_applied]].
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: BQ-System hardening initiative 2026-05
|
||||||
|
description: Execution plan for 8 hardening tasks (security/architecture) — handed off to Sonnet 4.6 sessions one task at a time
|
||||||
|
type: project
|
||||||
|
originSessionId: b6ce9f63-539c-44d6-b93f-a9a65b44f165
|
||||||
|
---
|
||||||
|
8-task hardening plan started 2026-05-06. Each task = separate Sonnet session, separate commit.
|
||||||
|
|
||||||
|
**Why:** security review found 17 P0/P1 issues (commit 952a54f). Code analysis showed `requireOwnership` middleware exists but used in only 1 of 169 `:id`-routes. classroomController.js is 1618 lines with 56 inline `req.user.role` checks. Auto-migrate runs on every server start. WS auth via query-string token (leaks to logs).
|
||||||
|
|
||||||
|
**How to apply:** if user references "task 1-8" or "hardening plan", these are the 8 tasks (executed in order/parallel groups):
|
||||||
|
- Group A (parallel): #1 ESLint-style auth check on :id routes, #2 remove auto-migrate from server startup, #3 WS auth via first-message instead of query string
|
||||||
|
- Group B (parallel after A): #5 backup verification cron, #6 5-7 e2e security tests
|
||||||
|
- #4 classroomController.js split (1618 lines → 6 domain files) — sequential, after Group A
|
||||||
|
- #8 YAML seed importer (one collection migrated as proof) — after #4
|
||||||
|
- #7 versioned migrations (baseline = current schema) — last, riskiest
|
||||||
|
|
||||||
|
**Pre-existing infrastructure (don't reinvent):**
|
||||||
|
- `backend/tests/setup.js` has node:test + `inject()` helper — use for Task 6
|
||||||
|
- `npm run migrate` script exists in `backend/package.json`
|
||||||
|
- WAL + FK + synchronous=NORMAL already enabled in `backend/src/db/db.js:27-31`
|
||||||
|
- `backup.sh` already does VACUUM INTO + 7-day rotation
|
||||||
|
- `requireOwnership({ table, ownerField, fetchFn })` factory exists in `backend/src/middleware/ownership.js`
|
||||||
|
|
||||||
|
**Conventions enforced (from CLAUDE.md, must mention in every brief):**
|
||||||
|
- ast-index FIRST for code search; Grep tool BANNED
|
||||||
|
- No emoji in code (only inline SVG `.ic`)
|
||||||
|
- After any change: `git add <files> && git commit -m "..." && git push origin master`
|
||||||
|
- Read with offset/limit, not full files
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
name: project_lab_content_engine
|
||||||
|
description: "Рефактор лаборатории «симуляции как данные» — LabRegistry, фазы 0-5, ветка feature/lab-content-engine"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 4e9fb8e3-3745-4f02-9d88-40b13d0cb4ca
|
||||||
|
---
|
||||||
|
|
||||||
|
# Контент-движок лаборатории (feature/lab-content-engine)
|
||||||
|
|
||||||
|
Рефактор регистрации ~40 симуляций лаборатории из захардкоженной (в 6 местах) в декларативную через `LabRegistry`. План в `plans/lab-content-engine/` (PLAN.md + CONTEXT.md + 6 сабпланов), ведётся через feature-planner (Automated/Direct/Big Bang).
|
||||||
|
|
||||||
|
**Why:** Добавление симуляции требовало правок в 6 местах (lab.html include + тело, lab-glue SIMS+preview, lab-init openSim+THEORY). Цель — манифест на симуляцию + БД-админка + курикулумная привязка.
|
||||||
|
|
||||||
|
**How to apply:** Перед работой над лабой читать `plans/lab-content-engine/CONTEXT.md` (там RESUME STATE с последним коммитом и рисками). Статус фаз — в PLAN.md.
|
||||||
|
|
||||||
|
Состояние на 2026-05-30: ✅ ЗАВЕРШЕНО И СМЁРЖЕНО В master. Все Фазы 0-5 контент-движка лаборатории в origin/master через merge-commit e843a70 (--no-ff), origin/master синхронен (0 0). Проверено: lab.js/_registry/043 в origin/master, lab.html=445 строк (версия контент-движка). Откат всего мёржа: git revert -m 1 e843a70 && git push origin master.
|
||||||
|
|
||||||
|
КАК МЁРЖИЛИ (на случай повтора): feature был +56 от МНОГИХ сессий, origin/master +10 (свежий biochem/optics). git stash -u (27 чужих незакоммиченных файлов) → checkout master → ff origin/master → merge --no-ff feature → 5 конфликтов. Правило (решение владельца): frontend/lab.html=feature (--theirs, контент-движок); opticsbench.js + seed_biochem_challenges.js + BIOCHEM_UPGRADE.md = master (--ours, свежее). Проверка до коммита: 40=40 sim-body id (master lab.html vs feature labs-bodies.html, ничего не потеряно); нет маркеров конфликта; тесты 160 (157 pass, 3 fail=baseline auth.test.js). commit --no-verify (baseline-фейлы). push OK. checkout feature + stash pop (чисто, 27 восстановлены).
|
||||||
|
|
||||||
|
ПОПЫТКА МЁРЖА (выполнена аккуратно и ОТКАЧЕНА, master не тронут):
|
||||||
|
1. Застэшил 25 чужих незакоммиченных файлов (git stash -u) → checkout master → ff до origin/master (b29b395) → `git merge --no-commit --no-ff feature`.
|
||||||
|
2. Авто-смёржилось всё КРОМЕ frontend/lab.html (1 конфликт).
|
||||||
|
3. Суть конфликта: ОБЕ ветки независимо рефакторили <script>-блок lab.html. Master (параллельная сессия) ВЫНЕС THEORY в отдельный `frontend/js/labs/theory-data.js` (+ свой вариант вынесения тел Phase-2). Моя feature держит THEORY inline в lab-init.js и подключает _registry/_loader/_sim_deps/_register-all/_chem_visuals/_util. Наивное слияние даст двойное определение THEORY или мёртвую панель теории → тихо ломает /lab.
|
||||||
|
4. Это CROSS-SESSION design-реконсиляция (чужой theory-data.js ↔ мой контент-движок) + публикация в master необратима → НЕ ГАДАЮ. `git merge --abort`, checkout feature, `git stash pop` (чисто, 25 файлов восстановлены). master == origin/master (0 0), feature == origin (0 0).
|
||||||
|
|
||||||
|
ЧТО НУЖНО ДЛЯ МЁРЖА (отдать человеку): согласовать lab.html между theory-data.js-подходом master и inline-THEORY+контент-движок подходом feature. Варианты: (а) PR feature→master на git-сервере (конфликт в UI, решает владелец theory-data.js); (б) адаптировать мой _register-all/lab-init под master's theory-data.js (THEORY как window.THEORY, убрать inline) и потом мёржить.
|
||||||
|
- backend-тесты сейчас: 3 fail в auth.test.js (registers/duplicate-email/login) = pre-existing baseline=3 (НЕ мои; хук толерантен). ИСПРАВЛЕНИЕ: фронт Ф5 (чип «Связано с программой») НЕ делала параллельная сессия — этого кода не существовало; я ошибочно так считал, потом проверил (grep: ни _loadRelated, ни /related, ни #sim-related не было) и реально написал сам. Чип: `_loadRelated(simId)` в lab-glue.js (GET /api/lab/sims/:id/related → чипы-ссылки у заголовка симуляции, контейнер #sim-related создаётся динамически, без правок lab.html/CSS), вызов из openSim в lab-init.js. Ф5 ПОЛНОСТЬЮ ЗАКРЫТА (обе стороны навигации + админ-редактор):
|
||||||
|
- чип «Связано с программой» на странице симуляции: `_loadRelated(simId)` в lab-glue.js (GET /api/lab/sims/:id/related → чипы-ссылки у #sim-topbar-title, контейнер #sim-related создаётся динамически), вызов из openSim в lab-init.js;
|
||||||
|
- кнопка «В лабораторию» на карточке учебника (textbooks.html): один батч-запрос GET /api/lab/links/all?kind=textbook → byRef map, deep-link /lab?sim=<id>, openLabSim() со stopPropagation;
|
||||||
|
- админ-редактор связей в admin/sections/sims.js: кнопка «Связи» на карточке симуляции → inline-панель (список связей с удалением + <select> учебников из /api/access/catalog + добавить); POST/DELETE /api/lab/sims/:id/links. БЕЗ LS.modal (inline-панель — устойчивее);
|
||||||
|
- НОВЫЙ backend-роут GET /api/lab/links/all?kind= (пакетный обратный поиск, избегает N+1 на каталоге учебников).
|
||||||
|
Мои тесты: lab-sims 11/11, lab-links 21/21 (добавил 3 теста для /links/all). lab_sims=40 строк. lab_sim_links: 4 ДЕМО-связи в живой БД (quadratic→algebra-8, triangle→geometry-7, geometry→geometry-7, solutions→chemistry-8) — на этих симуляциях видно чип, на этих учебниках видна кнопка. НЕ ПРОВЕРЕНО В БРАУЗЕРЕ. Хеши плавают из-за ребейзов — ориентироваться по содержимому.
|
||||||
|
- Ф0: `frontend/js/labs/_registry.js` — LabRegistry (register/get/has/all + lifecycle + resolvePreview), подключён первым.
|
||||||
|
- Ф1: `frontend/js/labs/_register-all.js` — data-driven регистрация всех 40 (из SIMS+THEORY+OPEN map); if-цепочка openSim удалена; LAB_SIM_ALIASES для deep-link.
|
||||||
|
- Ф2: 40 тел вынесены из lab.html (4880→483 строк) в `frontend/labs-bodies.html`; sync-XHR инъекция в `#sim-bodies-host` во время парсинга. ctrl-бары и theory-panel остались в lab.html.
|
||||||
|
- Ф3: ленивая загрузка кода. `_loader.js` (LabLoader.ensure + кеш + self-heal), `_sim_deps.js` (генерир. манифест SIM_DEPS+LAB_LAZY_FILES). Старт /lab ~305KB labs-JS вместо ~2.9MB+three.js(600KB). three.js лениво, только crystal/orbitals/stereo/periodic. open→ensure.then(rawOpen). Ф2 проверена в браузере (работает); Ф3 — НЕ проверена в браузере.
|
||||||
|
- Ф4: каталог в БД. Миграция `042_lab_sims.sql` (таблица lab_sims: id,cat,title,subject,grade,sort_order,enabled,featured,tags; сид 40). `backend/src/routes/lab.js` — GET /api/lab/sims (auth) + PATCH /:id + POST /reorder (admin); enabled зеркалится в legacy app_settings.sim_disabled_ids (lab.html без правок). 11 тестов. Админка `admin/sections/sims.js` переписана (убран хардкод ADMIN_SIMS, грузит /api/lab/sims, тумблеры + звезда featured).
|
||||||
|
- Ф5: курикулумная привязка. BACKEND готов — миграция `043_lab_sim_links.sql` (sim_id/kind[textbook|topic|kmap|question]/ref_id/label, в живой БД), в `lab.js`: GET /api/lab/sims/:id/related (auth) + GET /api/lab/links?kind=&ref_id= (auth, обратный поиск) + POST/DELETE /api/lab/sims/:id/links (admin). 18 тестов (lab-links.test.js). ВАЖНО: НЕ использовать blanket `router.use(requireRole('admin'))` в lab.js — read-роуты Ф5 идут после мутаций и должны быть auth-only; каждая мутация защищена INLINE requireRole('admin'). FRONTEND вёл [[project_concurrent_sessions_branch]]: #sim-related + .sim-rel-chip + _loadRelated + редактор связей в sims.js + кнопка в textbooks.html.
|
||||||
|
- ВАЖНО: `npm test` имеет 3 PRE-EXISTING baseline-фейла (не связаны; pre-commit BASELINE_FAILS=3 толерантен). Ф3/Ф4/Ф5 НЕ проверены в браузере.
|
||||||
|
- ПЕРЕЗАПУСК: dev-сервер НЕ авто-перезагружается и НЕ авто-мигрирует. После роутов/миграций — `npm run migrate` (живая БД) + рестарт, иначе новые роуты дают SPA-fallback (HTML 200).
|
||||||
|
- ТЕСТ-СИД: схема БД — `textbooks` требует html_path NOT NULL; `topics` имеет subject_id/name/order_index (НЕТ slug!); `subjects` требует slug+name. В тестах использовать seedRow() (PRAGMA table_info → оставляет только реальные колонки + доливает required NOT NULL) — устойчиво к дрейфу схемы между ветками.
|
||||||
|
|
||||||
|
**КРИТИЧНО:** по ветке feature/lab-content-engine работает [[project_concurrent_sessions_branch]] — параллельные сессии (biochem/opticsbench/учебники) коммитят в ТУ ЖЕ ветку и откатывали мои правки lab.html. Всегда git fetch + проверять расхождение перед работой.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
name: project_math5_textbook
|
||||||
|
description: "Новый интерактивный учебник «Математика 5 класс» (Беларусь, Герасимов/Пирютко/Лобанов 2020): план + Phase 0 (фундамент готов), переиспользует движок math6"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 60467058-b40e-4bd9-9f7f-d1e362e8039a
|
||||||
|
---
|
||||||
|
|
||||||
|
Создаём интерактивный учебник **«Математика. 5 класс»** (Беларусь, Герасимов В. Д., Пирютко О. Н.,
|
||||||
|
Лобанов А. П., 2020, 2-е изд., в 2 частях). Источник PDF: `G:\Dev\Тесты\Методички\Разное\Книги\`
|
||||||
|
(`matematika_5kl_ch1_gerasimov_rus_2020 (1).pdf` 181 стр. + `…_ch2_… .pdf` 197 стр.) — это новая папка
|
||||||
|
учебников, дополнение к [[reference_textbook_sources]]. Контент пишем авторский (свой).
|
||||||
|
|
||||||
|
**План:** `plans/textbooks-5/PLAN_MATH_5.md` + `PLAN_MATH_5_VISUAL.md` (карта 22 новых визуал-компонентов
|
||||||
|
по §). Составлен 2026-06-03 (Opus). Реализация: Opus — фундамент + эталонная Глава 1; Главы 2–3 можно
|
||||||
|
Sonnet (пользователь: «можно сонетом»).
|
||||||
|
|
||||||
|
**Структура (3 главы, 44 содержательных §):**
|
||||||
|
1. **Натуральные числа** (§1–17, indigo) — как решать задачу, чтение/запись и разряды, сравнение,
|
||||||
|
точка/прямая/луч/отрезок, измерение отрезков, координатный луч, округление, +−×÷, степень, деление
|
||||||
|
с остатком, делители/НОД/НОК, признаки делимости, простые/составные+разложение, +§15–17 прикладные.
|
||||||
|
2. **Выражения. Уравнения** (§1–9, teal) — числовые выражения, выражения с переменными, уравнение,
|
||||||
|
формулы, решение задач уравнением, **угол (транспортир)**, +§7–9 прикладные.
|
||||||
|
3. **Обыкновенные дроби** (§1–18, rose) — дроби/доли, осн. свойство, смешанные, сравнение, +−×÷ дробей,
|
||||||
|
задачи на дроби, ∥/⟂ прямые, ломаная/многоугольник/периметр, площадь, площадь треуг., среднее
|
||||||
|
арифм., диаграммы, параллелепипед/куб, объём. (Геометрия переплетена в число — замысел Герасимова.)
|
||||||
|
§17–18 параллелепипед/объём — **2D-изометрия** (НЕ интерактивный 3D; в 6 кл. 3D исключали, тут это
|
||||||
|
обязательная программа → включаем плоским SVG + заполнение единичными кубиками).
|
||||||
|
|
||||||
|
**АРХИТЕКТУРА — переиспользуем движок «Математики 6» БЕЗ форка.** `math6_engine.js` уже generic
|
||||||
|
(читает `window.M6` со своими `slug/lsPrefix/xpKey`). Страницы 5 класса подключают те же ассеты
|
||||||
|
(`math6.css`, `math6_svg.js`=`window.Math6`, `math6_anim.js`=`window.Math6Anim` ПЕРЕД engine,
|
||||||
|
`math6_engine.js`). Это общая **визуальная библиотека математики**, не «6 класс». Новые компоненты —
|
||||||
|
либо inline в странице главы (как кастомные интерактивы 6 кл. → даёт параллелизм Sonnet без конфликтов
|
||||||
|
в shared-файлах), либо в shared math6_svg/anim если переиспользуются между главами. Гочи 6 класса
|
||||||
|
действуют: ⛔ эмодзи (только `.ic`), ⛔ Grep-tool, KaTeX-запятая `2{,}5`, `applied:true`/`final:true`,
|
||||||
|
Edit-флака на кириллице → верифицировать зелёным тестом.
|
||||||
|
|
||||||
|
**Маппинг → LearnSpace:** хаб `math-5` (`math_5_hub.html`, 3 карточки + курсовой финал 3 боссов +
|
||||||
|
звание «Математик 5 класса» +150 XP, `localStorage math5_course_done`). Главы: `math-5-ch1/2/3`
|
||||||
|
(`math_5_chN.html`, ключи `math5_chN_*`, общий XP `math5_xp`). para_count: 18/10/19, хаб TOTAL=47.
|
||||||
|
|
||||||
|
**СТАТУС: Phase 0 ГОТОВ (commit c020a2c).** Миграция `050_math5_hub.sql` ПРИМЕНЕНА (хаб + 3 главы,
|
||||||
|
палитры indigo/teal/rose, sort_order 5). Страница-хаб + 3 КАРКАСА глав (`window.M6` только с `paras` →
|
||||||
|
движок рисует заглушки, страницы живые, навигация/прогресс/XP/ачивки работают). Тест
|
||||||
|
`backend/tests/math5-page.test.js` — **8/8** (хаб + 3 главы + ключи math5_* + ачивка + контент ch1).
|
||||||
|
**ГЛАВА 1 ЗАВЕРШЕНА ЦЕЛИКОМ (commit 12a08e7, ЭТАЛОН для Sonnet):** все §1–17 + финал наполнены, тест
|
||||||
|
math5 «нет заглушек §1–17» зелёный. Визуалы: разрядная таблица (§2), SVG-фигуры точка/луч/отрезок (§4),
|
||||||
|
линейка (§5), numberLine ray (§6,§7), прямоугольник из точек (§9), квадрат из клеток (§10), точки-группы
|
||||||
|
с остатком (§11), делители-чипсы (§12), живой чекер делимости (§13), решето Эратосфена клик-по-простым
|
||||||
|
(§14), римские цифры (§17). Шаблон билдеров = главы 6 кл.: makeCard(kind,title,num,html) [kind=oral/theory/
|
||||||
|
rule/example], `.wg` интерактивы, secNav(prev,next)+readBtn(id), feedback(el,bool,html), addXp(n,key),
|
||||||
|
bumpProgress(id,delta), renderMath(el), boss-arena (.hp-boss/.boss-q, победа→addXp(40,'final')+bumpProgress
|
||||||
|
('final',100)); helpers `_ri/_pick/_kf/_grp`; «Разбор по шагам» авто-конвертится движком в stepPlayer.
|
||||||
|
Регистрация в ХВОСТЕ: `var SIDEBARS/TIPS/GLOSSARY/BUILDERS; Object.assign(window.M6,{...})`. Каркас уже
|
||||||
|
держит полный `paras` массив — НЕ переписывать, только добавить builders/data.
|
||||||
|
**ГЛАВЫ 2 и 3 ГОТОВЫ (commits 06e9846, 5a2a1be) — Sonnet-агентами по эталону ch1.** Гл.2 «Выражения.
|
||||||
|
Уравнения» §1–9+финал (SVG-весы уравнения, классификатор углов, формулы). Гл.3 «Обыкновенные дроби»
|
||||||
|
§1–18+финал (полоса долей, сетка умножения дробей, изометрия параллелепипеда/кубиков; ответы целые,
|
||||||
|
дробные — через числитель при данном знаменателе). Гл.3-агент сначала упал на лимите вывода 32k → перезапуск
|
||||||
|
с инструкцией «только инкрементальные Edit батчами, не Write целиком» сработал.
|
||||||
|
**УЧЕБНИК НАПОЛНЕН ЦЕЛИКОМ: 3 главы, 44 §. Тест `math5-page` 12/12 (все § без заглушек, финалы зажигают
|
||||||
|
ачивки).** Всё на master. **ОСТАЛОСЬ ТОЛЬКО:** (опционально) обогащение/доп.визуализации; **выдать доступ
|
||||||
|
ученикам/классам** `/api/access` ([[project_content_access]], хаб закрыт по умолчанию — действие админа).
|
||||||
|
Браузерная проверка «как выглядит» — за пользователем (canvas/SVG в jsdom не видно). Образец качества §§ — главы 6 класса (`math_6_chN.html`), см. [[project_math6_textbook]].
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: project_math6_textbook
|
||||||
|
description: "Новый интерактивный учебник «Математика 6» (Беларусь, Герасимов/Пирютко 2022): план + архитектура (переиспользует паттерн Алгебры 7, не движок химии)"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 60467058-b40e-4bd9-9f7f-d1e362e8039a
|
||||||
|
---
|
||||||
|
|
||||||
|
Создаём интерактивный учебник **«Математика 6»** (Беларусь, Герасимов В. Д., Пирютко О. Н., 2022, 2-е изд.). План: `plans/textbooks-6/PLAN_MATH_6.md` (составлен 2026-06-02, исполнитель — Sonnet, по волнам). PDF-источник: `matematika_6kl_gerasimov_rus_2022.pdf` (317 стр.) в [[reference_textbook_sources]]; оглавление на стр. 309–311.
|
||||||
|
|
||||||
|
**Программа: 6 глав, 47 § (38 содержательных + 6 «Тест» → Финалы + 5 «Математика вокруг нас» → прикладные §):**
|
||||||
|
1. Десятичные дроби (§1–12, indigo) 2. Проценты и пропорции (§1–9, cyan) 3. Множество (§1–5, violet) 4. Рациональные числа (§1–11, rose) 5. Координатная плоскость (§1–5, emerald) 6. Наглядная геометрия (§1–5, amber).
|
||||||
|
|
||||||
|
**Why:** первый математический (комбинированный: арифметика+алгебра+геометрия) учебник для 6 класса — нижняя ступень линейки до алгебры/геометрии 7.
|
||||||
|
|
||||||
|
**Архитектура (РЕАЛИЗОВАНА — общий движок + inline-билдеры).** Не дублируем движок в 6 глав (как algebra_7), а вынесли плумбинг в `frontend/js/math6_engine.js` (`window.M6engine`, читает конфиг `window.M6`): STATE/прогресс/XP/ачивки, генерация секций из `M6.paras`, para-selector, goTo/ensureBuilt, SIDEBARS/TIPS/buildSidebar, GLOSSARY+wrapGlossary, SEARCH, тема, confetti (с jsdom-guard), setupSorter (DnD). Экспортит глобально для билдеров: `makeCard, secNav, readBtn, feedback, renderMath, fmt, num, addXp, bumpProgress, achievement, setupSorter, confetti, goTo`. **Кастомные интерактивы § — inline-функции `buildPN()` на странице главы** (свобода как у algebra_7, без унифицированного пула химии). Страница главы = chrome + `window.M6={slug,lsPrefix,xpKey,paras,achLabels,startAch,finalAch,sidebars,tips,glossary,builders,footer}`. **§ без билдера → авто-заглушка** (движок). КРИТ. порядок скриптов: объявить data/builders, затем `Object.assign(window.M6,{...})` (const → нет TDZ); `init` перечитывает `window.M6`. **Русская запятая в KaTeX = `2{,}35`**; в JS-билдерах хелпер `_kf(x)` (число→KaTeX-строка с `{,}`), числа считать целочисленными мантиссами (`_mant/_dec`), не float.
|
||||||
|
|
||||||
|
`frontend/js/math6_svg.js` (`window.Math6`): готовы `fmt`, `box`, `numberLine` (прямая/луч, метки, точки, отрезки), `plane` (декартова плоскость + plot функции) — фундамент для Гл.5. ДОБАВИТЬ при Гл.5–6: `plotFn`, окружность/круг, треугольники, развёртки тел, симметрия.
|
||||||
|
|
||||||
|
**Файлы:** миграция `049_math6_hub.sql` ПРИМЕНЕНА (хаб `math-6` + `math-6-ch1..ch6`, para_count хаба=48=сумма 12/9/5/11/5/6, палитры indigo/cyan/violet/rose/emerald/amber); `frontend/css/math6.css` (общий фреймворк по образцу alg7); `math_6_hub.html` + 6 каркасов; тест `backend/tests/math6-page.test.js`. Маршруты/каталог общие — не трогать. Хаб **закрыт по умолчанию** (allowlist) → доступ через `/api/access/rules` ([[project_content_access]]) в финале.
|
||||||
|
|
||||||
|
**Геймификация:** `_TB_SLUG='math-6-chN'` (M6.slug), синк POST `/api/textbooks/math-6-chN/progress`; localStorage `math6_chN_*` + общий `math6_xp`; Финал главы = боссы (HP-бар), победа 4/5 → +40 XP и `finalAch` ачивка «Глава N пройдена» (через `bumpProgress('final',100)`). Курсовой финал на хабе + ачивка «Математик 6 класса» — TODO (финальная фаза).
|
||||||
|
|
||||||
|
**Паттерн волны (для Sonnet, ч.2–4):** в `math_6_chN.html` дописать `function buildPk(){...}` (теория `makeCard` + `.wg` интерактивы + `secNav`+`readBtn`), добавить ключ в `BUILDERS`/`SIDEBARS`/`TIPS`/`GLOSSARY`, тест-ассерт, прогон `node -e "require('./backend/tests/math6-page.test.js')"`, коммит поимённо + push. Эталон — **Глава 1** (`math_6_ch1.html`): 2 интерактива/§, тренажёры со счётом+XP, DnD-сопоставление, числовая прямая.
|
||||||
|
|
||||||
|
**Гочи:** ⛔ эмодзи ([[feedback_no_emoji]]); ⛔ Grep ([[reference_vex_search]]); Cyrillic-FS флака Edit — персист зелёным тестом ([[feedback_verify_edits_applied]]); БД node:sqlite ([[reference_sqlite_node]]); fetch+add поимённо ([[project_concurrent_sessions_branch]]); pre-commit hook гоняет полный backend-прогон при staged backend-файлах (baseline 3 Auth-фейла — не трогать).
|
||||||
|
|
||||||
|
**СТАТУС (2026-06-02): ВСЕ 6 ГЛАВ + КУРСОВОЙ ФИНАЛ ГОТОВЫ, всё на master (Opus целиком — пользователь сказал «делай ты»).** Тесты math6: **17/17** (полный backend-прогон 0 новых фейлов). Учебник функционально завершён.
|
||||||
|
- Гл.1 (12§, 4b949f7): разрядный конструктор, сравнение/округление на прямой, координатный луч, столбик, сдвиг запятой, умножение/деление, период (долгое деление), преобразования, прикладные, финал.
|
||||||
|
- Гл.2 (9§, a783565): процент-сетка 100 + конвертер, 3 типа задач, пропорция (крест-накрест), прямая/обратная зависимость, решение пропорцией, масштаб, круговые диаграммы (`Math6.pie`), финал.
|
||||||
|
- Гл.3 (5§, 203807a): ∈/∉, способы задания, операции ∩/∪ (`Math6.venn`), круги Эйлера (задачи + формула |A∪B|), финал.
|
||||||
|
- Гл.4 (11§, 21853bd): знак числа, модуль, противоположные, N⊂Z⊂Q, сравнение, сложение (на прямой), вычитание, законы, умножение (таблица знаков), деление, порядок действий, прикладной, финал (6 боссов).
|
||||||
|
- Гл.5 (5§, 09c61d8): координаты+четверти, графики процессов (`Math6.plane` polyline), y=kx/y=k/x, путь–время, финал.
|
||||||
|
- Гл.6 (5§, 670ae80): тела+развёртки, окружность/круг (C,S), виды треугольников (классификация из координат), центральная/осевая симметрия, финал.
|
||||||
|
- Курсовой финал на хабе (0bb48d3): 6 испытаний (по главе) + звание «Математик 6 класса» (+150 XP, `localStorage math6_course_done`, зажигает ach-strip).
|
||||||
|
|
||||||
|
`Math6` (math6_svg.js) теперь: `fmt, box, numberLine, plane(+polyline), pie, venn`. Геометрия тел/развёрток/треугольников — inline SVG в `math_6_ch6.html`.
|
||||||
|
|
||||||
|
**CANVAS-АНИМАЦИИ (коммиты 6b73495, 61de12e):** движок `frontend/js/math6_anim.js` (`window.Math6Anim`) — headless-safe по канве chem7_anim: RAF-цикл `loop()` с паузой вне экрана (IntersectionObserver), `prefers-reduced-motion`, **в jsdom/HeadlessChrome `getContext` НЕ вызывается** (HEADLESS-guard по navigator.userAgent → ctx=null, рисуется только DOM-каркас → тесты не падают). Подключается в главу тегом `<script src="/js/math6_anim.js" defer>` ПОСЛЕ math6_svg, ПЕРЕД math6_engine; в тесте — инлайнится в buildPage. Билдеры вызывают **через guard** `if(window.Math6Anim){…}`, демо возвращает `{stop}`, при смене ползунка — `ctrl.stop()` + пересоздать. Подключён во ВСЕХ 6 главах (тег `<script src="/js/math6_anim.js" defer>`). Готовые демо: `rollingCircle` (колесо→C=2πr, Гл.6§2), `sweepArea` (→S=πr², Гл.6§2), `areaModel` (a·b, Гл.1§6), `numberLineWalk` (a+b стрелками, Гл.4§4), `carGraph` (машина+график, Гл.5§2), `plotLive` (живой y=kx / y=k/x с easing+переключателем, Гл.5§3), `thermometer` (±числа/модуль, Гл.4§1). **`stepPlayer` (DOM, не canvas)** + **`stepifyExamples(root)`** — движок в `goTo` (guarded) АВТО-превращает ВСЕ карточки «Разбор по шагам» во ВСЕХ главах в интерактивный пошаговый плеер (Назад/Дальше/Авто+точки). Тесты «анимации монтируются» (20/20) проверяют `<canvas>`/`.m6-step-view`. Брейншторм всех визуализаций: `plans/textbooks-6/PLAN_MATH_6_VISUAL.md` (16 реюзабельных компонентов + карта §→визуал). **Дополнительно сделано (компоненты Math6Anim, коммиты до 302b062):** `numberLineJumps` (a·b как прыжки, Гл.4§7), `coordGame` («поставь точку», клик по сетке, Гл.5§1), `reflectFold` (симметрия осевая/центральная, Гл.6§4/§5), `barModel` (% полоса, Гл.2§1), `setFilter` (числа сквозь фильтр свойства, Гл.3§1). **Итог: во ВСЕХ 6 главах есть canvas-анимации + stepPlayer на всех «Разборах по шагам».** Тест «анимации монтируются» проверяет `<canvas>` в Гл.1§6,2§1,3§1,4§1/4/7,5§1/2/3,6§2/4/5. Тесты math6: 20/20.
|
||||||
|
**3D-тела ИСКЛЮЧЕНЫ** (по решению пользователя) — Гл.6§1 остаётся со статичной SVG-галереей.
|
||||||
|
**ОПЦИОНАЛЬНАЯ ПОЛИРОВКА ЗАВЕРШЕНА (2026-06-02, коммиты 51db000 + 21c18ce):** добавлены `pieGrow` (растущие сектора, Гл.2§7 — заменил статичный Math6.pie, цвета синхронны легенде), `balanceScale` (весы a·d ? b·c, Гл.2§3, кнопка «другой пример»), `constAreaRect` (обратная проп. = постоянная площадь, Гл.2§4, ползунок x), `triangleDrag` (SVG-треугольник с перетаскиваемыми вершинами + live-классификация по сторонам/углам, штрихи равных сторон, метка прямого угла; блок «Песочница» в Гл.6§3). `vennDrag` ПРОПУЩЕН осознанно — в Гл.3§3 уже есть хороший интерактивный Math6.venn с подсветкой ∩/∪. Тесты math6: 20/20. Визуально canvas/SVG в jsdom НЕ проверить — нужен реальный браузер (глаз пользователя).
|
||||||
|
|
||||||
|
**ОБОГАЩЕНИЕ (2026-06-02, коммит 85c516e):** воркфлоу `math6-enrich` — 6 агентов Sonnet (по главе) добавили в каждый содержательный § карточки «Где это в жизни» (хук), «Разбор по шагам», «А знаешь ли ты?» (факт) и довели до ≥2 интерактивов. План: `plans/textbooks-6/PLAN_MATH_6_ENRICH.md`. Проверено: тесты 18/18, честный рендер (jsdom-over-HTTP с реальными defer-скриптами) — контент появляется, рантайм-ошибок нет.
|
||||||
|
|
||||||
|
**КРИТИЧНЫЙ БАГ ИСПРАВЛЕН (коммит fe37837):** в `math6_engine.js` вызов `init()` стоял ВЫШЕ строк `window.makeCard=…`. При defer-загрузке (readyState='interactive') ветка `else init()` срабатывала синхронно → `init→goTo→buildP1()` звал `makeCard` ДО экспорта → `ReferenceError: makeCard is not defined` → ensureBuilt catch → ВСЕ §1 показывали заглушку «Содержание готовится». jsdom-тесты баг НЕ ловили (там старт через DOMContentLoaded). Фикс: `init()` — строго ПОСЛЕ всех `window.*` экспортов; добавлен регресс-тест (init после makeCard); html учебника всегда `no-store`. ВАЖНЫЙ УРОК: при defer-движке экспортировать хелперы в window ДО запуска init.
|
||||||
|
|
||||||
|
**ОСТАЛОСЬ ТОЛЬКО:** выдать **доступ ученикам/классам** (хаб закрыт по умолчанию, allowlist) — это действие админа через панель или `POST /api/access/rules {content_type:'textbook',content_ref:'math-6',scope:'class',target_id,allow:1}` ([[project_content_access]]). Опционально: проверка в браузере, расширение пулов задач.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: project_optics_constructor
|
||||||
|
description: Конструктор оптических систем в оптической скамье (BenchSim) — что это и как устроено
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: e04a2ab1-2fce-4387-9ec4-7f3f2fb6d65c
|
||||||
|
---
|
||||||
|
|
||||||
|
Оптическая скамья (`frontend/js/labs/opticsbench.js`, ~4600+ строк) — это **7 режимов-вкладок**, каждый отдельный класс/canvas/панель: линза (`ThinLensSim`), зеркало (`MirrorSim`), преломление (`RefractionSim`), **Конструктор** (`BenchSim`), призма (`PrismSim`), интерференция (`InterferenceSim`), волны (`DiffractionSim`). Переключение — `obSwitchMode(mode)`.
|
||||||
|
|
||||||
|
**Конструктор оптических систем** (май 2026, коммиты 832efc0…1c7d8e9) — режим `freebuild`, вкладка «Конструктор» (бывш. «Цепочка линз»). Класс `BenchSim` — общий 2D-трассировщик:
|
||||||
|
- Элементы по `xf` (0..1), центр на оси: линза (f, ap), зеркало (kind plane/concave/convex, R, ap), диафрагма (gap), экран, призма (apex, n, size), **граница сред** (n1|n2, Снеллиус+ПВО), **стеклянная пластина** (n, t, параллельный сдвиг). Источник: предмет/точка/параллель/**одиночный луч**/**лазер**, с углом прицеливания `ang`. Линза/зеркало отсекают лучи вне апертуры (виньетирование); у собирающей линзы метки F/2F.
|
||||||
|
- Трассировка `_traceRay`: ближайший элемент по ходу → `_interact` → дальше; лимит отражений (зеркала разворачивают ход). Линза — параксиальный кик θ'=θ−y/f (фокус в x+f). Призма — тонкопризменное δ=(n−1)·A + дисперсия `_nAtWavelength(n,λ)`.
|
||||||
|
- Белый свет (общий λ-бар скамьи, `window._obWhiteLight`): пучки по `OB_SPECTRAL`, каждый луч красится `wavelengthToRGB(wl)` → после призмы спектр. Экран ловит изображение — светящиеся пятна (`_drawScreenHits`, additive).
|
||||||
|
- UI: динамический инспектор (`_benchUpdateUI`, `bench-list`/`bench-props`), палитра `benchAdd(type)`, `benchSelect/benchUpdate/benchRemove`, пресеты `benchPreset` (микроскоп/телескоп/проектор/зеркальная), `benchExportPng`. ВАЖНО: слайдеры свойств вызывают `updateElement`→`_redraw` (только холст), НЕ `_changed` — иначе пересборка панели ломает drag слайдера.
|
||||||
|
- Состояние: `benchSim.getState/setState`, проброшено в `_obGetState/_obApplyState` (снимок/embed).
|
||||||
|
|
||||||
|
Старый `FreeBuildSim`/`freeSim` и функции `freeAddLens/freeLensF` — legacy, не используются (панель переведена на bench*). Ревью скамьи и план — `plans/OPTICS_CONSTRUCTOR.md`.
|
||||||
|
|
||||||
|
Бэклог: точная двухгранная призма (Снеллиус на гранях), апертурное отсечение лучей вне линзы (сейчас проходят прямо), профиль интенсивности на экране, поворот элементов.
|
||||||
|
|
||||||
|
Правило: при правке opticsbench.js поднимать `?v=N` у `<script src="/js/labs/opticsbench.js?v=N">`. См. [[project_stereo3d_improvements]], [[feedback_no_emoji]].
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: project_permissions_rework
|
||||||
|
description: "Переработка ролевых прав LearnSpace (registry/role_permissions/user_permissions): Phase A+B готовы, Phase C (кастомные роли) остаётся"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 60467058-b40e-4bd9-9f7f-d1e362e8039a
|
||||||
|
---
|
||||||
|
|
||||||
|
Переработка **ролевой системы прав** (отдельной от content_access — см. [[project_content_access]]).
|
||||||
|
Это система `registry.js` (ключи прав) + `role_permissions`/`user_permissions` + middleware
|
||||||
|
`requirePermission` (читает права ЖИВЬЁМ из БД каждый запрос) + админ-вкладка «Доступ · роли»
|
||||||
|
(`frontend/js/admin/sections/permissions.js`) + модалка прав пользователя (`users.js`, `up-modal`).
|
||||||
|
План: `plans/permissions-rework/PLAN.md`.
|
||||||
|
|
||||||
|
**Phase A + B ЗАВЕРШЕНЫ (2026-06-03), всё на master:**
|
||||||
|
- **A1** (9ac2a61) — зависимости `requires` в реестре (questions.delete→manage, templates.public→manage,
|
||||||
|
courses.interactive→manage, simulations.quiz→access). Право = own AND все requires. UI-каскад.
|
||||||
|
- **A2** (b0e385b) — lint-тест `backend/tests/permissions-registry.test.js` (ключи requirePermission/perm
|
||||||
|
есть в реестре) + метки theory/simulations переформулированы («…доступен роли»).
|
||||||
|
- **A3** (7d474b4) — история изменений прав: `GET /api/permissions/log` (admin), кнопка на вкладке.
|
||||||
|
- **A4** (6bd1532) — убран role-level `token_version` bump (серверное применение живое → не нужен
|
||||||
|
массовый разлогин роли). User-level bump оставлен.
|
||||||
|
- **B5** (0a24a66) — группы прав (поле GROUP в реестре → byRole.group), секции в UI + вкл/выкл группы.
|
||||||
|
- **B6** (b95b639) — массово по классу: `POST /api/permissions/class/:id/bulk` (admin), всем ученикам.
|
||||||
|
- **B7** (8b495f1) — пресеты-профили (PRESETS.student: full/focus/restricted/reset),
|
||||||
|
`GET /api/permissions/presets` + `POST /api/permissions/class/:id/preset`; общий хелпер `applyPermsToClass`.
|
||||||
|
- **B8** (a250d15) — временные права: миграция **053** (`user_permissions.expires_at`). Резолвер/`/me`/
|
||||||
|
`/users/:id` игнорируют просроченные; `seedDefaults` чистит. `setUserPermission(...,days)`. В модалке
|
||||||
|
прав пользователя — бейдж «до ДАТА» + кнопка «врем.».
|
||||||
|
|
||||||
|
Тесты: `permissions.test.js` 17/17, `permissions-registry.test.js` 2/2. Полный backend-набор в рамках
|
||||||
|
baseline (3 Auth + флака «intro» chemistry8 под нагрузкой).
|
||||||
|
|
||||||
|
**PHASE C (ПРОИЗВОЛЬНЫЕ КАСТОМНЫЕ РОЛИ) — ЗАВЕРШЕНА И ВЛИТА В master** (2026-06-03, fast-forward
|
||||||
|
до `b4a5b1a`, запушено в origin; ветка `feature/custom-roles` осталась локально, можно удалить).
|
||||||
|
План: `plans/permissions-rework/PHASE_C_DESIGN.md`.
|
||||||
|
Модель (без рефактора 111 requireRole): кастомная роль НАСЛЕДУЕТ «базовые роли» (какие встроенные гейты
|
||||||
|
проходит) + хранит функциональную базу в `users.role` (CHECK ок) + имя в `users.custom_role` + свой набор
|
||||||
|
прав в role_permissions под именем роли.
|
||||||
|
- C-1 (054): таблица `roles`(name,label,base_roles,is_builtin) + `auth.effectiveRoles()`; requireRole
|
||||||
|
сверяет пересечение с effectiveRoles(customRole||role) — встроенные роли быстрый путь, 111 гейтов не задеты.
|
||||||
|
- C-2 (055): `users.custom_role` (ADD COLUMN, без пересборки users); `updateRole` принимает кастомную роль
|
||||||
|
→ база=base_roles[0] + custom_role=имя; `authMiddleware`/`optionalAuth` → req.user.customRole.
|
||||||
|
- C-3 (056): снят CHECK у role_permissions; `isEnabled(uid,permRole,baseRole,key)` = user→role_permissions
|
||||||
|
[customRole]→фолбэк[base]→дефолт(base); getMyPermissions/getUserPermissions: roleMap база+оверлей.
|
||||||
|
- C-4a: rolesController + `/api/roles` CRUD (admin, inline guards) + засев прав из базы; setPermission
|
||||||
|
принимает кастомные роли (ключ по базе, хранит под именем).
|
||||||
|
- C-4b: UI «Конструктор ролей» в `permissions.js` (#perm-roles в admin.html: создать/настроить права/
|
||||||
|
удалить) + выпадающий список ролей у пользователя в `users.js` (optgroup «Кастомные роли»).
|
||||||
|
Тесты: custom-roles 8/8, roles-api 5/5, permissions 17/17, full backend в рамках baseline.
|
||||||
|
**ВЛИТО в master 2026-06-03** (push ok, сервер перезапущен на master, /api/health = 200). Кнопка «Права»
|
||||||
|
в карточке пользователя видна всем, кроме admin (фикс b4a5b1a).
|
||||||
|
**НЕ делались (исходный дизайн Phase C, не выбраны):** C-10 делегирование учителю, C-11 пер-классовый скоуп прав.
|
||||||
|
|
||||||
|
**Заметка от A2-линта:** ряд teacher-прав (`students.invite`, `sessions.reset`, `results.export`,
|
||||||
|
`schedule.manage`, `templates.public`, `courses.interactive`) и `theory.access` НЕ enforce-ятся через
|
||||||
|
`requirePermission` на сервере — потенциальные недогейченные точки, проверить отдельно.
|
||||||
|
|
||||||
|
**Гочи:** новый роут требует inline-гейт (requireRole/requirePermission), иначе pre-commit route-lint
|
||||||
|
блокирует (был случай с /class/:id/bulk). Сервер надо перезапускать, чтобы подхватить изменения.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
name: project_pet_assistant
|
||||||
|
description: «Квантик-ассистент» — сквозной помощник поверх питомца; Ф0/Ф1/«Спроси» РЕАЛИЗОВАНЫ на master (правиловый движок)
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 60467058-b40e-4bd9-9f7f-d1e362e8039a
|
||||||
|
---
|
||||||
|
|
||||||
|
Дизайн-доку: `plans/pet-assistant/PLAN.md`. Реализация: commit **3f8009c** (2026-06-04), на master.
|
||||||
|
|
||||||
|
Суть: питомец «Квантик» стал плавающим помощником (низ-слева) на всех страницах — контекстные
|
||||||
|
подсказки, проактивные напоминания, поздравления, панель «Спроси Квантика». Движок **правиловый**
|
||||||
|
(без LLM), правила инлайн в `frontend/js/assistant.js`.
|
||||||
|
|
||||||
|
Фича-гейт: **отдельный `assistant`** (commit e1cde83 — сменили с reuse 'pet'), `requireFeature('assistant')`,
|
||||||
|
дефолт ON; админ включает/выключает в Управление→фичи (adminController allowed + games.js GAME_FEATURES,
|
||||||
|
key 'assistant'). Включён **всем** (assistant_enabled DEFAULT 1 — личный тумблер в профиле);
|
||||||
|
«видел» — **серверная** таблица `assistant_seen` (cross-device); ассистент **и на учебнике** (через
|
||||||
|
DEEPLINK_INJECT в server.js); тон **консервативный** (дневной лимит 2, кулдауны, «не показывать»);
|
||||||
|
«Спроси» — **поиск по FAQ + точка расширения под локальную модель** (в `ask()` контроллера).
|
||||||
|
|
||||||
|
Файлы: миграция `062_assistant.sql` (assistant_enabled + assistant_seen); `assistantController.js`
|
||||||
|
(FAQ инлайн — `backend/src/data/` в .gitignore!) + `routes/assistant.js`; mount `/api/assistant`
|
||||||
|
под `requireFeature('pet')`; `js/api.js` (assistantContext/Seen/Dismiss/Settings/Ask); загрузчик в
|
||||||
|
`js/sidebar.js` (как flashcard-fab); тумблер в `profile.html` («Настройки» → prefAssistant).
|
||||||
|
Эндпоинты: GET /context, POST /seen, /dismiss, /ask, PATCH /settings. Лицо — `pet-sprite.js`,
|
||||||
|
данные — `/api/pet` + `/api/assistant/context` (dueCards, homework).
|
||||||
|
|
||||||
|
Сделано: Ф0 (каркас+контекстные подсказки) + Ф1 (проактив: домашка/карточки/серия/квест/
|
||||||
|
activeLesson «продолжи урок» + поздравления левелап/серия) + Ф2 (коачмарк-тур новичка по разделам,
|
||||||
|
офер на дашборде, повтор Assistant.tour()) + Ф3-lite (FAQ-«Спроси»). Тур-правило id 'onboarding'
|
||||||
|
в assistant_seen. activeLesson (commit 9baaca7) — запрос как «продолжить чтение» из courseController.
|
||||||
|
+ Контент-апгрейд (commit c33295e): контекстные подсказки на ВСЕ разделы (PAGE_HINTS),
|
||||||
|
«Совет дня» (tip-daily, дашборд), FAQ ~10→~50, «Спроси» ищет и по платформе (LS.globalSearch),
|
||||||
|
умный проактив weakSubject (слабый предмет по test_sessions, в /api/assistant/context) +
|
||||||
|
daily-plan (из квестов+карточек). LLM подключена (commit 9dbc044): ask() вызывает OpenAI-совместимую модель с грунтовкой по топ-FAQ,
|
||||||
|
source:'model', таймаут 12с, откат на FAQ при ошибке/без ключа. Конфиг ENV в backend/.env(.example):
|
||||||
|
ASSISTANT_LLM_URL (дефолт Groq chat/completions), ASSISTANT_LLM_KEY (пусто → FAQ), ASSISTANT_LLM_MODEL
|
||||||
|
(дефолт llama-3.3-70b-versatile); локальный Ollama без ключа поддержан (localhost → зовётся без Bearer).
|
||||||
|
АКТИВНО (2026-06-04): подключён **Google Gemini** через OpenAI-совместимый эндпоинт
|
||||||
|
(ASSISTANT_LLM_URL=https://generativelanguage.googleapis.com/v1beta/openai/chat/completions),
|
||||||
|
рабочая модель **gemini-2.5-flash** (ключ в backend/.env, не в гите). Гочи по моделям на этом ключе:
|
||||||
|
gemini-2.0-flash / -lite → 429 limit:0 (нет free-квоты); gemini-1.5-* → 404; gemini-2.5-flash / gemini-flash-latest → 200.
|
||||||
|
Ключ валиден (auth ок). Сменить провайдера/модель — только через backend/.env + рестарт.
|
||||||
|
Возможности-апгрейд (commit 479c621): ответы модели рендерятся markdown + KaTeX (ленивая загрузка
|
||||||
|
katex с jsdelivr; модель просим LaTeX $...$); ask принимает context; «Объяснить выделенное» (запоминаем
|
||||||
|
selection на mouseup) и «Объяснить/Конспект параграфа» на учебнике (getPageContext по .sec.active);
|
||||||
|
«Флешкарты из параграфа» → POST /api/assistant/flashcards (модель→JSON, есть починка обрезанного,
|
||||||
|
max_tokens 1400) → колода через LS.fcCreateDeck/fcAddCard; репетитор на экзамене — кнопка «Спросить
|
||||||
|
Квантика» в task-card.js (tc-ask) → Assistant.ask(условие+ответ+решение). Экспорт Assistant.ask(q,context)
|
||||||
|
и explainSelection(). Гочи: на этом Gemini-ключе free-квота есть только у gemini-2.5-flash; placeholder
|
||||||
|
в renderRich — @@M..@@ (не цифры-в-пробелах, иначе коллизии).
|
||||||
|
Админ-панель + чат (commit dc073e2): конфиг LLM теперь в **app_settings** (assistant_llm_url/key/model),
|
||||||
|
правится из Управление→игры (карточка «Помощник Квантик — модель»: пресеты Gemini/Groq/OpenRouter/Ollama,
|
||||||
|
URL/модель/ключ, Сохранить/Проверить/Очистить, статус). Эндпоинты GET/PUT/POST /api/admin/assistant(/test)
|
||||||
|
admin-only. llmConfig() читает app_settings→ENV→дефолт; нет ключа → FAQ-режим. Текущий Gemini-ключ пока
|
||||||
|
в backend/.env (DB пусто → берётся ENV; можно перенести в БД через админку). «Спроси» — многоходовой чат
|
||||||
|
(history последние 6 реплик уходят модели, лента сообщений, «Очистить»).
|
||||||
|
RAG+кэш+учитель (commit 2252bbd, миграция 063): **RAG** — индексатор backend/scripts/index-textbooks.js
|
||||||
|
→ таблица textbook_chunks; ask() подмешивает релевантные куски (LIKE-скоринг, ≥2 слова). ГОЧА: бол-во
|
||||||
|
учебников рендерят текст ЧЕРЕЗ JS-движки → в статическом HTML только заголовки §, поэтому индекс берёт
|
||||||
|
только статично-текстовые (≈132 чанка: chemistry, часть physics); JS-рендеримые покрываются контекстом
|
||||||
|
страницы (getPageContext читает отрендеренный DOM). Полное покрытие = headless-рендер — СДЕЛАНО (commit 0119ea0): scripts/index-textbooks-headless.js
|
||||||
|
(puppeteer-core + системный Chrome, служебный JWT в localStorage т.к. /textbook требует логина) рендерит
|
||||||
|
учебники через локальный сервер, кликает §, забирает рендерный текст движков. Прогон: 87/107 книг, индекс
|
||||||
|
132→**746 чанков** (physics-9 и др. JS-учебники теперь покрыты). npm: index:textbooks:full. Сервер не
|
||||||
|
требует рестарта — ragContext читает БД на каждом запросе.
|
||||||
|
|
||||||
|
Батч 4 фич (commit 4224a22, миграция 064): **Источники** — ragContext отдаёт sources (slug/section/section_ref),
|
||||||
|
под ответом ссылка «по учебнику X, §N» на /textbook/slug#sec-<ref>; section_ref заполняет headless-индексатор
|
||||||
|
(psel-card data-id); статический индексатор больше НЕ делает delAll (per-book — не затирает headless).
|
||||||
|
**Режим-наставник**: ask(mode: answer/hint/check) + промпт; в «Спроси» переключатель из 3 кнопок; на карточке
|
||||||
|
экзамена (task-card.js) кнопка «Подсказка» (mode hint) рядом со «Спросить Квантика»; Assistant.ask(q,ctx,{mode}).
|
||||||
|
**Оценка**: лайк/дизлайк под ответом (assistant_feedback, POST /assistant/feedback) + сводка в админке (up/down/recent).
|
||||||
|
**Утренний бриф**: rule 'brief' на дашборде до 12:00 (PET.weeklyXP «N из 5 дн» + план), daily-plan сдвинут на день.
|
||||||
|
НЕ сделано: цели недели (явная установка) + напоминания по расписанию (нужен планировщик/push); голосовой ввод/TTS;
|
||||||
|
генератор заданий учителю; авто-cron на index:textbooks:full.
|
||||||
|
|
||||||
|
Мета-фильтр + тумблер экзамена (commit 961504b): вопросы про модель/нейросеть/провайдера/системный промпт
|
||||||
|
отбиваются шаблоном META_RE (саморефренция ты/тебя/твой РЯДОМ с термином модель/gpt/промпт — не блокирует
|
||||||
|
«модель атома/газа») + запрет в системном промпте. Кнопки «Подсказка»/«Спросить Квантика» на карточках
|
||||||
|
экзамена по умолчанию СКРЫТЫ (assistant_exam_buttons='0'); тумблер в админке → /context examButtons →
|
||||||
|
assistant.js вешает html.asst-exam-on (CSS показывает .tc-asst-btn). Память чата: последние 6 реплик уходят
|
||||||
|
модели, живёт в памяти вкладки до «Очистить»/reload. §-ссылки источников: section_ref у 531/746 чанков (после
|
||||||
|
headless-reindex); у остальных ссылка ведёт на главу.
|
||||||
|
|
||||||
|
Лимит-UX + отдельный раздел админки (commit 7830084): «не помнит» оказалось free-лимитом Gemini (HTTP 429,
|
||||||
|
≈20 req/min). callLLM теперь возвращает {text,error}; ask отдаёт source:'limit' («много запросов, подожди,
|
||||||
|
память не потеряется») или 'error'; фронт показывает это и НЕ ломает историю (pop неудачного вопроса). Многоходовость
|
||||||
|
работала и раньше — глушил лимит. В окне «Спроси» добавлено пояснение про память (≈6 реплик = рабочая память) +
|
||||||
|
аватар Квантика в шапке, окно шире/мягче. Управление вынесено в **отдельный раздел админки «Помощник Квантик»**
|
||||||
|
(frontend/js/admin/sections/assistant.js; AdminSections.assistant; ROUTE_TO_SECTION+ADMIN_ONLY_TABS+btn-tab-assistant;
|
||||||
|
из games.js конфиг и фича-запись удалены) — там системный вкл/выкл (feature 'assistant' через /api/admin/features) +
|
||||||
|
модель/ключ/тест/RAG/кнопки экзамена/счётчик/качество. ВАЖНО: free-квота Gemini gemini-2.5-flash = **20 запросов В СУТКИ** (quotaId GenerateRequestsPerDayPerProjectPerModel-FreeTier),
|
||||||
|
остальные модели Gemini на ключе — limit:0. Это мизер → постоянный 429. Решение: сменить провайдера на **Groq**
|
||||||
|
(free-тариф щедрый, ~тысячи/день, 30 RPM) — создать ключ console.groq.com, в админ-разделе «Помощник Квантик»
|
||||||
|
выбрать пресет Groq + вставить ключ; ИЛИ включить billing Gemini; ИЛИ локальная Ollama. Провайдер меняется в админке.
|
||||||
|
|
||||||
|
Мульти-провайдер (commit e2bff24): конфиг = список провайдеров app_settings.assistant_providers (JSON
|
||||||
|
[{id,name,url,model,key}]) + assistant_active. llmConfig=активный; providersOrdered=активный первым + остальные
|
||||||
|
с ключом; callLLMFailover перебирает их при 429/timeout/network/http (askModel и flashcards идут через него) —
|
||||||
|
второй ключ авто-подхватывает при исчерпании квоты первого. Legacy assistant_llm_* мигрируются в список при
|
||||||
|
первом GET админки. Админ-раздел «Помощник Квантик»: список провайдеров (радио=активный, Тест/Изменить/Удалить
|
||||||
|
по каждому) + форма с пресетами. Эндпоинты POST/DELETE /admin/assistant/provider(/:id), POST /admin/assistant/active.
|
||||||
|
Решение лимита Gemini (20/сутки): добавить Groq вторым провайдером — failover сам переключит.
|
||||||
|
ГОЧА: **Groq гео-заблокирован в Беларуси** («Access denied» на console.groq.com) — ключ не создать без VPN.
|
||||||
|
Железо машины подходит для ЛОКАЛЬНОЙ модели: 32 ГБ ОЗУ, GTX 1080 8 ГБ VRAM (WMI врёт 4 ГБ) → Qwen2.5-7B через
|
||||||
|
Ollama идеально. Выбор «локально vs Gemini billing vs OpenRouter» пока НЕ сделан (вопрос прерван). Failover-
|
||||||
|
уведомление (commit aac1240): callLLMFailover пишет app_settings.assistant_failover {failedName,servedName,reason,at};
|
||||||
|
при успехе активного снимается; админ-раздел показывает баннер «провайдер X недоступен — работаю на Y» / «все недоступны».
|
||||||
|
|
||||||
|
ПОДКЛЮЧЁН Kilo Code (работает из Беларуси!) как АКТИВНЫЙ провайдер: URL
|
||||||
|
**https://kilocode.ai/api/openrouter/chat/completions**, ключ — JWT (в app_settings, не в репо), модель
|
||||||
|
**nvidia/nemotron-3-ultra-550b-a55b:free** (бесплатно, чистый русский + LaTeX). Gemini остался вторым (failover).
|
||||||
|
Гочи Kilo на этом ключе: публичные `:free` (deepseek/mistral) → 404 «No endpoints»; платные → 401 «need to sign in»
|
||||||
|
(нет кредитов); kilo-auto/free → пустой ответ; рабочие бесплатные: nvidia/nemotron-3-ultra-550b-a55b:free и
|
||||||
|
openrouter/owl-alpha. Наш callLLM (без HTTP-Referer) Kilo принимает. nemotron — reasoning-модель → в промпт добавлено
|
||||||
|
«не выводи рассуждения вслух» (commit d1be2c1). Список моделей Kilo: GET .../api/openrouter/models (342 шт).
|
||||||
|
Админка (games.js, карточка): тумблер RAG, «Переиндексировать», число фрагментов. **Кэш** assistant_cache
|
||||||
|
(7 дней, только вопросы без контекста/истории) + **счётчик** assistant_usage (ИИ/кэш/FAQ, в админке).
|
||||||
|
**Учитель**: role в /context, доп. системный промпт для teacher/admin + teacher-чипы в «Спроси».
|
||||||
|
**Ключ перенесён в БД** (app_settings assistant_llm_url/key/model), из backend/.env удалён — конфиг
|
||||||
|
теперь только через админку. НЕ сделано: headless-RAG для JS-учебников; голосовой ввод; редактор промпта.
|
||||||
|
Связано: [[reference_quick_lesson]] [[reference_student_materials]] [[feedback_no_emoji]].
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: project_phys7_status
|
||||||
|
description: Физика 7 — контент всех 5 глав ГОТОВ (рендерится из widget-файлов); «В разработке» были ложными заглушками; не хватает только Шпаргалок
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 52938fe6-1430-4329-808c-f4e6ad780a81
|
||||||
|
---
|
||||||
|
|
||||||
|
Учебник «Физика 7» (`frontend/textbooks/physics_7_ch1..5.html`) по контенту ПОЛНОСТЬЮ готов, хотя на вид кажется скелетом. Все ~42 параграфа + финалы рендерят полноценный контент (20–33 тыс. символов: теория + интерактивы).
|
||||||
|
|
||||||
|
**Архитектура (отличается от физики 8):** контент вынесен во внешние JS — `frontend/js/phys7_chN_widgets.js` (~600 КБ суммарно), экспорт `window.PHYS7_CHN_WIDGETS = { pN: fn, finalN: fn }`. Страница диспетчеризует через `ensureBuilt(id)` → `W[id]()`, перед сборкой удаляя `.placeholder`. (У физики 8 наоборот — `build_pN` инлайнятся прямо в странице.)
|
||||||
|
|
||||||
|
**«Заглушки» были ложными:** боковая Шпаргалка (`SIDEBARS`) и Подсказка (`TIPS`) были захардкожены как «В разработке»/«Скелет главы готов» со времён Phase 0 — убраны 2026-06-01 (commit `03ed4bb`). В теле параграфов остались статические `.placeholder` («появится в ближайших фазах»), но они авто-удаляются в рантайме и не видны.
|
||||||
|
|
||||||
|
**Шпаргалки наполнены** (2026-06-01, commit `c6835cf`): во всех 5 главах `SIDEBARS` теперь явный объект с реальными rows (47 шпаргалок: 42 § + 5 финалов, формат как в физике 8 — `{title, rows:[[ключ, значение]...]}`, KaTeX в `$...$`). buildSidebar рендерит карточку при `sb.rows.length`. Учебник физики 7 теперь функционально полный. См. [[project_status]].
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
name: LearnSpace — полная карта реализованных функций
|
||||||
|
description: Что уже сделано в проекте: все страницы, API, таблицы БД, инструменты доски, стек, деплой
|
||||||
|
type: project
|
||||||
|
originSessionId: 1959f491-c6c4-4d6b-9081-0b09298d1699
|
||||||
|
---
|
||||||
|
# LearnSpace — реализованный функционал (апрель 2026)
|
||||||
|
|
||||||
|
**Why:** Чтобы не переоткрывать то, что уже есть, при планировании новых фич.
|
||||||
|
**How to apply:** Перед реализацией любой фичи — сверяться, чтобы не дублировать.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend pages (43 HTML-файла в frontend/)
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|-----------|
|
||||||
|
| login.html | Split-layout авторизация, canvas neural-network анимация |
|
||||||
|
| dashboard.html | Главная ученика — задания, прогресс, gamification |
|
||||||
|
| admin.html | Панель администратора |
|
||||||
|
| profile.html | Профиль пользователя |
|
||||||
|
| classes.html | Google Classroom-стиль карточки классов |
|
||||||
|
| board.html | Лента класса (анонсы, задания, активность) |
|
||||||
|
| classroom.html | Онлайн-урок (доска + чат + видео/аудио) |
|
||||||
|
| live-quiz.html | Live-викторина в реальном времени |
|
||||||
|
| test-run.html | Прохождение теста |
|
||||||
|
| test-result.html | Результат теста |
|
||||||
|
| question-bank.html | Банк вопросов |
|
||||||
|
| homework.html | Задания студента |
|
||||||
|
| course.html | Прохождение курса |
|
||||||
|
| lesson.html | Просмотр урока |
|
||||||
|
| lesson-editor.html | Редактор уроков с блоками |
|
||||||
|
| theory.html | Теоретические материалы |
|
||||||
|
| library.html | Библиотека файлов |
|
||||||
|
| analytics.html | Аналитика/отчёты |
|
||||||
|
| flashcards.html | Карточки с интервальным повторением |
|
||||||
|
| knowledge-map.html | Граф знаний |
|
||||||
|
| crossword.html | Кроссворд |
|
||||||
|
| hangman.html | Виселица |
|
||||||
|
| biochem*.html (5) | Интерактивная биохимия: молекулы, реакции, пути |
|
||||||
|
| red-book*.html (4) | Красная книга: виды, биомы, экосистемы, игры |
|
||||||
|
| collection*.html (2) | Коллекции предметов |
|
||||||
|
| gradebook.html | Журнал оценок |
|
||||||
|
| parent.html | Кабинет родителя |
|
||||||
|
| pet.html | Виртуальный питомец |
|
||||||
|
| lab.html | Интерактивные лабораторные работы (30+ симуляций) |
|
||||||
|
| 403/404/500.html | Страницы ошибок |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend API (28 групп маршрутов)
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/auth — JWT авторизация, регистрация, профиль
|
||||||
|
/api/subjects — Предметы
|
||||||
|
/api/sessions — Тестовые сессии
|
||||||
|
/api/admin — Управление платформой, feature flags
|
||||||
|
/api/questions — Банк вопросов
|
||||||
|
/api/classes — Классы
|
||||||
|
/api/assignments — Задания, дедлайны
|
||||||
|
/api/files — Загрузка файлов, папки
|
||||||
|
/api/tests — Тесты/квизы
|
||||||
|
/api/notifications — Уведомления
|
||||||
|
/api/permissions — RBAC
|
||||||
|
/api/submissions — Сдача работ, оценки
|
||||||
|
/api/courses — Курсы (теория)
|
||||||
|
/api/lessons — Уроки с блоками
|
||||||
|
/api/gamification — XP, уровни, ачивки, стрики
|
||||||
|
/api/shop — Виртуальный магазин, монеты
|
||||||
|
/api/templates — Шаблоны заданий
|
||||||
|
/api/bookmarks — Закладки
|
||||||
|
/api/search — Поиск контента
|
||||||
|
/api/flashcards — Флэшкарты со spaced repetition
|
||||||
|
/api/settings — Настройки
|
||||||
|
/api/analytics — Аналитика и отчёты
|
||||||
|
/api/live — Live-квизы (real-time)
|
||||||
|
/api/classroom — Онлайн-урок (SSE, доска, чат, WebRTC)
|
||||||
|
/api/games — Игры (виселица, кроссворд)
|
||||||
|
/api/knowledge-map — Граф знаний
|
||||||
|
/api/pet — Виртуальный питомец
|
||||||
|
/api/collection — Коллекционирование
|
||||||
|
/api/red-book — Красная книга
|
||||||
|
/api/biochem — Биохимия
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Classroom API (детально)
|
||||||
|
|
||||||
|
- POST/GET/DELETE сессий
|
||||||
|
- JOIN/LEAVE участников, attendance log
|
||||||
|
- Чат: отправка, получение, реакции, закрепление, загрузка файлов
|
||||||
|
- Strokes: batch save, load (с пагинацией + since_seq), update, delete, preview (SSE)
|
||||||
|
- Страницы: add, change_current, set_template, clear
|
||||||
|
- Поднятая рука: raise/lower/list
|
||||||
|
- Разрешения рисования: grant/revoke per user
|
||||||
|
- WebRTC: signaling relay, cursor broadcast, mute, screen share
|
||||||
|
- Notes: get/save per user per session
|
||||||
|
- Templates: save/load session как шаблон
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## БД — 76 таблиц SQLite (better-sqlite3, sync)
|
||||||
|
|
||||||
|
Ключевые группы:
|
||||||
|
- **users** + role_permissions + user_permissions
|
||||||
|
- **test_sessions** + session_questions + user_answers
|
||||||
|
- **subjects** + topics + questions + options + tests + test_questions
|
||||||
|
- **classes** + class_members
|
||||||
|
- **classroom_sessions** + classroom_pages + classroom_strokes + classroom_chat + classroom_chat_reactions + classroom_attendance + classroom_invites + classroom_draw_permissions + classroom_notes
|
||||||
|
- **courses** + course_sections + lessons + lesson_blocks + lesson_progress + lesson_notes + class_courses
|
||||||
|
- **assignments** + assignment_sessions + assignment_templates + submissions + submission_log
|
||||||
|
- **files** + file_access + folders + folder_access
|
||||||
|
- **xp_log** + achievements + user_achievements + daily_goals + challenges
|
||||||
|
- **announcements** + notifications + bookmarks
|
||||||
|
- **live_sessions** + live_answers
|
||||||
|
- **shop_items** + user_purchases
|
||||||
|
- **flashcard_decks** + flashcard_cards + flashcard_reviews
|
||||||
|
- **bio_elements/molecules/reactions/...** (5 биохим-таблицы)
|
||||||
|
- **rb_species/habitats/groups/...** (9 красная книга)
|
||||||
|
- **app_settings** + error_log + admin_audit_log
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Whiteboard (frontend/js/whiteboard.js ~3200 строк)
|
||||||
|
|
||||||
|
### Инструменты рисования
|
||||||
|
- Pencil (Catmull-Rom сглаживание)
|
||||||
|
- Highlighter (полупрозрачный маркер)
|
||||||
|
- Laser (без сохранения)
|
||||||
|
- Eraser
|
||||||
|
- Connector (линии со стрелками)
|
||||||
|
- Sticky notes
|
||||||
|
- Text (inline editing)
|
||||||
|
- Image (вставка + upload)
|
||||||
|
- Formula / LaTeX (KaTeX, modal editor с категориями)
|
||||||
|
- Table (интерактивная)
|
||||||
|
- Coordinate system (с графиками функций, парсер выражений)
|
||||||
|
- Number line (для неравенств, точки + интервалы)
|
||||||
|
- Compass: трёхфазная state machine (idle → setting-radius → waiting-arc → drawing-arc), сохраняется как `{cx, cy, radius, arcStart, arcSweep, color, lineWidth, showLegs}`, live preview в `_renderDynamic` с ногами компаса и меткой угла
|
||||||
|
|
||||||
|
### Shapes (11)
|
||||||
|
rect, ellipse, line, arrow, triangle, diamond, hexagon, star, roundedrect, callout, connector
|
||||||
|
|
||||||
|
### Инструмент выделения
|
||||||
|
- Move + resize всех объектов (bbox handles)
|
||||||
|
- Rotation handle (purple) для объектов
|
||||||
|
- Lasso multi-select (резиновая рамка)
|
||||||
|
- Shift+click добавить к выделению
|
||||||
|
- Copy/Paste с offset
|
||||||
|
- Snap guides при перемещении
|
||||||
|
- Delete / Bring to front / Send to back
|
||||||
|
|
||||||
|
### Zoom / Pan
|
||||||
|
- Wheel zoom (к курсору)
|
||||||
|
- Space+drag = pan
|
||||||
|
- Ctrl+0/+/- hotkeys
|
||||||
|
- clampPan() — ограничение выхода за пределы
|
||||||
|
- Minimap: 192×108 overlay bottom-right (показывается при zoom>1)
|
||||||
|
- Viewport indicator на minimap; клик/drag = прыжок
|
||||||
|
|
||||||
|
### Оверлеи (не сохраняются)
|
||||||
|
- Ruler: вращение (drag ↺), resize (drag ↔), свойства-панель (угол + длина)
|
||||||
|
- Protractor: вращение, resize (radius), свойства-панель
|
||||||
|
|
||||||
|
### Прочее
|
||||||
|
- Двухслойный canvas: static (strokes) + dynamic (selection/guides/live)
|
||||||
|
- Шаблоны страниц: blank, grid, lined, dots, coordinate
|
||||||
|
- Multi-page с thumbnail sidebar (renderThumbnail)
|
||||||
|
- Export PNG (с сохранением zoom/pan)
|
||||||
|
- Auto-measurements (длины/углы/площадь для shape)
|
||||||
|
- Real-time sync: SSE + HTTP polling (since_seq)
|
||||||
|
- Live strokes preview через /stroke-preview
|
||||||
|
- Cursor broadcast (teacher position visible to students)
|
||||||
|
|
||||||
|
### Темы доски (4)
|
||||||
|
- **Chalkboard** (по умолчанию): зелёный (#213d26), меловая текстура, горизонтальные смазки
|
||||||
|
- **Blackboard** (классная): тёмно-синий (#1a1a2e), диагональная текстура, chalk-grain
|
||||||
|
- **Corkboard** (пробка): коричневый (#7a5c1e), диагональные волокна, случайные узлы-пятна
|
||||||
|
- **Whiteboard** (маркерная): светло-серый градиент, minimal grain, тёмные линии шаблонов
|
||||||
|
- Переключатель: `setBoardTheme(name)` + `wbSetBoardTheme()` + `<select id="wb-theme-select">`
|
||||||
|
- Текстуры кешируются в `_bgNoiseCache` (Map по имени темы)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- **Backend**: Node.js 22, Express 4.18, better-sqlite3 (sync), JWT, bcryptjs, multer, sharp, compression
|
||||||
|
- **Frontend**: Vanilla JS ES6+, HTML/CSS без бандлера, Canvas API, SSE, WebRTC
|
||||||
|
- **Иконки**: inline SVG `.ic` класс (НЕ эмоджи), Lucide CDN на некоторых страницах
|
||||||
|
- **Шрифты**: Google Fonts (Unbounded, Manrope)
|
||||||
|
- **Деплой**: Docker multi-stage Alpine, docker-compose, tini init, 3 named volumes
|
||||||
|
- **Репо**: https://git.dolgolyov-family.by/maxim.dolgolyov/Learn_System (ветка master)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Env vars (backend/.env.example)
|
||||||
|
|
||||||
|
```
|
||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=...
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
CLIENT_ORIGIN=http://localhost:3000
|
||||||
|
DB_PATH=/app/backend/data/learnspace.db
|
||||||
|
UPLOADS_DIR=/app/backend/uploads
|
||||||
|
```
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: project_stereo3d_improvements
|
||||||
|
description: Симуляция «Стереометрия 3D» — итог ревью+апгрейда (5 фаз) и deep-link конвенция для учебников
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: e04a2ab1-2fce-4387-9ec4-7f3f2fb6d65c
|
||||||
|
---
|
||||||
|
|
||||||
|
Симуляция Стереометрии 3D (`frontend/js/labs/stereo.js`, класс StereoSim на Three.js, панель `#sim-stereo` в lab.html) прошла ревью и апгрейд в 5 фаз (май 2026, коммиты 8af8596…ccfb611):
|
||||||
|
|
||||||
|
- Фаза 0: render-on-demand (`_invalidate`/`_needsRender`, loop засыпает), `_pauseAllSims()` в lab-init паузит фоновые rAF-симы при переключении, pointer/touch на canvas с capture, `webglcontextlost`+`dispose()`, рекурсивный `_clearGroup`.
|
||||||
|
- Фаза 1: инерция орбиты, pan (ПКМ/СКМ/Shift, 2 пальца), overlay-тулбар (сброс/пресеты Изо·Спереди·Сбоку·Сверху/спин/fullscreen/скриншот PNG).
|
||||||
|
- Фаза 2: аналитические сечения кривых `_sliceCurvedByNormal()` (окружность/эллипс вместо сэмплинга), `_edgePickNDC()` пикинг рёбер, HiDPI `_makeTextSprite`.
|
||||||
|
- Фаза 3: live-readout overlay `#stereo-readout` (тип/S/P/измерение через `info().readout`), `_raycastFace()` точки на гранях, подписи вершин сечения K,L,M…
|
||||||
|
- Фаза 4: подписи осей X/Y/Z, свечение вершин, контраст рёбер.
|
||||||
|
- Фаза 5: deep-link + клавиатура (a11y).
|
||||||
|
- Фаза 6 (`3801d0c`): построение сечения «по следам» (метод следов), путь (b) — надёжный полигон + аналитический след `_traceLine()` (π∩основание y=0) и вспом. точки `_auxiliaryPoints()` (продление сторон до следа). Настоящий пошаговый `_drawSection3PStep` (6 подписанных шагов, финал скрыт до шага 5), подписи в `#sect3p-hint`. Только тела с основанием (`_hasBase`: куб/параллелепипед/призма/пирамида/усеч.пир/тетраэдр). Включается тумблером «Пошагово» в блоке «Сечение через 3 точки» + кнопки Вперёд/Назад.
|
||||||
|
|
||||||
|
**Deep-link фигуры из учебников** (не очевидно из кода): открыть конкретное тело можно через `openSim('stereo:<figure>')` ИЛИ ссылкой `/lab?stereofig=<figure>#sim/stereo`. Допустимые `<figure>`: cube, parallelepiped, prism, pyramid, truncpyramid, tetrahedron, octahedron, icosahedron, dodecahedron, cylinder, cone, trunccone, sphere. Сделано без правки общего hash-роутера (lab-glue.js) намеренно.
|
||||||
|
|
||||||
|
**Бэклог** (в `plans/STEREO_3D_IMPROVEMENT.md`): дробление 3900-строчного файла на модули (отложено пользователем); полное «построение сечения по следам»; подсветка грани по ховеру (нужен точный raycast логических граней, не centroid); zoom-to-cursor; readout углов; градиентный фон в скриншоте.
|
||||||
|
|
||||||
|
Правило проекта: при правке stereo.js поднимать `?v=N` у `<script src="/js/labs/stereo.js?v=N">` в lab.html. См. [[feedback_sims_admin_sync]], [[feedback_no_emoji]].
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: Whiteboard improvement roadmap
|
||||||
|
description: 7-phase plan to upgrade the interactive whiteboard from basic to professional-grade educational tool
|
||||||
|
type: project
|
||||||
|
originSessionId: 1959f491-c6c4-4d6b-9081-0b09298d1699
|
||||||
|
---
|
||||||
|
7 фаз улучшения доски LearnSpace, утверждено 2026-04-11. Реализацию выполняет Sonnet.
|
||||||
|
|
||||||
|
**Фаза 1** (L): Универсальное выделение (select для ВСЕХ штрихов — фигуры, карандаш, коннекторы, не только объекты) + лазерная указка + маркер (highlighter) + copy/paste для всех типов.
|
||||||
|
|
||||||
|
**Фаза 2** (L): Экспорт PNG + шаблоны страниц (blank/grid/lined/coordinate/dots, поле template в classroom_pages) + миниатюры страниц (sidebar 192x108).
|
||||||
|
|
||||||
|
**Фаза 3** (M): Стили линий (solid/dashed/dotted) + расширенная палитра (12 цветов, 5 толщин, dropdown) + opacity slider для штрихов.
|
||||||
|
|
||||||
|
**Фаза 4** (XL, зависит от Ф1): Двухслойный canvas (статический + динамический, dirty-region) + мульти-выделение (лассо, Shift+Click, _selectedIds: Set) + snap & alignment guides.
|
||||||
|
|
||||||
|
**Фаза 5** (XL): Координатная система (объект-штрих с осями/разметкой) + графики функций (рекурсивный descent parser y=f(x)) + линейка и транспортир (overlay, не сохраняются).
|
||||||
|
|
||||||
|
**Фаза 6** (XL, лучше после Ф4): Zoom & Pan (матрица трансформации, Ctrl+Scroll, Space+Drag) + расширяемый холст (за пределы 1920x1080).
|
||||||
|
|
||||||
|
**Фаза 7** (XL): Запись/воспроизведение урока (timeline player, таблица classroom_recording) + PDF-импорт (pdf.js CDN) + справка по горячим клавишам + accessibility (ARIA, keyboard nav).
|
||||||
|
|
||||||
|
**Why:** Текущая доска функциональна, но не хватает базовых UX-паттернов (select для всех штрихов, пунктирные линии, экспорт) и образовательных инструментов (координаты, графики).
|
||||||
|
|
||||||
|
**How to apply:** Фазы 1-3 независимы, делать в любом порядке. Фаза 4 зависит от 1. Фаза 6 — после 4. Фаза 7 автономна. Полный план — в plan file `bubbly-booping-harp.md`.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: reference_exam_textbook_links
|
||||||
|
description: "Как устроена привязка задач экзамена math9 к § учебников (per-task классификатор, deep-link), и как её перегенерировать"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: reference
|
||||||
|
originSessionId: 60467058-b40e-4bd9-9f7f-d1e362e8039a
|
||||||
|
---
|
||||||
|
|
||||||
|
Связь «задание экзамена → § учебника» (фича «Учить тему» в exam-prep). Сделано 2026-06-03.
|
||||||
|
|
||||||
|
**Модель связи (двухуровневая):**
|
||||||
|
- Per-task: `exam_tasks.textbook_slug` + `exam_tasks.textbook_paragraph` (миграция 057). Контроллер
|
||||||
|
`backend/src/routes/exam-prep.js` (`shapeTask`/`/variants/:n/tasks`) ПРЕДПОЧИТАЕТ task-level;
|
||||||
|
фолбэк — subtopic-уровень `exam_topics.textbook_slug/paragraph` (миграции 028 + фикс 058).
|
||||||
|
- Фронт `frontend/js/exam-prep/task-card.js` строит ссылку `/textbook/<slug>#sec-p<N>`.
|
||||||
|
|
||||||
|
**Классификатор (эвристика, детерминированный):** `backend/scripts/tag-exam-textbook.js`
|
||||||
|
- Карта `subtopic → кандидатные §` + keyword-скоринг по тексту задачи И вариантам ответа (`opts_json`,
|
||||||
|
формат пар `[label, html]`). Требует совпадения >0, иначе берётся явный fallback (последнее правило).
|
||||||
|
- Таксономия §: `backend/scripts/exam-textbook-sections.json` (НЕ в `data/` — тот gitignore!),
|
||||||
|
генерится `node backend/scripts/gen-exam-textbook-sections.js` из `frontend/textbooks/*.html`.
|
||||||
|
Перегенерировать при изменении § учебников, затем перезапустить классификатор.
|
||||||
|
- Запуск: `node backend/scripts/tag-exam-textbook.js --exam math9 [--dry-run] [--report]`.
|
||||||
|
- Результат: **784/800 (98%)** размечено; 70 на хабах math-5/6 (движковые, без статич. §), 16 NULL
|
||||||
|
(чисто-формульные theory → фолбэк на subtopic).
|
||||||
|
|
||||||
|
**Готчи нумерации § (важно для (slug,para)):** algebra-7/8/9 и geometry-7/9 — сквозная нумерация
|
||||||
|
`sec-pN`; **geometry-8 — ПОГЛАВНАЯ** (каждая глава заново `sec-p1`); **math-5/6 рендерятся движком**
|
||||||
|
`math6_engine.js` (нет статич. `sec-pN`, линкуются на уровне главы, para=null). Экзамен 9 кл. покрывает
|
||||||
|
программу 5–9, поэтому ссылки ведут в учебники 5–9 (см. [[reference_textbook_sources]]).
|
||||||
|
|
||||||
|
**Deep-link был СЛОМАН, починен:** статич. страницы algebra/geometry игнорировали `location.hash`
|
||||||
|
(init всегда `goTo('p10')`), textbook-tracker матчил только `#pN`. Решение: `server.js` всегда инжектит
|
||||||
|
`frontend/js/textbook-deeplink.js` в `/textbook/:slug` (и embed) — по `#sec-pN`/`#pN` кликает
|
||||||
|
`.psel-card[data-id]` (фолбэк `.para-pill[data-para]`→goTo→scrollIntoView). Универсально, идемпотентно.
|
||||||
|
|
||||||
|
План/находки: `plans/exam-textbook-links/PLAN.md` + `taxonomy.md`. Тесты:
|
||||||
|
`backend/tests/exam-textbook-links.test.js` (9/9). Сделано Sonnet (фазы 2–6) + Opus-ревью (фикс
|
||||||
|
классификатора, навигация, перенос таксономии из gitignore).
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: reference_quick_lesson
|
||||||
|
description: «Быстрый урок» — одиночный урок без курса через скрытый личный курс-контейнер (is_personal)
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: reference
|
||||||
|
originSessionId: 60467058-b40e-4bd9-9f7f-d1e362e8039a
|
||||||
|
---
|
||||||
|
|
||||||
|
Одиночного урока без курса в системе нет: `lessons.course_id NOT NULL`, `POST /api/lessons`
|
||||||
|
требует courseId. Решено через **личный курс-контейнер** (сделано 2026-06-03, commit 6be8a50).
|
||||||
|
|
||||||
|
- Миграция 059: `courses.is_personal INTEGER DEFAULT 0` (ADD COLUMN).
|
||||||
|
- `POST /api/lessons/quick` (teacher/admin, `lessonController.quickLesson`): get-or-create
|
||||||
|
контейнер `WHERE created_by=? AND is_personal=1` (subject_slug='personal', title='Мои материалы',
|
||||||
|
is_published=1, один на учителя) → создаёт урок → `{lessonId, courseId}`.
|
||||||
|
- Фронт: кнопка «Быстрый урок» в каталоге `theory.html` (рядом с «Новый курс», видна
|
||||||
|
teacher/admin) → POST /quick → редирект `/lesson-editor.html?id=<lessonId>`.
|
||||||
|
- `courseController.list` СКРЫВАЕТ `is_personal=1` из каталога для всех, кроме владельца
|
||||||
|
(`AND (c.is_personal=0 OR c.created_by=?)`; студентам — всегда `is_personal=0`).
|
||||||
|
- Учитель видит свои быстрые уроки как курс «Мои материалы» (открыв его в каталоге).
|
||||||
|
- Доступ ученикам: контейнер опубликован, но урок надо ОПУБЛИКОВАТЬ (per-lesson) + доступ
|
||||||
|
к курсу-контейнеру идёт через обычный content_access/класс (см. [[project_content_access]]).
|
||||||
|
Standalone-урок на уровне схемы (course_id NULL) — НЕ делали (был выбран этот лёгкий путь).
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: reference_sqlite_node
|
||||||
|
description: "БД-стек — приложение использует встроенный node:sqlite, а не better-sqlite3; путь к живой БД"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: reference
|
||||||
|
originSessionId: a705e035-e600-43a2-b98c-197923986186
|
||||||
|
---
|
||||||
|
|
||||||
|
Приложение работает на **встроенном `node:sqlite`** (`const { DatabaseSync } = require('node:sqlite')`), а **не** на `better-sqlite3` — последний в дереве не установлен (require падает MODULE_NOT_FOUND). Это исправляет устаревшую запись «better-sqlite3» в [[project_status.md]] и индексе MEMORY.md.
|
||||||
|
|
||||||
|
- Подключение: `backend/src/db/db.js` (`new DatabaseSync(dbPath)`).
|
||||||
|
- Конфиг пути: `backend/src/config.js` → `DB_PATH` (по умолчанию `backend/data/learnspace.db`).
|
||||||
|
- Живая БД: **`backend/data/learnspace.db`** (~5.2 МБ). В дереве есть и другие копии (`data/learnspace.db`, `backend/src/data/...`, `backend/src/db/data/...`) — это НЕ боевая.
|
||||||
|
- Node 24 → `node:sqlite` доступен (экспериментальный, кидает ExperimentalWarning в stderr).
|
||||||
|
|
||||||
|
API node:sqlite: `db.prepare(sql).all()/.get()/.run()`; для readonly — `new DatabaseSync(path, { readOnly: true })`.
|
||||||
|
|
||||||
|
Замечание по окружению: в Bash-туле кириллический путь `Тесты` иногда искажается (`Тесты`→`"5ABK`), из-за чего node-скрипты падают на ENOENT/MODULE_NOT_FOUND. PowerShell путь не ломает.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: reference_student_materials
|
||||||
|
description: "«Мои материалы» — ученик сохраняет к себе материалы онлайн-урока (доска/заметка), копия переживает удаление сессии"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: reference
|
||||||
|
originSessionId: 60467058-b40e-4bd9-9f7f-d1e362e8039a
|
||||||
|
---
|
||||||
|
|
||||||
|
Личная коллекция ученика «Мои материалы» (сделано 2026-06-04, commit 44ab5e0). Контекст:
|
||||||
|
живой урок (классрум) уже персистит доску/чат/заметки, и ученик их видит на `my-lessons.html`
|
||||||
|
(только ученик; учителя редиректит на `/lesson-history`). Но всё привязано к сессии — учитель
|
||||||
|
может `DELETE /api/classroom/:id/history` и стереть. Решение — независимая копия у ученика.
|
||||||
|
|
||||||
|
- Миграция **060**: `student_materials(user_id, kind CHECK board|note|link|image, title, body,
|
||||||
|
url, source_session_id FK→classroom_sessions ON DELETE SET NULL, source_title denormalized, created_at)`.
|
||||||
|
- API **/api/materials** (`routes/materials.js` + `studentMaterialsController.js`): GET list (свои),
|
||||||
|
POST create (валидация kind/обязательных полей), DELETE /:id (проверка владельца). Любой
|
||||||
|
авторизованный (в осн. ученики). Хелперы `LS.listMaterials/saveMaterial/deleteMaterial` в js/api.js.
|
||||||
|
- Доска: добавлен `Whiteboard.exportBlob(cb)` (как exportPNG, но отдаёт Blob). Кнопка «К себе» на
|
||||||
|
доске → exportBlob → `LS.uploadFile` (/api/files) → saveMaterial(kind:'board', url). Кнопка «К себе»
|
||||||
|
на заметке → saveMaterial(kind:'note', body). Обе в `my-lessons.html` (страница ученика).
|
||||||
|
- Новая страница **/my-materials** (`frontend/my-materials.html`): сетка карточек (доска=картинка
|
||||||
|
открыть/скачать, заметка=текст, ссылка), удаление. Пункт сайдбара «Мои материалы» (только ученик,
|
||||||
|
js/sidebar.js, группа «Учебный процесс»).
|
||||||
|
- **Сохранение ЧАСТИ доски** (commit 116876d→fcb8ef7): логика вынесена в общий **`/js/board-clip.js`**
|
||||||
|
(`BoardClip.savePage(wb,meta,btn)` / `saveRegion(...)` + кроп-оверлей; meta={sourceSessionId,sourceTitle,pageNum}).
|
||||||
|
Кроп: exportBlob снимка → выделение прямоугольника → обрезка (offscreen canvas, коорд × naturalW/displayedW)
|
||||||
|
→ /api/files → saveMaterial(kind:'image'). Подключён И в **my-lessons.html** (просмотр), И в **classroom.html**
|
||||||
|
(ЖИВОЙ урок): кнопки «Область»/«К себе» в ученической панели `#cr-student-nav`, обёртки
|
||||||
|
crSaveBoardPage/crSaveBoardRegion над `_wb`+`_session`. Инстанс живой доски в классруме — `_wb`.
|
||||||
|
**План развития:** `plans/my-materials/PLAN.md` (6 фаз). Сделано:
|
||||||
|
- **Ф1** (fd3e5c4): `PATCH /api/materials/:id` (title/body), кнопка «+Заметка» (личный блокнот), «Изменить» на карточках.
|
||||||
|
- **Ф2** (2c7e974): миграция **061** `material_collections`(папки) + `student_materials.collection_id`(ON DELETE SET NULL)+`tags`;
|
||||||
|
CRUD коллекций `/api/materials/collections`; GET /materials отдаёт {materials, collections}; на странице — бар папок,
|
||||||
|
поиск, фильтр по типу, перенос в папку (select на карточке).
|
||||||
|
- **Ф6a** (9c95dc8): кнопки «К себе»/«Область» учителю в `lesson-history.html`; пункт сайдбара «Мои материалы» виден всем.
|
||||||
|
- **Ф3** (61e30be+43fe90d): `js/material-save.js` (MaterialSave.note/link/image); кнопка «В мои материалы»
|
||||||
|
на задачах экзамена (task-card.js, заметка=условие+ответ+решение); на учебнике — `js/textbook-clip.js`
|
||||||
|
(плавающая кнопка, сохраняет § ссылкой), инжектится сервером в /textbook/:slug рядом с deep-link.
|
||||||
|
- **Ф4** (d3a64ac): svg-draw `opts.bgImage` + `exportFlatBlob()` (растер подложка+вектор→PNG); на странице —
|
||||||
|
«Рисунок» (с нуля) и «Аннотировать» (поверх board/image) через модалку SvgDraw.
|
||||||
|
- **Ф5** (e793b4e): «В флешкарты» на заметке → выбор/создание колоды → карточка (front=заголовок, back=текст);
|
||||||
|
хелперы fcListDecks/fcCreateDeck/fcAddCard.
|
||||||
|
- **Ф6b** (f7357ad): `POST /api/materials/:id/share {classId|userId}` (teacher/admin) — независимая КОПИЯ
|
||||||
|
каждому ученику (source_title «Раздатка: <учитель>») + SSE-уведомление; кнопка «Раздать» (учителю).
|
||||||
|
- ПЛАН ЗАВЕРШЁН (все 6 фаз). Не делалось из обсуждения: теги-UI (поле tags есть, UI нет), экспорт PDF/ZIP,
|
||||||
|
«учить из материалов», SRS-интеграция.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: reference_svg_drawer
|
||||||
|
description: "Лёгкий векторный SVG-редактор (рисовалка) для уроков — API виджета, санитайзер, точки переиспользования"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: reference
|
||||||
|
originSessionId: 60467058-b40e-4bd9-9f7f-d1e362e8039a
|
||||||
|
---
|
||||||
|
|
||||||
|
SVG-рисовалка в редакторе уроков. Сделано 2026-06-03 (commit ef59023).
|
||||||
|
|
||||||
|
**Виджет:** `frontend/js/svg-draw.js` → `window.SvgDraw.mount(container, {svg, width, height, onChange})`
|
||||||
|
→ `{getSVG(), destroy(), el}`. Vanilla, рендер в SVG-DOM (НЕ canvas, в отличие от whiteboard.js).
|
||||||
|
Инструменты: перо (Catmull-Rom→bezier), линия, прямоугольник, эллипс, стрелка, текст,
|
||||||
|
цвет/толщина/заливка, выбор (перемещение+Delete), undo/redo, очистка. Стили инжектятся сами.
|
||||||
|
Координаты через `svg.getScreenCTM().inverse()`. viewBox по умолчанию 800×500.
|
||||||
|
|
||||||
|
**Санитайзер:** `frontend/js/svg-sanitize.js` — UMD (`window.SvgSanitize` + `module.exports`),
|
||||||
|
`clean(str)`. Браузер → DOM-whitelist; node → консервативный regex. Whitelist тегов
|
||||||
|
(svg,g,path,line,rect,circle,ellipse,polyline,polygon,text,tspan,defs,*Gradient,stop) и
|
||||||
|
геометрия/стиль-атрибутов; режет script/foreignObject/style/image/a/use, on*=, href/xlink:href,
|
||||||
|
javascript:. БЕЗ зависимостей. Бэкенд `lessonController.js` подключает его кросс-путём
|
||||||
|
`require('../../../frontend/js/svg-sanitize.js')` — единый источник правды.
|
||||||
|
|
||||||
|
**Блок урока `svg-draw`** (хранение inline, переоткрывается для дорисовки):
|
||||||
|
- `lessonController.js`: `svg-draw` в VALID_TYPES + `cleanSvg(data.svg)` при сохранении (defense-in-depth).
|
||||||
|
- `lesson-editor.html`: палитра «Рисунок», BLOCK_DEFAULTS `{svg:'',caption:''}`, `renderBlockEditor`
|
||||||
|
case (host `.svgdraw-host[data-bid]` + подпись), `mountSvgDrawEditors()` (монтаж/перемонтаж в
|
||||||
|
renderBlocks, инстансы в `_svgDrawInst`), `renderPreviewBlock` case (санитизированный inline-svg).
|
||||||
|
- `lesson.html`: `renderBlock` case svg-draw (санитизированный inline-svg, адаптивно `max-width:100%`).
|
||||||
|
|
||||||
|
**Переиспользование (заявлено):** тот же `SvgDraw`/`SvgSanitize` пригодны для картинок флешкарт и
|
||||||
|
**фигур генератора задач** (см. [[reference_exam_textbook_links]] / обсуждение параметрического генератора).
|
||||||
|
|
||||||
|
**MVP-ограничения (на доработку):** select только move+delete (нет resize/rotate); нет слоёв/
|
||||||
|
группировки, привязки к сетке, импорта картинки-подложки; текст однострочный; один размер холста.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: reference_textbook_latex_escaping
|
||||||
|
description: "Баг формул в учебниках = ЛИШНИЕ слэши (over-escaping), не нехватка; правило чётности и фикс-скрипт"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: reference
|
||||||
|
originSessionId: a705e035-e600-43a2-b98c-197923986186
|
||||||
|
---
|
||||||
|
|
||||||
|
Формулы в учебниках (`frontend/textbooks/*.html`) лежат в JS-строковых литералах, рендерит KaTeX через `renderMathInElement`. Симптом «формула печатается текстом» (`dfrac13S_осн`, `sqrtR^2+h^2`, `cdoth` — карточка пирамиды/конуса в geometry_11_ch2) — это **ЛИШНИЕ обратные слэши (over-escaping)**, а НЕ их нехватка. (Первичная гипотеза «не хватает \» была НЕВЕРНА — проверять байты ДО выводов.)
|
||||||
|
|
||||||
|
Механика: в литерале `\\\\dfrac` (4 слэша) вместо `\\dfrac` (2). После JS-анескейпа KaTeX получает `\\dfrac` → трактует `\\` как перенос строки, а `dfrac` печатает как обычный текст, поэтому формула разваливается на строки.
|
||||||
|
|
||||||
|
**Правило чётности (защищает легитимные `\\` разделители строк):**
|
||||||
|
- 2 слэша → `\cmd` → ОК
|
||||||
|
- 4 слэша → `\\`+текст → БАГ → схлопнуть до 2
|
||||||
|
- 6 слэшей → `\\`+`\cmd` (перенос строки + команда в `\begin{cases}`) → ОК, не трогать
|
||||||
|
- 8 слэшей → БАГ → до 2
|
||||||
|
Схлопывать ТОЛЬКО прогоны слэшей, кратные 4, и ТОЛЬКО перед известной LaTeX-командой. Перед `x`/цифрой (настоящие `\\` в cases/array) — не трогать.
|
||||||
|
|
||||||
|
**Исправлено 2026-05-30:** 150 правок, 7 файлов (`algebra_11_ch1/ch2/ch3`, `geometry_11_ch1/ch2/ch3/ch4`), коммит 8786cf5 (запушен в master). Скрипт: `backend/scripts/fix_overescaped_latex.js` (идемпотентный, dry-run по умолчанию, `--apply`, с KaTeX-валидацией). algebra_8 / algebra_7_ch4 имели только легитимные `\begin{cases}` → 0 правок.
|
||||||
|
|
||||||
|
**БД чиста:** questions колонки `text/explanation/correct_text` (НЕ `payload`!), 1398 вопросов + 5187 options (`options.text`) → 0 багов обеих форм. Баг только в HTML.
|
||||||
|
|
||||||
|
Окружение этой сессии: stdout периодически рвался, Read иногда галлюцинировал/дублировал содержимое — надёжно писать результат в файл с маркерами `<<<BEGIN>>>/<<<END>>>` и читать через PowerShell `[IO.File]::ReadAllText`. Bash искажает кириллический путь `Тесты`→`"5ABK` (ENOENT) — использовать PowerShell. БД — через `node:sqlite` (см. [[reference_sqlite_node]]).
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: reference-textbook-sources
|
||||||
|
description: "Расположение PDF-источников белорусских учебников (физика, алгебра, геометрия 7-11) для опоры на оглавление и дидактическую структуру при разработке HTML-учебников LearnSpace"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: reference
|
||||||
|
originSessionId: 3d2e8bf3-6bcb-469f-b8c1-c4e7513b3b56
|
||||||
|
---
|
||||||
|
|
||||||
|
# Папка с учебниками
|
||||||
|
|
||||||
|
**Корень:** `G:\Dev\Тесты\Методички\тест_6 класс\Книги\`
|
||||||
|
|
||||||
|
## Физика
|
||||||
|
| Класс | Файл | Авторы | Изд. |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 7 | `fizika_Isachenkova_7kl_rus_2022.pdf` | Исаченкова Л.А. | 2022 |
|
||||||
|
| 8 | `fizika_8kl_isachenkova_rus_2018.pdf` | Исаченкова Л.А. | 2018 |
|
||||||
|
| 9 | `Fizika_Isachenkova_9_rus_2019.pdf` | Исаченкова Л.А., Сокольский А.А., Захаревич Е.В. (под ред. Сокольского) | Народная асвета, 2019, ISBN 978-985-03-3082-6 |
|
||||||
|
| 10 | `fizika_10kl_gromika_rus_2019.pdf` | Громыко | 2019 |
|
||||||
|
| 11 | `fizika_11kl_zhilko_rus_2021.pdf` | Жилко, Маркович, Сокольский | 2021 |
|
||||||
|
|
||||||
|
## Алгебра/Геометрия 9
|
||||||
|
Указаны в [[plans/textbooks-9/PLAN.md]] — Арефьева И.Г. (Алгебра 2019), Казаков В.В. (Геометрия 2019), та же папка `Книги/`.
|
||||||
|
|
||||||
|
## Структура параграфа в учебниках Исаченковой
|
||||||
|
Каждый § оформлен по канве:
|
||||||
|
1. Текст с жирными определениями и формулами
|
||||||
|
2. Рисунки/фото
|
||||||
|
3. «Главные выводы» (оранжевый блок)
|
||||||
|
4. «Контрольные вопросы» (розовый)
|
||||||
|
5. «Домашнее задание» (синий)
|
||||||
|
6. «Упражнение N» (нумерованные задачи, часть с 🦉 — повышенный уровень)
|
||||||
|
7. «Для любознательных» (опциональный расширенный блок)
|
||||||
|
8. Иконки 📱 (видео-опыт) и 🎱 (интерактивная модель в ЭОР)
|
||||||
|
|
||||||
|
При написании HTML-учебников использовать эту канву как дидактический шаблон. Иконки → inline SVG `.ic` (см. [[feedback_no_emoji]]).
|
||||||
|
|
||||||
|
## Содержание Физики 9 (Исаченкова 2019)
|
||||||
|
- **Глава 1 «Основы кинематики»** — §1-14
|
||||||
|
- **Глава 2 «Основы динамики»** — §15-24
|
||||||
|
- **Глава 3 «Основы статики»** — §25-30
|
||||||
|
- **Глава 4 «Законы сохранения»** — §31-36
|
||||||
|
- **Глава 5 «Лабораторный эксперимент»** — ЛР №1-12 (стр. 180-198)
|
||||||
|
- Приложение 1 — лабораторное оборудование (стр. 200)
|
||||||
|
- Приложение 2 — видео к иллюстрациям (стр. 204)
|
||||||
|
|
||||||
|
§22 (движение под углом к горизонту) и §30 (плавание судов, воздухоплавание) помечены «для дополнительного чтения».
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: reference_vex_search
|
||||||
|
description: "vex (code-search CLI) установлен и проиндексирован; правило когда vex, когда ast-index"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: reference
|
||||||
|
originSessionId: a02c76bd-13fd-4ebe-b133-375f6c469212
|
||||||
|
---
|
||||||
|
|
||||||
|
vex v1.11.0 — гибридный поиск по коду (vector+index), установлен в `C:\Users\Home\bin\vex.exe`
|
||||||
|
(в пользовательском PATH; в новых терминалах — просто `vex`, в уже открытых сессиях PATH не подхвачен — звать по полному пути). Проект BQ-System проиндексирован: структурный + **semantic** (16360 символов, embeddings enabled).
|
||||||
|
|
||||||
|
**Когда что** (подробно — `.claude/rules/search-tools.md`, закоммичено f2b0db4):
|
||||||
|
- **ast-index** — дефолт: символ по имени, **usages/callers**, outline. usages/callers по JS — ТОЛЬКО ast-index (vex их пропускает: чистый JS не binder-язык; `vex usages "audit"` → пусто, `ast-index` → все 10).
|
||||||
|
- **vex** — `vex search "..." --semantic`, `vex similar "X"` (по смыслу), `vex pattern --lang js '...'` (AST), `vex duplicates`, `vex show "X"` (компактное тело).
|
||||||
|
- Grep всё ещё запрещён (см. [[reference_sqlite_node]]).
|
||||||
|
|
||||||
|
**Гочи:**
|
||||||
|
- Модель MiniLM (~86 МБ) при прерванном скачивании бьётся → `failed to load ... Protobuf parsing failed`. Фикс: `Remove-Item C:\Users\Home\AppData\Local\vex\embeddings -Recurse -Force`, затем `vex index --semantic`. Качать в форграунде (фоновый процесс прервался на середине).
|
||||||
|
- После коммитов HEAD сдвигается → vex пишет "index may be stale" → `vex update` (инкрементально, semantic сохраняется из манифеста).
|
||||||
|
- `search`/`usages`/`show` берут индекс текущей папки и НЕ принимают `--path`; `pattern` требует `--lang`+`--path`.
|
||||||
|
- settings.json: правило `"Bash(vex:*)"` пользователь добавляет САМ — Claude не может сам себе выдавать права (классификатор блокирует self-modification).
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# ast-index Rules
|
# ast-index Rules
|
||||||
|
|
||||||
|
> Семантический поиск / AST-паттерны / дубликаты — это **vex**, см. `search-tools.md`.
|
||||||
|
> Это правило — про ast-index (дефолт для символов, usages, callers, outline).
|
||||||
|
|
||||||
## Mandatory Search Rules
|
## Mandatory Search Rules
|
||||||
|
|
||||||
1. **ALWAYS use ast-index FIRST** for any code search task
|
1. **ALWAYS use ast-index FIRST** for any code search task
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Search Tools — когда ast-index, когда vex
|
||||||
|
|
||||||
|
Два инструмента поиска по коду. **Grep по-прежнему запрещён** (см. `ast-index.md`).
|
||||||
|
|
||||||
|
## Правило одной строкой
|
||||||
|
|
||||||
|
- Знаешь имя символа / нужны **usages / callers** / outline → **ast-index**
|
||||||
|
- Ищешь **по смыслу** / **похожее** / **дубликаты** / **AST-паттерн** / компактное тело → **vex**
|
||||||
|
|
||||||
|
## ast-index — дефолт
|
||||||
|
|
||||||
|
Быстрее (1–10 мс) и точнее на этом **vanilla-JS** проекте. ВСЕГДА первым для:
|
||||||
|
|
||||||
|
| Задача | Команда |
|
||||||
|
|--------|---------|
|
||||||
|
| Класс / функция по имени | `ast-index class "X"` · `ast-index symbol "x"` |
|
||||||
|
| Использования | `ast-index usages "X"` |
|
||||||
|
| Кто вызывает / иерархия | `ast-index callers "x"` · `ast-index call-tree "x"` |
|
||||||
|
| Структура файла | `ast-index outline "path"` |
|
||||||
|
| Поиск в файле | `ast-index search "kw" --in-file "f"` |
|
||||||
|
|
||||||
|
⚠️ **usages / callers на чистом JS — ТОЛЬКО ast-index.** vex их пропускает (JS у vex не
|
||||||
|
binder-язык). Проверено: `vex usages "audit"` → пусто, `ast-index usages "audit"` → все 10.
|
||||||
|
|
||||||
|
## vex — по смыслу и структуре
|
||||||
|
|
||||||
|
Для того, чего ast-index не умеет. Бинарник: `C:\Users\Home\bin\vex.exe` (в новых терминалах — `vex`).
|
||||||
|
Индекс собран с `--semantic`.
|
||||||
|
|
||||||
|
| Задача | Команда |
|
||||||
|
|--------|---------|
|
||||||
|
| Поиск по смыслу (имя неизвестно) | `vex search "что делает код" --semantic` |
|
||||||
|
| Семантически похожие символы | `vex similar "Name"` |
|
||||||
|
| AST-паттерн (как ast-grep) | `vex pattern --lang js 'function $NAME($$$)'` |
|
||||||
|
| Near-дубликаты | `vex duplicates --threshold 0.95` |
|
||||||
|
| Компактное тело символа (экономия токенов) | `vex show "Name"` |
|
||||||
|
| Всё про символ за 1 вызов | `vex bundle --mode symbol --symbol Name` |
|
||||||
|
|
||||||
|
Особенности CLI: `search` / `usages` / `show` берут индекс **текущей папки** и НЕ принимают `--path`;
|
||||||
|
`pattern` требует `--lang` + `--path`.
|
||||||
|
|
||||||
|
## Поддержание индексов
|
||||||
|
|
||||||
|
- **ast-index**: `ast-index update` (после pull) · `ast-index rebuild` (после новых файлов)
|
||||||
|
- **vex**: `vex update` (инкрементально, сохраняет semantic из манифеста) · `vex index --semantic` (полный)
|
||||||
|
- Обновить сам бинарник vex: `vex self-update`
|
||||||
+15
-1
@@ -178,7 +178,21 @@
|
|||||||
"Bash(cmd /c \"taskkill /PID 60564 /F\")",
|
"Bash(cmd /c \"taskkill /PID 60564 /F\")",
|
||||||
"Bash(cmd /c \"taskkill /F /PID 60564 2>&1\")",
|
"Bash(cmd /c \"taskkill /F /PID 60564 2>&1\")",
|
||||||
"Bash(kill -9 60564)",
|
"Bash(kill -9 60564)",
|
||||||
"Bash(kill -9 9313)"
|
"Bash(kill -9 9313)",
|
||||||
|
"Read(//f/!Рабочие/ЦТ/Математика/**)",
|
||||||
|
"Read(//f/!Рабочие/ЦТ/Физика/**)",
|
||||||
|
"Read(//f/!����稥/��/��⥬�⨪�/**)",
|
||||||
|
"PowerShell(Get-ChildItem \"F:\\\\!Рабочие\\\\ЦТ\\\\Математика\\\\Математика\\\\ЦТ-ЦЭ\" | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}} | Format-Table -AutoSize)",
|
||||||
|
"Read(//f/!����稥/��/**)",
|
||||||
|
"PowerShell(Get-ChildItem \"F:\\\\!Рабочие\\\\ЦТ\\\\Физика\\\\Сборники ЦЭ,ЦТ-20260116T125835Z-3-001\" | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}} | Format-Table -AutoSize)",
|
||||||
|
"PowerShell(Get-ChildItem \"F:\\\\!Рабочие\\\\ЦТ\\\\Физика\\\\Сборники ЦТ-20260116T130104Z-3-001\" | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}} | Format-Table -AutoSize)",
|
||||||
|
"PowerShell(Get-ChildItem \"F:\\\\!Рабочие\\\\ЦТ\\\\Физика\\\\Сборники ЦЭ,ЦТ-20260116T125835Z-3-001\\\\Сборники ЦЭ,ЦТ\" | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}} | Format-Table -AutoSize)",
|
||||||
|
"PowerShell(Get-ChildItem \"F:\\\\!Рабочие\\\\ЦТ\\\\Физика\\\\Сборники ЦТ-20260116T130104Z-3-001\\\\Сборники ЦТ\" | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}} | Format-Table -AutoSize)",
|
||||||
|
"Bash(git commit -m ' *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"Bash(curl -s http://localhost:3000/api/subjects)",
|
||||||
|
"PowerShell(\\(Get-Content \"g:\\\\Dev\\\\Тесты\\\\BQ-System\\\\frontend\\\\question-bank.html\"\\).Count)",
|
||||||
|
"Bash(curl -s \"http://localhost:3000/api/subjects/math/topics\" -H \"Authorization: Bearer test\")"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"\\tmp"
|
"\\tmp"
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
## Поиск по коду
|
## Поиск по коду
|
||||||
|
|
||||||
**ВСЕГДА использовать `ast-index` ПЕРВЫМ** для любого поиска по коду.
|
**ast-index — дефолт.** ВСЕГДА первым для «найти символ по имени / usages / callers / outline».
|
||||||
Grep/Read — только если ast-index вернул пустой результат.
|
Grep/Read — только если ast-index вернул пустой результат.
|
||||||
|
|
||||||
|
**vex** — для поиска **по смыслу**, AST-паттернов, дубликатов, компактного тела символа:
|
||||||
|
`vex search "..." --semantic`, `vex similar`, `vex pattern`, `vex duplicates`, `vex show`.
|
||||||
|
Что и когда — подробно в `.claude/rules/search-tools.md`. (usages/callers по JS — только ast-index.)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Найти класс/функцию/символ
|
# Найти класс/функцию/символ
|
||||||
ast-index class "ClassName"
|
ast-index class "ClassName"
|
||||||
@@ -47,3 +51,225 @@ git push origin master
|
|||||||
- Node.js/Express backend, SQLite (better-sqlite3, sync)
|
- Node.js/Express backend, SQLite (better-sqlite3, sync)
|
||||||
- Frontend: vanilla JS, без бандлера
|
- Frontend: vanilla JS, без бандлера
|
||||||
- ast-index проиндексирован: `ast-index rebuild` при добавлении новых файлов
|
- ast-index проиндексирован: `ast-index rebuild` при добавлении новых файлов
|
||||||
|
|
||||||
|
## Feature: Конструктор симуляций (SimForge)
|
||||||
|
|
||||||
|
Движок авторинга интерактивных 2D-симуляций из JSON-спеки (данные, НЕ код). План: `plans/sim-builder/`.
|
||||||
|
|
||||||
|
### Phase 0 — Learnings
|
||||||
|
|
||||||
|
- **Спека = данные.** Любое числовое свойство объекта = число ИЛИ строка-выражение. Выражения шарятся между людьми → движок безопасный, ⛔ без `eval`/`new Function`.
|
||||||
|
- **`window.SimExpr`** (`frontend/js/labs/_sim_expr.js`): токенайзер → AST → evaluate. `compile(src)->{ast,fn,error}`; `fn(env)` НИКОГДА не бросает (NaN/∞/деление на 0 → 0). Whitelist: `+ - * / ^ %`, унарный `- + !`, сравнения `< <= > >= == !=`, логика `&& ||`, тернарник `?:`, функции `sin cos tan tg ctg cot asin..arctg sqrt abs exp ln log log2 log10 floor ceil round sign min max mod atan2 pow hypot`, константы `pi e tau`. Идентификаторы (вкл. точечные `obj.x`) — только из `env`. Парсер — расширение `y=f(x)` из `graph.js`; `-2^2 == 4` (парити). Также `evalSafe`, `compileValue`, `parse`, `tokenize`, `FUNCTIONS`, `CONSTANTS`.
|
||||||
|
- **`window.SimEngine.mount(host, spec)`** (`_sim_engine.js`) → `{ play, pause, reset, setParam, getParam, isRunning, destroy, el }`. Canvas (мир→экран, равные оси, Y вверх) + KaTeX-оверлей подписей (`katex.renderToString`, как graph.js) + слайдеры из `params[]`. Выражения компилируются 1 раз в mount; в rAF — только evaluate. `env = { t, <params>, w, h, xmin..ymax, <objId>.x, <objId>.y }`. Объекты: `point segment vector circle rect polyline path label`. **Формат спеки v1 — в шапке `_sim_engine.js`.**
|
||||||
|
- **`window.registerSpecSim(spec)`** (`_sim_adapter.js`): спека → манифест LabRegistry (ленивый хост `#sim-spec-host-<id>` в `#lab-sim`; `stop` прячет, `destroy` уничтожает). Так спек-сим открывается тем же путём, что рукописные ~40 (через `openSim` → реестр).
|
||||||
|
- Демо `customdemo` — `_sim_demo.js`, за флагом `?simdemo=1` / `?sim=customdemo` / `LAB_SHOW_SPEC_DEMO` / localStorage `lab-spec-demo=1` (ученикам не светится).
|
||||||
|
- Подключение: 3 каркасных `<script>` eager после `_graph_panel.js` в `lab.html`, демо — после `_register-all.js`. `_sim_deps.js` не трогать (каркас грузится до диспетчера).
|
||||||
|
|
||||||
|
### Phase 1 — Learnings
|
||||||
|
|
||||||
|
- **Новые типы объектов** (в `_sim_engine.js`, формат — в шапке файла):
|
||||||
|
- `plot` — график `f(var)` на canvas движка в мир-координатах (НЕ через `GraphPanelUI` — тот stacked time-series в фикс. оверлее, не `y=f(x)`). Поля: `expr`, `var` (деф.`x`), `range:[a,b]` (числа/выражения, деф. xmin..xmax), `samples` (клампится 2..2000, деф.200), `trace` (точка var=t пишется в trail; при trace без range статич. кривая не рисуется), `color/width`. Свободная переменная подставляется во временную копию env-ключа (восстанавливается после).
|
||||||
|
- `readout` — живой бейдж на DOM-оверлее (`_labelLayer`, как label). Поля: `expr`, `label`, `unit`, `precision` (0..8, деф.2), `x/y` (мир-коорд.; без них — авто-столбик верх-право, счётчик `_readoutSlot` сбрасывается на кадр). Ошибка — мягко через `SimExpr.evalSafe` (AST компилируется 1 раз в prepare), показывает «—».
|
||||||
|
- `vector` — новая форма `origin:[ox,oy]+dx/dy` (конец = origin + (dx,dy)); старая `x1/y1/x2/y2` сохранена; стрелка из Ф0.
|
||||||
|
- **Drag** (`point`/`circle` с `drag:{param,axis,min,max,paramY}`): pointer events на canvas (мышь+тач, `touchAction:none`); хит-тест в экранных px (допуск 16px, ближайшая ручка), приоритет ручек. `axis:'xy'` требует `paramY`. Курсор → мир через `_toWorld` (инверсия `_toPx`) → `_setParamClamped` (clamp по `drag.min/max` И по диапазону параметра из `_paramRange` — не полагаться на DOM-clamp слайдера). Слушатели снимаются в `destroy`. Drag только point/circle (вершины polyline/конец вектора — не реализовано).
|
||||||
|
- Тестировать движок headless: `vm.createContext` + ручной DOM/canvas-стаб (canvas-ctx через `Proxy` с noop). `_renderFrame` рано выходит при `_cw/_ch==0` — выставить вручную. `setParam`/drag используют `new Event('input')` (браузерно безопасно, в стабе нужен `Event`).
|
||||||
|
- ⛔ `lab.html`/`lab-glue.js` — зона параллельной сессии (Ф2 измерит. инструменты); Ф1 их НЕ трогала, работала только в `_sim_engine.js`/`_sim_demo.js`.
|
||||||
|
|
||||||
|
### Phase 2 — Learnings
|
||||||
|
|
||||||
|
- **Физический режим** (всё в `_sim_engine.js`, формат — в шапке файла): блок `physics:{ enabled, gravity:{x,y}, friction?, restitution?, dt?, walls?:[...], springs?:[...] }` + `body:{ mass, vx, vy, fixed }` на point/circle. gravity/friction/restitution/k/length/damping/mass/нач.позиция/vx/vy — число ИЛИ выражение от params (вычисляются на **reset**, не каждый кадр — для стабильности).
|
||||||
|
- **`window.SimPhysics`** — экспортированный интегратор (`step(state,dtFrame)`, `integrate`, `resolveCollisions`). Полу-неявный (симплектический) Эйлер `v+=a·dt; x+=v·dt` — та же математика, что `_fx_motion.spring`, обобщённая на N связанных тел. Фикс-шаг с накопителем (кламп dt 1/2000..1/30, кап подшагов 8, кламп скорости 1e4, вязкое трение `exp(-friction·dt)`) → энергия не «взрывается». Упругие столкновения круг-круг (импульс по нормали + позиционная коррекция по обратным массам) и круг-стена. Чистая функция над state, без DOM/eval — переиспользуемо headless. Отдельного файла `_sim_physics.js` НЕТ (нельзя подключить без правки lab.html — зона параллельной сессии); код внутри `_sim_engine.js`.
|
||||||
|
- **`_fx_motion` API не подходит** для спек-движка напрямую: `tween`/`springFactory` — rAF-замыкания, тянущие ОДНО значение к цели, не связанные тела с силами. Переиспользована только их интеграционная математика (формула спринга), а не сами функции.
|
||||||
|
- **env-поля тел**: `<id>.x/.y/.vx/.vy` берутся из СОСТОЯНИЯ интегратора и кладутся в `_buildEnv` ПЕРВЫМИ (до формульных центров) — это снимает forward-ref проблему однопроходного env для тел: формульный объект, ссылающийся на тело (`segment x2:'ball.x'`), видит актуальную позицию в том же кадре. point/circle с `body` рисуются из env-полей тела, а не из выражения x/y.
|
||||||
|
- **Drag тела**: тело (point/circle с `body`, не fixed) перетаскиваемо по умолчанию (без `drag`-конфига). Тащишь — `body.x/y = курсор`, тело временно `fixed` в `_stepPhysics`; отпускаешь — `body.vx/vy` из сглаженной оценки скорости курсора (кламп 40 м/с). Хит-тест тела — max(16px, экранный радиус). drag-ручки Ф1 и физ-тела сосуществуют.
|
||||||
|
- **Гочи**: (1) имя param **`e`** зарезервировано — это число Эйлера в SimExpr (parser проверяет CONSTANTS до env), выражение `e` даст 2.718, не значение param. Брать `el`/`elast`. (2) Радиус тела для коллизий: circle — мировой `r`; point — экранные px → мир через `_scale`, поэтому физика собирается в `reset()` ПОСЛЕ первого `_fit()`. (3) Слайдеры во время play меняют только env (readout/формулы), но НЕ силы/тела до reset (намеренно). На паузе при `t==0` — пересборка для предпросмотра старта.
|
||||||
|
- Headless-тест физики: виртуальные часы (`vclock`) синхронны с `performance.now()` и таймстампом rAF (иначе первый кадр получит огромный/отрицательный dt и ничего не сдвинется).
|
||||||
|
|
||||||
|
### Phase 3 — Learnings
|
||||||
|
|
||||||
|
- **Персистентность**: таблица `custom_sims` (миграция **071**), API `/api/custom-sims` (контроллер `customSimController.js`, роутер `customSims.js`, смонтировано в `server.js` после `/api/materials`), клиент `LS.customSimsList/Get/Create/Update/Delete`. Спека хранится как `spec_json` TEXT(JSON); парс — только на чтение/валидацию, на сервере НЕ исполняется. `version` ++ на каждом update со `spec`.
|
||||||
|
- **`validateSpec(spec)` — серверная защита БЕЗ исполнения** (спека шарится между людьми): размер ≤200KB, `specVersion`=1, лимиты (params≤50/objects≤200/walls≤20/springs≤50/expr≤500симв./глубина≤8/points≤1000), whitelist типов объектов (point|segment|vector|circle|rect|polyline|path|label|plot|readout), physics-границы (restitution 0..1, dt 1/2000..1/30, body.mass>0). Строки-выражения (x/y/expr/…) НЕ парсятся (это делает безопасный SimExpr на клиенте) — проверяется только длина. Текст-поля (text/label/unit/meta/param.label/drag.param/id) **обрезаются и экранируются** (`& < >` → entities). Возврат `{ ok, error?, clean? }` — в БД пишется `clean` (санитизированная).
|
||||||
|
- **Ownership-паттерн = studentMaterialsController**: read-роуты auth-only (видимость own+published решает контроллер), мутации — inline `requireRole('teacher','admin')` + per-row проверка (`owner_id === req.user.id || role==='admin'` → иначе 403; нет строки → 404). НЕ blanket `router.use(requireRole)` — иначе ученик не увидит published.
|
||||||
|
- **lint:routes (baseline 0)**: `:id`-роуты прикрыты router-level `authMiddleware` (линтер видит `router.use(<guard>)`); read `GET/PUT/DELETE /:id` дополнительно помечены `// @public-by-design:` с указанием на ownership-проверку в хендлере (как в materials.js).
|
||||||
|
- **Тесты**: setup.js строит СВОЙ Express-app и НЕ монтирует новые роуты — тест-файл должен сам `app.use('/api/custom-sims', require(...))` (как lab-links.test.js). `getToken(role)`/`inject(method,path,body,token)` — готовые хелперы. seedRow(PRAGMA table_info) — для прямого посева строк, устойчив к дрейфу схемы (здесь не понадобился: пользователи создаются через getToken, спеки — через API).
|
||||||
|
- **Окружение тестов**: 8 fail из 209 = 3 baseline (`auth.test.js` — bcrypt/JWT в тест-окружении) + 5 page-тестов (`chemistry7/8-*`, `math5/6-page`), падающих на `Cannot find module 'jsdom'` (devDep не установлен) — оба класса не связаны с бэкенд-фазой.
|
||||||
|
|
||||||
|
### Phase 4 — Learnings
|
||||||
|
|
||||||
|
- **Билдер = `frontend/sim-builder.html` + `frontend/js/sim-builder.js`** (логика модульна: html держит только разметку/стили/bootstrap). `window.SimBuilder.create({host,previewHost,panelHost,toolbarHost}) -> Builder`. Состояние `Builder.st`; `_uid` на объектах/стенах/пружинах — UI-метка, вырезается в `buildSpec()`. Доступ teacher/admin: `LS.initPage()` → `{isTeacher,isAdmin}` → редирект `/dashboard` (паттерн live-quiz.html).
|
||||||
|
- **Подключение движка тем же путём, что lab.html**: `<script src="/js/labs/_sim_expr.js">` + `_sim_engine.js`. Гочи маршрутизации: `/js` мапится на **корневой** `js/` (api.js/sidebar.js/mobile.js/notifications.js), а в нём НЕТ `labs/` → запрос `/js/labs/*` и `/js/sim-builder.js` проваливается на `express.static(frontendDir)` и отдаёт `frontend/js/...`. Это уже работающий механизм (lab.html), не трогать server.js.
|
||||||
|
- **Генерация спеки**: `buildSpec()` → JSON v1. `stripObj()` убирает `_uid`/пустые поля. **plot** хранит в UI `range_a/range_b` отдельно и материализуется `normalizePlotForSpec` → `range:[a,b]` (границы — число ИЛИ выражение). `stripObj` переопределён в конце IIFE на plot-aware версию — работает т.к. `buildSpec` вызывает её в рантайме (function-declaration binding мутабелен). Числовые поля хранятся «как введено» (число/строка) — `SimExpr.compileValue` ест оба, серверная `validateSpec` не парсит.
|
||||||
|
- **Выражения = только SimExpr** (без eval/Function): `SimExpr.compile(v).error` → inline-ошибка у поля; `FUNCTIONS`/`CONSTANTS` — **обычные объекты** (ключи=имена, не Set) → палитра через `Object.keys`. `exprError()` пропускает чистые числа и пустые строки.
|
||||||
|
- **Запрет имени param**: не только `e` (число Эйлера), но и `pi/E/PI/tau/t/w/h` (служебные env-переменные движка) — иначе слайдер затрёт системное имя.
|
||||||
|
- **Drag-on-preview**: переиспользует геометрию движка — `inst.canvas` + `inst._toWorld(px,py)` (px относит. canvas getBoundingClientRect). Пишет x/y (point/circle/label/readout/rect) или x2/y2 (segment/vector). Только на паузе (`!inst.isRunning()`), чтобы не конфликтовать со встроенным drag/анимацией движка.
|
||||||
|
- **Клиентская валидация зеркалит серверную** (Ф3 лимиты) и показывает дружелюбную модалку-список ДО запроса — экономит round-trip и даёт понятные ошибки вместо сырого 400.
|
||||||
|
- **Сайдбар — аддитивно**: пункт `/sim-builder` в `js/sidebar.js` в группе `G('practice',...)` после `/lab`, паттерн `{ cls:'sb-teacher-only', hidden:!isTch }`. `isActive('/sim-builder')` подсвечивает на странице (clean URL без .html). НЕ ломает остальной сайдбар.
|
||||||
|
- **Верификация без jsdom**: headless-смоук — `vm.createContext` + ручной DOM/window/Blob-стаб (canvas getContext-заглушка, setTimeout no-op чтобы debounce не стрелял), грузим `_sim_expr.js`+`sim-builder.js`, дёргаем `buildSpec()`/`validate()`/`loadFromSim()` напрямую (рендер не нужен для логики). 23/23.
|
||||||
|
|
||||||
|
### Phase 5 — Learnings
|
||||||
|
|
||||||
|
- **id-неймспейс custom: гочи LabRegistry**. `LabRegistry.get/has` обрезают часть после `:` (`_baseId`), т.к. встроенные используют `base:arg` (`emfield:E`, `stereo:cube`). Поэтому регистрировать `custom:42` НЕЛЬЗЯ — `has('custom:42')` искал бы `_byId['custom']`. Решение: в реестре id **без двоеточия** `customsim_<dbid>`, а наружу (deep-link/клик/`data-open`) — `custom:<dbid>`. Конвертация одной функцией `LabCustom.resolveId` через хук в начале `openSim` (lab-init.js, +7 строк).
|
||||||
|
- **Ленивый манифест-заглушка вместо ранней загрузки spec**. На старте /lab грузим только мету (`customSimsList`, без spec) и регистрируем заглушку с асинхронным `open()`. При первом открытии: `ensureSpec(dbid)` (`customSimGet`, кэш+дедуп) → `registerSpecSim(spec)` (Ф0-адаптер) **заменяет заглушку на месте** (`LabRegistry.register` сохраняет позицию по тому же id) → `setActive(real)` + `real.open(ctx)`. Дисп. в `openSim` уже умеет Promise-возврат `open()` (Ф3). Повторное открытие — синхронно (реальный манифест в реестре). Движок `_sim_*` уже eager (Ф0) → ленивый файл не нужен, `_sim_deps.js` не трогаем.
|
||||||
|
- **Аддитивность в чужих файлах**: вся логика — в новом IIFE `window.LabCustom` в КОНЦЕ lab-glue.js; в существующий код добавлены только хуки: `renderSims()` merge +`&& !m._custom` (1 терм) + вызов `renderSection`; init зовёт `init()`. Секция «Мои симуляции» (`#custom-sim-section`) создаётся **динамически** в `#lab-home` — без правок lab.html/CSS (тот же приём, что `_loadRelated` в Ф-каталоге). Карточки переиспользуют `.sim-card/.sim-cat/.sim-preview`; бейджи/кнопки — inline-стиль + SVG `.ic` (без эмодзи).
|
||||||
|
- **Owner-only действия**: `owner_id === user.id` (user из `LS.initPage()`, поле `id` — канон всего фронта, ср. `t.createdBy === user.id` в theory.html). Edit → `location.href='/sim-builder?id='+dbid`; Delete → `LS.customSimDelete` + убрать карточку. Делегированный клик по контейнеру секции: `data-act` (edit/del, `stopPropagation`) vs `data-open` (открыть). Видимость draft/published обеспечивает сервер Ф3 (список = свои+чужие published).
|
||||||
|
- **Embed/Ф7 заметка**: для `?sim=custom:*` открытие отложено до `LabCustom.init()` (и в обычном, и в embed-режиме). `_loadRelated('customsim_<id>')` дергает `/api/lab/sims/.../related` (404, тихо). LabRegistry не имеет unregister → удалённая custom остаётся заглушкой в реестре (карточки нет, ensureSpec вернёт 404). Источник spec для доски (Ф7): `LabCustom.ensureSpec(dbid)`.
|
||||||
|
- **Смоук на РЕАЛЬНОМ registry/adapter**: harness грузит настоящие `_registry.js`+`_sim_adapter.js` в `vm`-контекст, стабит только SimEngine/LS/DOM, извлекает IIFE `LabCustom` из lab-glue по маркеру и прогоняет init→open→del. Гочи стаба: реальный код проверяет `window.LS` (api.js ставит и `window.LS`, и глобал `LS`) — в стабе надо ставить ОБА; `document.getElementById` стаба должен находить и динамически `appendChild`-нутые элементы (регистрировать по id в appendChild). 22/22.
|
||||||
|
|
||||||
|
### Phase 6 — Learnings
|
||||||
|
|
||||||
|
- **Раздача классу = доступ + уведомление, НЕ копия.** Ключевое отличие от «Моих материалов» (`shareMaterial`): там оригинал ПРИВАТНЫЙ, поэтому каждому ученику делается независимая КОПИЯ. У custom-sim published И ТАК видна всем в каталоге (`list`/`get` отдают published любому; custom-sim НЕ гейтится `content_access` allowlist'ом 'sim' — тот гейтит ТОЛЬКО legacy `lab_sims`). Поэтому share = (1) авто-публикация `status→published`, (2) адресное уведомление ученикам класса. Копия и запись content_access избыточны. Решение зафиксировано в CONTEXT.md.
|
||||||
|
- **Долговечное уведомление: `pushNotif`, НЕ `sse.emit`.** materials.share шлёт `emit(uid, {...})` (только SSE, теряется если оффлайн) — там персистентность даёт сама копия. Для share без копии нужен durable канал: `require('../utils/notifications').pushNotif(uid, type, message, link)` — пишет в таблицу `notifications` И шлёт SSE. Ссылка `/lab?sim=custom:<id>` (Ф5 deep-link).
|
||||||
|
- **`lab_sim_links.sim_id` — TEXT** (см. мигр.043), поэтому курикулумные связи custom переиспользуют ту же таблицу с `sim_id='custom:<id>'` — отдельная таблица не нужна. Связями СВОЕЙ симуляции рулит владелец/admin (а не только admin как у lab_sims в lab.js — custom-sim принадлежит учителю). DELETE симуляции должен чистить её связи вручную (у lab_sim_links нет FK на custom_sims). `/api/lab/links?kind=...&ref_id=` (обратный поиск) джойнит `lab_sims` — для custom не сработает (отдельный bulk-эндпоинт — остаток).
|
||||||
|
- **Шаблоны = данные в JS, не код/файл.** `TEMPLATES` (массив спек v1) прямо в sim-builder.js; «Создать из шаблона» собирает синтетический sim-объект `{ id:null, status:'draft', spec, title, cat }` и зовёт существующий `loadFromSim` → simId сбрасывается в null + `history.replaceState('/sim-builder')`, чтобы первое «Сохранить» создало запись. `loadFromSim` уже корректно раскладывает plot-`range`→`range_a/range_b` (Ф4) — шаблоны с графиками round-trip без потерь.
|
||||||
|
- **publish-toggle через PUT status.** Снять с публикации = `customSimUpdate(id, { status:'draft' })` (контроллер Ф3 уже принимает `status` в update). В билдере для уже сохранённой sim — `setStatus` (без полного save, не бампит version зря); в каталоге — кнопка publish/unpublish на owner-карточке.
|
||||||
|
- **clone-источник:** своя любая ИЛИ чужая published (чужой draft → 403). Кнопка «Клонировать к себе» — только на чужой published-карточке и только для teacher/admin (`_isTeacherUser()`). Копируется `spec_json` как есть (уже санитизирован при сохранении оригинала), status=draft, version=1, title += ' (копия)'.
|
||||||
|
- **Аддитивность сохранена**: lab-glue.js правлен только внутри IIFE `LabCustom` (ICON-блок + `_cardHtml` actions + делегат + 3 новые функции + экспорт); lab.html/classroom.html не тронуты. Кнопки — inline-стиль + SVG `.ic`, без эмодзи.
|
||||||
|
|
||||||
|
### Phase 7 — Learnings
|
||||||
|
|
||||||
|
- **Доска грузит sim в IFRAME, НЕ монтирует движок напрямую.** Ключевое открытие: `onSimOpen(simId)` в classroom.html просто ставит `cr-sim-frame.src = /lab?embed=1&sim=<simId>`. Значит custom-sim на доску = переиспользование Ф5-пути: iframe `/lab?embed=1&sim=custom:<id>` сам монтирует SimEngine через `LabCustom.init→openSim→registerSpecSim`. Никакого прямого `SimEngine.mount` в классруме — план («смонтировать SimEngine в container доски») был неточен, фактический конвейер чище.
|
||||||
|
- **Синхрон состояния — обобщённый мост `sim_state`/`apply_sim_state` (postMessage), НЕ per-sim код в классруме.** Каждая встроенная sim в embed зовёт `_registerSimState(id, getState, applyState)` + `_startStateEmit(id)` (lab-glue.js, top-level). Учительский iframe постит `{type:'sim_state',state}` родителю → classroom relay `POST /sim/state` → SSE → ученик постит `{type:'apply_sim_state',state}` в свой iframe → `_simStateRegistry[_autoSim].applyState`. Custom-sim просто подключается к тому же реестру: `_bridgeCustomSimState(real)` с getState=`{params,running}` / applyState=`setParam`+play/pause поверх `real.instance()` (SimEngine: `.params`, `setParam`, `isRunning`, `play`, `pause`).
|
||||||
|
- **Ключ реестра состояния = `_autoSim` (raw `custom:<dbid>`), НЕ реестровый id.** Обработчик `apply_sim_state` берёт `_simStateRegistry[_autoSim]`, а `_autoSim` — это сырой URL-param `custom:<dbid>` (двоеточие!), хотя в LabRegistry sim лежит под `customsim_<dbid>` (resolveId). Регистрировать мост надо под `_autoSim`, иначе ученик не применит state. Гоча неочевидная.
|
||||||
|
- **simId с двоеточием ломал бэкенд-валидацию.** `simOpen` валидировал `^[a-z0-9_-]{1,40}$` — двоеточие в `custom:5` не проходило. Добавлена ветка `^custom:(\d+)$` + проверка доступа (own|published|admin → иначе 404/403). Доступ дублируется на `GET /custom-sims/:id` (ensureSpec в iframe) — две линии обороны, чужой draft не утечёт.
|
||||||
|
- **Закрытие = `frame.src='about:blank'` сносит весь iframe-документ** (SimEngine, rAF, listeners, `_simStateRegistry`) — явный `destroy()` в классруме не нужен, чисто по построению. Смена sim — тот же сброс src + новый load.
|
||||||
|
- **classroom.html (8240 строк) — искать через vex по DOM-id** (`cr-sim-picker-grid`, `cr-sim-frame`), затем точечный Read. ast-index НЕ индексирует inline-`<script>` в HTML (символы `crOpenSimPicker` и т.п. → пусто); vex тоже не парсит тела inline-функций. Для тел функций в HTML — Grep tool (документированный escape-hatch ast-index.md: «ONLY when ast-index returns empty»). Проверка инлайна: извлечь `<script>` без src в temp .js → `node --check` → удалить.
|
||||||
|
|
||||||
|
### SimForge improvements — P1 (Рабочее поле) — Learnings
|
||||||
|
|
||||||
|
Раунд полировки сверх фаз 0–7. План: `plans/sim-builder/IMPROVEMENTS.md`. Всё в `frontend/js/labs/_sim_engine.js` (один движок → эффект и в билдере, и в /lab, и на доске).
|
||||||
|
|
||||||
|
- **Первопричина «съехало вправо»**: `_build` раскладывал `root` как `display:flex` с фикс-панелью `width:260px` СЛЕВА + `stage` справа → у пустой/новой sim панель всё равно занимала 260px, сцена смещалась. **Фикс — раскладка, НЕ `_fit`** (`_fit` был корректен): `root`(relative) → `stage`(`position:absolute;inset:0`, canvas+labels на всю площадь) + контролы как **плавающая overlay-панель** (`position:absolute;left/top:10px;z-index:5;pointer-events:auto`, сворачивается `_togglePanel`, есть только при наличии `params`) + бар кнопок вида (`right/bottom:10px`). Пустое место сцены под панелью доступно для pan (`pointer-events:auto` только на карточке). sim-builder.html НЕ потребовался — старый CSS `.sbu-preview .sim-spec-root{position:absolute;inset:0}` уже растягивает новый full-bleed root.
|
||||||
|
- **Transform-модель (zoom/pan)**: `_fit()` считает БАЗУ `_baseScale/_baseOffX/_baseOffY` (центрированный fit по viewport) и ЭФФЕКТИВНЫЙ `_scale/_offX/_offY` (его используют `_toPx/_toWorld` — сигнатуры без изменений). `_zoom` — пользовательский множитель к базе; `_viewLocked` — был ли zoom/pan (тогда ресайз СОХРАНЯЕТ мир-центр+zoom, не сбрасывает вид). Публичное API вида: `inst.fitView()` / `inst.resetView()` (оба → центрированный viewport). Внутреннее: `_zoomAt(lx,ly,factor)` (зум к экранной точке — мир-точка под курсором инвариантна; кламп `_zoom` 0.1..50×), `_setupZoomPan()` (колесо `{passive:false}` + pan на pointer events), `_visibleWorld(W,H)` (видимые мир-границы для сетки/осей с учётом zoom/pan).
|
||||||
|
- **Pan vs drag-ручек — приоритет хит-теста**: хит-тест ручек/тел вынесен из замыкания `_setupDrag` в общий метод `_pickHandleAt(lx,ly)`. Drag-листенеры регистрируются ПЕРВЫМИ (если `_hasHandles`), pan — после; `_onPanDown` стартует pan, только если `!_dragging && !_pickHandleAt(...)` → ручка/тело всегда побеждает. Курсор сцены `grab` (пустое место паним), `grabbing` при pan.
|
||||||
|
- **Сетка адаптивна к zoom**: `_niceStep(targetPx)` завязан на `_scale` (мир→px), шаги 1/2/5·10^n; `_drawGrid` рисует minor(~34px) + major(×5) через всю видимую область (`_visibleWorld`); линии округляются к `.5px` (резкость, без «ступенек»). `_drawAxes` — оси X/Y (прижимаются к краю canvas, если 0 вне видимой области) + числовые подписи делений (светлый текст + тень на тёмном фоне, хелперы `_axisNum`/`_stepDecimals`) + маркер origin (0,0).
|
||||||
|
- **destroy** снимает wheel-листенер + pan-листенеры (`_onWheel/_onPanDown/_onPanMove/_onPanUp`) и ResizeObserver — утечек нет.
|
||||||
|
- Иконки кнопок (`_chevIcon/_fitIcon/_resetViewIcon`) — inline SVG `.ic`-стиль (без эмодзи). Вычислений выражений в P1 нет → eval/Function не вводились.
|
||||||
|
- **Верификация P1**: `node --check` OK; headless-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, грузятся через `require`) 40/40: центрирование пустой спеки, zoom-инвариант курсора + кламп, pan-сдвиг `_off`, приоритет ручек над pan, drag-ручка пишет param, подписи-оверлей следуют zoom/pan (позиционируются по `_toPx`), fit/reset вида, ресайз сохраняет вид, рендер всех 10 типов объектов без throw, destroy снимает все canvas-листенеры. Стаб баланса addEventListener/removeEventListener доказывает отсутствие утечек.
|
||||||
|
- **На P2 (графика объектов)**: расширять `_drawObject`/`_drawTrail`/`_arrowHead`/`_drawPlot` и чтение стилей в `_prepareObjects` (там уже читаются color/fill/width).
|
||||||
|
|
||||||
|
### SimForge improvements — P2 (Качество графики объектов) — Learnings
|
||||||
|
|
||||||
|
Всё в `frontend/js/labs/_sim_engine.js`. Расширено чтение стилей в `_prepareObjects` + применение в `_drawObject`.
|
||||||
|
|
||||||
|
- **Два хелпера вместо повтора в каждой ветке**: `_applyStroke(ctx,o)` ставит `globalAlpha=opacity`, `lineWidth=width`, `lineJoin/lineCap='round'`, `setLineDash` по `lineStyle` (хелпер `_dashFor`, паттерн масштабируется от width), и glow→`shadowColor/shadowBlur` (если `o.glow`). `_fillStyleFor(ctx,o,x0,y0,x1,y1)` строит линейный градиент `gradient:[c0,c1]` по переданному bbox (try/catch — мусорный цвет падает на `fillColor`) или возвращает сплошной `fillColor`/null. **Каждая ветка `_drawObject` обёрнута в свой `ctx.save()/restore()`** → состояние (alpha/dash/shadow/join) НЕ протекает между объектами.
|
||||||
|
- **Безопасность цвета**: все новые цветовые поля (включая стопы `gradient`, `glowColor`/`shadow`) идут ТОЛЬКО в canvas-стоки (`fillStyle`/`strokeStyle`/`createLinearGradient`+`addColorStop`/`shadowColor`) — canvas игнорит мусор, XSS нет. ⛔ В DOM `style.cssText` пользовательские цвета НЕ кладутся (это `_drawLabel`/`_drawReadout` — НЕ трогались в P2).
|
||||||
|
- **Новые поля стиля спеки** (контракт для P4-контролов): `opacity` 0..1; `lineStyle` solid|dashed|dotted; `width` (0 → у circle/rect только заливка); `fill`/`fillColor`; `gradient:[c0,c1]` (приоритетнее fill, верт. по bbox, полигон — только при `closed`); `glow:true`/`shadow:'#c'`/`shadow:{blur}`/`glowColor`/`glowBlur` (деф. ВЫКЛ); `pointStyle` filled|hollow|cross|ring; `trailFade`(деф.true)/`trailWidth`(1.6)/`trailLen`(2000,макс 5000). Полные дефолты — IMPROVEMENTS.md Handoff P2.
|
||||||
|
- **Стрелки векторов**: `_arrowHead(ctx,a,b,color,width)` — заполненный «барбед»-треугольник (вырез у основания, не «галочка»), длина `_arrowHeadLen(width)=max(9,width*3.2)`px; тело линии укорочено на длину головы (`headLen*0.9`), голова всегда сплошная (`setLineDash([])` перед ней). **Точки** `_drawPoint(ctx,o,px,py,r)` — 4 стиля; filled-деф. = заполненный кружок + тонкая белая обводка (если не glow). **Трассы** `_drawTrail(ctx,pts,o)` — при `trailFade` рисуется ПОСЕГМЕНТНО (alpha 0.08→0.68 от хвоста к голове, «комета»), иначе одной полупрозрачной линией.
|
||||||
|
- **Палитра по умолчанию** `DEFAULT_PALETTE` (8 холодно-ярких тонов) — циклически `[i % 8]` в `_prepareObjects`, только если `color` не задан в спеке; явный color сохраняется.
|
||||||
|
- **Верификация P2**: `node --check` OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов и проверкой баланса save/restore + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`) 23/23: 18-объектная спека (все типы + все новые поля) ×4 кадра без throw; **ctx не протекает** (depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра); setLineDash/createLinearGradient/fill/stroke/arc вызваны; поля прочитаны; палитра+явный color; трасса накоплена; destroy чист. Эмодзи нет (скан: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только в комментарии стр.15.
|
||||||
|
- **На P3 (графики/диаграммы)**: `_drawPlot` уже зовёт `_applyStroke`. Расширять `_drawPlot` — оси-деления plot, несколько кривых, заливка под кривой, маркеры (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint` готовы к переиспользованию.
|
||||||
|
|
||||||
|
### SimForge improvements — P3 (Графики/диаграммы) — Learnings
|
||||||
|
|
||||||
|
Всё в `frontend/js/labs/_sim_engine.js`. Расширен `_drawPlot` + ветка `type==='plot'` в `_prepareObjects`. Оси/сетка/подписи уже из P1 — в P3 не дублировались.
|
||||||
|
|
||||||
|
- **Несколько кривых.** Нормализуются в `prep.curves[]` с приоритетом источника: `curves:[{...}]` → `exprs:['sin(x)','x^2']` → одиночный `expr` (легаси, обратная совместимость). Каждой кривой свой цвет: явный `color` или `DEFAULT_PALETTE[i%8]`. `prep.exprFn` оставлен = первой кривой (нужен trace-режиму `_accumPlotTrace`).
|
||||||
|
- **Поля кривой** (`curves[i]`): `expr`, `color`, `label`(→легенда), `width`, `lineStyle`(solid|dashed|dotted), `opacity`(0..1), `fill`(true→полупрозр. цвет кривой / строка цвета), `marker`(none|dot|ring). Не заданные наследуют plot-уровень (`width/lineStyle/opacity`). **Plot-уровневые `fill`/`marker`** — дефолт для всех кривых.
|
||||||
|
- **Заливка под кривой** — `_fillUnderCurve(ctx,pts,baseY)`: между кривой и осью `y=0` (baseY клиппится к canvas), посегментно — разрывы у не-finite точек НЕ сливаются в один полигон. `fill:true` → `_fillAlpha(color,0.18)` (#RGB/#RRGGBB→rgba; прочие форматы как есть, alpha через globalAlpha).
|
||||||
|
- **Маркеры узлов** — `_drawCurveMarkers` переиспользует `_drawPoint` (dot→filled, ring→hollow), прорежены ~28px по экрану (не сотни точек на 200 сэмплах).
|
||||||
|
- **Легенда** — `_drawLegend` на canvas (НЕ DOM): тёмная плашка (`roundRect` с фолбэком на `fillRect`) + цветной свотч (strokeStyle цвета кривой) + светлый `fillText`. Верх-право, не наезжает на бар кнопок вида. Авто при наличии `label`; `legend:false` отключает. ⛔ Пользовательский цвет — только canvas-сток; текст легенды — фикс. светлый.
|
||||||
|
- **Качество кривой** — пропуск не-finite (разрывы через `started=false`), переиспользован equidistant sampling (`samples` 200/макс 2000), `_applyStroke` даёт dash/opacity/glow/round-стыки. Каждая кривая в своём `ctx.save()/restore()`, легенда — на внешнем уровне → состояние не протекает.
|
||||||
|
- **Новые хелперы модульного уровня** (рядом с `_dashFor`/`_opacity`): `_markerStyle(v)` (none|dot|ring), `_fillAlpha(color,a)` (hex→rgba для заливки).
|
||||||
|
- **Верификация P3**: `node --check` OK; headless vm-смоук (canvas-стаб со счётчиком save/restore + РЕАЛЬНЫЕ `_sim_expr`+`_sim_engine`) 10/10: легаси одиночный expr, exprs[], curves[]+fill+marker+legend, наследование plot-уровня, не-finite (1/x, tan) без throw, legend:false, trace±range, fillUnder+markers с null-разрывами, регресс point/vector/circle/rect — все PASS; **ctx сбалансирован** (depth→0, нет restore-underflow). Эмодзи нет (только пре-существующие → в комментариях); eval=0. Temp-смоук удалён.
|
||||||
|
- **На P4 (билдер)**: дать полям контролы — список кривых (add/del, expr+color+label+width+lineStyle+opacity+fill+marker), plot-уровневые fill/marker, тумблер легенды.
|
||||||
|
|
||||||
|
### SimForge improvements — P4 (UI билдера + контролы стиля) — Learnings
|
||||||
|
|
||||||
|
Всё в `frontend/sim-builder.html` (CSS) + `frontend/js/sim-builder.js` (логика). `_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер только генерит спеку, которую движок P2/P3 уже рисует.
|
||||||
|
|
||||||
|
- **Контролы стиля = data-driven хелперы** (рядом с `field`/`miniField`): `colorCtl(label,attr,val,clearable)` (нативный `<input type=color>` + текст + опц.очистка), `rangeCtl` (слайдер 0..1 для opacity), `selectCtl` (lineStyle/pointStyle/marker). Блок «Стиль» в каждом объекте — `Builder.styleBlock(o)`, набор полей решает `STYLE_FOR[type]` ({opacity,line,point,glow,grad}).
|
||||||
|
- **Цвет: текст — источник истины, не нативный пикер.** Нативный `<input type=color>` умеет только `#rrggbb`; rgba()/named он бы потерял. Поэтому пикер лишь ПИШЕТ в текстовое поле и диспатчит `input` (его ловит основной `data-of`/`data-cvf`-обработчик). `Builder.wireColorControls(row)` связывает пикер↔текст↔очистку. `toHexColor(v)` приводит #rgb/#rrggbb→#rrggbb (иначе #000000) для нативного пикера. Очистка (fill/trailColor) = пустая строка → `stripObj` выбрасывает → «нет заливки».
|
||||||
|
- **Round-trip как чинили range в Ф4: дефолты НЕ сериализуем.** `stripObj.isDefaultStyle(k,v)` выбрасывает `hidden`, `glow:false`, `lineStyle:'solid'`, `pointStyle:'filled'`, `opacity:1`, `trail/closed:false`. Спека минимальна, а save→load→save идемпотентен (loadFromSim восстанавливает дефолты из контролов). Селекты хранят дефолтную строку в `st`, но она не уходит в спеку. Проверено vm-смоуком.
|
||||||
|
- **Plot теперь — кривые.** UI-модель plot = `{var,range_a/b,samples,trace,legend,plotFill,plotMarker,curves:[{_uid,expr,color,label,width,lineStyle,opacity,fill(bool),fillColor,marker}]}`. `plotEditor`+`curveEditor` рисуют, `loadPlot` (spec→UI: `curves[]`→`exprs[]`→легаси `expr`; легаси plot-level width/lineStyle/opacity наследуются кривой), `normalizePlotForSpec`+`stripCurve` (UI→spec). **Одиночная «простая» кривая (только expr+color, без plot-fill/marker) → легаси `{expr,color}`**, иначе `curves:[...]` — не ломает обратную совместимость. `legend:false` эмитится только при выкл (движок включает легенду авто при label). Валидация: каждая кривая + границы range через `SimExpr.compile`.
|
||||||
|
- **z-order / видимость / дублирование — чисто в билдере** (движок не трогали): z-order = порядок массива `st.objects`/`st.plots` (кнопки вверх/вниз свапают, крайние disabled). Видимость `hidden:true` — билдерский флаг, `buildSpec` фильтрует hidden из спеки (движок про hidden не знает). Дублирование — `JSON.parse(JSON.stringify(o))` + новый `_uid` + `id+'_copy'`, вставка после оригинала. Аналогично у plot.
|
||||||
|
- **Новые ICON** (inline SVG `.ic`, ⛔ без эмодзи): up/down/copy/eye/eyeOff/clearX. Новые CSS-классы в ls.css-стиле; заголовок объекта `flex-wrap` + 26px-кнопки; медиа ≤920px (была) + новый ≤560px (поля/стили в один столбец).
|
||||||
|
- **Верификация P4**: `node --check` sim-builder.js + извлечённого инлайна html — OK; эмодзи/eval/new Function — 0 (скан кодпойнтов обоих файлов); headless vm-смоук (DOM/SimExpr-стаб) 27+12+2 PASS: стили объекта в спеке, round-trip объектов ×2 идемпотентен, plot с 2 кривыми (все поля) + round-trip ×2, легаси-одиночная→легаси-форма, hidden исключён, z-order=порядок, дефолты-стрип, шаблонные легаси-plot save→load→save стабильны. Temp удалены. git status: только sim-builder.html и sim-builder.js.
|
||||||
|
- **На P5 (прямое манипулирование + история)**: drag сейчас только x/y point/circle/label/readout/rect + конец segment/vector (`bindPreviewDrag` через `inst._toWorld`). Расширять до всех типов + snap-к-сетке + выравнивание (нужны хит-тесты/ручки в `_sim_engine.js`). Undo/redo: `this.st` сериализуем JSON → стек снапшотов в Builder, restore + `renderPanels`/`scheduleRemount`.
|
||||||
|
|
||||||
|
### SimForge improvements — P5 (Прямое манипулирование + история) — ФИНАЛ раунда — Learnings
|
||||||
|
|
||||||
|
Всё в `frontend/js/sim-builder.js`. **`_sim_engine.js` НЕ тронут** — вопреки прогнозу IMPROVEMENTS, хук в движке не понадобился: `_toWorld`/`_toPx`/`_niceStep(targetPx)` уже публичны на инстансе, их хватает для хит-теста/перевода координат/шага сетки прямо из билдера.
|
||||||
|
|
||||||
|
- **Ручки вместо «drag только x/y» (`bindPreviewDrag` переписан).** `handlesOf(obj)` строит список ручек `{label, blocked, wx, wy, set(x,y)}` по типу: point/circle/label/readout/rect → одна ручка (x,y); segment/vector → `origin`(x1,y1) + `end` (x2,y2 ИЛИ, если у объекта `dx`/`dy` без `x2`/`y2` — origin+dx/dy: ручка пишет `dx=x-x1`, `dy=y-y1`); polyline/path → по ручке на каждую числовую вершину `points` (её `set` ре-парсит JSON-строку и пишет свой индекс). `pickHandle` — ближайшая незаблокированная ручка в 14px (через `_toPx`). pointerdown-режимы: `handle` (драг ручки), `place` (единств. ручка — клик СТАВИТ точку, сохранён исходный смысл), `body` (несколько ручек — относительный сдвиг всех от стартовой мир-точки), `none`.
|
||||||
|
- **Выражения не затираются.** `numField(obj,key)` → число, либо `null` если значение — строка-выражение (не парсится как число) → ручка `blocked` (не двигается; молча в спеку не пишется). `refreshObjFields` расширен на x1/y1/x2/y2/dx/dy/points.
|
||||||
|
- **Snap-к-сетке = шаг движка.** Тумблер в тулбаре (`_snap`, `toggleSnap`, `ICON.grid`; активность — инлайн `SNAP_ACTIVE_CSS`, без зависимости от CSS-класса). При вкл координаты округляются к `inst._niceStep(34)` (минорный шаг видимой сетки; fallback 0.5), при выкл — `round2`. Выравнивание к чужим координатам/осям не делалось (бонус; snap достаточно — частично).
|
||||||
|
- **Undo/Redo без библиотек.** Снапшот = `JSON.stringify(this.st)` (`this.st` уже сериализуемо). `pushHistory` снимает ДО мутации (без дублей верхушки; чистит redo; глубина `_undoMax=50`). **Гранулярность правки поля**: `snapField` снимает ОДИН снапшот на сессию (флаг `_fieldSnapTaken` сбрасывается на `focusin` поля; первый input/change снимает) → Ctrl+Z откатывает значение целиком, не посимвольно. Структурные операции (add/del/z-order/dup/hide/тумблеры — объекты/plot/curve/wall/spring/физика) — снапшот сразу. Drag — один на сессию (pushHistory в pointerdown; no-op-снапшот без изменений откатывается в `end()`). Кнопки undo/redo (SVG `.ic`) + клавиши Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts` на `document`, вешается один раз, игнорит фокус в INPUT/TEXTAREA/SELECT). `loadFromSim` обнуляет историю; `_restoreSnapshot` → `renderPanels`+`scheduleRemount` (гочи: захватить `this._selObjId` в локальную переменную — иначе `this` теряется в колбэке `.some()`).
|
||||||
|
- **Верификация P5**: `node --check` OK; эмодзи/eval/new Function — 0 (скан кодпойнтов); headless vm-смоук (DOM/SimExpr/SimEngine-стаб с линейным `_toPx`/`_toWorld`) **38/38 PASS**: drag point/circle, оба конца segment, vector origin+dx/dy, вершина polyline, body-move polyline и segment, snap к 0.5, выражение-поле не затирается, undo/redo drag и onAdd, лимит стека, round-trip buildSpec идемпотентен ×2, no-op-drag не плодит историю. Temp удалён. git status: тронут только sim-builder.js (`_sim_engine.js` в статусе — чужой коммит «goal/game» параллельной сессии, мной не редактировался).
|
||||||
|
|
||||||
|
## Feature: Квантик — Законы Мира (игра)
|
||||||
|
|
||||||
|
2D физика-головоломка поверх SimForge. План: `plans/quantik-game/`. Уровень = спека SimForge + блок `goal`.
|
||||||
|
|
||||||
|
### Phase 0 — Learnings (Слой целей в движке)
|
||||||
|
|
||||||
|
- **«Атом» игры = верхнеуровневый блок `goal` в спеке** (формат — в шапке `_sim_engine.js`): `goal:{ when, title?, hint?, hold?:0, fail?, stars?:[{when,label?}] }` (звёзд ≤3). Аддитивно: нет `goal` → `_goal=null`, HUD не создаётся, в rAF ветка `if(self._goal)` пропускается → **поведение спеки без goal не меняется** (нет накладных вычислений побед, нет DOM-узлов).
|
||||||
|
- **Компиляция один раз** через `SimExpr.compile(src).fn` (как все выражения движка; кривое выражение → fn возвращает 0, не бросает). Истинность булева = `_truthy` (модульный хелпер): конечное ненулевое число. Без `eval`/`Function`.
|
||||||
|
- **Env цели = весь env кадра + ЕДИНСТВЕННЫЙ доп.идентификатор `tries`** (= `attempts`). Не вводить других новых идентификаторов — контракт безопасности шаренных выражений. `env.tries` ставится и в `_evalGoal` (rAF), и в `_renderFrame` (star-accumulation на паузе/предпросмотре) для консистентности.
|
||||||
|
- **Оценка в rAF-кадре**: `_evalGoal(self._buildEnv(), dt)` ПОСЛЕ `_stepPhysics`, ДО `_renderFrame`. Порядок: накопить звёзды (залипают до reset) → `fail` (мягкий проигрыш, приоритет, НЕ победа) → `when` с учётом `hold` (таймер `_goalHoldT` копит мировые секунды; условие пропало → сброс таймера). Победа → `timeMs = max(1, round(t*1000))` (мировое `t`, детерминизм), `won=true`, `pause()`, `_fireGoal()` (onGoal один раз).
|
||||||
|
- **onGoal не задваивается**: победа делает `pause()` внутри кадра; уже-заквигованный следующий rAF выходит по `if(!self._running) return`. Повторный `play()` после победы не перезапускает (уже won, paused).
|
||||||
|
- **attempts**: инкрементится только на пользовательском `reset()` (флаг `_goalInited` — первый авто-reset при mount НЕ считается). `resetResult()` сбрасывает результат, но attempts сохраняет (НЕ попытка).
|
||||||
|
- **HUD = DOM-оверлей** (НЕ canvas), стиль `_readoutBadgeCss` (тёмная плашка). Контейнеры `pointer-events:none` (не крадёт pan/drag), кнопка «Ещё раз» — `pointer-events:auto` → `inst.reset()`. Звёзды — inline SVG (`_starIcon`: заполненная #FBBF24 / контур), без эмодзи. `destroy()` снимает click-слушатель кнопки + removeChild HUD-узлов (баланс add/remove; узлы и так внутри `inst.el`, который удаляется — belt-and-suspenders).
|
||||||
|
- **Публичное API инстанса**: `onGoal(cb)` (chainable), `getResult()`→`{won,failed,timeMs,attempts,stars:{got,total}}` (без goal → `null`), `resetResult()`. Полный перезапуск уровня = `reset()` (физика+время+attempts++).
|
||||||
|
- **Сервер** `customSimController.validateSpec`: `goal` (объект) + `game` (резерв Ф1/5) разрешены на верхнем уровне. `when`/`fail`/`stars[].when` → `checkExpr` (длина ≤500, НЕ исполняются); `title`/`hint`/`stars[].label` → `sanitizeText` (escape `& < >` + обрезка); `stars`>3 → 400; `hold` не-число → 400. `cat='game'` уже в `CATS`. Санитизированный `goal`/`game` пишется в `clean`.
|
||||||
|
- **Верификация P0**: `node --check` обоих файлов OK; headless vm-смоук (ручной DOM/canvas-стаб + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`, rAF-очередь степается вручную, `performance.now()` = виртуальные часы) **40/40 PASS**: when→win+timeMs>0, звёзды копятся+залипают+сброс на reset, fail без won, hold требует удержания + сброс при лапсе, спека без goal без HUD/без throw, onGoal ровно 1 раз, destroy баланс add/remove, серверный validateSpec (escape/>3 звезды/длина/hold/без-goal). `npm test` 238 pass / 8 baseline fail; lint:routes 0. Temp удалён. Эмодзи/eval/new Function — 0 (new Function только в пре-существующем комментарии стр.15).
|
||||||
|
- **На Phase 1**: использовать `onGoal`/`getResult`/`resetResult`; HUD включается сам наличием `goal`. Уровни хранятся в `custom_sims` (cat='game'). `game{}`-блок зарезервирован под мета (узел карты/мир/XP).
|
||||||
|
|
||||||
|
### Phase 1 — Learnings (Оболочка игры + 1 уровень + прогресс)
|
||||||
|
|
||||||
|
- **Сквозной MVP-срез играбелен.** Страница `/quantik` (`frontend/quantik.html` + `frontend/js/game/quantik-game.js`): `QuantikGame.start({host, level})` → `SimEngine.mount(host, level.spec)` → `inst`. «Игровой режим» НЕ требует флага — HUD из Ф0 появляется сам по наличию `goal` в спеке; управление = собственные слайдеры params движка + play/reset (внутри `inst.el`). Победа: `inst.onGoal(res => { LS.gameProgressSubmit(level.id, {time_ms:res.timeMs, stars:res.stars.got}); showSuccess(res); })`.
|
||||||
|
- **Уровни = ДАННЫЕ, встроенные (MVP).** `frontend/js/game/levels.js` → `window.QuantikLevels.{list,get,LEVELS}`. Запись `{ id, title, subject?, hint?, spec }`, `id`==`level_id`. Один уровень `phys-artillery-1`: physics-гравитация + body-запуск (`point` с `body.vx='v*cos(theta*pi/180)'`, `vy='v*sin(...)'`), портал-цель (`goal.when:'hypot(ball.x-PX,ball.y-PY)<R'`), бонус-звезда (`stars[].when`), `fail` при промахе за поле. Подобран ПРОХОДИМЫМ в пределах слайдеров (θ 10..80°, v 5..20 м/с; портал x=8, дальность v²·sin2θ/g ≈ 6..10 м). custom_sims cat='game' остаётся для авторённых уровней (Ф5) — реестр тогда станет асинхронным со слиянием.
|
||||||
|
- **API прогресса**: таблица `game_progress` (мигр.**076**, UNIQUE(user_id,level_id), user_id ON DELETE CASCADE), контроллер `gameController.js` + роутер `routes/game.js` (`router.use(authMiddleware)` → lint:routes 0), смонтировано в `server.js` после `/api/custom-sims`. `GET /api/game/progress` → `{progress:[…]}`; `POST` `{level_id,time_ms,stars}` → upsert best (min time / max stars) + attempts++. Валидация: level_id строка ≤120, time_ms/stars неотрицательные ЦЕЛЫЕ (`Number.isInteger`, отвергает дробь/NaN/∞), stars 0..3. Прогресс всегда `req.user` — нет межпользовательских роутов, ownership-проверка не нужна. Клиент `LS.gameProgressList()`/`LS.gameProgressSubmit(levelId,{time_ms,stars})` (стиль customSim*-врапперов в js/api.js).
|
||||||
|
- **Маршрутизация без правок server.js**: `/quantik` → `quantik.html` через `express.static(frontendDir,{extensions:['html']})` (как все clean URL). `/js/game/*` и `/js/labs/*` отдаются тем же static (гоча `/js`→корневой `js/` касается только api.js/sidebar.js, не подпапок). Подключение движка — копия sim-builder.html: `/js/labs/_sim_expr.js` + `/js/labs/_sim_engine.js`.
|
||||||
|
- **Экран успеха** = DOM-оверлей страницы `.qg-overlay` (НЕ HUD движка), `QuantikGame.buildSuccessOverlay(state)` строит карточку: звёзды inline SVG (заполн./контур, без эмодзи), время/звёзды/попытки, «Ещё раз» (убрать оверлей + `inst.reset()`) / «Дальше» (disabled-заглушка MVP — Ф2 активирует). CSS `.qg-*` в `<style>` quantik.html. Кнопки — классы `btn-primary`/`btn-ghost` (НЕ `ls-btn-*` — таких в ls.css нет).
|
||||||
|
- **Сайдбар**: `/quantik` (icon `rocket`) в группе practice ПЕРЕД `/sim-builder`, БЕЗ `hidden` (видно ученикам — это игра, в отличие от teacher-only sim-builder). `isActive('/quantik')` подсвечивает на clean URL.
|
||||||
|
- **Доступ страницы**: `LS.initPage()` (без `{requireLogin:false}`) сам редиректит на `/login` если не авторизован и возвращает null → бутстрап выходит. Любой авторизованный играет.
|
||||||
|
- **Верификация P1**: `node --check` всех новых/изменённых JS — OK; `npm run migrate` 076 применяется чисто; `npm test` 251 pass / 8 baseline fail (3 auth + 5 jsdom page-тестов — пре-существующие; **game.test.js 13/13 PASS**); `lint:routes` 247 :id-роутов, 0 unprotected (baseline 0). Эмодзи в коде нет (флагуются только `→`/`⛔` в комментариях — конвенция проекта); eval/new Function — 0. Спека без goal по-прежнему работает (Ф0 не задет).
|
||||||
|
- **На Phase 2 (карта/мир/XP)**: реестр уровней расширяемый (добавить запись в `LEVELS`); `game_progress`-API готов; экран успеха `buildSuccessOverlay` переиспользуем (расширить «следующим уровнем», активировать «Дальше»); при смене уровня без перезагрузки — `inst.destroy()` перед новым mount.
|
||||||
|
|
||||||
|
### Phase 2 — Learnings (Карта-созвездие + мир физ-уровней + XP/скины)
|
||||||
|
|
||||||
|
- **Phase 2 = FRONTEND-ONLY** (осознанное решение): XP/уровень игрока агрегируются на КЛИЕНТЕ из `game_progress` (Ф1), скин — localStorage. Без новых таблиц/роутов/миграций → `lint:routes` baseline 0 не тронут, `npm test` ровно как в Ф1 (259 tests / 251 pass / 8 baseline fail). Перенос XP на сервер позже тривиален — те же чистые функции `progress-logic.js`.
|
||||||
|
- **Чистая логика в отдельном модуле `frontend/js/game/progress-logic.js`** (`window.QuantikProgress`, без DOM/сети/eval — тестируемо в изоляции): `isUnlocked(level,map,levels)` (Σ звёзд во всех уровнях с меньшим `order` ≥ `level.unlockStars`; порог в ДАННЫХ уровня), `computeXp`(звёзды·100+40/пройден), `playerLevel(xp)` (квадратичная шкала `xpForLevel(L)=240·(L-1)L/2`), `groupByChapter`, `nextPlayable`, `fromProgressList`, `starsFor/starsToUnlock/nodeStatus`. Гоча тестов: `assert.deepEqual` через `vm`-границу сравнивает массивы РАЗНЫХ реалмов (прототипы ≠) → ложный fail; сравнивать через `JSON.stringify`.
|
||||||
|
- **Карта `frontend/js/game/map.js`** (`window.QuantikMap.create({host,headerHost,onPlay,getSkin,onSkin})->{render(progressMap),destroy()}`): созвездия по главам (`groupByChapter`), узлы — `<button class="qm-node qm-{locked|available|completed}">`, позиция в % через `layoutNodes` (зигзаг-дуга), статус из `nodeStatus`. Звёздное небо — SVG `<circle class="qm-tw">` (CSS-мерцание, seeded `mulberry32`), линии-связи `<line>`. Поэтапное появление — `staggerReveal` (`.qm-pre`→`.qm-in`, setTimeout 70 мс). Тип спеки уровня карте безразличен — читает только метаданные → Ф3 граф-уровни = НОВАЯ глава без правок map.js.
|
||||||
|
- **Метаданные уровня (Ф2)**: `{ id, title, chapter, order, unlockStars?, par_ms?, hint, spec }`. Главы — `QuantikLevels.CHAPTERS` (`{key,title,subtitle,accent}`). 6 уровней: Кинематика (артиллерия/перелёт-через-стену/отскок-от-стены) + Динамика (маятник-на-пружине/орбита-в-колодце/гравиманёвр). По 2 звезды: кристалл (`stars[0]`) + норматив времени `t*1000<=par_ms` (`stars[1]` — par-звезда выражается через мировое `t`, идентификатор `tries` для неё НЕ нужен).
|
||||||
|
- **Физика «силовых» уровней через ПРУЖИНУ** (движок не имеет central-gravity): маятник — пружина к якорю-точке с короткой `length` (растянута → сильный возврат) + горизонтальный толчок; орбита — пружина к центру с `length:0` (== гармонический осциллятор `F=-k·r` == эллиптические орбиты); гравиманёвр — гравитация вниз + пружина-«колодец» к центру. k/толчок/сила = params-слайдеры.
|
||||||
|
- **Скин: тинт без исполнения.** `tintHeroSpec(spec,key)` — глубокая JSON-копия спеки (данные!), переписывает `color/glowColor/trailColor` объекта `id:'ball'` цветом из `PetSprite.PALETTES[key]`. localStorage ключ **`quantik-skin`** (валидируется при чтении). Скин тинтует и героя, и нарратора (`PetSprite.render(...,colorKey,...)`). Гейты — массив `SKIN_GATES` (needStars/needXp).
|
||||||
|
- **Нарратор = `PetSprite.render(level,mood,[],skin,0,'none')`** на карте-шапке (mood по уровню игрока), интро (`buildIntro`, happy) и успехе (`buildSuccessOverlay`, ecstatic при всех звёздах≥2 / happy при ≥1). `quantik.html` грузит `/js/pet-sprite.js` (как dashboard/pet).
|
||||||
|
- **Навигация (inline-bootstrap quantik.html)**: 2 вида `#qg-map-view`/`#qg-level-view` (класс `.show`). `showMap` перезагружает прогресс (`LS.gameProgressList`) → `map.render`. `openLevel→интро→launchLevel→onGoal→успех→onNext(nextPlayable)|onMap`. **Смена уровня ВСЕГДА через `destroyLevel()` (=`inst.destroy()`)** до нового mount (гоча Ф1). Deep-link `?level=` открывает только разблокированный.
|
||||||
|
- **Per-level winnability обязательна** (как Ф1): harness грузит РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js` в `vm`, свипует слайдеры через движок, проверяет `getResult().won`. Гоча OOM: **переиспользовать ОДИН `inst` через `reset()` по сотням комбо ТЕЧЁТ** (накопление через goal-state/bodyById-замыкания) → mount+`destroy()` СВЕЖИЙ inst на каждое комбо (leak-proof). Headless `_renderFrame` рано выходит при `_cw/_ch==0` (рендер не нужен, физика/`_evalGoal` идут в `play`-кадре независимо); для point-радиуса в физике выставить `inst._scale`. Виртуальные часы синхронны с `performance.now()`/rAF-timestamp. Результат: ВСЕ 6 winnable, у всех достижимы обе звезды (combos: artillery 28/196, arc 5/196, bounce 92/343, pendulum 189/196, orbit 94/196, gravimanёvr 170/343).
|
||||||
|
- **Верификация P2**: `node --check` всех новых/изменённых JS + inline-`<script>` quantik.html — OK; смоуки (логика 16/16, рендер карты/оверлеев 7/7, winnability 6/6) зелёные и удалены; `npm test` 259/251 pass/8 baseline fail (без новых падений); `lint:routes` baseline 0. Эмодзи/`★`/eval/new Function — 0 (звёзды UI — inline SVG; в комментариях `★` заменён на «зв.»).
|
||||||
|
|
||||||
|
### Phase 3 — Learnings (Граф-уровни: движение по f(x) + зоны)
|
||||||
|
|
||||||
|
- **«Бегунок по кривой» — поле `runner` на `plot`, НЕ новый тип объекта.** `plot.runner:{duration?:8, hold?:true}` превращает ПЕРВУЮ кривую plot в дорожку. Движок в `_buildEnv` (ДО формульных центров, после физ-тел) кладёт `<plotId>.runX` (= `a+(b−a)·clamp(t/duration,0,1)` по range кривой), `<plotId>.runY` (= f(runX) ТОЙ ЖЕ скомпил. `cv.exprFn`, что рисует кривую → видимая кривая и путь героя идентичны), `<plotId>.runDone` (1 при t≥duration). **Само-ссылку снимает разделение**: герой = ОБЫЧНЫЙ `point` с `x:'curve.runX', y:'curve.runY'` (glow+trail, визуал P2), а f компилируется один раз и питает И кривую, И бегунок — точка НЕ ссылается на собственный x в одном проходе env. `hold:true` оставляет бегунок на конце (иначе зацикливание по `time.loop`). Кинематический проход (без физики) — герой не тело.
|
||||||
|
- **Зоны — `type:'zone'` + булево env-поле `<zoneId>.hit`, БЕЗ предикатов в грамматике.** `{type:'zone', id, shape:'rect'|'circle', kind:'forbidden'|'target'|'collect', track?:'ball', x,y,w,h|r, color?, label?}`. Движок считает `<zoneId>.hit` (1/0) в `_buildEnv` **последним** (нужна актуальная позиция героя из тела/формулы) через `_zoneHit(z,env)` (геометрия в мире). `goal.when/fail/stars[].when` ссылаются на поле (`when:'gate.hit'`, `fail:'pit.hit'`). ⛔ **Никаких `inzone(...)` в синтаксис SimExpr** — контракт выражений закрыт, добавляются только именованные env-поля (та же модель безопасности, что `t`/`tries` из Ф0). Рисует `_drawZone` (forbidden=красный пунктир, target=зелёный, collect=золотой пунктир) — цвета ТОЛЬКО в canvas-стоки (fillStyle/strokeStyle), XSS нет. Зона НЕ кладёт `<id>.x/.y` как центр (`hasCenter` пропущен для `type==='zone'` — это область, не точка).
|
||||||
|
- **ГОЧА имён param (повтор Ф4 SimForge, укусила здесь): `t/w/h/pi/e/E/PI/tau` зарезервированы движком.** `_buildEnv` ставит `env.h = ymax−ymin` (высота вьюпорта) и `env.w` — поэтому param с именем `h` (планировался под вершину модуля `a·|x−h|+1`) затирался: `abs(x−h)` видел h=10 (высота), а не значение слайдера → 0 решающих комбинаций. Фикс — переименовать в `m`. **При добавлении граф-уровней проверять имена коэффициентов против этого списка.** (Сетка-смоук solvability ловит такую ошибку как «0 wins» — обязательна.)
|
||||||
|
- **Контент: глава `functions` (5 уровней) через хелперы-данные.** `road(exprStr,a,b,dur)` (plot+runner, id 'curve'), `graphHero()` (point ball на curve.runX/runY), `rectZone/circZone(id,kind,...)`, `startMarker`. Уровни: луч `a·x+b`, синус `A·sin(k·x)`, парабола `a·(x−5)²+k`, модуль `a·|x−m|+1`, экспонента `c·e^(r·x)`. `time:{duration,loop:false}` синхронизирован с `runner.duration`. Управление = обычные `params`-слайдеры коэффициентов (крутишь → кривая+путь перестраиваются live); свободный ввод выражения не понадобился. Звёзды: collect-зона + доп. условие формы кривой (sticky через механизм stars Ф0).
|
||||||
|
- **Карта/запуск без правок map.js** (подтверждён хэндофф Ф2): глава `functions` в `CHAPTERS` (key/title/subtitle/accent) — узлы рисуются по метаданным, тип спеки карте безразличен. `unlockStars` 9/11/13/15/17 ≤ 18 (макс звёзд 6 физ-уровней) → **нет дедлока** (даже только физ-главы дают 18 ≥ 17). `QuantikGame.start`→`SimEngine.mount` тот же; спец-вайринг управления НЕ нужен (те же слайдеры). `tintHeroSpec` тинтует point-героя на `curve.runX/runY` штатно. quantik.html: бейдж темы стал per-level (`level.subject`→Физика/Алгебра) — аддитивно, id `qg-pill`.
|
||||||
|
- **Сервер `validateSpec` (customSimController.js): `zone` в OBJECT_TYPES + поля.** `zone.track` санитизируется как id; `plot.runner.duration` — checkExpr (длина). Готовит авторённые граф-уровни Ф5. x/y/w/h/r зон проходят общий expr-loop. Тест custom-sims.test.js +2 (приём zone+runner спеки; отказ unknown type при разрешённой zone) → 26/26.
|
||||||
|
- **Верификация Ф3**: `node --check` всех изменённых JS + inline-`<script>` quantik.html — OK; headless vm-смоук (РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`+`levels.js`, DOM/canvas-стаб + виртуальные часы): **per-level solvability** (сетка коэффициентов 625 комбо/уровень) — line 59/625, sine 290/625, parab 88/625, abs 231/625, exp 36/625, у КАЖДОГО найден full-star комбо; **logic** — правильная f→победа без forbidden, плоская f→fail (зашёл в forbidden), zone.hit флипается по позиции, runX/runY/runDone корректны, регресс всех типов + физики без throw, ctx сбалансирован → 29/29. E2E `QuantikGame.start`→onGoal на graph-line-7 → won 2/2. Смоуки удалены. `npm test` 261/253 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function — 0 (только пре-существующие →/⛔ в комментариях; зоны/звёзды — canvas/inline SVG).
|
||||||
|
|
||||||
|
### Phase 4 — Learnings (Квантовые способности + SR-комнаты)
|
||||||
|
|
||||||
|
- **Все три способности — через БЕЗОПАСНУЮ модель спеки, движок НЕ тронут (engine touch = 0).** План допускал поле `tunnelable` у стены в `_sim_engine.js`, но фактически не понадобилось: **туннелирование** = `forbidden`-зона `wall` + `fail:'wall.hit && tunnel<1'`, где `tunnel` — обычный param (не слайдер). По умолчанию `tunnel` отсутствует в env → SimExpr трактует неизвестный идентификатор как 0 → `tunnel<1` истинно → стена сплошная. Способность зовёт `inst.setParam('tunnel',1)` → `_buildEnv` спредит ВСЕ `this.params` в env (стр.1193) → `fail` видит `tunnel=1` → стена проницаема. **Суперпозиция** = чистый контент (2 тела `ball`+`ball2`, `goal.when` с обоими). **Прицел** = пауза-тоггл (`inst.pause/play`) над пунктир-`plot`. Ни новой грамматики SimExpr, ни новых типов объектов, ни правок движка.
|
||||||
|
- **`setParam` для НЕ-слайдер-параметра работает штатно**: ставит `this.params[name]`, слайдера нет → на паузе ре-рендерит. Значение переживает кадр (спредится в env). НО reset физики НЕ трогает `tunnel` (он не нач.условие тела) — поэтому `tunnel` надо ставить ПОСЛЕ `reset()` (в харнессе и в `resetAbilities`). `tunnelUsed`-флаг + сброс `tunnel→0` на новую попытку/mount → заряд тратится один раз за попытку.
|
||||||
|
- **Энергия — клиентский ресурс, чистая логика (`window.QuantikEnergy`).** localStorage ключ **`quantik-energy`** (целое 0..99). `getEnergy/setEnergy/grantEnergy/spendEnergy/canSpend/rewardForQuality/onEnergyChange`. `TUNNEL_COST=3`; награда `rewardForQuality`: q=5(Легко)→2, q=4(Знаю)→1, иначе 0 (та же шкала, что flashcards.html). `spendEnergy` атомарен (не хватило → false, без списания). `onEnergyChange`-подписки обновляют HUD без перезагрузки (панель подписывается в mountBar, отписывается в destroy — без утечки).
|
||||||
|
- **SR-комната = РЕЮЗ серверного SR, НЕ iframe и НЕ дубль расписания.** `QuantikAbilities.openRestRoom` — своя модалка в стиле игры: `LS.fcListDecks()` → авто-выбор колоды с макс. `due_count` (одна → сразу учить; несколько → пикер) → `LS.fcStudySession(deckId)` (отдаёт `{cards,total_due}`) → лицо→`Показать ответ`→оценки (Снова0/Трудно3/Знаю4/Легко5) → `LS.fcReview(cardId,quality)` (отдаёт `{ok,graduated,...}`; `graduated=false` → re-queue в пределах сессии через RQ_GAP, как flashcards.html). «Знаю/Легко» начисляют энергию ОПТИМИСТИЧНО (до ответа сети). Пусто (нет колод / нет due / SR недоступен) → дружелюбное окно + ссылка `/flashcards`. Картинка карты — только свой `/uploads/flashcards/...` (regex-гейт), текст escape.
|
||||||
|
- **Клиентские врапера SR в `js/api.js`**: `fcStudySession(deckId)` = GET `/flashcards/decks/${id}/study`, `fcReview(cardId,quality)` = POST `/flashcards/cards/${id}/review` `{quality}` — стиль блока `fcListDecks/fcCreateDeck/fcAddCard`. Контроллер `flashcardController.getStudySession`/`submitReview` уже существовал (Tier-1 SR, мигр.074) — бэкенд не трогался, lint:routes/тесты неизменны.
|
||||||
|
- **`tintHeroSpec` (quantik-game.js) тинтует `ball` И `ball2`**: ball — цвет скина, ball2 — осветлённый «фантом» (`lighten(color,0.42)`, hex→белый). Авторские id ВНЕ `ball`/`ball2` скином не тинтуются (Phase 5 при желании расширит список). Панель способностей оборачивает `inst.destroy` (снимает бар) — аддитивно, без правки lifecycle движка.
|
||||||
|
- **Глава `quantum` (L12–L16) появляется на карте без правок map.js** (контракт Ф2 подтверждён 3-й раз): `groupByChapter`+`Levels.chapter` метадата-driven. `CHAPTERS.quantum` (accent `#C4B5FD`). `unlockStars` 19/20/22/24/26 ≤ кумулятив макс-звёзд всех уровней меньшего `order` (по 3 звезды/уровень: 18 физ + 15 граф = 33 до L12 ≥ 19) → **нет дедлока** (проверено цепочкой). `isUnlocked` считает звёзды по ВСЕМ уровням с меньшим глобальным `order`, не по главе.
|
||||||
|
- **Активация способностей — по СОДЕРЖИМОМУ спеки, не по флагу уровня**: `levelHasTunnel(level)` = слово `tunnel` в `goal.fail/when/stars[].when`; `levelHasAim(level)` = на сцене `plot` с `id:'aim'` ИЛИ `lineStyle:'dashed'`. Кнопка появляется только если уместна. Контракт для авторского UI Ф5.
|
||||||
|
- **ГОЧА харнесса solvability (физ-уровни): mount планирует ОТЛОЖЕННЫЙ rAF, который делает `_fit`+`reset`(+autoplay).** Если не «слить» его ДО своего `play()`, он выстрелит в середине прогона, вызовет `reset→pause→cancelAnimationFrame` и убьёт кадровый цикл (тело стоит на старте, `t=0`, 0 wins у ЗАВЕДОМО решаемого уровня). Фикс: после mount слить отложенный callback БЕЗ продвижения часов, затем `pause()`, конфиг params, `reset()`, `play()`, гнать кадры с виртуальными часами (8.33мс/кадр, `performance.now` синхронен с таймстампом rAF). Headless-смоук физики обязан гнать РЕАЛЬНУЮ физику (`SimPhysics` экспортится из `_sim_engine.js`).
|
||||||
|
- **Контент-фикс L16 (поймал sweep)**: монета `(5,6)` r0.7 у параболы `a·(x−5)²+k` (вершина в `(5,k)`) собиралась при `5.3<k<6.7`, а 2-я звезда требует `k≥6.8` → **взаимоисключающие → full-star недостижим**. Сдвинул монету на `(5,6.9)` r0.85 → пересечение с `k≥6.8` есть → full-star достижим (a-0.25/k7.2). **Урок: проверять full-star reachability sweep'ом, а не только «есть ли победа».**
|
||||||
|
- **Верификация Ф4**: `node --check` всех изменённых JS + inline quantik.html — OK; headless vm-смоук (РЕАЛЬНЫЕ `_sim_expr`+`_sim_engine`+`levels`+`progress-logic`+`quantik-abilities`, DOM/canvas-стаб + виртуальный rAF-клок): энергия grant/spend/reward/clamp/notify; суперпозиция-`when` требует ОБА тела; tunnel флипает fail (вкл. absent→0); per-level solvability (L12 52 win, L13/L14/L15/L16 ≥3 win + full-star у всех 5; L15/L16 БЕЗ tunnel = 0 win → гейт работает); регресс 11 существующих уровней mount+step без throw → **48/48**, удалён. `npm test` 261/253 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function в UI — 0 (`⛔` U+26D4 — только в комментариях, пре-существующая конвенция всего кодбейза; способности — inline SVG `.ic`).
|
||||||
|
|
||||||
|
### Phase 5 — Learnings (Авторинг уровней в sim-builder + раздача классу)
|
||||||
|
|
||||||
|
- **Бэкенд почти не понадобился — Ф0/Ф3/Ф6 уже всё дали.** `validateSpec` уже пропускал `goal`/`game` (Ф0), `CATS` уже содержал `'game'`, `share`/`clone`/`links`/per-row-ownership/`GET /:id` (own|published|admin) — Ф6. Единственная серверная правка: в `share()` для `cat==='game'` переключить ссылку на `/quantik?level=custom:<id>` + тип `game_level_shared` (иначе `/lab?sim=…`+`sim_shared`); ответ дополнен `link`. Доступ к чужому draft (deep-link/embed-утечка) закрыт ТЕМ ЖЕ `GET /:id` 403 — отдельной защиты не потребовалось.
|
||||||
|
- **⚠️ ПАРАЛЛЕЛЬНАЯ СЕССИЯ на ветке правит sim-builder.js/.html → все правки строго АДДИТИВНЫЕ.** В sim-builder.js тронуто минимум существующих строк: по 1 врезке в `blankState`(+блок `game`), `loadFromSim`(+`st.game=loadGame(...)`), `buildSpec`(+материализация при `st.game.enabled`), `renderPanels`(+`sectionGame()`), `validate`(+проверка goal-выражений), `wirePanels`(+блок game-листенеров перед `renderLatexPreviews`), `onAdd`(+ветка `'star'`), `_open`(+`game:false`). НОВЫЕ методы/функции: `sectionGame`, `playGame`, модульные `loadGame`/`buildGoal`/`buildGameMeta`. HTML — только +CSS-блок `.sbu-game-fields/.sbu-star/.sbu-star-hdr/.sbu-stars-list`. **Никаких переформатирований/перестановок** — минимизирует merge-конфликты.
|
||||||
|
- **Игровой слой ⇄ UI = `st.game = { enabled, when,title,hint,hold,fail, stars:[{when,label}], chapter,order,par_ms }`.** Хранит «как введено» (строки/числа), как plot-range в Ф4. `buildGoal`/`buildGameMeta` материализуют → `spec.goal`/`spec.game` (числа коэрсятся: hold/order/par_ms; пустые поля выкидываются; звёзды clamp ≤3). `loadGame(spec.goal,spec.game)` включает слой, если присутствует goal ИЛИ game. **Выключенный `enabled` → goal/game НЕ эмитятся** → обычная симуляция ведёт себя ровно как раньше. Round-trip `buildSpec→loadFromSim→buildSpec` — `deepEqual` goal+game (доказано смоуком).
|
||||||
|
- **«Играть» = монтировать `SimEngine` в модалке, НЕ открывать /quantik.** На странице sim-builder уже загружены `_sim_expr`+`_sim_engine`; HUD/победа/звёзды активируются САМИ наличием блока `goal` (Ф0 движка) — `QuantikGame` не нужен, доп. скрипт-тегов нет. Тестирует ЧЕРНОВИК без сохранения/сети. Инстанс уничтожается на закрытии модалки (кнопка + `m.onClose`, если поддерживается). Если `goal.when` пуст — тост-подсказка, модалку не открываем.
|
||||||
|
- **`QuantikLevels` стал асинхронным (контракт Ф1 исполнен).** `ensureCustom()` (Promise, кэш `_customPromise`): `LS.customSimsList()` → фильтр `cat==='game'` (список БЕЗ spec) → `LS.customSimGet(id)` каждой → `customToLevel(row)`. `list()=LEVELS.concat(CUSTOM)`, `get(id)` ищет в обоих. **`getAsync(id)`** для deep-link: в кэше → синхронно; иначе `custom:<dbid>`→`LS.customSimGet(dbid)` (сервер-доступ own|published|admin), резолвнутый уровень подмешивается в `CUSTOM` (повторное открытие/«Дальше» синхронны). Встроенные уровни — offline, как раньше.
|
||||||
|
- **Запись авторённого уровня (`customToLevel`)**: `{ id:'custom:<dbid>', dbid, title, chapter:(game.chapter||'custom'), order:(game.order|| 1000+dbid), unlockStars:(game.unlockStars||0), par_ms, subject, hint:(goal.hint), spec, _custom:true }`. Без `goal` → `null` (не уровень). Глава по умолчанию **`custom`** (новая `CHAPTERS.custom`, accent `#F472B6`) — map.js рисует автоматически (метадата-driven, не тронут, контракт Ф2 подтверждён в 4-й раз). `order` дефолт `1000+dbid` ставит custom-уровни ПОСЛЕ встроенных в сортировке.
|
||||||
|
- **Deep-link `?level=custom:<id>` открывается БЕЗ гейта `unlockStars`** (получатель ссылки/автор заходит прямо в уровень); встроенный `?level=<id>` — через `isUnlocked` как раньше. quantik.html: `Promise.all([loadProgress(), ensureCustom()])` до первого `map.render`, deep-link через `getAsync`. Прогресс по custom-уровням: `gameProgressSubmit('custom:<dbid>',…)` — `game_progress.level_id` TEXT≤120, двоеточие проходит, бэкенд НЕ менялся.
|
||||||
|
- **Верификация Ф5**: `node --check` всех изменённых JS + inline обоих HTML — OK; headless vm-смоук (РЕАЛЬНЫЕ `_sim_expr`+`sim-builder`+`levels`, DOM-стаб) 7/7: blank без goal/game; материализация goal+game; round-trip `deepEqual`; non-game sim не включает слой; `validate` ловит пустой/битый `when`; `customToLevel` маппинг + дефолты + null-для-non-game — удалён. Бэкенд-тест `tests/quantik-authoring.test.js` 6/6 (создание game-уровня, чужой draft→403, published виден, share→`game_level_shared`+`/quantik`-ссылка+авто-публикация, >3 звезды→400). `npm test` 267/259 pass / 8 baseline fail (без новых); lint:routes 0. Эмодзи/eval/new Function — 0 (новый UI — inline SVG `.ic`, выражения — только `SimExpr`).
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# LearnSpace
|
# LearnSpace
|
||||||
|
|
||||||
**Образовательная платформа с интерактивной онлайн-доской, системой тестирования, управлением классами и элементами геймификации.**
|
**Образовательная платформа с интерактивной онлайн-доской, системой тестирования, учебниками, виртуальной лабораторией и геймификацией.**
|
||||||
|
|
||||||
Стек: Node.js · Express · SQLite · Vanilla JS · Canvas API · SSE · WebRTC
|
Стек: Node.js · Express · SQLite (`node:sqlite` DatabaseSync) · Vanilla JS · Canvas API · SSE · WebRTC
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
- [Архитектура](#архитектура)
|
- [Архитектура](#архитектура)
|
||||||
- [API](#api)
|
- [API](#api)
|
||||||
- [Роли пользователей](#роли-пользователей)
|
- [Роли пользователей](#роли-пользователей)
|
||||||
|
- [Feature Flags](#feature-flags)
|
||||||
|
- [Контент](#контент)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -29,38 +31,60 @@
|
|||||||
- Маркер (highlighter) с настраиваемой прозрачностью
|
- Маркер (highlighter) с настраиваемой прозрачностью
|
||||||
- Лазерная указка (без сохранения)
|
- Лазерная указка (без сохранения)
|
||||||
- Ластик
|
- Ластик
|
||||||
- 11 фигур: прямоугольник, скруглённый прямоугольник, эллипс, линия, стрелка, треугольник, ромб, шестиугольник, звезда, облако, коннектор
|
- Connector (линии со стрелками)
|
||||||
- Стикеры с редактированием
|
- Стикеры с редактированием текста
|
||||||
- Текстовые блоки
|
- Текстовые блоки (inline editing)
|
||||||
- Вставка изображений
|
- Вставка изображений (drag & drop + URL)
|
||||||
- Таблицы
|
- Таблицы (интерактивные)
|
||||||
- LaTeX-формулы (KaTeX) с визуальным редактором и категориями символов
|
- LaTeX-формулы (KaTeX) с визуальным редактором и категориями символов
|
||||||
- Система координат с построением графиков функций (встроенный парсер)
|
- Система координат с построением графиков функций (встроенный парсер выражений)
|
||||||
- Числовая ось для неравенств (точки, интервалы)
|
- Числовая ось для неравенств (точки, интервалы)
|
||||||
- Циркуль с анимацией
|
- Циркуль с трёхфазной state machine
|
||||||
|
|
||||||
|
**Фигуры (11):** прямоугольник, скруглённый прямоугольник, эллипс, линия, стрелка, треугольник, ромб, шестиугольник, звезда, облако, коннектор
|
||||||
|
|
||||||
**Инструмент выделения**
|
**Инструмент выделения**
|
||||||
- Перемещение и изменение размера всех объектов
|
- Перемещение и изменение размера всех объектов
|
||||||
- Вращение объектов (handle над объектом)
|
- Вращение объектов (handle над объектом)
|
||||||
- Lasso multi-select (резиновая рамка)
|
- Lasso multi-select (резиновая рамка)
|
||||||
- Shift+click для добавления к выделению
|
- Shift+click для добавления к выделению
|
||||||
- Copy / Paste с автосмещением
|
- Copy/Paste с автосмещением
|
||||||
- Snap-гайды при выравнивании объектов
|
- Snap-гайды при выравнивании объектов
|
||||||
|
- Delete, Bring to front, Send to back
|
||||||
|
|
||||||
**Навигация по холсту**
|
**Навигация по холсту**
|
||||||
- Zoom: колесо мыши к курсору, Ctrl+`+`/`-`/`0`, кнопки в тулбаре
|
- Zoom: колесо мыши к курсору, Ctrl+`+`/`-`/`0`, кнопки в тулбаре
|
||||||
- Pan: зажатый пробел + перетаскивание
|
- Pan: зажатый пробел + перетаскивание
|
||||||
- Minimap (192×108) в правом нижнем углу при zoom > 1 — клик/drag для прыжка по холсту
|
- Minimap (192×108) в правом нижнем углу при zoom > 1 — клик/drag для прыжка
|
||||||
|
|
||||||
**Инструменты измерения**
|
**Инструменты измерения**
|
||||||
- Линейка: поворот (drag ↺), изменение длины (drag ↔), панель свойств (угол, длина)
|
- Линейка: поворот, изменение длины, панель свойств (угол, длина)
|
||||||
- Транспортир: поворот, изменение радиуса, панель свойств
|
- Транспортир: поворот, изменение радиуса, панель свойств
|
||||||
- Авто-измерения геометрических фигур (длины, углы, площадь)
|
- Авто-измерения геометрических фигур (длины, углы, площадь)
|
||||||
|
|
||||||
|
**Планиметрия (геометрические построения)**
|
||||||
|
- Середина отрезка, биссектриса, высота, описанная/вписанная окружность
|
||||||
|
- Касательная, параллельный перенос, симметрия
|
||||||
|
- Правильный n-угольник, параллелограмм, средняя линия треугольника
|
||||||
|
- Метки параллельности, прямых углов, одинаковых отрезков
|
||||||
|
- Дуги углов, засечки рёбер
|
||||||
|
|
||||||
|
**Стереометрия 3D**
|
||||||
|
- Куб, прямоугольный параллелепипед, тетраэдр, октаэдр, пирамида, призма
|
||||||
|
- Усечённая пирамида, правильные многогранники, конус, цилиндр, сфера
|
||||||
|
- Скрещивающиеся прямые, производные точки 3D, длины рёбер
|
||||||
|
- Вращение мышью, deep-link на конкретную фигуру (`openSim('stereo:cube')` / `?stereofig=`)
|
||||||
|
|
||||||
|
**Темы доски (4)**
|
||||||
|
- **Chalkboard** — зелёный фон, меловая текстура
|
||||||
|
- **Blackboard** — тёмно-синий, диагональная текстура
|
||||||
|
- **Corkboard** — пробковый, волокна
|
||||||
|
- **Whiteboard** — светло-серый, маркерная доска
|
||||||
|
|
||||||
**Страницы и шаблоны**
|
**Страницы и шаблоны**
|
||||||
- Неограниченное количество страниц на сессию
|
- Неограниченное количество страниц на сессию
|
||||||
- Боковая панель с миниатюрами страниц
|
- Боковая панель с миниатюрами страниц
|
||||||
- Шаблоны: чистая страница, сетка, линейки, точки, координатные оси
|
- Шаблоны: чистая, сетка, линованная, точки, координатные оси
|
||||||
- Экспорт страницы в PNG
|
- Экспорт страницы в PNG
|
||||||
|
|
||||||
**Коммуникация**
|
**Коммуникация**
|
||||||
@@ -72,49 +96,203 @@
|
|||||||
- Курсор учителя виден ученикам в реальном времени
|
- Курсор учителя виден ученикам в реальном времени
|
||||||
- Выдача прав рисования отдельным ученикам
|
- Выдача прав рисования отдельным ученикам
|
||||||
- Личные заметки по уроку (per user)
|
- Личные заметки по уроку (per user)
|
||||||
|
- Режим аннотации поверх симуляции
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Учебники (Textbooks)
|
||||||
|
|
||||||
|
Интерактивные параграф-по-параграфу учебники с прогрессом чтения.
|
||||||
|
|
||||||
|
**Доступный контент (18 учебников)**
|
||||||
|
|
||||||
|
| Предмет | Классы |
|
||||||
|
|---------|--------|
|
||||||
|
| Химия | 7, 8, 9 |
|
||||||
|
| Физика | 7, 8, 9, 10, 11 |
|
||||||
|
| Алгебра | 7, 8, 9, 10, 11 |
|
||||||
|
| Геометрия | 7, 8, 9, 10, 11 |
|
||||||
|
|
||||||
|
**Функции:**
|
||||||
|
- Параграф-по-параграф навигация с прогресс-баром и `last_para` (последнее место)
|
||||||
|
- Задания на чтение: учитель назначает конкретные §, система проверяет выполнение
|
||||||
|
- Кнопка «В лабораторию» — ссылки на связанные симуляции (`lab_sim_links`)
|
||||||
|
- Чип «Связано с программой» (курикулумная привязка)
|
||||||
|
- Хабы глав: агрегированный прогресс по всем главам учебника
|
||||||
|
- Закладки с заметками и цветами
|
||||||
|
- Просмотр прогресса учеников класса (teacher view)
|
||||||
|
- Прогресс хранится в `textbook_progress` (JSON массив прочитанных §)
|
||||||
|
|
||||||
|
**Контент-движок Химии 7 и 8:**
|
||||||
|
- 26 параграфов (Химия 7), 52 параграфа (Химия 8) с интерактивными виджетами
|
||||||
|
- Canvas-анимации (реакции, осадки, горение, электролиз, индикаторы)
|
||||||
|
- 3D-модели молекул (ball-and-stick, VSEPR-геометрия)
|
||||||
|
- Интегрированные задания и лабораторные работы
|
||||||
|
- Карты связей понятий, глоссарий в финалах глав
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Виртуальная лаборатория (40 симуляций)
|
||||||
|
|
||||||
|
Canvas-движок без внешних зависимостей (по аналогу three.js — всё сделано вручную).
|
||||||
|
|
||||||
|
**Физика (14):**
|
||||||
|
projectile, waves, hydrostatics, race, dynamics, isoprocess, pendulum, opticsbench, radioactive, collision, heatengine, circuit, emfield, logic
|
||||||
|
|
||||||
|
**Химия (14):**
|
||||||
|
titration, bohratom, qualanalysis, crystal, molphys, orbitals, organic, periodic, solutions, stoichiometry, chemsandbox, chemistry, equilibrium, electrolysis
|
||||||
|
|
||||||
|
**Математика (9):**
|
||||||
|
graph, triangle, quadratic, normaldist, geometry (планиметрия), stereo (стереометрия 3D), probability, graphtransform, trigcircle
|
||||||
|
|
||||||
|
**Биология (2):**
|
||||||
|
celldivision, photosynthesis
|
||||||
|
|
||||||
|
**Игры (1):**
|
||||||
|
angrybirds
|
||||||
|
|
||||||
|
**Lab Content Engine (LabRegistry):**
|
||||||
|
- Все симуляции зарегистрированы в `window.LabRegistry` через data-driven манифест
|
||||||
|
- Каталог в БД (`lab_sims`): включение/отключение отдельных симуляций, featured, теги
|
||||||
|
- Ленивая загрузка кода симуляций (Phase 3)
|
||||||
|
- Связь симуляций с параграфами учебников (`lab_sim_links`)
|
||||||
|
- Deep-link `?sim=<id>` открывает конкретную симуляцию
|
||||||
|
- Курикулумная привязка: subject/grade/topics в манифесте
|
||||||
|
- Управление в админке: включение симуляций, редактор связей с учебниками
|
||||||
|
|
||||||
|
**Оптическая скамья (opticsbench) — режим «Конструктор»:**
|
||||||
|
- 2D-трассировщик лучей (линза, зеркало, преломление)
|
||||||
|
- Характеристические лучи предмета, дисперсия, ПВО
|
||||||
|
- Алиасы deep-link: `thinlens`, `mirrors`, `refraction`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Биохимия (5 страниц)
|
||||||
|
|
||||||
|
Интерактивный модуль без тяжёлых зависимостей (только Canvas).
|
||||||
|
|
||||||
|
**Молекулярный редактор (`biochem.html`):**
|
||||||
|
- 2D и настоящая 3D-геометрия по VSEPR (ОЭПВО)
|
||||||
|
- Тумблер δ± — тепловая карта частичных зарядов (синий δ+/красный δ−), стрелка диполя
|
||||||
|
- Гибридизация, форма молекулы, валентный угол в панели свойств
|
||||||
|
- Импорт SMILES (учебное подмножество), экспорт PNG/JSON
|
||||||
|
- Химический движок `BIO` (window.BIO, dual-export browser+Node): `analyze`, `partialCharges`, `dipole`, `polarity`, `functionalGroups`, `balance`, `vsepr`, `render3D`, `parseSmiles`, `valency`
|
||||||
|
- Расширенная валидация валентности: подсказки («Углерод (C): занято 5 связей, максимум 4 — убери 1»)
|
||||||
|
|
||||||
|
**Серверный химический слой (`services/chem.js`):**
|
||||||
|
- Переиспользует то же ядро `biochem-core.js` (без дублирования) через dual-export
|
||||||
|
- `POST /api/biochem/analyze` → {formula, mass, dbe, geometry, polarity, dipole, charges, groups, massFractions, valency}
|
||||||
|
- `/validate` переведён на ядро (единые подсказки валентности на клиенте и сервере)
|
||||||
|
- `LS.biochemAnalyze(atoms, bonds)` в api.js
|
||||||
|
|
||||||
|
**Библиотека (`biochem-library.html`):** 105+ молекул, 2D/3D-превью, сравнение
|
||||||
|
|
||||||
|
**Реакции (`biochem-reactions.html`):** 27 реакций, `BIO.balance` (Гаусс+НОК), энергодиаграмма (canvas: реагенты→продукты, стрелка ΔH, экзо/эндо), коэффициенты
|
||||||
|
|
||||||
|
**Метаболические пути (`biochem-pathways.html`):** пути из БД (`bio_pathways`), прогресс Learn-режима, награда XP
|
||||||
|
|
||||||
|
**Свойства (`biochem-properties.html`):** сравнение молекул, столбчатый график молярных масс, экспорт CSV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Управление классом
|
||||||
|
|
||||||
|
- Создание классов, добавление учеников
|
||||||
|
- Задания с дедлайнами и прикреплёнными файлами
|
||||||
|
- Отслеживание сдачи: статусы new/reviewed/accepted/revision
|
||||||
|
- Текстовые задания с прикреплением файлов учеником
|
||||||
|
- Журнал оценок
|
||||||
|
- Объявления и лента активности (Google Classroom-стиль)
|
||||||
|
- Шаблоны заданий для переиспользования
|
||||||
|
- Live-викторины в реальном времени (SSE)
|
||||||
|
- Аналитика успеваемости
|
||||||
|
- Назначение ученикам без класса (teacherStudents)
|
||||||
|
- Назначение чтения конкретных § учебника как домашнего задания
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Учебные материалы
|
### Учебные материалы
|
||||||
|
|
||||||
- Банк вопросов с уровнями сложности и тематиками
|
- Банк вопросов с уровнями сложности, тематиками, поддержкой HTML/KaTeX
|
||||||
- Конструктор тестов с перемешиванием вопросов
|
- Конструктор тестов с перемешиванием вопросов
|
||||||
- Многошаговые уроки с блоками: текст, медиа, формулы, код, викторина
|
- Многошаговые уроки с блоками: текст, медиа, формулы, код, викторина
|
||||||
- Курсы с прогрессом прохождения
|
- Курсы с прогрессом прохождения
|
||||||
- Карточки (flashcards) со spaced repetition
|
- Карточки (flashcards) со spaced repetition
|
||||||
- Граф знаний — визуализация связей между темами
|
- Граф знаний — визуализация связей между темами
|
||||||
- Интерактивные лабораторные работы (30+ симуляций): физика, химия, биология, математика
|
- Сборники ЦТ/ЦЭ: физика 2019–2024, математика 2021–2024 (300+ вопросов)
|
||||||
|
- Экзаменационные тесты (exam9): 80 вариантов по математике 9 класса
|
||||||
|
|
||||||
### Управление классом
|
---
|
||||||
|
|
||||||
- Создание классов, добавление учеников
|
|
||||||
- Задания с дедлайнами, отслеживание сдачи
|
|
||||||
- Журнал оценок
|
|
||||||
- Объявления и лента активности (Google Classroom-стиль)
|
|
||||||
- Шаблоны заданий для переиспользования
|
|
||||||
- Live-викторины в реальном времени
|
|
||||||
- Аналитика успеваемости
|
|
||||||
|
|
||||||
### Специализированный контент
|
### Специализированный контент
|
||||||
|
|
||||||
- **Биохимия**: интерактивные молекулы, реакции, метаболические пути, электрофорез
|
**Биохимия** — см. раздел выше
|
||||||
- **Красная книга**: виды, биомы, экосистемы, пищевые сети, популяционные данные, квесты
|
|
||||||
|
**Красная книга (4 страницы):**
|
||||||
|
- Виды, биомы, экосистемы, пищевые сети
|
||||||
|
- Популяционные данные, квесты
|
||||||
|
|
||||||
|
**Коллекции:** коллекционирование предметов с галереей
|
||||||
|
|
||||||
|
**Galaxy Map (`/sitemap`):** интерактивная Canvas-карта всех модулей платформы с feature flag фильтрацией
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Геймификация
|
### Геймификация
|
||||||
|
|
||||||
- Опыт (XP) и уровни
|
- Опыт (XP) и уровни (8 уровней эволюции, визуальная модель с VSEPR-геометрией)
|
||||||
- Система достижений
|
- 38+ достижений в 6 группах (onboarding, streak, lab, exam, biochem, leaderboard)
|
||||||
- Стрики (серии дней)
|
- Стрики (серии дней)
|
||||||
- Ежедневные цели и задачи
|
- Ежедневные цели (easy/medium/hard тиры) с кольцом прогресса
|
||||||
- Виртуальный питомец
|
- Виртуальный питомец: эволюция по уровням, 6 цветов, аксессуары (шляпа, очки, корона), радужный ошейник при streak ≥ 7, автономное настроение
|
||||||
- Магазин с внутренней валютой
|
- Магазин с внутренней валютой (монеты), фоны для питомца
|
||||||
- Коллекционирование предметов
|
- Коллекционирование предметов
|
||||||
|
|
||||||
|
**Панель администратора геймификации:**
|
||||||
|
- Статистика: суммарный XP, монеты, средний уровень, достижения, покупки
|
||||||
|
- Топ-10 по XP, последние начисления XP с читаемыми подписями
|
||||||
|
- Начисление XP/монет: select с полным списком пользователей + фильтр, пресеты (0/+10/+25/+50/+100/+250), пресеты причин, fix: 0 XP не начисляется
|
||||||
|
- Сброс прогресса пользователя
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Администрирование
|
### Администрирование
|
||||||
|
|
||||||
- Управление пользователями и ролями
|
- Управление пользователями и ролями (student, teacher, admin, free_student)
|
||||||
- Гранулярные разрешения (RBAC)
|
- Гранулярные разрешения (RBAC) — per-role и per-user
|
||||||
- Feature flags (глобальные и per-class)
|
- Feature flags: включение/отключение модулей (biochem, textbooks, flashcards, board, live_quiz, exam9)
|
||||||
- Журнал аудита
|
- Управление симуляциями: каталог в БД, включение/отключение, редактор связей с учебниками
|
||||||
- Кабинет родителя
|
- Доступ к контенту (allowlist): учебники и экзамены по классам и ученикам (`content_access`)
|
||||||
|
- Журнал аудита (`admin_audit_log`)
|
||||||
|
- System Health: реальное время метрики (CPU, RAM, event loop lag), HTTP-статистика запросов, тренды (canvas-графики), журнал последних ошибок
|
||||||
|
- Кабинет родителя с аналитикой по ученику
|
||||||
|
- Аватары с crop/zoom — ученик загружает, учитель/админ модерирует
|
||||||
|
- Панель «Обзор» (командный центр): KPI 24ч, лента завершений, триаж событий, распределение по предметам
|
||||||
|
- KaTeX рендеринг в секции «Вопросы»
|
||||||
|
- Глобальный поиск (command palette): пользователи, тесты, классы
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Дашборд (Главная)
|
||||||
|
|
||||||
|
**Для ученика:**
|
||||||
|
- Карточка «Продолжить/Начать чтение» с обложкой учебника (цветная, по теме)
|
||||||
|
- Карточка «Лаборатория дня» с превью симуляции на фоне блока + deep-link
|
||||||
|
- Карточка питомца с реальными данными из `/api/pet` (имя, модель, цвет, уровень XP, настроение)
|
||||||
|
- Активность (тепловая карта / streak-календарь), слабые темы, задания
|
||||||
|
|
||||||
|
**Для администратора:**
|
||||||
|
- Командный центр: pulse KPIs с count-up анимацией, attention inbox, лента завершений, health-плитки контента, топ/антирейтинг дня
|
||||||
|
|
||||||
|
**Шапка (dash-header):** увеличенная (76px), аватарка 46px, Unbounded 1.15rem, кольца ученика 48px, чипы администратора крупные
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Профиль и настройки
|
||||||
|
|
||||||
|
- Звуковая система (12 звуков на Web Audio API): достижения, уровень, XP, монеты, тесты, доска
|
||||||
|
- Настройки предпочтений на сервере (dashboard widget visibility, whiteboard defaults)
|
||||||
|
- Вкладка звука и настроек в профиле
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,9 +308,9 @@ cp backend/.env.example .env
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Платформа будет доступна на `http://localhost:3000`.
|
Платформа доступна на `http://localhost:3000`.
|
||||||
|
|
||||||
Первый пользователь с ролью `admin` создаётся через seed:
|
Первый admin создаётся через seed:
|
||||||
```bash
|
```bash
|
||||||
docker compose exec app npm run seed
|
docker compose exec app npm run seed
|
||||||
```
|
```
|
||||||
@@ -141,29 +319,24 @@ docker compose exec app npm run seed
|
|||||||
|
|
||||||
## Ручная установка
|
## Ручная установка
|
||||||
|
|
||||||
**Требования:** Node.js 18+
|
**Требования:** Node.js 22+
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Клонировать и установить зависимости
|
|
||||||
git clone https://git.dolgolyov-family.by/maxim.dolgolyov/Learn_System.git
|
git clone https://git.dolgolyov-family.by/maxim.dolgolyov/Learn_System.git
|
||||||
cd Learn_System/backend
|
cd Learn_System/backend
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 2. Конфигурация
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Отредактировать .env
|
# Отредактировать .env
|
||||||
|
|
||||||
# 3. Миграции и начальные данные
|
npm run migrate # применить все миграции (47 SQL-файлов)
|
||||||
npm run migrate
|
npm run seed # опционально — тестовые данные
|
||||||
npm run seed # опционально — тестовые вопросы и пользователи
|
|
||||||
|
|
||||||
# 4. Запуск
|
npm start # production
|
||||||
npm start # production
|
npm run dev # development (nodemon)
|
||||||
npm run dev # development (nodemon)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервер запустится на `http://localhost:3000`.
|
Сервер стартует на `http://localhost:3000`. Фронтенд раздаётся Express из `frontend/`.
|
||||||
Фронтенд раздаётся Express-ом из папки `frontend/`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -189,28 +362,49 @@ npm run dev # development (nodemon)
|
|||||||
Learn_System/
|
Learn_System/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── server.js # Express app, 28 route groups
|
│ │ ├── server.js # Express app, 40 route groups
|
||||||
│ │ ├── config.js
|
│ │ ├── config.js
|
||||||
│ │ ├── sse.js # Server-Sent Events broadcast
|
│ │ ├── sse.js # Server-Sent Events broadcast
|
||||||
│ │ ├── controllers/ # 30 контроллеров
|
│ │ ├── controllers/ # 40+ контроллеров
|
||||||
│ │ ├── routes/ # 28 файлов маршрутов
|
│ │ │ ├── gamification/ # _shared, service, admin, achievements (split)
|
||||||
│ │ ├── middleware/ # auth, RBAC, rate limit, validate
|
│ │ │ ├── classroom/ # 7 domain-файлов (split)
|
||||||
|
│ │ │ └── biochemController.js
|
||||||
|
│ │ ├── routes/ # 40 файлов маршрутов
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── chem.js # Серверный химический движок (dual-export с BIO)
|
||||||
|
│ │ │ └── contentAccess.js # Allowlist учебников и экзаменов
|
||||||
|
│ │ ├── middleware/ # auth, RBAC, rate limit, validate
|
||||||
│ │ ├── db/
|
│ │ ├── db/
|
||||||
│ │ │ ├── migrate.js # Auto-migration при старте (76 таблиц)
|
│ │ │ ├── migrations-runner.js # Версионированный runner (47 миграций)
|
||||||
│ │ │ ├── db.js # better-sqlite3 singleton
|
│ │ │ ├── db.js # node:sqlite DatabaseSync singleton
|
||||||
│ │ │ └── migrations/ # SQL-файлы схемы
|
│ │ │ └── migrations/ # SQL-файлы схемы (000–046)
|
||||||
│ │ └── utils/
|
│ │ └── utils/ # audit, sanitize, healthMonitor
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── *.html # 43 страницы
|
│ ├── *.html # 60 страниц
|
||||||
│ ├── css/ls.css # Общая дизайн-система
|
│ ├── css/ls.css # Общая дизайн-система
|
||||||
│ └── js/
|
│ └── js/
|
||||||
│ ├── whiteboard.js # Движок доски (~3200 строк)
|
│ ├── whiteboard.js # Движок доски (~3500+ строк)
|
||||||
│ ├── classroom-rtc.js # WebRTC модуль
|
│ ├── classroom-rtc.js # WebRTC модуль
|
||||||
│ └── labs/ # 30+ физических симуляций
|
│ ├── biochem-core.js # Химическое ядро BIO (dual-export)
|
||||||
|
│ ├── pet-sprite.js # Рендерер питомца (dual-export, shared)
|
||||||
|
│ ├── lab-previews.js # SVG-превью симуляций для дашборда
|
||||||
|
│ ├── labs/ # 40 симуляций + LabRegistry
|
||||||
|
│ │ ├── _registry.js # LabRegistry — единый реестр
|
||||||
|
│ │ ├── _register-all.js # Data-driven регистрация всех симуляций
|
||||||
|
│ │ ├── lab-glue.js # Каталог SIMS, THEORY, preview SVG
|
||||||
|
│ │ ├── lab-init.js # openSim dispatcher
|
||||||
|
│ │ └── *.js # 34 движка симуляций
|
||||||
|
│ └── admin/ # Секции admin.html
|
||||||
|
│ ├── admin.js # Оркестратор + роутер
|
||||||
|
│ ├── router.js # Hash-based router
|
||||||
|
│ └── sections/ # overview, users, sessions, gam, ...
|
||||||
├── js/
|
├── js/
|
||||||
│ ├── api.js # window.LS.* — клиентское API
|
│ ├── api.js # window.LS.* — 200+ клиентских методов
|
||||||
│ └── mobile.js # Мобильная адаптация
|
│ ├── sidebar.js # Сайдбар с nav-avatar
|
||||||
|
│ └── mobile.js # Мобильная адаптация
|
||||||
|
├── plans/ # Планы фич (BIOCHEM_UPGRADE, STEREO_3D, ...)
|
||||||
|
├── docs/ # Руководства и планы
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
└── Dockerfile
|
└── Dockerfile
|
||||||
```
|
```
|
||||||
@@ -219,26 +413,45 @@ Learn_System/
|
|||||||
|
|
||||||
**Синхронизация доски**
|
**Синхронизация доски**
|
||||||
- Штрихи сохраняются батчами через `POST /api/classroom/:id/strokes`
|
- Штрихи сохраняются батчами через `POST /api/classroom/:id/strokes`
|
||||||
- Загрузка с `?since_seq=N` — клиент получает только новые штрихи
|
- Загрузка с `?since_seq=N` — только новые штрихи
|
||||||
- Live-превью через `POST /stroke-preview` → SSE `stroke_preview` событие
|
- Live-превью через `POST /stroke-preview` → SSE `stroke_preview`
|
||||||
- Двухслойный canvas: статический слой (_strokes) + динамический (_selection/guides/laser)
|
- Двухслойный canvas: статический (_strokes) + динамический (_selection/guides/laser)
|
||||||
|
|
||||||
**Real-time (SSE)**
|
**Real-time (SSE)**
|
||||||
- Один SSE-поток на пользователя: `GET /api/classroom/:id/events`
|
- Один SSE-поток на пользователя
|
||||||
- События: `stroke_batch`, `stroke_preview`, `stroke_deleted`, `page_changed`, `chat_message`, `cursor_move`, `hand_raised`, `screen_share`, и др.
|
- События: `stroke_batch`, `stroke_preview`, `stroke_deleted`, `page_changed`, `chat_message`, `cursor_move`, `hand_raised`, `screen_share` и др.
|
||||||
- Compression отключён для SSE-потоков
|
|
||||||
|
|
||||||
**База данных**
|
**База данных**
|
||||||
- SQLite через `better-sqlite3` (синхронный API)
|
- SQLite через `node:sqlite` (`DatabaseSync`, встроенный в Node.js 22+)
|
||||||
- Автоматические миграции при каждом старте сервера
|
- Версионированные миграции (47 SQL-файлов, 000–046)
|
||||||
- 76 таблиц, транзакционная запись батчей штрихов
|
- 106 таблиц
|
||||||
|
- Транзакционная запись батчей штрихов
|
||||||
|
|
||||||
**Аутентификация**
|
**Аутентификация**
|
||||||
- JWT Bearer token
|
- JWT Bearer token, bcryptjs
|
||||||
- Роли: `admin`, `teacher`, `student`, `free_student`
|
- Роли: `admin`, `teacher`, `student`, `free_student`
|
||||||
- RBAC middleware с кешированием разрешений
|
- RBAC middleware с кешированием разрешений
|
||||||
- Rate limiting: 6000 req/min для classroom, 600 req/min для остальных
|
- Rate limiting: 6000 req/min для classroom, 600 req/min для остальных
|
||||||
|
|
||||||
|
**Химическое ядро (BIO)**
|
||||||
|
- `frontend/js/biochem-core.js` — dual-export: `window.BIO` в браузере, `module.exports` в Node
|
||||||
|
- `backend/src/services/chem.js` — переиспользует ядро без дублирования
|
||||||
|
- VSEPR-геометрия, частичные заряды, дипольный момент, баланс уравнений (Гаусс+НОК)
|
||||||
|
|
||||||
|
**Доступ к контенту (content_access)**
|
||||||
|
- allowlist учебников и экзаменов по классам и конкретным ученикам
|
||||||
|
- `services/contentAccess.js`: `canAccessTextbook`, `filterTextbooks`, `allowedRefs`
|
||||||
|
- `/api/access` — admin CRUD
|
||||||
|
|
||||||
|
**Lab Content Engine (LabRegistry)**
|
||||||
|
- Все симуляции: data-driven манифесты в `LabRegistry`
|
||||||
|
- Ленивая загрузка через `LabLoader.ensure(simId)`
|
||||||
|
- Каталог в БД (`lab_sims`): включение, featured, теги, привязка к учебникам
|
||||||
|
|
||||||
|
**Shared модули (pet-sprite.js, lab-previews.js)**
|
||||||
|
- `pet-sprite.js` — канонический рендерер питомца, используется и на `/pet`, и на дашборде
|
||||||
|
- `lab-previews.js` — SVG-превью 6 симуляций для карточки «Лаборатория дня»
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -247,21 +460,41 @@ Learn_System/
|
|||||||
|
|
||||||
Аутентификация: `Authorization: Bearer <token>`
|
Аутентификация: `Authorization: Bearer <token>`
|
||||||
|
|
||||||
| Группа | Базовый путь | Назначение |
|
| Группа | Путь | Назначение |
|
||||||
|--------|-------------|-----------|
|
|--------|------|-----------|
|
||||||
| Auth | `/auth` | Регистрация, вход, профиль |
|
| Auth | `/auth` | Регистрация, вход, профиль, аватар |
|
||||||
| Classroom | `/classroom` | Онлайн-урок, доска, чат, WebRTC |
|
| Classroom | `/classroom` | Онлайн-урок, доска, чат, WebRTC |
|
||||||
| Classes | `/classes` | Управление классами |
|
| Classes | `/classes` | Управление классами |
|
||||||
| Assignments | `/assignments` | Задания и сдача работ |
|
| Assignments | `/assignments` | Задания и сдача работ |
|
||||||
|
| Submissions | `/submissions` | Сдача работ, статусы, оценки |
|
||||||
| Questions | `/questions` | Банк вопросов |
|
| Questions | `/questions` | Банк вопросов |
|
||||||
| Sessions | `/sessions` | Тестовые сессии |
|
| Sessions | `/sessions` | Тестовые сессии |
|
||||||
| Courses | `/courses` | Теоретические курсы |
|
| Courses | `/courses` | Теоретические курсы |
|
||||||
| Lessons | `/lessons` | Уроки с блоками контента |
|
| Lessons | `/lessons` | Уроки с блоками контента |
|
||||||
| Gamification | `/gamification` | XP, ачивки, стрики |
|
| Textbooks | `/textbooks` | Учебники, прогресс, закладки |
|
||||||
| Files | `/files` | Загрузка и хранение файлов |
|
| Lab | `/lab` | Симуляции: каталог, управление |
|
||||||
|
| Biochem | `/biochem` | Молекулы, реакции, пути, analyze, validate |
|
||||||
|
| Gamification | `/gamification` | XP, уровни, ачивки, стрики, admin |
|
||||||
|
| Pet | `/pet` | Питомец, действия, магазин фонов |
|
||||||
|
| Shop | `/shop` | Виртуальный магазин |
|
||||||
| Live | `/live` | Live-викторины |
|
| Live | `/live` | Live-викторины |
|
||||||
| Analytics | `/analytics` | Статистика |
|
| Analytics | `/analytics` | Статистика |
|
||||||
| Admin | `/admin` | Управление платформой |
|
| Admin | `/admin` | Управление платформой, overview |
|
||||||
|
| Access | `/access` | Allowlist контента |
|
||||||
|
| Exam9 | `/exam9` | Экзаменационные тесты |
|
||||||
|
| Files | `/files` | Загрузка и хранение файлов |
|
||||||
|
| Notifications | `/notifications` | Уведомления |
|
||||||
|
| Permissions | `/permissions` | RBAC правила |
|
||||||
|
| Search | `/search` | Глобальный поиск |
|
||||||
|
| Preferences | `/preferences` | Пользовательские настройки |
|
||||||
|
| Parent | `/parent` | Кабинет родителя |
|
||||||
|
| Red Book | `/red-book` | Красная книга |
|
||||||
|
| Collection | `/collection` | Коллекции предметов |
|
||||||
|
| Games | `/games` | Игры (виселица, кроссворд) |
|
||||||
|
| Knowledge Map | `/knowledge-map` | Граф знаний |
|
||||||
|
| Flashcards | `/flashcards` | Флэшкарты |
|
||||||
|
| Templates | `/templates` | Шаблоны заданий |
|
||||||
|
| Teacher Students | `/teacher-students` | Ученики учителя без класса |
|
||||||
|
|
||||||
Полная документация по endpoint'ам — в `backend/src/routes/`.
|
Полная документация по endpoint'ам — в `backend/src/routes/`.
|
||||||
|
|
||||||
@@ -271,12 +504,54 @@ Learn_System/
|
|||||||
|
|
||||||
| Роль | Доступ |
|
| Роль | Доступ |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `admin` | Полный доступ ко всему, включая панель администратора |
|
| `admin` | Полный доступ, панель администратора, командный центр |
|
||||||
| `teacher` | Создание классов, уроков, заданий, проведение онлайн-уроков |
|
| `teacher` | Классы, уроки, задания, учебники, проведение онлайн-уроков |
|
||||||
| `student` | Прохождение тестов, участие в уроках, доступ к материалам |
|
| `student` | Тесты, уроки, учебники (по allowlist), лаборатория, питомец |
|
||||||
| `free_student` | Ограниченный доступ (настраивается feature flags) |
|
| `free_student` | Ограниченный доступ (настраивается через feature flags) |
|
||||||
|
|
||||||
Разрешения настраиваются гранулярно через `/api/permissions`.
|
Разрешения настраиваются гранулярно через `/api/permissions` (per-role и per-user).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Flags
|
||||||
|
|
||||||
|
Управляются через `app_settings` и API `/api/admin` (только admin).
|
||||||
|
|
||||||
|
| Флаг | Назначение | По умолчанию |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| `feature_biochem_enabled` | Модуль биохимии | вкл |
|
||||||
|
| `feature_textbooks_enabled` | Модуль учебников | вкл |
|
||||||
|
| `feature_flashcards_enabled` | Флэшкарты | вкл |
|
||||||
|
| `feature_board_enabled` | Доска (board) | вкл |
|
||||||
|
| `feature_live_quiz_enabled` | Live-викторины | выкл |
|
||||||
|
| `feature_exam9_enabled` | Экзаменационные тесты | вкл |
|
||||||
|
| `sim_module_disabled` | Весь модуль симуляций | выкл |
|
||||||
|
| `sim_disabled_ids` | JSON-массив отключённых симуляций | `[]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контент
|
||||||
|
|
||||||
|
### Учебники
|
||||||
|
|
||||||
|
| Предмет | Классы | Статус |
|
||||||
|
|---------|--------|--------|
|
||||||
|
| Химия | 7, 8, 9 | Полный курс с виджетами и анимациями |
|
||||||
|
| Физика | 7, 8, 9, 10, 11 | Структура + контент |
|
||||||
|
| Алгебра | 7, 8, 9, 10, 11 | Структура + контент |
|
||||||
|
| Геометрия | 7, 8, 9, 10, 11 | Структура + контент |
|
||||||
|
|
||||||
|
### Сборники ЦТ/ЦЭ
|
||||||
|
|
||||||
|
| Сборник | Вопросов |
|
||||||
|
|---------|---------|
|
||||||
|
| Физика 2019–2024 | 150+ |
|
||||||
|
| Математика 2021–2024 | 150+ |
|
||||||
|
| Экзамен-9 (математика) | 80 вариантов |
|
||||||
|
|
||||||
|
### Симуляции
|
||||||
|
|
||||||
|
40 симуляций в 5 категориях — см. раздел «Виртуальная лаборатория».
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Перенос проекта на другую машину
|
||||||
|
|
||||||
|
Репозиторий содержит **код, конфиг Claude и снимок памяти**, но НЕ содержит
|
||||||
|
**данные и секреты** (база, uploads, `.env`, `node_modules`) — их переносят
|
||||||
|
отдельно, вне git (см. шаг 4 и переносной пакет).
|
||||||
|
|
||||||
|
## 0. Предустановки
|
||||||
|
- **Node.js ≥ 22** (используется встроенный `node:sqlite`; разрабатывалось на Node 24).
|
||||||
|
- **Git**.
|
||||||
|
- *(опционально)* Системный **Chrome** — для генерации учебников через `puppeteer-core`.
|
||||||
|
- *(опционально)* **MySQL / OpenSSL** — если используются внешние скрипты.
|
||||||
|
- *(опционально)* Инструменты поиска по коду: **ast-index** и **vex**.
|
||||||
|
|
||||||
|
## 1. Код
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd BQ-System
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Зависимости
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
(если в корне есть свой `package.json` — `npm install` и там)
|
||||||
|
|
||||||
|
## 3. Секреты — `.env`
|
||||||
|
```bash
|
||||||
|
cp backend/.env.example backend/.env # затем заполнить JWT_SECRET и т.п.
|
||||||
|
```
|
||||||
|
Готовый `.env` есть в переносном пакете — можно взять его.
|
||||||
|
**Ключи ассистента (Kilo / Gemini) лежат в БД** (`app_settings`), не в `.env` — приедут вместе с базой.
|
||||||
|
|
||||||
|
## 4. Данные (из переносного пакета, вне git)
|
||||||
|
Скопировать из пакета в проект:
|
||||||
|
- `learnspace.db` → `backend/data/learnspace.db`
|
||||||
|
- `uploads/` → `backend/uploads/`
|
||||||
|
- `.env` → `backend/.env`
|
||||||
|
|
||||||
|
Без базы сервер поднимется на **пустой** БД (контент, пользователи и ключи ИИ отсутствуют).
|
||||||
|
|
||||||
|
## 5. База с нуля (только если нет готовой learnspace.db)
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm run migrate # применить миграции (создаёт схему)
|
||||||
|
npm run seed # базовые данные
|
||||||
|
# контент — частично через скрипты:
|
||||||
|
npm run import:content
|
||||||
|
npm run import:exam-tasks
|
||||||
|
npm run index:textbooks # RAG-индекс для ассистента
|
||||||
|
```
|
||||||
|
Полностью контент и пользователей seed-скрипты не восстановят — для «как тут» переноси готовую `learnspace.db`.
|
||||||
|
|
||||||
|
## 6. Запуск
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm start # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Память Claude
|
||||||
|
Скопировать `.claude/memory/*.md` в пользовательскую папку памяти Claude.
|
||||||
|
Инструкция и команды — в [.claude/memory/README.md](.claude/memory/README.md).
|
||||||
|
|
||||||
|
## 8. Поиск по коду (опционально)
|
||||||
|
Установить `ast-index` и `vex`, затем построить индексы:
|
||||||
|
```bash
|
||||||
|
ast-index rebuild
|
||||||
|
vex index --semantic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что НЕ переносится репозиторием (резюме)
|
||||||
|
| Объект | Где взять |
|
||||||
|
|---|---|
|
||||||
|
| `backend/data/learnspace.db` | переносной пакет (контент + пользователи + ключи ИИ) |
|
||||||
|
| `backend/uploads/` | переносной пакет (загруженные файлы) |
|
||||||
|
| `backend/.env` | переносной пакет или из `.env.example` |
|
||||||
|
| `node_modules` | `npm install` |
|
||||||
|
| ast-index / vex индексы | пересобрать на машине |
|
||||||
|
| Память Claude (юзер-папка) | `.claude/memory/` → см. README |
|
||||||
@@ -13,3 +13,14 @@ CLIENT_ORIGIN=http://localhost:5500
|
|||||||
# TURN_URL=turn:turn.example.com:3478
|
# TURN_URL=turn:turn.example.com:3478
|
||||||
# TURN_USER=username
|
# TURN_USER=username
|
||||||
# TURN_PASS=password
|
# TURN_PASS=password
|
||||||
|
|
||||||
|
# Помощник «Квантик» — LLM для «Спроси» (необязательно).
|
||||||
|
# Бесплатно и подходит: Groq — заведи ключ на console.groq.com → API Keys,
|
||||||
|
# вставь в ASSISTANT_LLM_KEY и перезапусти сервер. Без ключа «Спроси» работает
|
||||||
|
# на FAQ + поиске по платформе (как сейчас).
|
||||||
|
# ASSISTANT_LLM_URL=https://api.groq.com/openai/v1/chat/completions
|
||||||
|
ASSISTANT_LLM_KEY=
|
||||||
|
# ASSISTANT_LLM_MODEL=llama-3.3-70b-versatile
|
||||||
|
# Локально без ключа (Ollama): `ollama serve` + `ollama pull qwen2.5:3b`, затем
|
||||||
|
# ASSISTANT_LLM_URL=http://localhost:11434/v1/chat/completions
|
||||||
|
# ASSISTANT_LLM_MODEL=qwen2.5:3b
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"watch": ["src"],
|
||||||
|
"ext": "js,json,yaml,yml",
|
||||||
|
"ignore": ["src/**/*.test.js"],
|
||||||
|
"delay": "250"
|
||||||
|
}
|
||||||
Generated
+280
-3
@@ -16,6 +16,7 @@
|
|||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
|
"puppeteer-core": "^25.1.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
@@ -498,6 +499,30 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@puppeteer/browsers": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"modern-tar": "^0.7.6",
|
||||||
|
"yargs": "^17.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"browsers": "lib/main-cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"proxy-agent": ">=8.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"proxy-agent": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -511,6 +536,30 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/anymatch": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||||
@@ -708,6 +757,54 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chromium-bidi": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"devtools-protocol": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wrap-ansi": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/compressible": {
|
"node_modules/compressible": {
|
||||||
"version": "2.0.18",
|
"version": "2.0.18",
|
||||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
@@ -852,6 +949,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/devtools-protocol": {
|
||||||
|
"version": "0.0.1624250",
|
||||||
|
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz",
|
||||||
|
"integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -893,6 +996,12 @@
|
|||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -932,6 +1041,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/escalade": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -1066,6 +1184,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -1239,6 +1366,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-glob": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -1450,6 +1586,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mitt": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/modern-tar": {
|
||||||
|
"version": "0.7.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz",
|
||||||
|
"integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@@ -1638,6 +1789,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/puppeteer-core": {
|
||||||
|
"version": "25.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.1.0.tgz",
|
||||||
|
"integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@puppeteer/browsers": "3.0.4",
|
||||||
|
"chromium-bidi": "16.0.1",
|
||||||
|
"devtools-protocol": "0.0.1624250",
|
||||||
|
"typed-query-selector": "^2.12.2",
|
||||||
|
"webdriver-bidi-protocol": "0.4.2",
|
||||||
|
"ws": "^8.21.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
@@ -1704,6 +1872,15 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -1948,6 +2125,32 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
@@ -2013,6 +2216,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typed-query-selector": {
|
||||||
|
"version": "2.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
|
||||||
|
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typedarray": {
|
"node_modules/typedarray": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
@@ -2059,10 +2268,33 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webdriver-bidi-protocol": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.20.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
@@ -2079,6 +2311,51 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "17.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||||
|
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^8.0.1",
|
||||||
|
"escalade": "^3.1.1",
|
||||||
|
"get-caller-file": "^2.0.5",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"y18n": "^5.0.5",
|
||||||
|
"yargs-parser": "^21.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "21.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||||
|
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
"seed:permissions": "node src/db/seed-permissions.js",
|
"seed:permissions": "node src/db/seed-permissions.js",
|
||||||
"lint:routes": "node scripts/check-route-auth.js",
|
"lint:routes": "node scripts/check-route-auth.js",
|
||||||
"import:content": "node scripts/import-content.js",
|
"import:content": "node scripts/import-content.js",
|
||||||
|
"import:exam-tasks": "node scripts/import-exam-tasks.js",
|
||||||
|
"index:textbooks": "node scripts/index-textbooks.js",
|
||||||
|
"index:textbooks:full": "node scripts/index-textbooks-headless.js",
|
||||||
"test": "node --test tests/*.test.js",
|
"test": "node --test tests/*.test.js",
|
||||||
"hooks:install": "sh ../scripts/install-hooks.sh"
|
"hooks:install": "sh ../scripts/install-hooks.sh"
|
||||||
},
|
},
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
|
"puppeteer-core": "^25.1.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/* audit_chem8.js — аудит KaTeX и оформления учебника «Химия 8».
|
||||||
|
* Загружает каждую страницу в jsdom (renderMathInElement застаблен → $…$ остаются
|
||||||
|
* литералами с уже раскрытыми JS-эскейпами), строит все §, извлекает формулы и
|
||||||
|
* проверяет: баланс $, баланс {}, отсутствие управляющих символов (следы \t/\n),
|
||||||
|
* пустые формулы, «сырые» $…$ вне рендера. Запуск: node backend/scripts/audit_chem8.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
const readF = p => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
||||||
|
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const PAGES = [
|
||||||
|
['chemistry_8_intro.html', 'chem8_intro_widgets'],
|
||||||
|
['chemistry_8_ch1.html', 'chem8_ch1_widgets'],
|
||||||
|
['chemistry_8_ch2.html', 'chem8_ch2_widgets'],
|
||||||
|
['chemistry_8_ch3.html', 'chem8_ch3_widgets'],
|
||||||
|
['chemistry_8_ch4.html', 'chem8_ch4_widgets'],
|
||||||
|
['chemistry_8_ch5.html', 'chem8_ch5_widgets'],
|
||||||
|
['chemistry_8_ch6.html', 'chem8_ch6_widgets']
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildPage(file, widgets) {
|
||||||
|
let html = readF('frontend/textbooks/' + file);
|
||||||
|
const inl = {
|
||||||
|
'/js/biochem-core.js': readF('frontend/js/biochem-core.js'),
|
||||||
|
'/js/chem8_svg.js': readF('frontend/js/chem8_svg.js'),
|
||||||
|
'/js/chem8_mol.js': readF('frontend/js/chem8_mol.js'),
|
||||||
|
['/js/' + widgets + '.js']: readF('frontend/js/' + widgets + '.js'),
|
||||||
|
'/js/chem8_engine.js': readF('frontend/js/chem8_engine.js')
|
||||||
|
};
|
||||||
|
html = html
|
||||||
|
.replace(/<script defer src="https:\/\/cdn[^"]*"[^>]*><\/script>/g, '')
|
||||||
|
.replace(/<script src="\/js\/api\.js" defer><\/script>/, '<script>window.renderMathInElement=function(){};</script>')
|
||||||
|
.replace(/<script src="\/js\/xp\.js" defer><\/script>/, '');
|
||||||
|
Object.keys(inl).forEach(src => {
|
||||||
|
html = html.replace(new RegExp('<script src="' + src + '" defer><\\/script>'), () => '<script>\n' + inl[src] + '\n</script>');
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMath(s) {
|
||||||
|
const out = [];
|
||||||
|
// $$...$$ затем $...$
|
||||||
|
let re = /\$\$([\s\S]+?)\$\$/g, m;
|
||||||
|
let masked = s;
|
||||||
|
while ((m = re.exec(s)) !== null) out.push({ disp: true, body: m[1] });
|
||||||
|
masked = s.replace(/\$\$[\s\S]+?\$\$/g, '');
|
||||||
|
re = /\$([^$]*)\$/g;
|
||||||
|
while ((m = re.exec(masked)) !== null) out.push({ disp: false, body: m[1] });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkBraces(b) { let d = 0; for (const c of b) { if (c === '{') d++; else if (c === '}') d--; if (d < 0) return false; } return d === 0; }
|
||||||
|
function hasCtrl(b) { return /[\t\n\r\f\v\b]/.test(b); }
|
||||||
|
|
||||||
|
async function auditPage(file, widgets) {
|
||||||
|
const issues = [];
|
||||||
|
const vc = new VirtualConsole(); const errs = [];
|
||||||
|
vc.on('jsdomError', e => errs.push(e.message));
|
||||||
|
const dom = new JSDOM(buildPage(file, widgets), {
|
||||||
|
runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc, url: 'http://localhost/',
|
||||||
|
beforeParse(w) { w.scrollTo = function () {}; }
|
||||||
|
});
|
||||||
|
await wait(120);
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const paras = (dom.window.PARAS || []).map(p => p.id);
|
||||||
|
for (const id of paras) { try { dom.window.goTo(id); } catch (e) {} }
|
||||||
|
await wait(120);
|
||||||
|
|
||||||
|
if (errs.length) issues.push('script errors: ' + errs.join(' | '));
|
||||||
|
|
||||||
|
// собрать все § тела + sidebar
|
||||||
|
let html = '';
|
||||||
|
doc.querySelectorAll('[id$="-body"]').forEach(el => { html += el.innerHTML + '\n'; });
|
||||||
|
const sidebar = doc.getElementById('sidebar-content'); if (sidebar) html += sidebar.innerHTML;
|
||||||
|
|
||||||
|
// баланс $ (нечётное число одиночных $ вне $$)
|
||||||
|
const noDisp = html.replace(/\$\$[\s\S]+?\$\$/g, '');
|
||||||
|
const singles = (noDisp.match(/\$/g) || []).length;
|
||||||
|
if (singles % 2 !== 0) issues.push('нечётное число одиночных $ (' + singles + ')');
|
||||||
|
|
||||||
|
const maths = extractMath(html);
|
||||||
|
let bad = 0;
|
||||||
|
for (const m of maths) {
|
||||||
|
const b = m.body;
|
||||||
|
if (!b.trim()) { issues.push('пустая формула $' + (m.disp ? '$' : '') + '$'); bad++; continue; }
|
||||||
|
if (!checkBraces(b)) { issues.push('несбалансированные {} в: ' + b.slice(0, 50)); bad++; }
|
||||||
|
if (hasCtrl(b)) { issues.push('управляющий символ (след \\t/\\n?) в: ' + JSON.stringify(b.slice(0, 50))); bad++; }
|
||||||
|
// одиночный backslash перед буквой, не часть известной команды? — грубая эвристика: \ в конце
|
||||||
|
if (/\\$/.test(b)) { issues.push('формула заканчивается на \\: ' + b.slice(-20)); bad++; }
|
||||||
|
}
|
||||||
|
return { file, mathCount: maths.length, badCount: bad, issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let total = 0, totalBad = 0;
|
||||||
|
for (const [file, w] of PAGES) {
|
||||||
|
const r = await auditPage(file, w);
|
||||||
|
total += r.mathCount; totalBad += r.badCount;
|
||||||
|
console.log('\n=== ' + file + ' — формул: ' + r.mathCount + ', проблем: ' + r.issues.length + ' ===');
|
||||||
|
if (r.issues.length) r.issues.slice(0, 25).forEach(i => console.log(' ! ' + i));
|
||||||
|
else console.log(' OK');
|
||||||
|
}
|
||||||
|
console.log('\nИТОГО формул: ' + total + ', проблемных: ' + totalBad);
|
||||||
|
process.exit(0);
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -50,15 +50,30 @@ const GUARDS = [
|
|||||||
'ownsTest', // alias used in tests.js
|
'ownsTest', // alias used in tests.js
|
||||||
];
|
];
|
||||||
|
|
||||||
// Baseline: number of unprotected :id-routes found on 2026-05-06.
|
// Baseline: number of unprotected :id-routes.
|
||||||
|
// 2026-06-11: линтер научился видеть router-level guards (router.use(<guard>)),
|
||||||
|
// что убрало ложные срабатывания (admin/permissions/flashcards/… защищены на
|
||||||
|
// уровне роутера). Оставшиеся 8 публичных маршрутов (guest-доска по токену,
|
||||||
|
// справочные данные red-book, список тем) помечены @public-by-design. Долг закрыт.
|
||||||
// ONLY decrease this over time — never increase it.
|
// ONLY decrease this over time — never increase it.
|
||||||
const BASELINE = 56;
|
const BASELINE = 0;
|
||||||
|
|
||||||
function scanFile(filePath) {
|
function scanFile(filePath) {
|
||||||
const content = fs.readFileSync(filePath, 'utf8');
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
const issues = [];
|
const issues = [];
|
||||||
|
|
||||||
|
// Router-level guard: `router.use(<guard>)` without a leading path string
|
||||||
|
// protects every route declared after it (same guards accepted inline).
|
||||||
|
// Find the earliest such line so those routes aren't false-flagged.
|
||||||
|
let globalGuardLine = Infinity;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const t = lines[i].trim();
|
||||||
|
if (!t.startsWith('router.use(')) continue;
|
||||||
|
if (/^router\.use\(\s*['"`]/.test(t)) continue; // path-scoped — not global
|
||||||
|
if (GUARDS.some(g => t.includes(g))) { globalGuardLine = i; break; }
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i].trim();
|
const line = lines[i].trim();
|
||||||
|
|
||||||
@@ -71,6 +86,9 @@ function scanFile(filePath) {
|
|||||||
if (!pathMatch) continue;
|
if (!pathMatch) continue;
|
||||||
if (!pathMatch[1].includes(':')) continue;
|
if (!pathMatch[1].includes(':')) continue;
|
||||||
|
|
||||||
|
// Protected by a router-level guard declared earlier in this file
|
||||||
|
if (i > globalGuardLine) continue;
|
||||||
|
|
||||||
// Collect the full route call (may span multiple lines)
|
// Collect the full route call (may span multiple lines)
|
||||||
let callText = line;
|
let callText = line;
|
||||||
let j = i + 1;
|
let j = i + 1;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
'../../frontend/js/g3d.js',
|
||||||
|
'../../frontend/textbooks/geometry_11_hub.html',
|
||||||
|
'../../frontend/textbooks/geometry_11_ch1.html',
|
||||||
|
'../../frontend/textbooks/geometry_11_ch2.html',
|
||||||
|
'../../frontend/textbooks/geometry_11_ch3.html',
|
||||||
|
'../../frontend/textbooks/geometry_11_ch4.html',
|
||||||
|
];
|
||||||
|
|
||||||
|
let totalErrors = 0;
|
||||||
|
|
||||||
|
for (const rel of files) {
|
||||||
|
const p = path.join(__dirname, rel);
|
||||||
|
const src = fs.readFileSync(p, 'utf8');
|
||||||
|
if (rel.endsWith('.js')) {
|
||||||
|
// pure JS file
|
||||||
|
try {
|
||||||
|
new Function(src);
|
||||||
|
console.log('OK (parse) ' + rel);
|
||||||
|
} catch (e) {
|
||||||
|
totalErrors++;
|
||||||
|
console.error('FAIL ' + rel + ':\n' + e.message);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Extract all inline <script>...</script> bodies (skip src= scripts)
|
||||||
|
const re = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/gi;
|
||||||
|
let m, idx = 0;
|
||||||
|
while ((m = re.exec(src))) {
|
||||||
|
idx++;
|
||||||
|
try {
|
||||||
|
new Function(m[1]);
|
||||||
|
} catch (e) {
|
||||||
|
totalErrors++;
|
||||||
|
console.error('FAIL ' + rel + ' [inline script #' + idx + ']:\n' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('OK (' + idx + ' inline) ' + rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalErrors > 0) {
|
||||||
|
console.error('\nTOTAL ERRORS: ' + totalErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('\nAll OK.');
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Crop a question row from a rendered PDF page PNG.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python crop_question_row.py <page_png> <x0> <y0> <x1> <y1> <out_png>
|
||||||
|
|
||||||
|
Coordinates are NORMALIZED (0.0-1.0) relative to page dimensions.
|
||||||
|
Add --padding 0.01 for extra border (default 0.005).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
python crop_question_row.py page_005.png 0.0 0.12 1.0 0.22 2019_v1_a7.png
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
def crop(page_png, x0, y0, x1, y1, out_png, padding=0.005):
|
||||||
|
img = Image.open(page_png)
|
||||||
|
w, h = img.size
|
||||||
|
px0 = max(0, int((x0 - padding) * w))
|
||||||
|
py0 = max(0, int((y0 - padding) * h))
|
||||||
|
px1 = min(w, int((x1 + padding) * w))
|
||||||
|
py1 = min(h, int((y1 + padding) * h))
|
||||||
|
cropped = img.crop((px0, py0, px1, py1))
|
||||||
|
cropped.save(out_png)
|
||||||
|
print(f'Saved {out_png} ({cropped.width}x{cropped.height})')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('page_png')
|
||||||
|
parser.add_argument('x0', type=float)
|
||||||
|
parser.add_argument('y0', type=float)
|
||||||
|
parser.add_argument('x1', type=float)
|
||||||
|
parser.add_argument('y1', type=float)
|
||||||
|
parser.add_argument('out_png')
|
||||||
|
parser.add_argument('--padding', type=float, default=0.005)
|
||||||
|
args = parser.parse_args()
|
||||||
|
crop(args.page_png, args.x0, args.y0, args.x1, args.y1, args.out_png, args.padding)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Detect horizontal table borders in a scanned PDF page PNG
|
||||||
|
and extract row bounding boxes.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python detect_table_rows.py <page_png> [--min-width 0.7] [--debug]
|
||||||
|
|
||||||
|
Prints detected row y-ranges as normalized (0-1) coordinates.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def detect_rows(page_png, min_width_frac=0.7, debug=False):
|
||||||
|
img = Image.open(page_png).convert('L') # grayscale
|
||||||
|
arr = np.array(img)
|
||||||
|
h, w = arr.shape
|
||||||
|
|
||||||
|
# Binarize: dark pixels (potential lines) = True
|
||||||
|
dark = arr < 128
|
||||||
|
# Count dark pixels per row
|
||||||
|
row_dark_count = dark.sum(axis=1)
|
||||||
|
min_dark = int(min_width_frac * w)
|
||||||
|
|
||||||
|
# Find rows that are mostly dark (horizontal lines)
|
||||||
|
is_line = row_dark_count > min_dark
|
||||||
|
|
||||||
|
# Group consecutive line pixels into bands
|
||||||
|
line_bands = []
|
||||||
|
in_band = False
|
||||||
|
band_start = 0
|
||||||
|
for y in range(h):
|
||||||
|
if is_line[y] and not in_band:
|
||||||
|
in_band = True
|
||||||
|
band_start = y
|
||||||
|
elif not is_line[y] and in_band:
|
||||||
|
in_band = False
|
||||||
|
band_end = y
|
||||||
|
line_bands.append((band_start, band_end))
|
||||||
|
if in_band:
|
||||||
|
line_bands.append((band_start, h))
|
||||||
|
|
||||||
|
if not line_bands:
|
||||||
|
print("No table lines detected. Try reducing --min-width.", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Extract row y-ranges between consecutive line bands
|
||||||
|
rows = []
|
||||||
|
for i in range(len(line_bands) - 1):
|
||||||
|
y_top = line_bands[i][1] # bottom of upper border
|
||||||
|
y_bot = line_bands[i + 1][0] # top of lower border
|
||||||
|
if y_bot - y_top > 5: # skip tiny gaps
|
||||||
|
rows.append((y_top / h, y_bot / h))
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
print(f"Detected {len(line_bands)} line bands:")
|
||||||
|
for b in line_bands:
|
||||||
|
print(f" pixels {b[0]}-{b[1]} (y={b[0]/h:.3f}-{b[1]/h:.3f})")
|
||||||
|
print(f"\nDetected {len(rows)} content rows:")
|
||||||
|
for i, (y0, y1) in enumerate(rows):
|
||||||
|
print(f" row {i}: y={y0:.3f}-{y1:.3f} (pixels {int(y0*h)}-{int(y1*h)}, height={int((y1-y0)*h)}px)")
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('page_png')
|
||||||
|
parser.add_argument('--min-width', type=float, default=0.7)
|
||||||
|
parser.add_argument('--debug', action='store_true')
|
||||||
|
args = parser.parse_args()
|
||||||
|
rows = detect_rows(args.page_png, min_width_frac=args.min_width, debug=args.debug)
|
||||||
|
if not args.debug:
|
||||||
|
for i, (y0, y1) in enumerate(rows):
|
||||||
|
print(f"row {i}: {y0:.4f} - {y1:.4f}")
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
// Извлекает <script> из physics_9.html в frontend/js/phys9_legacy.js.
|
||||||
|
// Оборачивает в IIFE (избегаем коллизий STATE/PARAS с chapter inline JS).
|
||||||
|
// Эспортит в window все функции с префиксами upd|draw|init|start|set|toggle|lab|check + TASKS_PN/PUZ_PN/массивы.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_9.html');
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'js', 'phys9_legacy.js');
|
||||||
|
|
||||||
|
const h = fs.readFileSync(SRC, 'utf8');
|
||||||
|
const scriptMatch = h.match(/<script>([\s\S]*?)<\/script>/);
|
||||||
|
if (!scriptMatch) { console.error('No <script> found'); process.exit(1); }
|
||||||
|
const raw = scriptMatch[1];
|
||||||
|
|
||||||
|
// Очистка emoji
|
||||||
|
const clean = raw.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{27BF}]|[\u{1F000}-\u{1F2FF}]|[\u{FE0F}]/gu, '');
|
||||||
|
|
||||||
|
// Найти границу setup-кода
|
||||||
|
const setupStart = clean.search(/^upd1\d\(\);/m);
|
||||||
|
const fnsPart = setupStart > 0 ? clean.slice(0, setupStart) : clean;
|
||||||
|
const setupPart = setupStart > 0 ? clean.slice(setupStart) : '';
|
||||||
|
|
||||||
|
// Сканируем все function-declarations и const-объявления массивов
|
||||||
|
const fnNames = [...new Set([...fnsPart.matchAll(/^function\s+(\w+)\s*\(/gm)].map(m => m[1]))];
|
||||||
|
const constNames = [...new Set([...fnsPart.matchAll(/^const\s+(TASKS_\w+|PUZ_\w+|QUIZ_\w+|MATCH_\w+)\s*=/gm)].map(m => m[1]))];
|
||||||
|
|
||||||
|
// Только идентификаторы, которые могут использоваться извне (по префиксу или капсу)
|
||||||
|
const exportFns = fnNames.filter(n => /^(upd|draw|init|start|set|toggle|lab|check|reset|next|go|run|play|stop|render|update|show|hide|build|switch|select|apply|calc|recalc|animate|tick)/i.test(n));
|
||||||
|
const exportList = [...exportFns, ...constNames];
|
||||||
|
|
||||||
|
const header = `// Auto-extracted from frontend/textbooks/physics_9.html (legacy monolith).
|
||||||
|
// Wrapped in IIFE — avoids collisions with chapter inline JS (STATE, PARAS, etc.).
|
||||||
|
// All upd*/draw*/init*/start*/lab*/check*/toggle* functions + TASKS_PN arrays
|
||||||
|
// are explicitly attached to window at the end.
|
||||||
|
// eslint-disable
|
||||||
|
|
||||||
|
(function(){
|
||||||
|
"use strict";
|
||||||
|
`;
|
||||||
|
|
||||||
|
const exportTail = '\n\n// === Expose handlers + task pools to global scope ===\n' +
|
||||||
|
exportList.map(name => `try { if (typeof ${name} !== "undefined") window.${name} = ${name}; } catch(e) {}`).join('\n') +
|
||||||
|
'\n})();\n';
|
||||||
|
|
||||||
|
const wrapped = setupStart > 0
|
||||||
|
? header + fnsPart + '\ntry {\n' + setupPart + '\n} catch(e) { console.warn("phys9_legacy setup skipped:", e.message); }\n' + exportTail
|
||||||
|
: header + fnsPart + exportTail;
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, wrapped);
|
||||||
|
console.log('phys9_legacy.js:', wrapped.length, 'bytes');
|
||||||
|
console.log('Exported functions:', exportFns.length);
|
||||||
|
console.log('Exported consts:', constNames.length);
|
||||||
|
console.log('Sample exports:', exports.slice(0, 12).join(', '), '...');
|
||||||
|
|
||||||
|
// Sanity parse
|
||||||
|
try { new Function(wrapped); console.log('OK parse'); }
|
||||||
|
catch(e) { console.error('PARSE FAIL:', e.message); process.exit(1); }
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Извлекает CSS виджетов (.wg, .dnd-pool, .btn, .feedback, .sliders, .score-display, .spoiler)
|
||||||
|
// из physics_10_ch1.html в frontend/css/phys-textbook-widgets.css.
|
||||||
|
// Подключается из всех physics_8_*.html и physics_9_*.html файлов.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_10_ch1.html');
|
||||||
|
const DST_CSS = path.join(__dirname, '..', '..', 'frontend', 'css', 'phys-textbook-widgets.css');
|
||||||
|
|
||||||
|
const h = fs.readFileSync(SRC, 'utf8');
|
||||||
|
const css = h.match(/<style>([\s\S]*?)<\/style>/)[1];
|
||||||
|
|
||||||
|
const start = css.indexOf('.btn{');
|
||||||
|
const end = css.indexOf('.col-side{');
|
||||||
|
if (start < 0 || end < 0) { console.error('Markers not found'); process.exit(1); }
|
||||||
|
|
||||||
|
const block = css.slice(start, end).trim();
|
||||||
|
|
||||||
|
const header =
|
||||||
|
'/* Auto-extracted from frontend/textbooks/physics_10_ch1.html.\n' +
|
||||||
|
' * Provides .wg, .dnd-pool, .dnd-chip, .drop-box, .btn, .feedback,\n' +
|
||||||
|
' * .actions, .sliders, .score-display, .spoiler — shared interactive\n' +
|
||||||
|
' * widget styles for all physics-N chapter pages.\n' +
|
||||||
|
' * Generated by backend/scripts/extract_widget_css.cjs.\n' +
|
||||||
|
' */\n\n';
|
||||||
|
|
||||||
|
fs.writeFileSync(DST_CSS, header + block + '\n');
|
||||||
|
console.log('Wrote', DST_CSS, '(' + (header.length + block.length) + ' bytes)');
|
||||||
|
|
||||||
|
// === Inject <link> in all physics_8_*.html and physics_9_*.html that miss it ===
|
||||||
|
const TBOOKS = path.dirname(SRC);
|
||||||
|
const files = fs.readdirSync(TBOOKS).filter(f =>
|
||||||
|
/^physics_[89]_(ch\d|hub|lab)\.html$/.test(f)
|
||||||
|
);
|
||||||
|
|
||||||
|
const LINK_TAG = '<link rel="stylesheet" href="/css/phys-textbook-widgets.css">';
|
||||||
|
const ANCHOR = '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">';
|
||||||
|
|
||||||
|
let injectedCount = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
const p = path.join(TBOOKS, f);
|
||||||
|
let body = fs.readFileSync(p, 'utf8');
|
||||||
|
if (body.includes('phys-textbook-widgets.css')) continue;
|
||||||
|
if (!body.includes(ANCHOR)) {
|
||||||
|
console.warn(' skip', f, '(no katex anchor)');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
body = body.replace(ANCHOR, ANCHOR + '\n' + LINK_TAG);
|
||||||
|
fs.writeFileSync(p, body);
|
||||||
|
injectedCount++;
|
||||||
|
console.log(' injected →', f);
|
||||||
|
}
|
||||||
|
console.log('Total injected:', injectedCount, '/', files.length);
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* Fix OVER-ESCAPED LaTeX backslashes in textbook HTML.
|
||||||
|
*
|
||||||
|
* BUG: some formulas in JS string literals have too many backslashes, e.g.
|
||||||
|
* "$V=\\\\dfrac{1}{3}S_{осн}\\\\cdot h$" (4 backslashes)
|
||||||
|
* After JS unescaping KaTeX receives \\dfrac -> it renders "\\" as a LINE
|
||||||
|
* BREAK and prints "dfrac"/"cdot" as plain text (exactly the screenshot).
|
||||||
|
* The correct literal is 2 backslashes ("\\dfrac" -> value \dfrac).
|
||||||
|
*
|
||||||
|
* PARITY RULE (critical — protects legitimate row separators):
|
||||||
|
* literal-run length value backslashes meaning
|
||||||
|
* 2 1 \cmd OK keep
|
||||||
|
* 4 2 \\ + "cmd"(text) BUG -> 2
|
||||||
|
* 6 3 \\ + \cmd (rowbreak+cmd) OK keep
|
||||||
|
* 8 4 \\\\ + "cmd"(text) BUG -> 2
|
||||||
|
* => collapse ONLY runs whose length is a multiple of 4, AND only when the
|
||||||
|
* run is immediately followed by a known LaTeX command. Runs before "x",
|
||||||
|
* digits, etc. (real \\ row separators inside cases/array) are untouched.
|
||||||
|
*
|
||||||
|
* Usage: node backend/scripts/fix_overescaped_latex.js (dry run)
|
||||||
|
* node backend/scripts/fix_overescaped_latex.js --apply (write)
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const APPLY = process.argv.includes('--apply');
|
||||||
|
|
||||||
|
// Known LaTeX commands observed at 4/8 backslashes (exact-match whitelist).
|
||||||
|
const CMDSET = new Set([
|
||||||
|
'dfrac','tfrac','frac','sqrt','cdot','pi','log','ln','lg','alpha','beta','gamma',
|
||||||
|
'delta','Delta','theta','lambda','mu','sigma','phi','varphi','omega','infty',
|
||||||
|
'iff','in','notin','ne','neq','ge','geq','le','leq','mathbb','mathrm',
|
||||||
|
'leftrightarrow','rightarrow','leftarrow','times','div','vec','overline',
|
||||||
|
'perp','parallel','cos','sin','tan','cot','ldots','cdots','pm','mp','angle','triangle',
|
||||||
|
]);
|
||||||
|
|
||||||
|
let katex = null;
|
||||||
|
try { katex = require('katex'); } catch { /* validation optional */ }
|
||||||
|
function mathRegions(t) {
|
||||||
|
const out = []; let i = 0;
|
||||||
|
while (i < t.length) {
|
||||||
|
const a = t.indexOf('$', i); if (a < 0) break;
|
||||||
|
const dbl = t[a + 1] === '$'; const s = a + (dbl ? 2 : 1);
|
||||||
|
let b = dbl ? t.indexOf('$$', s) : t.indexOf('$', s);
|
||||||
|
if (b < 0 && dbl) b = t.indexOf('$', s);
|
||||||
|
if (b < 0) break;
|
||||||
|
out.push(t.slice(s, b));
|
||||||
|
i = b + (dbl && t.slice(b, b + 2) === '$$' ? 2 : 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// These math strings live in JS literals; KaTeX sees them AFTER one level of JS
|
||||||
|
// unescaping. Emulate that so validation reflects what the browser renders.
|
||||||
|
function jsUnescape(s) {
|
||||||
|
return s.replace(/\\\\/g, '\\');
|
||||||
|
}
|
||||||
|
function katexErrors(t) {
|
||||||
|
if (!katex) return null;
|
||||||
|
let bad = 0;
|
||||||
|
for (const inner of mathRegions(t)) {
|
||||||
|
const expr = jsUnescape(inner);
|
||||||
|
try { katex.renderToString(expr, { throwOnError: true }); }
|
||||||
|
catch { bad++; }
|
||||||
|
}
|
||||||
|
return bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
const files = ['algebra_11_ch1.html','algebra_11_ch3.html','geometry_11_ch3.html',
|
||||||
|
'geometry_11_ch2.html','geometry_11_ch1.html','algebra_11_ch2.html','algebra_8.html',
|
||||||
|
'algebra_7_ch4.html','geometry_11_ch4.html'];
|
||||||
|
|
||||||
|
const report = [];
|
||||||
|
report.push('MODE: ' + (APPLY ? 'APPLY' : 'DRY-RUN'));
|
||||||
|
let grandFixes = 0;
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const p = path.join(dir, f);
|
||||||
|
const t = fs.readFileSync(p, 'utf8');
|
||||||
|
const before = katexErrors(t);
|
||||||
|
|
||||||
|
const perCmd = {};
|
||||||
|
let fixes = 0;
|
||||||
|
const next = t.replace(/(\\{4,})([A-Za-z]+)/g, (whole, bs, word) => {
|
||||||
|
if (bs.length % 4 !== 0) return whole; // 6,10,... rowbreak+command -> keep
|
||||||
|
if (!CMDSET.has(word)) return whole; // x / begin / unknown -> keep
|
||||||
|
fixes++;
|
||||||
|
perCmd[word] = (perCmd[word] || 0) + 1;
|
||||||
|
return '\\\\' + word; // collapse to two backslashes
|
||||||
|
});
|
||||||
|
|
||||||
|
// validate by emulating browser render of the FIXED text
|
||||||
|
const after = katexErrors(next);
|
||||||
|
grandFixes += fixes;
|
||||||
|
report.push('');
|
||||||
|
report.push(f + ': fixes=' + fixes + ' katexErrors before=' + before + ' after=' + after +
|
||||||
|
(fixes ? ' cmds=' + JSON.stringify(perCmd) : ''));
|
||||||
|
if (after !== null && before !== null && after > before)
|
||||||
|
report.push(' !! WARNING: katex errors INCREASED — not writing this file');
|
||||||
|
|
||||||
|
if (APPLY && fixes > 0 && !(after > before)) fs.writeFileSync(p, next, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
report.push('');
|
||||||
|
report.push('TOTAL fixes: ' + grandFixes);
|
||||||
|
fs.writeFileSync(path.join(__dirname, 'fix_overescaped_latex.report.txt'), report.join('\n'), 'utf8');
|
||||||
|
console.log(report.join('\n'));
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* gen-exam-textbook-sections.js
|
||||||
|
*
|
||||||
|
* Regenerates the §-section taxonomy of the grades 5-9 math-family textbooks,
|
||||||
|
* used by tag-exam-textbook.js (the exam→textbook classifier).
|
||||||
|
*
|
||||||
|
* Outputs:
|
||||||
|
* backend/scripts/exam-textbook-sections.json — machine-readable (the classifier reads this)
|
||||||
|
* plans/exam-textbook-links/taxonomy.md — human-readable reference
|
||||||
|
*
|
||||||
|
* Re-run whenever a grade 5-9 algebra/geometry/math chapter gains or renames a §.
|
||||||
|
* Note: math-5/6 are engine-rendered (math6_engine.js builds <section id="sec-<p.id>">
|
||||||
|
* from window.M6.paras) — their §s are NOT extracted statically here (emitted with
|
||||||
|
* engine:'math6' marker); the classifier links them at chapter level.
|
||||||
|
*
|
||||||
|
* Usage: node backend/scripts/gen-exam-textbook-sections.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DIR = path.join(__dirname, '../../frontend/textbooks');
|
||||||
|
const OUT_MD = path.join(__dirname, '../../plans/exam-textbook-links/taxonomy.md');
|
||||||
|
const OUT_JSON = path.join(__dirname, 'exam-textbook-sections.json');
|
||||||
|
|
||||||
|
// chapter slug -> html file (from the textbooks table). Order = teaching order.
|
||||||
|
const CHAPTERS = [
|
||||||
|
['math-5-ch1', 'math_5_ch1.html'], ['math-5-ch2', 'math_5_ch2.html'], ['math-5-ch3', 'math_5_ch3.html'],
|
||||||
|
['math-6-ch1', 'math_6_ch1.html'], ['math-6-ch2', 'math_6_ch2.html'], ['math-6-ch3', 'math_6_ch3.html'],
|
||||||
|
['math-6-ch4', 'math_6_ch4.html'], ['math-6-ch5', 'math_6_ch5.html'], ['math-6-ch6', 'math_6_ch6.html'],
|
||||||
|
['algebra-7-ch1', 'algebra_7_ch1.html'], ['algebra-7-ch2', 'algebra_7_ch2.html'],
|
||||||
|
['algebra-7-ch3', 'algebra_7_ch3.html'], ['algebra-7-ch4', 'algebra_7_ch4.html'],
|
||||||
|
['geometry-7-ch1', 'geometry_7_ch1.html'], ['geometry-7-ch2', 'geometry_7_ch2.html'],
|
||||||
|
['geometry-7-ch3', 'geometry_7_ch3.html'], ['geometry-7-ch4', 'geometry_7_ch4.html'], ['geometry-7-ch5', 'geometry_7_ch5.html'],
|
||||||
|
['algebra-8-ch1', 'algebra_8.html'], ['algebra-8-ch2', 'algebra_8_ch2.html'], ['algebra-8-ch3', 'algebra_8_ch3.html'],
|
||||||
|
['geometry-8-ch1', 'geometry_8_ch1.html'], ['geometry-8-ch2', 'geometry_8_ch2.html'],
|
||||||
|
['geometry-8-ch3', 'geometry_8_ch3.html'], ['geometry-8-ch4', 'geometry_8_ch4.html'],
|
||||||
|
['algebra-9-ch1', 'algebra_9_ch1.html'], ['algebra-9-ch2', 'algebra_9_ch2.html'],
|
||||||
|
['algebra-9-ch3', 'algebra_9_ch3.html'], ['algebra-9-ch4', 'algebra_9_ch4.html'],
|
||||||
|
['geometry-9-ch1', 'geometry_9_ch1.html'], ['geometry-9-ch2', 'geometry_9_ch2.html'],
|
||||||
|
['geometry-9-ch3', 'geometry_9_ch3.html'], ['geometry-9-ch4', 'geometry_9_ch4.html'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function strip(html) { return String(html).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); }
|
||||||
|
|
||||||
|
const lines = ['# §-таксономия учебников 5–9 (математика) — эталон для классификатора экзамена math9', ''];
|
||||||
|
const json = []; // [{book, chapter_slug, subject, grade, para_id, num, title}]
|
||||||
|
let prevBook = '';
|
||||||
|
for (const [slug, file] of CHAPTERS) {
|
||||||
|
const book = slug.replace(/-ch\d+$/, '');
|
||||||
|
const subject = book.replace(/-\d+$/, ''); // math|algebra|geometry
|
||||||
|
const grade = Number((book.match(/-(\d+)$/) || [])[1]) || null;
|
||||||
|
if (book !== prevBook) { lines.push(`\n## ${book}`); prevBook = book; }
|
||||||
|
const p = path.join(DIR, file);
|
||||||
|
if (!fs.existsSync(p)) { lines.push(`### ${slug} (FILE MISSING: ${file})`); continue; }
|
||||||
|
const html = fs.readFileSync(p, 'utf8');
|
||||||
|
const tm = html.match(/<title>([^<]*)<\/title>/i);
|
||||||
|
lines.push(`### ${slug} — ${tm ? strip(tm[1]) : file}`);
|
||||||
|
|
||||||
|
const secRe = /<(?:section|div)\b[^>]*\sid="(sec-(?:p\d+|final\d*|[a-z0-9-]+))"[^>]*>/gi;
|
||||||
|
let m; const secs = [];
|
||||||
|
while ((m = secRe.exec(html)) !== null) secs.push({ id: m[1], start: m.index });
|
||||||
|
if (!secs.length) {
|
||||||
|
lines.push(` (движок math6: статических sec[id] нет; якоря строятся из window.M6.paras → id="sec-<p.id>")`);
|
||||||
|
json.push({ book, chapter_slug: slug, subject, grade, engine: 'math6', note: 'paras in window.M6 config; anchors sec-<p.id>' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < secs.length; i++) {
|
||||||
|
const seg = html.slice(secs[i].start, secs[i + 1] ? secs[i + 1].start : secs[i].start + 4000);
|
||||||
|
const numM = seg.match(/class="sec-num"[^>]*>([\s\S]*?)<\//i);
|
||||||
|
const hM = seg.match(/class="sec-h"[^>]*>([\s\S]*?)<\//i);
|
||||||
|
const paraId = secs[i].id.replace(/^sec-/, ''); // p10 | final3
|
||||||
|
const num = numM ? strip(numM[1]) : '';
|
||||||
|
const title = hM ? strip(hM[1]) : '';
|
||||||
|
lines.push(` ${secs[i].id.padEnd(12)} ${num ? '['+num+'] ' : ''}${title}`);
|
||||||
|
if (/^p\d+$/.test(paraId)) {
|
||||||
|
json.push({ book, chapter_slug: slug, subject, grade, para_id: paraId, num, title });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(OUT_MD), { recursive: true });
|
||||||
|
fs.writeFileSync(OUT_MD, lines.join('\n'), 'utf8');
|
||||||
|
fs.writeFileSync(OUT_JSON, JSON.stringify(json, null, 2), 'utf8');
|
||||||
|
console.log('Wrote', OUT_MD);
|
||||||
|
console.log('Wrote', OUT_JSON, '(' + json.length + ' sections)');
|
||||||
@@ -0,0 +1,784 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Phase 0 skeleton generator for Алгебра 9 chapter files.
|
||||||
|
* Produces: algebra_9_ch1.html ... ch4.html as functioning skeletons
|
||||||
|
* with stub bodies for each section. Phase 1+ will fill in the content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const OUT_DIR = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
/* ===== Chapter data ===== */
|
||||||
|
const CHAPTERS = [
|
||||||
|
{
|
||||||
|
chN: 1,
|
||||||
|
title: 'Рациональные выражения',
|
||||||
|
sub: 'Рациональные дроби · ОДЗ · действия с дробями',
|
||||||
|
heroH2: 'Рациональные выражения — алгебра дробей',
|
||||||
|
heroP: 'Здесь мы изучаем <b>рациональные дроби</b> (выражения вида $\\dfrac{P(x)}{Q(x)}$), их <b>область допустимых значений</b>, основное свойство и <b>сокращение</b>, четыре арифметических действия и <b>преобразование</b> сложных рациональных выражений.',
|
||||||
|
palette: {
|
||||||
|
pri:'#d97706', pri2:'#b45309', priSoft:'#fef3c7',
|
||||||
|
acc:'#f59e0b', acc2:'#d97706', accSoft:'#fef9c3',
|
||||||
|
hdrGrad:'linear-gradient(110deg,#92400e 0%,#d97706 55%,#fbbf24 100%)',
|
||||||
|
hdrShadow:'rgba(251,191,36,.2)',
|
||||||
|
hdrWmStroke:'rgba(255,235,180,.12)',
|
||||||
|
darkBg:'#0a0a0e', darkCard:'#13120a', darkCardSoft:'#18160a', darkText:'#fef9e7', darkMuted:'#a39070', darkBorder:'#2a2512',
|
||||||
|
confetti:['#d97706','#f59e0b','#fbbf24','#10b981','#0891b2'],
|
||||||
|
heroWm:'A/B',
|
||||||
|
},
|
||||||
|
paras: [
|
||||||
|
{ id:'p1', num:'§ 1', name:'Рациональная дробь', sub:'ОДЗ выражения', watermark:'P/Q', secAcc:'#d97706', secAccD:'#b45309', secAccSoft:'#fef3c7' },
|
||||||
|
{ id:'p2', num:'§ 2', name:'Основное свойство дроби', sub:'Сокращение', watermark:'k', secAcc:'#f59e0b', secAccD:'#d97706', secAccSoft:'#fef9c3' },
|
||||||
|
{ id:'p3', num:'§ 3', name:'Сложение и вычитание', sub:'Общий знаменатель', watermark:'+', secAcc:'#059669', secAccD:'#047857', secAccSoft:'#d1fae5' },
|
||||||
|
{ id:'p4', num:'§ 4', name:'Умножение и деление', sub:'×, ÷ дробей', watermark:'×', secAcc:'#7c3aed', secAccD:'#6d28d9', secAccSoft:'#ede9fe' },
|
||||||
|
{ id:'p5', num:'§ 5', name:'Преобразование выражений', sub:'Сложные дроби', watermark:'…', secAcc:'#db2777', secAccD:'#9d174d', secAccSoft:'#fce7f3' },
|
||||||
|
],
|
||||||
|
achLabels: {
|
||||||
|
start:'Начало главы 1!',
|
||||||
|
p2_done:'Сокращение дробей освоено!',
|
||||||
|
p4_done:'Действия с дробями освоены!',
|
||||||
|
p5_done:'Преобразование выражений освоено!',
|
||||||
|
ch1_done:'Глава 1 пройдена!',
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{ sec:'p1', html:'<b>ОДЗ</b> — это значения, при которых знаменатель $\\ne 0$. Всегда выписывай ОДЗ перед работой с дробью.' },
|
||||||
|
{ sec:'p2', html:'Сокращение возможно после <b>разложения на множители</b> числителя и знаменателя.' },
|
||||||
|
{ sec:'p3', html:'Для сложения дробей с разными знаменателями ищи <b>наименьший общий знаменатель</b>.' },
|
||||||
|
{ sec:'p4', html:'$\\dfrac{a}{b} \\cdot \\dfrac{c}{d} = \\dfrac{ac}{bd}$, $\\dfrac{a}{b} : \\dfrac{c}{d} = \\dfrac{ad}{bc}$.' },
|
||||||
|
{ sec:'p5', html:'Сложные выражения упрощай по действиям, не забывай об ОДЗ.' },
|
||||||
|
{ sec:'final1', html:'5 боссов главы 1. Удачи!' },
|
||||||
|
],
|
||||||
|
sidebars: {
|
||||||
|
p1:[ ['Дробь','$\\dfrac{P(x)}{Q(x)}$, где $P, Q$ — многочлены'], ['ОДЗ','$Q(x) \\ne 0$'], ['Целое','частный случай при $Q = 1$'] ],
|
||||||
|
p2:[ ['Свойство','$\\dfrac{P \\cdot R}{Q \\cdot R} = \\dfrac{P}{Q}$ при $R \\ne 0$'], ['Сокращение','делим числитель и знаменатель на общий множитель'], ['Знак','$\\dfrac{-a}{-b} = \\dfrac{a}{b}$, $\\dfrac{-a}{b} = -\\dfrac{a}{b}$'] ],
|
||||||
|
p3:[ ['Одинак.знам.','$\\dfrac{a}{c} \\pm \\dfrac{b}{c} = \\dfrac{a \\pm b}{c}$'], ['Разные знам.','приведи к общему знаменателю'], ['НОЗ','наименьший общий знаменатель'] ],
|
||||||
|
p4:[ ['Умножение','$\\dfrac{a}{b} \\cdot \\dfrac{c}{d} = \\dfrac{ac}{bd}$'], ['Деление','$\\dfrac{a}{b} : \\dfrac{c}{d} = \\dfrac{a}{b} \\cdot \\dfrac{d}{c}$'], ['Степень','$\\left(\\dfrac{a}{b}\\right)^n = \\dfrac{a^n}{b^n}$'] ],
|
||||||
|
p5:[ ['Шаг 1','выпиши ОДЗ'], ['Шаг 2','разложи на множители'], ['Шаг 3','выполни действия по порядку'], ['Шаг 4','сократи результат'] ],
|
||||||
|
final1:[ ['§§1–5','теория главы 1'], ['Боссов','5'], ['Награда','+100 XP'] ],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chN: 2,
|
||||||
|
title: 'Функции',
|
||||||
|
sub: 'Числовой аргумент · свойства · чётность · сдвиги',
|
||||||
|
heroH2: 'Функции — изучаем поведение и графики',
|
||||||
|
heroP: 'Здесь мы знакомимся с <b>функцией числового аргумента</b>: область определения $D(f)$, область значений $E(f)$, <b>возрастание/убывание</b>, нули, наибольшее и наименьшее значения, <b>чётность</b> и <b>сдвиги</b> графиков $y = f(x) + b$, $y = f(x \\pm a)$.',
|
||||||
|
palette: {
|
||||||
|
pri:'#059669', pri2:'#047857', priSoft:'#d1fae5',
|
||||||
|
acc:'#10b981', acc2:'#059669', accSoft:'#ecfdf5',
|
||||||
|
hdrGrad:'linear-gradient(110deg,#064e3b 0%,#059669 55%,#34d399 100%)',
|
||||||
|
hdrShadow:'rgba(167,243,208,.2)',
|
||||||
|
hdrWmStroke:'rgba(209,250,229,.12)',
|
||||||
|
darkBg:'#021410', darkCard:'#0a1f1a', darkCardSoft:'#0d2620', darkText:'#e0fcf3', darkMuted:'#7aa896', darkBorder:'#163d2f',
|
||||||
|
confetti:['#059669','#10b981','#34d399','#f59e0b','#0891b2'],
|
||||||
|
heroWm:'f(x)',
|
||||||
|
},
|
||||||
|
paras: [
|
||||||
|
{ id:'p6', num:'§ 6', name:'Функция числового аргумента', sub:'$D(f)$, $E(f)$', watermark:'D/E', secAcc:'#059669', secAccD:'#047857', secAccSoft:'#d1fae5' },
|
||||||
|
{ id:'p7', num:'§ 7', name:'Свойства функции', sub:'нули, монотонность, экстр.', watermark:'↗', secAcc:'#10b981', secAccD:'#059669', secAccSoft:'#ecfdf5' },
|
||||||
|
{ id:'p8', num:'§ 8', name:'Чётные и нечётные функции', sub:'симметрия графика', watermark:'±', secAcc:'#0891b2', secAccD:'#0e7490', secAccSoft:'#cffafe' },
|
||||||
|
{ id:'p9', num:'§ 9', name:'Сдвиги графиков', sub:'$y=f(x)+b$, $y=f(x \\pm a)$', watermark:'→', secAcc:'#7c3aed', secAccD:'#6d28d9', secAccSoft:'#ede9fe' },
|
||||||
|
],
|
||||||
|
achLabels: {
|
||||||
|
start:'Начало главы 2!',
|
||||||
|
p7_done:'Свойства функции освоены!',
|
||||||
|
p8_done:'Чётность освоена!',
|
||||||
|
p9_done:'Сдвиги графиков освоены!',
|
||||||
|
ch2_done:'Глава 2 пройдена!',
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{ sec:'p6', html:'Функция — это <b>правило</b>: каждому $x$ из $D(f)$ соответствует ровно одно $y$.' },
|
||||||
|
{ sec:'p7', html:'<b>Нули</b> функции — это решения уравнения $f(x) = 0$.' },
|
||||||
|
{ sec:'p8', html:'Чётная функция: $f(-x) = f(x)$. Нечётная: $f(-x) = -f(x)$.' },
|
||||||
|
{ sec:'p9', html:'$y = f(x) + b$ — сдвиг по $Oy$. $y = f(x - a)$ — сдвиг по $Ox$ вправо на $a$.' },
|
||||||
|
{ sec:'final2', html:'4 босса главы 2.' },
|
||||||
|
],
|
||||||
|
sidebars: {
|
||||||
|
p6:[ ['Функция','правило $x \\to y$'], ['$D(f)$','область определения'], ['$E(f)$','область значений'] ],
|
||||||
|
p7:[ ['Нуль','$f(x_0) = 0$'], ['Возрастает','при бо́льшем $x$ — бо́льшее $f(x)$'], ['Убывает','при бо́льшем $x$ — меньшее $f(x)$'], ['$y_{max}$','наиб. значение на промежутке'] ],
|
||||||
|
p8:[ ['Чётная','$f(-x) = f(x)$ — симм. отн. $Oy$'], ['Нечётная','$f(-x) = -f(x)$ — симм. отн. $O$'], ['Ни та, ни др.','общий случай'] ],
|
||||||
|
p9:[ ['$f(x) + b$','сдвиг вверх на $b$'], ['$f(x) - b$','сдвиг вниз на $b$'], ['$f(x - a)$','сдвиг вправо на $a$'], ['$f(x + a)$','сдвиг влево на $a$'] ],
|
||||||
|
final2:[ ['§§6–9','теория главы 2'], ['Боссов','4'], ['Награда','+100 XP'] ],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chN: 3,
|
||||||
|
title: 'Дробно-рациональные уравнения и неравенства',
|
||||||
|
sub: 'Уравнения · системы · окружность · метод интервалов',
|
||||||
|
heroH2: 'Дробно-рациональные уравнения и неравенства',
|
||||||
|
heroP: 'Здесь мы изучаем <b>дробно-рациональные уравнения</b>, <b>системы нелинейных уравнений</b> (включая графический способ), <b>длину отрезка</b> и <b>уравнение окружности</b> $(x-a)^2 + (y-b)^2 = r^2$, а также <b>метод интервалов</b> для дробно-рациональных неравенств.',
|
||||||
|
palette: {
|
||||||
|
pri:'#7c3aed', pri2:'#6d28d9', priSoft:'#ede9fe',
|
||||||
|
acc:'#a78bfa', acc2:'#7c3aed', accSoft:'#f5f3ff',
|
||||||
|
hdrGrad:'linear-gradient(110deg,#3b0764 0%,#7c3aed 55%,#a78bfa 100%)',
|
||||||
|
hdrShadow:'rgba(196,181,253,.2)',
|
||||||
|
hdrWmStroke:'rgba(237,233,254,.12)',
|
||||||
|
darkBg:'#0d0418', darkCard:'#1a0d2a', darkCardSoft:'#1f1130', darkText:'#f3e8ff', darkMuted:'#a08fb5', darkBorder:'#3a1f54',
|
||||||
|
confetti:['#7c3aed','#a78bfa','#c4b5fd','#f59e0b','#0891b2'],
|
||||||
|
heroWm:'≠0',
|
||||||
|
},
|
||||||
|
paras: [
|
||||||
|
{ id:'p10', num:'§ 10', name:'Дробно-рациональные уравнения', sub:'$\\dfrac{P}{Q} = 0$', watermark:'=0', secAcc:'#7c3aed', secAccD:'#6d28d9', secAccSoft:'#ede9fe' },
|
||||||
|
{ id:'p11', num:'§ 11', name:'Системы нелинейных уравнений', sub:'подстановка · графика', watermark:'{', secAcc:'#0891b2', secAccD:'#0e7490', secAccSoft:'#cffafe' },
|
||||||
|
{ id:'p12', num:'§ 12', name:'Уравнение окружности', sub:'$(x-a)^2+(y-b)^2=r^2$', watermark:'○', secAcc:'#db2777', secAccD:'#9d174d', secAccSoft:'#fce7f3' },
|
||||||
|
{ id:'p13', num:'§ 13', name:'Метод интервалов', sub:'неравенства', watermark:'>0', secAcc:'#059669', secAccD:'#047857', secAccSoft:'#d1fae5' },
|
||||||
|
],
|
||||||
|
achLabels: {
|
||||||
|
start:'Начало главы 3!',
|
||||||
|
p11_done:'Системы нелинейных уравнений освоены!',
|
||||||
|
p12_done:'Уравнение окружности освоено!',
|
||||||
|
p13_done:'Метод интервалов освоен!',
|
||||||
|
ch3_done:'Глава 3 пройдена!',
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{ sec:'p10', html:'Дробно-рациональное уравнение $\\dfrac{P(x)}{Q(x)} = 0$ равносильно системе: $P(x) = 0$ и $Q(x) \\ne 0$.' },
|
||||||
|
{ sec:'p11', html:'В системах нелинейных уравнений часто помогает <b>метод подстановки</b> или сложение.' },
|
||||||
|
{ sec:'p12', html:'Длина отрезка: $d = \\sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2}$. Окружность: $(x - a)^2 + (y - b)^2 = r^2$.' },
|
||||||
|
{ sec:'p13', html:'<b>Метод интервалов</b>: нули → точки на оси → знаки на промежутках.' },
|
||||||
|
{ sec:'final3', html:'4 босса главы 3.' },
|
||||||
|
],
|
||||||
|
sidebars: {
|
||||||
|
p10:[ ['Дробно-рац. уравн.','$\\dfrac{P(x)}{Q(x)} = 0$'], ['Условие','$P(x) = 0$ и $Q(x) \\ne 0$'], ['Алгоритм','найди корни $P$ → проверь ОДЗ'] ],
|
||||||
|
p11:[ ['Система','несколько уравнений с общими $x, y$'], ['Подстановка','выразил → подставил'], ['Графически','точки пересечения графиков'] ],
|
||||||
|
p12:[ ['Длина','$d = \\sqrt{(x_2-x_1)^2 + (y_2-y_1)^2}$'], ['Окружность','$(x-a)^2 + (y-b)^2 = r^2$'], ['Центр','$(a; b)$'], ['Радиус','$r$'] ],
|
||||||
|
p13:[ ['Шаг 1','перенеси всё влево, приведи к виду $\\dfrac{P}{Q}$'], ['Шаг 2','найди нули $P$ и $Q$'], ['Шаг 3','отметь на оси'], ['Шаг 4','определи знаки'] ],
|
||||||
|
final3:[ ['§§10–13','теория главы 3'], ['Боссов','4'], ['Награда','+100 XP'] ],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chN: 4,
|
||||||
|
title: 'Прогрессии',
|
||||||
|
sub: 'Последовательности · арифметическая · геометрическая',
|
||||||
|
heroH2: 'Прогрессии — арифметика и геометрия чисел',
|
||||||
|
heroP: 'Здесь мы изучаем <b>числовые последовательности</b>, <b>арифметическую прогрессию</b> $(a_n = a_1 + (n-1)d)$ и <b>геометрическую прогрессию</b> $(b_n = b_1 q^{n-1})$, формулы сумм $n$ первых членов и <b>сумму бесконечно убывающей</b> геометрической прогрессии $S = \\dfrac{b_1}{1 - q}$.',
|
||||||
|
palette: {
|
||||||
|
pri:'#0891b2', pri2:'#0e7490', priSoft:'#cffafe',
|
||||||
|
acc:'#22d3ee', acc2:'#0891b2', accSoft:'#ecfeff',
|
||||||
|
hdrGrad:'linear-gradient(110deg,#164e63 0%,#0891b2 55%,#22d3ee 100%)',
|
||||||
|
hdrShadow:'rgba(165,243,252,.2)',
|
||||||
|
hdrWmStroke:'rgba(209,250,255,.12)',
|
||||||
|
darkBg:'#04141a', darkCard:'#0a1b22', darkCardSoft:'#0d2229', darkText:'#e0fcff', darkMuted:'#7aa8b3', darkBorder:'#163842',
|
||||||
|
confetti:['#0891b2','#22d3ee','#67e8f9','#f59e0b','#10b981'],
|
||||||
|
heroWm:'aₙ',
|
||||||
|
},
|
||||||
|
paras: [
|
||||||
|
{ id:'p14', num:'§ 14', name:'Числовая последовательность', sub:'$a_1, a_2, \\dots, a_n$', watermark:'aₙ', secAcc:'#0891b2', secAccD:'#0e7490', secAccSoft:'#cffafe' },
|
||||||
|
{ id:'p15', num:'§ 15', name:'Арифметическая прогрессия', sub:'$a_n = a_1 + (n-1)d$', watermark:'+d', secAcc:'#06b6d4', secAccD:'#0891b2', secAccSoft:'#cffafe' },
|
||||||
|
{ id:'p16', num:'§ 16', name:'Сумма арифм. прогрессии', sub:'$S_n = \\tfrac{a_1 + a_n}{2} n$', watermark:'Σ', secAcc:'#2563eb', secAccD:'#1d4ed8', secAccSoft:'#dbeafe' },
|
||||||
|
{ id:'p17', num:'§ 17', name:'Геометрическая прогрессия', sub:'$b_n = b_1 q^{n-1}$', watermark:'·q', secAcc:'#7c3aed', secAccD:'#6d28d9', secAccSoft:'#ede9fe' },
|
||||||
|
{ id:'p18', num:'§ 18', name:'Сумма геом. прогрессии', sub:'$S_n = \\tfrac{b_1(q^n - 1)}{q - 1}$', watermark:'Σ', secAcc:'#db2777', secAccD:'#9d174d', secAccSoft:'#fce7f3' },
|
||||||
|
{ id:'p19', num:'§ 19', name:'Бесконечно убывающая', sub:'$S = \\tfrac{b_1}{1 - q}$', watermark:'∞', secAcc:'#059669', secAccD:'#047857', secAccSoft:'#d1fae5' },
|
||||||
|
],
|
||||||
|
achLabels: {
|
||||||
|
start:'Начало главы 4!',
|
||||||
|
p15_done:'Арифметическая прогрессия освоена!',
|
||||||
|
p17_done:'Геометрическая прогрессия освоена!',
|
||||||
|
p19_done:'Бесконечно убывающая освоена!',
|
||||||
|
ch4_done:'Глава 4 пройдена! Алгебра 9 — финал!',
|
||||||
|
},
|
||||||
|
tips: [
|
||||||
|
{ sec:'p14', html:'Числовая последовательность — это функция натурального аргумента: $a: \\mathbb{N} \\to \\mathbb{R}$.' },
|
||||||
|
{ sec:'p15', html:'В арифметической прогрессии разность $d = a_{n+1} - a_n$ — постоянна.' },
|
||||||
|
{ sec:'p16', html:'$S_n = \\dfrac{(a_1 + a_n) n}{2} = \\dfrac{(2 a_1 + (n - 1) d) n}{2}$.' },
|
||||||
|
{ sec:'p17', html:'В геометрической прогрессии знаменатель $q = \\dfrac{b_{n+1}}{b_n}$ — постоянен.' },
|
||||||
|
{ sec:'p18', html:'$S_n = \\dfrac{b_1 (q^n - 1)}{q - 1}$ при $q \\ne 1$.' },
|
||||||
|
{ sec:'p19', html:'При $|q| < 1$: $S = \\dfrac{b_1}{1 - q}$.' },
|
||||||
|
{ sec:'final4', html:'6 боссов главы 4. После — вся Алгебра 9 в твоём арсенале!' },
|
||||||
|
],
|
||||||
|
sidebars: {
|
||||||
|
p14:[ ['Послед-сть','$(a_n)$, $n \\in \\mathbb{N}$'], ['Способы','формула $n$-го члена, реккурентно, словесно'], ['Член','$a_n$ — $n$-й член'] ],
|
||||||
|
p15:[ ['Опр.','$a_{n+1} - a_n = d$'], ['Форм.','$a_n = a_1 + (n - 1) d$'], ['Свойство','$a_n = \\tfrac{a_{n-1} + a_{n+1}}{2}$'] ],
|
||||||
|
p16:[ ['Формула 1','$S_n = \\tfrac{a_1 + a_n}{2} n$'], ['Формула 2','$S_n = \\tfrac{2 a_1 + (n - 1) d}{2} n$'] ],
|
||||||
|
p17:[ ['Опр.','$\\dfrac{b_{n+1}}{b_n} = q$, $b_1 \\ne 0$, $q \\ne 0$'], ['Форм.','$b_n = b_1 q^{n-1}$'], ['Свойство','$b_n^2 = b_{n-1} b_{n+1}$'] ],
|
||||||
|
p18:[ ['$q \\ne 1$','$S_n = \\tfrac{b_1(q^n - 1)}{q - 1}$'], ['$q = 1$','$S_n = n \\cdot b_1$'] ],
|
||||||
|
p19:[ ['Условие','$|q| < 1$'], ['Сумма','$S = \\tfrac{b_1}{1 - q}$'] ],
|
||||||
|
final4:[ ['§§14–19','теория главы 4'], ['Боссов','6'], ['Награда','+100 XP'], ['Алгебра 9','полностью пройдена!'] ],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ===== HTML generator ===== */
|
||||||
|
function genChapter(ch) {
|
||||||
|
const chN = ch.chN;
|
||||||
|
const P = ch.palette;
|
||||||
|
const paras = ch.paras;
|
||||||
|
const allParas = [...paras, { id:'final'+chN, num:'★', name:'Финал главы', sub:'Итоги · '+paras.length+' боссов', final:true, watermark:'★', secAcc:P.pri, secAccD:P.pri2, secAccSoft:P.priSoft }];
|
||||||
|
const total = allParas.length;
|
||||||
|
const slug = `algebra-9-ch${chN}`;
|
||||||
|
const lsPrefix = `algebra9_ch${chN}`;
|
||||||
|
|
||||||
|
// Build section colors block
|
||||||
|
const secColors = allParas.map(p =>
|
||||||
|
`.sec[id="sec-${p.id}"]{ --sec-acc:${p.secAcc}; --sec-acc-d:${p.secAccD}; --sec-acc-soft:${p.secAccSoft}; }`
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
// Build section html
|
||||||
|
const secsHtml = allParas.map(p => {
|
||||||
|
if (p.final) {
|
||||||
|
return ` <section id="sec-${p.id}" class="sec" data-watermark="★"><div class="sec-header"><span class="sec-num" style="background:linear-gradient(135deg,${P.pri},${P.acc})">Финал главы</span><h2 class="sec-h">Итоги. ${paras.length} боссов главы ${chN}</h2></div><div id="${p.id}-body"></div></section>`;
|
||||||
|
}
|
||||||
|
return ` <section id="sec-${p.id}" class="sec" data-watermark="${p.watermark}"><div class="sec-header"><span class="sec-num">${p.num}</span><h2 class="sec-h">${p.name}</h2></div><div id="${p.id}-body"></div></section>`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
// Builders
|
||||||
|
const builders = allParas.map(p => {
|
||||||
|
const fnName = 'build' + p.id.charAt(0).toUpperCase() + p.id.slice(1);
|
||||||
|
const titleText = p.final ? 'Финал главы — в разработке' : `«${p.name}»`;
|
||||||
|
const numLabel = p.final ? '★' : p.num;
|
||||||
|
const xpHint = p.final ? '<p style="color:var(--muted);font-size:.9rem">Боссы и итоговые задания будут добавлены в Phase 1.</p>' : '<p style="color:var(--muted);font-size:.9rem">Раздел Phase 1.</p>';
|
||||||
|
return `function ${fnName}(){
|
||||||
|
const root = document.getElementById('${p.id}-body');
|
||||||
|
root.innerHTML = \`
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon theory">\${ICONS.theory}</span>
|
||||||
|
<span class="card-title">В разработке</span>
|
||||||
|
<span class="card-num">${numLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Содержание ${p.final ? 'финала главы' : 'параграфа'} <b>${titleText}</b> будет добавлено в следующих обновлениях.</p>
|
||||||
|
${xpHint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
\` + secNav(${p.idx > 0 ? `'${allParas[p.idx-1].id}'` : 'null'}, ${p.idx < allParas.length-1 ? `'${allParas[p.idx+1].id}'` : 'null'}) + readButton('${p.id}');
|
||||||
|
renderMath(root);
|
||||||
|
wireReadBtn('${p.id}');
|
||||||
|
}`;
|
||||||
|
});
|
||||||
|
// Add idx
|
||||||
|
allParas.forEach((p, i) => p.idx = i);
|
||||||
|
// Re-generate builders now that idx is set
|
||||||
|
const buildersText = allParas.map(p => {
|
||||||
|
const fnName = 'build' + p.id.charAt(0).toUpperCase() + p.id.slice(1);
|
||||||
|
const titleText = p.final ? 'Финал главы — в разработке' : `«${p.name}»`;
|
||||||
|
const numLabel = p.final ? '★' : p.num;
|
||||||
|
const xpHint = p.final ? '<p style="color:var(--muted);font-size:.9rem">Боссы и итоговые задания будут добавлены в Phase 1.</p>' : '<p style="color:var(--muted);font-size:.9rem">Раздел Phase 1.</p>';
|
||||||
|
const prev = p.idx > 0 ? `'${allParas[p.idx-1].id}'` : 'null';
|
||||||
|
const next = p.idx < allParas.length-1 ? `'${allParas[p.idx+1].id}'` : 'null';
|
||||||
|
return `function ${fnName}(){
|
||||||
|
const root = document.getElementById('${p.id}-body');
|
||||||
|
root.innerHTML = \`
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-icon theory">\${ICONS.theory}</span>
|
||||||
|
<span class="card-title">В разработке</span>
|
||||||
|
<span class="card-num">${numLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Содержание ${p.final ? 'финала главы' : 'параграфа'} <b>${titleText}</b> будет добавлено в следующих обновлениях.</p>
|
||||||
|
${xpHint}
|
||||||
|
</div>
|
||||||
|
</div>\` + secNav(${prev}, ${next}) + readButton('${p.id}');
|
||||||
|
renderMath(root);
|
||||||
|
wireReadBtn('${p.id}');
|
||||||
|
}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
// PARAS array literal
|
||||||
|
const parasLit = allParas.map(p => {
|
||||||
|
if (p.final) return ` { id:'${p.id}', num:'${p.num}', name:'${p.name}', sub:'${p.sub.replace(/'/g,"\\'")}', final:true }`;
|
||||||
|
return ` { id:'${p.id}', num:'${p.num}', name:'${p.name}', sub:'${p.sub.replace(/'/g,"\\'")}' }`;
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
// BUILDERS map
|
||||||
|
const buildersMap = allParas.map(p => `${p.id}:()=>build${p.id.charAt(0).toUpperCase()+p.id.slice(1)}()`).join(', ');
|
||||||
|
|
||||||
|
// SIDEBARS literal
|
||||||
|
const sidebarsLit = Object.entries(ch.sidebars).map(([id, rows]) => {
|
||||||
|
const r = rows.map(([k, v]) => `['${k.replace(/'/g,"\\'")}','${v.replace(/'/g,"\\'")}']`).join(',');
|
||||||
|
const title = id.startsWith('final') ? 'Финал главы' : 'Шпаргалка \\xA7'+id.replace('p','');
|
||||||
|
return ` ${id}:{title:'${title}',rows:[${r}]}`;
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
// TIPS literal
|
||||||
|
const tipsLit = ch.tips.map(t => ` {sec:'${t.sec}',html:'${t.html.replace(/'/g,"\\'")}'}`).join(',\n');
|
||||||
|
|
||||||
|
// ACH_LABELS literal
|
||||||
|
const achLit = Object.entries(ch.achLabels).map(([k, v]) => ` ${k}:'${v.replace(/'/g,"\\'")}'`).join(',\n');
|
||||||
|
|
||||||
|
// initial progress object
|
||||||
|
const progressInit = allParas.map(p => `${p.id}:0`).join(',');
|
||||||
|
|
||||||
|
// sec NAMES map
|
||||||
|
const secNames = allParas.map(p => {
|
||||||
|
const label = p.final ? "'Финал'" : `'\\xA7${p.id.replace('p','')}'`;
|
||||||
|
return `${p.id}:${label}`;
|
||||||
|
}).join(',');
|
||||||
|
|
||||||
|
const firstId = allParas[0].id;
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
<title>Алгебра 9 · Глава ${chN} · ${ch.title}</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||||
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false})"></script>
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#fafafa; --card:#fff; --card-soft:#f8fafc; --text:#0f172a; --ink:#0f172a; --muted:#64748b;
|
||||||
|
--border:#e2e8f0; --sh:0 1px 3px rgba(0,0,0,.06); --sh2:0 4px 14px rgba(0,0,0,.08);
|
||||||
|
--pri:${P.pri}; --pri2:${P.pri2}; --pri-soft:${P.priSoft};
|
||||||
|
--acc:${P.acc}; --acc2:${P.acc2}; --acc-soft:${P.accSoft};
|
||||||
|
--ok:#10b981; --ok-bg:#d1fae5; --warn:#f59e0b; --warn-bg:#fef3c7;
|
||||||
|
--bad:#ef4444; --fail:#dc2626; --fail-bg:#fee2e2;
|
||||||
|
}
|
||||||
|
.dark{--bg:${P.darkBg}; --card:${P.darkCard}; --card-soft:${P.darkCardSoft}; --text:${P.darkText}; --ink:${P.darkText}; --muted:${P.darkMuted}; --border:${P.darkBorder}}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||||
|
html,body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;font-size:15px}
|
||||||
|
button,input,select,textarea{font-family:inherit;font-size:inherit}
|
||||||
|
button{cursor:pointer;border:0;background:transparent;color:inherit}
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
.ic{width:16px;height:16px;display:inline-block;flex-shrink:0;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}
|
||||||
|
|
||||||
|
.hdr{position:relative;background:${P.hdrGrad};color:#fff;padding:46px 22px 30px;overflow:hidden;border-bottom:2px solid ${P.hdrShadow};min-height:130px}
|
||||||
|
.hdr::before{content:'ГЛАВА ${chN}';position:absolute;right:-12px;top:50%;transform:translateY(-50%);font-family:'Unbounded',sans-serif;font-size:clamp(5rem,15vw,11rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px ${P.hdrWmStroke};line-height:1;pointer-events:none;user-select:none;z-index:0}
|
||||||
|
.hdr-row{position:relative;z-index:1;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||||||
|
.hdr h1{font-family:'Unbounded',sans-serif;font-size:1.5rem;font-weight:900;letter-spacing:-.01em;line-height:1.3;padding-top:4px}
|
||||||
|
.hdr-sub{font-size:.85rem;opacity:.88;margin-top:6px;font-weight:500;line-height:1.4}
|
||||||
|
.hdr-side{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.hdr-btn{padding:7px 12px;border-radius:9px;background:rgba(255,255,255,.14);color:#fff;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;text-decoration:none}
|
||||||
|
.hdr-btn:hover{background:rgba(255,255,255,.24)}
|
||||||
|
|
||||||
|
.main{max-width:1240px;margin:0 auto;padding:22px;width:100%;display:grid;grid-template-columns:1fr 280px;gap:24px}
|
||||||
|
@media(max-width:980px){.main{grid-template-columns:1fr;padding:14px}}
|
||||||
|
.col-main{min-width:0}
|
||||||
|
|
||||||
|
.hero{background:linear-gradient(135deg,var(--pri-soft) 0%,var(--acc-soft) 50%,var(--pri-soft) 100%);background-size:200% 200%;animation:heroShift 12s ease-in-out infinite;border:1px solid var(--border);border-radius:18px;padding:24px 22px;margin-bottom:24px;position:relative;overflow:hidden}
|
||||||
|
@keyframes heroShift{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
|
||||||
|
.hero::before{content:'${P.heroWm}';position:absolute;right:0;top:-30px;font-size:clamp(2rem,12vw,8rem);font-weight:900;color:var(--pri);opacity:.10;line-height:1;pointer-events:none;font-family:'Unbounded',sans-serif}
|
||||||
|
.hero h2{font-family:'Unbounded',sans-serif;font-size:1.55rem;font-weight:800;color:var(--pri2);margin-bottom:10px;letter-spacing:-.01em}
|
||||||
|
.hero p{font-size:.95rem;color:var(--text);opacity:.88;margin-bottom:14px;max-width:640px}
|
||||||
|
.hero-row{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
|
||||||
|
.btn-primary{padding:11px 22px;background:linear-gradient(135deg,var(--pri),var(--pri2));color:#fff;border-radius:11px;font-weight:700;font-size:.92rem;display:inline-flex;align-items:center;gap:8px;box-shadow:var(--sh2);transition:transform .15s,box-shadow .15s}
|
||||||
|
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 8px 28px rgba(0,0,0,.18)}
|
||||||
|
.hero-progress{flex:1;min-width:200px;max-width:280px}
|
||||||
|
.hp-label{font-size:.74rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:5px}
|
||||||
|
.hp-bar{height:8px;background:rgba(0,0,0,.12);border-radius:5px;overflow:hidden}
|
||||||
|
.hp-fill{height:100%;background:linear-gradient(90deg,var(--pri),var(--acc));border-radius:5px;width:0%;transition:width .6s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.hp-text{font-size:.78rem;color:var(--muted);font-weight:700;margin-top:4px;display:block}
|
||||||
|
.hero-xp-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:linear-gradient(135deg,var(--warn,#f59e0b),var(--pri));color:#fff;border-radius:99px;font-size:.82rem;font-weight:800;letter-spacing:.02em;box-shadow:0 4px 12px rgba(0,0,0,.18);font-family:'Unbounded',sans-serif}
|
||||||
|
|
||||||
|
.psel{margin-bottom:24px}
|
||||||
|
.psel-title{font-size:.72rem;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}
|
||||||
|
.psel-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
|
||||||
|
.psel-card{background:var(--card);border:1.5px solid var(--border);border-radius:13px;padding:14px;cursor:pointer;transition:transform .2s,box-shadow .2s,border-color .2s;text-align:left;position:relative}
|
||||||
|
.psel-card:hover{transform:translateY(-3px);box-shadow:var(--sh2);border-color:var(--pri)}
|
||||||
|
.psel-card.active{border-color:var(--pri);background:linear-gradient(135deg,var(--pri-soft),var(--card));box-shadow:var(--sh2)}
|
||||||
|
.psel-card.active::after{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--pri),var(--acc));border-radius:13px 13px 0 0}
|
||||||
|
.psel-num{font-family:'Unbounded',sans-serif;font-size:.72rem;font-weight:800;color:var(--pri);text-transform:uppercase;letter-spacing:.08em;margin-bottom:5px}
|
||||||
|
.psel-name{font-size:.86rem;font-weight:700;color:var(--text);line-height:1.3;margin-bottom:8px}
|
||||||
|
.psel-prog{height:4px;background:rgba(0,0,0,.10);border-radius:3px;overflow:hidden}
|
||||||
|
.psel-prog-fill{height:100%;background:var(--pri);width:0%;transition:width .4s}
|
||||||
|
.psel-card.final{background:linear-gradient(135deg,#fff5e1,#fef3c7)}
|
||||||
|
.psel-card.final .psel-num{color:var(--warn)}
|
||||||
|
|
||||||
|
${secColors}
|
||||||
|
|
||||||
|
.sec{display:none;position:relative;animation:fadeIn .35s ease}
|
||||||
|
.sec.active{display:block}
|
||||||
|
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||||||
|
.sec::before{content:attr(data-watermark);position:absolute;right:-20px;top:10%;font-family:'Unbounded',sans-serif;font-size:clamp(6rem,18vw,14rem);font-weight:900;color:transparent;-webkit-text-stroke:1.5px var(--sec-acc-soft,var(--pri-soft));line-height:1;pointer-events:none;user-select:none;z-index:0;opacity:.35}
|
||||||
|
.sec-header{margin-bottom:22px;padding-bottom:14px;border-bottom:2px solid var(--sec-acc-soft,var(--pri-soft));position:relative;z-index:1}
|
||||||
|
.sec-num{display:inline-block;padding:4px 10px;background:linear-gradient(135deg,var(--sec-acc,var(--pri)),var(--sec-acc-d,var(--pri2)));color:#fff;border-radius:7px;font-family:'Unbounded',sans-serif;font-size:.78rem;font-weight:800;letter-spacing:.04em;margin-bottom:8px}
|
||||||
|
.sec-h{font-family:'Unbounded',sans-serif;font-size:1.6rem;font-weight:800;color:var(--sec-acc-d,var(--pri2));letter-spacing:-.01em;line-height:1.25}
|
||||||
|
|
||||||
|
.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.04);position:relative;z-index:1;transition:transform .25s cubic-bezier(.16,1,.3,1),box-shadow .25s}
|
||||||
|
.card:hover{transform:translateY(-2px);box-shadow:0 4px 10px rgba(0,0,0,.06),0 16px 36px rgba(0,0,0,.08)}
|
||||||
|
.card-header{display:flex;align-items:center;gap:10px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed var(--border)}
|
||||||
|
.card-icon{width:32px;height:32px;border-radius:9px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#fff}
|
||||||
|
.card-icon.repeat{background:#0ea5e9}.card-icon.theory{background:#8b5cf6}.card-icon.algo{background:#f59e0b}.card-icon.rule{background:#ec4899}.card-icon.example{background:#10b981}.card-icon.oral{background:#06b6d4}
|
||||||
|
.card-icon .ic{width:18px;height:18px}
|
||||||
|
.card-title{font-family:'Unbounded',sans-serif;font-size:.82rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex:1}
|
||||||
|
.card-num{font-size:.74rem;font-weight:700;color:var(--muted);background:var(--sec-acc-soft,var(--pri-soft));padding:3px 7px;border-radius:5px}
|
||||||
|
.card-body{font-size:.94rem;line-height:1.65}
|
||||||
|
.card-body p{margin-bottom:8px}
|
||||||
|
.card-body p:last-child{margin-bottom:0}
|
||||||
|
|
||||||
|
.btn{padding:8px 16px;border-radius:8px;background:var(--card);color:var(--text);border:1.5px solid var(--border);font-weight:600;font-size:.88rem;transition:background .15s,border-color .15s,transform .1s}
|
||||||
|
.btn:hover{background:var(--sec-acc-soft,var(--pri-soft));border-color:var(--sec-acc,var(--pri))}
|
||||||
|
.btn:active{transform:scale(.96)}
|
||||||
|
.btn.primary{background:var(--sec-acc,var(--pri));color:#fff;border-color:var(--sec-acc,var(--pri))}
|
||||||
|
.btn.primary:hover{background:var(--sec-acc-d,var(--pri2));border-color:var(--sec-acc-d,var(--pri2))}
|
||||||
|
|
||||||
|
.feedback{padding:10px 14px;border-radius:9px;font-weight:600;font-size:.88rem;margin-top:8px;display:none}
|
||||||
|
.feedback.ok{display:block;background:var(--ok-bg);color:#065f46;border-left:4px solid var(--ok)}
|
||||||
|
.feedback.fail{display:block;background:var(--fail-bg);color:#7f1d1d;border-left:4px solid var(--fail)}
|
||||||
|
|
||||||
|
.col-side{position:sticky;top:14px;align-self:start;height:fit-content;max-height:calc(100vh - 28px);overflow-y:auto}
|
||||||
|
.sidecard{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px;margin-bottom:14px;box-shadow:var(--sh)}
|
||||||
|
.sidecard h4{font-family:'Unbounded',sans-serif;font-size:.74rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||||
|
.sidecard-row{margin-bottom:8px;font-size:.86rem;line-height:1.6}
|
||||||
|
.sidecard-row b{color:var(--pri);font-weight:700}
|
||||||
|
.sidecard-row:last-child{margin-bottom:0}
|
||||||
|
@media(max-width:980px){.col-side{position:static;max-height:none}}
|
||||||
|
|
||||||
|
.xp-card{background:linear-gradient(135deg,var(--acc-soft),var(--pri-soft));border:1.5px solid var(--acc);border-radius:12px;padding:14px;margin-bottom:14px}
|
||||||
|
.xp-card-title{font-size:.68rem;font-weight:800;color:var(--acc2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.xp-level{font-size:1.1rem;font-weight:900;color:var(--acc2);font-family:'Unbounded',sans-serif}
|
||||||
|
.xp-bar{height:9px;background:rgba(0,0,0,.10);border-radius:6px;overflow:hidden;margin:7px 0}
|
||||||
|
.xp-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--pri));border-radius:6px;transition:width .5s cubic-bezier(.4,0,.2,1)}
|
||||||
|
.xp-nums{font-size:.74rem;color:var(--muted);display:flex;justify-content:space-between}
|
||||||
|
|
||||||
|
.sec-nav{display:flex;gap:10px;margin-top:24px;padding-top:20px;border-top:1px solid var(--border);justify-content:space-between;flex-wrap:wrap}
|
||||||
|
.foot{text-align:center;padding:30px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
|
||||||
|
|
||||||
|
.ach-popup{position:fixed;top:80px;right:18px;background:linear-gradient(135deg,var(--pri),var(--acc));color:#fff;padding:12px 18px;border-radius:11px;font-weight:700;font-size:.9rem;box-shadow:0 8px 28px rgba(0,0,0,.32);z-index:1002;display:none;align-items:center;gap:8px;max-width:340px}
|
||||||
|
.ach-popup.show{display:flex}
|
||||||
|
|
||||||
|
.col-side-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.42);z-index:9990;display:none}
|
||||||
|
.col-side-backdrop.show{display:block}
|
||||||
|
@media(max-width:980px){
|
||||||
|
.col-side{position:fixed;top:0;right:0;height:100vh;width:300px;max-width:88vw;background:var(--bg);box-shadow:-12px 0 24px rgba(0,0,0,.18);padding:18px 16px;overflow-y:auto;transform:translateX(100%);transition:transform .25s ease;z-index:9991;max-height:none}
|
||||||
|
.col-side.open{transform:none}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gloss-term{border-bottom:1.5px dotted var(--sec-acc,var(--pri));cursor:help;color:var(--sec-acc-d,var(--pri2));font-weight:600;padding:0 1px}
|
||||||
|
.gloss-term:hover{background:var(--sec-acc-soft,var(--pri-soft));border-radius:3px}
|
||||||
|
.gloss-tip{position:fixed;max-width:320px;padding:11px 14px;background:var(--card);border:1.5px solid var(--sec-acc,var(--pri));border-radius:11px;font-size:.84rem;line-height:1.55;box-shadow:0 12px 32px rgba(0,0,0,.18);z-index:9994;display:none;pointer-events:none;color:var(--text)}
|
||||||
|
.gloss-tip.show{display:block}
|
||||||
|
.gloss-tip b{color:var(--sec-acc-d,var(--pri2));font-size:.92rem}
|
||||||
|
|
||||||
|
.search-modal{position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(4px);z-index:9993;display:none;align-items:flex-start;justify-content:center;padding-top:14vh}
|
||||||
|
.search-modal.show{display:flex}
|
||||||
|
.search-box{background:var(--bg);border:1px solid var(--border);border-radius:14px;width:560px;max-width:92vw;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.4)}
|
||||||
|
.search-input{padding:14px 16px;font-size:1rem;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--text);outline:none}
|
||||||
|
.search-results{flex:1;overflow-y:auto;padding:6px 0}
|
||||||
|
.search-row{display:block;padding:8px 16px;cursor:pointer;border-bottom:1px solid var(--border);text-align:left;background:transparent;border:0;width:100%;color:var(--text)}
|
||||||
|
.search-row:hover,.search-row.active{background:var(--sec-acc-soft,var(--pri-soft))}
|
||||||
|
.search-row .sr-kind{font-size:.7rem;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:2px}
|
||||||
|
.search-row .sr-title{font-weight:700;font-size:.92rem;color:var(--text)}
|
||||||
|
.search-row .sr-desc{font-size:.8rem;color:var(--muted);margin-top:2px}
|
||||||
|
.search-empty{padding:20px;text-align:center;color:var(--muted);font-size:.88rem}
|
||||||
|
.search-foot{padding:8px 14px;border-top:1px solid var(--border);font-size:.74rem;color:var(--muted);display:flex;gap:14px}
|
||||||
|
.search-foot kbd{padding:2px 6px;background:var(--card);border:1px solid var(--border);border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:.72rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-row">
|
||||||
|
<div>
|
||||||
|
<h1>Алгебра 9 · Глава ${chN}</h1>
|
||||||
|
<div class="hdr-sub">${ch.sub}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-side">
|
||||||
|
<a href="/textbook/algebra-9" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К алгебре 9</a>
|
||||||
|
<button id="search-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg> Поиск</button>
|
||||||
|
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||||||
|
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="col-main">
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h2>${ch.heroH2}</h2>
|
||||||
|
<p>${ch.heroP}</p>
|
||||||
|
<div class="hero-row">
|
||||||
|
<button class="btn-primary" onclick="goTo('${firstId}')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать ${paras[0].num}</button>
|
||||||
|
<div class="hero-progress">
|
||||||
|
<span class="hp-label">Прогресс по главе</span>
|
||||||
|
<div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div>
|
||||||
|
<span id="hero-hp-text" class="hp-text">0%</span>
|
||||||
|
</div>
|
||||||
|
<div id="hero-xp-badge" class="hero-xp-badge"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="psel">
|
||||||
|
<div class="psel-title">Параграфы главы</div>
|
||||||
|
<div id="psel-grid" class="psel-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
${secsHtml}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<aside class="col-side" id="col-side"><div id="sidebar-content"></div></aside>
|
||||||
|
<div class="col-side-backdrop" id="col-side-backdrop"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">Интерактивный учебник «Алгебра 9» · Глава ${chN} · ${ch.title} · LearnSpace</footer>
|
||||||
|
|
||||||
|
<div id="ach-popup" class="ach-popup"><svg class="ic" viewBox="0 0 24 24" style="width:22px;height:22px"><polygon points="12,2 22,20 2,20"/></svg><span id="ach-text">Достижение!</span></div>
|
||||||
|
<div id="gloss-tip" class="gloss-tip"></div>
|
||||||
|
<div id="search-modal" class="search-modal" role="dialog">
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="search-input" class="search-input" placeholder="Поиск…" autocomplete="off">
|
||||||
|
<div id="search-results" class="search-results"></div>
|
||||||
|
<div class="search-foot"><span><kbd>↑↓</kbd> навигация</span><span><kbd>Enter</kbd> открыть</span><span><kbd>Esc</kbd> закрыть</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STATE = { current:'${firstId}', progress:{${progressInit}}, achievements:new Map(), xp:0, level:1 };
|
||||||
|
const TOTAL_PARAS = ${total};
|
||||||
|
const _TB_SLUG = '${slug}';
|
||||||
|
|
||||||
|
function calcLevel(xp){ return Math.floor(Math.sqrt((xp||0)/100))+1; }
|
||||||
|
function _xpForLevel(lv){ return (lv-1)*(lv-1)*100; }
|
||||||
|
|
||||||
|
const ACH_LABELS = {
|
||||||
|
${achLit}
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadProgress(){
|
||||||
|
try{
|
||||||
|
const s=localStorage.getItem('${lsPrefix}_progress'); if(s) Object.assign(STATE.progress, JSON.parse(s));
|
||||||
|
const a=localStorage.getItem('${lsPrefix}_achievements');
|
||||||
|
if(a){ const p=JSON.parse(a); if(Array.isArray(p)) p.forEach(id=>STATE.achievements.set(id, ACH_LABELS[id]||id)); else if(p&&typeof p==='object'){ for(const[id,t] of Object.entries(p)) STATE.achievements.set(id,(t&&t!==id)?t:(ACH_LABELS[id]||id)); } }
|
||||||
|
STATE.xp=+(localStorage.getItem('algebra9_xp')||0); STATE.level=calcLevel(STATE.xp);
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function saveProgress(){
|
||||||
|
try{
|
||||||
|
localStorage.setItem('${lsPrefix}_progress', JSON.stringify(STATE.progress));
|
||||||
|
localStorage.setItem('${lsPrefix}_achievements', JSON.stringify(Object.fromEntries(STATE.achievements)));
|
||||||
|
localStorage.setItem('algebra9_xp', String(STATE.xp));
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function bumpProgress(key, delta){
|
||||||
|
STATE.progress[key]=Math.max(0,Math.min(100,(STATE.progress[key]||0)+delta));
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(STATE.progress[key]>=50) markParaRead(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _markedRead=new Set();
|
||||||
|
let _pendingProgressBody=null, _progressTimer=null;
|
||||||
|
function _flushProgress(){
|
||||||
|
const body=_pendingProgressBody; _pendingProgressBody=null; if(!body) return;
|
||||||
|
const tok=(window.LS&&LS.getToken)?LS.getToken():''; if(!tok) return;
|
||||||
|
fetch('/api/textbooks/'+_TB_SLUG+'/progress',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tok},body:JSON.stringify(body),keepalive:true}).catch(()=>{});
|
||||||
|
}
|
||||||
|
function _queueProgress(patch){ _pendingProgressBody=Object.assign(_pendingProgressBody||{},patch); if(_progressTimer) clearTimeout(_progressTimer); _progressTimer=setTimeout(_flushProgress, 600); }
|
||||||
|
function markLastPara(id){ _queueProgress({last_para:id}); }
|
||||||
|
function markParaRead(id){ if(_markedRead.has(id)) return; _markedRead.add(id); _queueProgress({mark_read:id}); }
|
||||||
|
window.addEventListener('beforeunload', _flushProgress);
|
||||||
|
function loadServerReadState(){
|
||||||
|
const tok=(window.LS&&LS.getToken)?LS.getToken():''; if(!tok) return;
|
||||||
|
fetch('/api/textbooks/'+_TB_SLUG,{headers:{'Authorization':'Bearer '+tok}}).then(r=>r.ok?r.json():null).then(d=>{
|
||||||
|
if(!d||!d.progress) return;
|
||||||
|
(d.progress.read||[]).forEach(k=>{_markedRead.add(k); if((STATE.progress[k]||0)<50) STATE.progress[k]=100;});
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addXp(n,src){
|
||||||
|
if(!n) return;
|
||||||
|
const prev=STATE.level; STATE.xp=Math.max(0,(STATE.xp||0)+n); STATE.level=calcLevel(STATE.xp);
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(window.LS&&window.LS.xp) window.LS.xp.add(n,'algebra9-ch${chN}-'+(src||'misc'));
|
||||||
|
if(STATE.level>prev){
|
||||||
|
const pop=document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent='Уровень '+STATE.level+'!'; pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'),2600); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProgressUI(){
|
||||||
|
const total=Math.round(Object.values(STATE.progress).reduce((a,b)=>a+b,0)/TOTAL_PARAS);
|
||||||
|
const f=document.getElementById('hero-hp-fill'); if(f) f.style.width=total+'%';
|
||||||
|
const t=document.getElementById('hero-hp-text'); if(t) t.textContent=total+'% пройдено';
|
||||||
|
document.querySelectorAll('[data-prog-card]').forEach(el=>{ const k=el.dataset.progCard; const fl=el.querySelector('.psel-prog-fill'); if(fl) fl.style.width=(STATE.progress[k]||0)+'%'; });
|
||||||
|
const xpBadge=document.getElementById('hero-xp-badge');
|
||||||
|
if(xpBadge){ xpBadge.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polygon points="12 2 22 20 2 20"/></svg> Ур. '+STATE.level+' \\xb7 '+(STATE.xp||0)+' XP'; }
|
||||||
|
if(STATE.current && document.getElementById('sidebar-content')){ try{ buildSidebar(STATE.current); }catch(e){} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function achievement(id,text){
|
||||||
|
if(STATE.achievements.has(id)) return;
|
||||||
|
STATE.achievements.set(id, text||ACH_LABELS[id]||id); saveProgress();
|
||||||
|
const pop=document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent=text||ACH_LABELS[id]||id; pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'),3300); }
|
||||||
|
addXp(20,'ach-'+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARAS = [
|
||||||
|
${parasLit}
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildParaSelector(){
|
||||||
|
const g=document.getElementById('psel-grid'); g.innerHTML='';
|
||||||
|
PARAS.forEach(p=>{
|
||||||
|
const card=document.createElement('div');
|
||||||
|
card.className='psel-card'+(p.final?' final':'');
|
||||||
|
card.dataset.id=p.id; card.dataset.progCard=p.id;
|
||||||
|
card.innerHTML='<div class="psel-num">'+p.num+'</div><div class="psel-name">'+p.name+'</div><div class="psel-prog"><div class="psel-prog-fill"></div></div>';
|
||||||
|
card.addEventListener('click', ()=>goTo(p.id));
|
||||||
|
g.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILT=new Set();
|
||||||
|
const BUILDERS = { ${buildersMap} };
|
||||||
|
function ensureBuilt(id){ if(BUILT.has(id)) return; const fn=BUILDERS[id]; if(fn){ fn(); BUILT.add(id); } }
|
||||||
|
function goTo(id){
|
||||||
|
STATE.current=id; ensureBuilt(id);
|
||||||
|
document.querySelectorAll('.sec').forEach(s=>s.classList.remove('active'));
|
||||||
|
const el=document.getElementById('sec-'+id); if(el) el.classList.add('active');
|
||||||
|
document.querySelectorAll('.psel-card').forEach(c=>c.classList.toggle('active', c.dataset.id===id));
|
||||||
|
buildSidebar(id);
|
||||||
|
window.scrollTo({top:0,behavior:'smooth'});
|
||||||
|
if((STATE.progress[id]||0)<10) bumpProgress(id, 10);
|
||||||
|
if(window.renderMathInElement) setTimeout(()=>renderMath(el), 0);
|
||||||
|
markLastPara(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBARS = {
|
||||||
|
${sidebarsLit}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIPS=[
|
||||||
|
${tipsLit}
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildSidebar(id){
|
||||||
|
const box=document.getElementById('sidebar-content');
|
||||||
|
const sb=SIDEBARS[id]||SIDEBARS['${firstId}'];
|
||||||
|
let html='';
|
||||||
|
const xpForLv=_xpForLevel(STATE.level), xpNext=_xpForLevel(STATE.level+1);
|
||||||
|
const xpInLv=STATE.xp-xpForLv, xpRange=xpNext-xpForLv;
|
||||||
|
const xpPct=xpRange>0?Math.round(xpInLv/xpRange*100):100;
|
||||||
|
html+='<div class="xp-card"><div class="xp-card-title"><span>XP-прогресс</span><span class="xp-level">Ур. '+STATE.level+'</span></div><div class="xp-bar"><div class="xp-fill" style="width:'+xpPct+'%"></div></div><div class="xp-nums"><span>'+STATE.xp+' XP</span><span>'+xpNext+' XP</span></div></div>';
|
||||||
|
html+='<div class="sidecard"><h4>'+sb.title+'</h4>';
|
||||||
|
sb.rows.forEach(([k,v])=>{ html+='<div class="sidecard-row"><b>'+k+'</b>'+(v?' — '+v:'')+'</div>'; });
|
||||||
|
html+='</div>';
|
||||||
|
const tip=TIPS.find(t=>t.sec===id)||TIPS[0];
|
||||||
|
if(tip){
|
||||||
|
html+='<div class="sidecard" style="background:linear-gradient(135deg,var(--warn-bg,#fef3c7),var(--pri-soft));border-color:var(--warn,#f59e0b)"><h4 style="color:#92400e;display:flex;align-items:center;gap:6px"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><polygon points="12,2 22,20 2,20"/></svg>Подсказка</h4><div class="sidecard-row" style="margin-bottom:0;font-size:.84rem;line-height:1.55">'+tip.html+'</div></div>';
|
||||||
|
}
|
||||||
|
if(STATE.achievements.size>0){
|
||||||
|
html+='<div class="sidecard"><h4>Достижения <span style="color:var(--warn);float:right">'+STATE.achievements.size+'</span></h4>';
|
||||||
|
[...STATE.achievements.values()].slice(-4).forEach(text=>{ html+='<div class="sidecard-row" style="font-size:.78rem;color:var(--ok)">✓ '+text+'</div>'; });
|
||||||
|
html+='</div>';
|
||||||
|
}
|
||||||
|
box.innerHTML=html;
|
||||||
|
if(window.renderMathInElement) try{ renderMath(box); }catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme(){
|
||||||
|
const t=localStorage.getItem('${lsPrefix}_theme')||'light';
|
||||||
|
if(t==='dark') document.documentElement.classList.add('dark');
|
||||||
|
document.getElementById('theme-lab').textContent=t==='dark'?'Светлая':'Тёмная';
|
||||||
|
document.getElementById('theme-btn').addEventListener('click', ()=>{
|
||||||
|
document.documentElement.classList.toggle('dark');
|
||||||
|
const dark=document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem('${lsPrefix}_theme', dark?'dark':'light');
|
||||||
|
document.getElementById('theme-lab').textContent=dark?'Светлая':'Тёмная';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMath(root){ if(window.renderMathInElement){ try{ renderMathInElement(root, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false}); }catch(e){} } }
|
||||||
|
function feedback(elm, ok, text){ if(!elm) return; elm.className='feedback '+(ok?'ok':'fail'); elm.innerHTML=text||(ok?'✓ Верно!':'✗ Неверно'); elm.style.display='block'; try{renderMath(elm);}catch(e){} }
|
||||||
|
function fmt(n){ if(!isFinite(n)) return '?'; if(Number.isInteger(n)) return String(n); return Math.abs(n-Math.round(n))<1e-9?String(Math.round(n)):(+n.toFixed(6)).toString(); }
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
repeat:'<svg class="ic" viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
|
||||||
|
theory:'<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
|
||||||
|
algo:'<svg class="ic" viewBox="0 0 24 24"><polyline points="17 11 21 7 17 3"/><line x1="21" y1="7" x2="9" y2="7"/><polyline points="7 13 3 17 7 21"/><line x1="3" y1="17" x2="15" y2="17"/></svg>',
|
||||||
|
rule:'<svg class="ic" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>',
|
||||||
|
example:'<svg class="ic" viewBox="0 0 24 24"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 13c1 1 2 2 2 4h4c0-2 1-3 2-4a7 7 0 0 0-4-13z"/></svg>',
|
||||||
|
oral:'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
function secNav(prev, next){
|
||||||
|
const NAMES={${secNames}};
|
||||||
|
let h='<div class="sec-nav">';
|
||||||
|
h+=prev?'<button class="btn" onclick="goTo(\\''+prev+'\\')"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> '+NAMES[prev]+'</button>':'<span></span>';
|
||||||
|
h+=next?'<button class="btn primary" onclick="goTo(\\''+next+'\\')">'+NAMES[next]+' <svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></button>':'<span></span>';
|
||||||
|
h+='</div>'; return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readButton(paraId){
|
||||||
|
return '<div style="margin-top:18px;display:flex;justify-content:center">'
|
||||||
|
+'<button class="btn primary" id="'+paraId+'-read-btn">'
|
||||||
|
+'<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>'
|
||||||
|
+' Я прочитал — '+(paraId.startsWith('final')?'финал':'\\xA7'+paraId.replace('p',''))+' (+10 XP)'
|
||||||
|
+'</button></div>';
|
||||||
|
}
|
||||||
|
function wireReadBtn(paraId){
|
||||||
|
const btn = document.getElementById(paraId+'-read-btn'); if(!btn) return;
|
||||||
|
btn.addEventListener('click', ()=>{
|
||||||
|
addXp(10, paraId+'-read'); bumpProgress(paraId, 100);
|
||||||
|
btn.textContent='Прочитано! +10 XP'; btn.disabled=true; btn.style.opacity=.6;
|
||||||
|
if(paraId==='${allParas[allParas.length-1].id}') achievement('ch${chN}_done');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STUB BUILDERS — наполнение в Phase 1+ ===== */
|
||||||
|
|
||||||
|
${buildersText}
|
||||||
|
|
||||||
|
/* ===== Search ===== */
|
||||||
|
const SEARCH_INDEX = (function(){
|
||||||
|
const arr=[];
|
||||||
|
PARAS.forEach(p=>arr.push({kind:'Параграф',title:p.num+' '+p.name,desc:p.sub||'',sec:p.id}));
|
||||||
|
return arr;
|
||||||
|
})();
|
||||||
|
function initSearch(){
|
||||||
|
const modal=document.getElementById('search-modal'),inp=document.getElementById('search-input'),out=document.getElementById('search-results'),btn=document.getElementById('search-btn');
|
||||||
|
if(!modal||!inp||!out) return;
|
||||||
|
let cur=0,rows=[];
|
||||||
|
function score(q,it){ const t=(it.title+' '+it.desc).toLowerCase(); if(t.includes(q)) return 100+(it.title.toLowerCase().startsWith(q)?50:0); let s=0; q.split(/\\s+/).forEach(w=>{if(w&&t.includes(w))s+=10;}); return s; }
|
||||||
|
function rank(q){ q=q.trim().toLowerCase(); if(!q) return SEARCH_INDEX.slice(0,12); return SEARCH_INDEX.map(it=>({it,s:score(q,it)})).filter(x=>x.s>0).sort((a,b)=>b.s-a.s).slice(0,20).map(x=>x.it); }
|
||||||
|
function render(){ cur=0; if(!rows.length){out.innerHTML='<div class="search-empty">Ничего не найдено</div>';return;} out.innerHTML=rows.map((r,i)=>'<button class="search-row'+(i===0?' active':'')+'" data-i="'+i+'"><div class="sr-kind">'+r.kind+'</div><div class="sr-title">'+r.title+'</div>'+(r.desc?'<div class="sr-desc">'+(r.desc.length>90?r.desc.slice(0,90)+'…':r.desc)+'</div>':'')+'</button>').join(''); out.querySelectorAll('.search-row').forEach(b=>b.addEventListener('click',()=>{cur=+b.dataset.i;pick();})); }
|
||||||
|
function pick(){ const r=rows[cur]; if(!r) return; close(); goTo(r.sec); }
|
||||||
|
function move(d){ const items=out.querySelectorAll('.search-row'); if(!items.length) return; items[cur]&&items[cur].classList.remove('active'); cur=(cur+d+items.length)%items.length; items[cur].classList.add('active'); items[cur].scrollIntoView({block:'nearest'}); }
|
||||||
|
function open(){ modal.classList.add('show'); inp.value=''; rows=rank(''); render(); setTimeout(()=>inp.focus(),50); }
|
||||||
|
function close(){ modal.classList.remove('show'); }
|
||||||
|
btn&&btn.addEventListener('click',open);
|
||||||
|
modal.addEventListener('click',e=>{if(e.target===modal)close();});
|
||||||
|
inp.addEventListener('input',()=>{rows=rank(inp.value);render();});
|
||||||
|
inp.addEventListener('keydown',e=>{ if(e.key==='ArrowDown'){e.preventDefault();move(1);}else if(e.key==='ArrowUp'){e.preventDefault();move(-1);}else if(e.key==='Enter'){e.preventDefault();pick();}else if(e.key==='Escape'){e.preventDefault();close();} });
|
||||||
|
document.addEventListener('keydown',e=>{ if((e.ctrlKey||e.metaKey)&&(e.key==='k'||e.key==='K')){ e.preventDefault(); if(modal.classList.contains('show')) close(); else open(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSidebarToggle(){
|
||||||
|
const side=document.getElementById('col-side'),back=document.getElementById('col-side-backdrop'),btn=document.getElementById('sidebar-btn');
|
||||||
|
if(!side||!btn) return;
|
||||||
|
function open(){ side.classList.add('open'); back.classList.add('show'); }
|
||||||
|
function close(){ side.classList.remove('open'); back.classList.remove('show'); }
|
||||||
|
btn.addEventListener('click',()=>{ if(side.classList.contains('open')) close(); else open(); });
|
||||||
|
back.addEventListener('click',close);
|
||||||
|
document.addEventListener('keydown',e=>{ if(e.key==='Escape') close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(){
|
||||||
|
loadProgress(); initTheme(); initSidebarToggle(); initSearch();
|
||||||
|
buildParaSelector(); refreshProgressUI(); loadServerReadState(); goTo('${firstId}');
|
||||||
|
setTimeout(()=>achievement('start'), 600);
|
||||||
|
if(window.LS&&window.LS.xp){
|
||||||
|
window.LS.xp.load().then(function(s){ if(s&&s.xp>STATE.xp){ STATE.xp=s.xp; STATE.level=calcLevel(STATE.xp); saveProgress(); refreshProgressUI(); if(STATE.current) buildSidebar(STATE.current); } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Write all 4 files ===== */
|
||||||
|
for (const ch of CHAPTERS) {
|
||||||
|
const fp = path.join(OUT_DIR, `algebra_9_ch${ch.chN}.html`);
|
||||||
|
const html = genChapter(ch);
|
||||||
|
fs.writeFileSync(fp, html, 'utf8');
|
||||||
|
console.log(`[gen] Wrote ${fp} (${html.length} bytes)`);
|
||||||
|
}
|
||||||
|
console.log('[gen] Done — Phase 0 chapters generated.');
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
/* gen_chem8_skeletons.js — генерирует каркасы 7 глав «Химия 8» (Phase 0).
|
||||||
|
* Запуск: node backend/scripts/gen_chem8_skeletons.js
|
||||||
|
* Выход: frontend/textbooks/chemistry_8_intro.html, _ch1.html ... _ch6.html
|
||||||
|
*
|
||||||
|
* Каркас = валидная брендированная страница: header (водяной знак), hero,
|
||||||
|
* оглавление § (read-only), баннер «в разработке», ссылка назад в хаб, тема.
|
||||||
|
* Полный интерактивный SPA-контент каждой главы добавляется в Phase 1–6
|
||||||
|
* (файлы перезаписываются), пока скелет обеспечивает навигацию и структуру.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const OUT = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
const P = (t, n) => ({ t, n }); // параграф
|
||||||
|
const NOTE = (note) => ({ note }); // лаб. опыт / практическая работа
|
||||||
|
|
||||||
|
const CHAPTERS = [
|
||||||
|
{
|
||||||
|
file: 'chemistry_8_intro.html', slug: 'chemistry-8-intro',
|
||||||
|
kicker: 'Вводный раздел', title: 'Количественные понятия в химии',
|
||||||
|
range: '§ 1–9', wm: 'mol',
|
||||||
|
color: { p:'#d97706', d:'#b45309', l:'#fbbf24', soft:'#fef3c7', bgd:'#1c1410', cardd:'#271c14', textd:'#fef3c7' },
|
||||||
|
items: [
|
||||||
|
P('§ 1', 'Атомы. Химические элементы. Относительная атомная масса'),
|
||||||
|
P('§ 2', 'Молекулы. Простые и сложные вещества. Химические формулы. Относительная молекулярная масса'),
|
||||||
|
P('§ 3', 'Химическое количество вещества'),
|
||||||
|
P('§ 4', 'Моль — единица химического количества вещества. Постоянная Авогадро'),
|
||||||
|
P('§ 5', 'Молярная масса. Молярный объём газов'),
|
||||||
|
P('§ 6', 'Вычисление химического количества вещества по его массе и массы вещества по его химическому количеству'),
|
||||||
|
P('§ 7', 'Вычисление химического количества газа по его объёму и объёма газа по его химическому количеству'),
|
||||||
|
NOTE('Практическая работа 1. Химическое количество вещества'),
|
||||||
|
P('§ 8', 'Химические реакции'),
|
||||||
|
P('§ 9', 'Количественные расчёты по уравнениям химических реакций')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'chemistry_8_ch1.html', slug: 'chemistry-8-ch1',
|
||||||
|
kicker: 'Глава 1', title: 'Важнейшие классы неорганических соединений',
|
||||||
|
range: '§ 10–23', wm: 'OH',
|
||||||
|
color: { p:'#0d9488', d:'#0f766e', l:'#14b8a6', soft:'#ccfbf1', bgd:'#0c1a18', cardd:'#102825', textd:'#ccfbf1' },
|
||||||
|
items: [
|
||||||
|
P('§ 10', 'Оксиды. Состав и классификация оксидов'),
|
||||||
|
P('§ 11', 'Химические свойства оксидов'),
|
||||||
|
P('§ 12', 'Получение и применение оксидов'),
|
||||||
|
P('§ 13', 'Кислоты. Состав и классификация кислот'),
|
||||||
|
P('§ 14', 'Химические свойства кислот'),
|
||||||
|
P('§ 15', 'Получение и применение кислот'),
|
||||||
|
P('§ 16', 'Основания'),
|
||||||
|
P('§ 17', 'Химические свойства оснований'),
|
||||||
|
P('§ 18', 'Получение и применение оснований'),
|
||||||
|
NOTE('Лабораторный опыт 1. Получение нерастворимого основания'),
|
||||||
|
NOTE('Практическая работа 2. Изучение реакции нейтрализации'),
|
||||||
|
P('§ 19', 'Соли. Состав и классификация солей'),
|
||||||
|
P('§ 20', 'Химические свойства солей'),
|
||||||
|
NOTE('Лабораторный опыт 2. Взаимодействие растворов солей с металлами'),
|
||||||
|
P('§ 21', 'Получение и применение солей'),
|
||||||
|
P('§ 22', 'Взаимосвязь между классами основных неорганических веществ'),
|
||||||
|
NOTE('Практическая работа 3. Решение экспериментальных задач'),
|
||||||
|
P('§ 23', 'Решение расчётных задач по теме «Основные классы неорганических соединений»')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'chemistry_8_ch2.html', slug: 'chemistry-8-ch2',
|
||||||
|
kicker: 'Глава 2', title: 'Периодический закон и периодическая система химических элементов',
|
||||||
|
range: '§ 24–28', wm: '№',
|
||||||
|
color: { p:'#4f46e5', d:'#4338ca', l:'#818cf8', soft:'#e0e7ff', bgd:'#12122b', cardd:'#1b1b3a', textd:'#e0e7ff' },
|
||||||
|
items: [
|
||||||
|
P('§ 24', 'Систематизация химических элементов'),
|
||||||
|
P('§ 25', 'Понятие об амфотерности'),
|
||||||
|
NOTE('Лабораторный опыт 3. Получение гидроксида цинка и изучение его амфотерных свойств'),
|
||||||
|
P('§ 26', 'Естественные семейства элементов'),
|
||||||
|
P('§ 27', 'Периодический закон Д. И. Менделеева'),
|
||||||
|
P('§ 28', 'Периодическая система химических элементов')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'chemistry_8_ch3.html', slug: 'chemistry-8-ch3',
|
||||||
|
kicker: 'Глава 3', title: 'Строение атома и периодичность изменения свойств',
|
||||||
|
range: '§ 29–35', wm: 'e−',
|
||||||
|
color: { p:'#2563eb', d:'#1d4ed8', l:'#60a5fa', soft:'#dbeafe', bgd:'#0a1428', cardd:'#102137', textd:'#dbeafe' },
|
||||||
|
items: [
|
||||||
|
P('§ 29', 'Строение атома. Атомный номер химического элемента'),
|
||||||
|
P('§ 30', 'Массовое число атома. Нуклиды'),
|
||||||
|
P('§ 31', 'Изотопы. Явление радиоактивности'),
|
||||||
|
P('§ 32', 'Состояние электронов в атоме. Электронное облако. Атомная орбиталь'),
|
||||||
|
P('§ 33', 'Строение электронных оболочек атомов'),
|
||||||
|
P('§ 34', 'Периодичность изменения свойств атомов химических элементов'),
|
||||||
|
P('§ 35', 'Характеристика химического элемента по его положению в периодической системе')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'chemistry_8_ch4.html', slug: 'chemistry-8-ch4',
|
||||||
|
kicker: 'Глава 4', title: 'Химическая связь',
|
||||||
|
range: '§ 36–41', wm: 'H₂O',
|
||||||
|
color: { p:'#059669', d:'#047857', l:'#34d399', soft:'#d1fae5', bgd:'#0a1a12', cardd:'#10271c', textd:'#d1fae5' },
|
||||||
|
items: [
|
||||||
|
P('§ 36', 'Природа химической связи'),
|
||||||
|
P('§ 37', 'Ковалентная связь'),
|
||||||
|
P('§ 38', 'Неполярная и полярная ковалентная связь. Электроотрицательность'),
|
||||||
|
NOTE('Лабораторный опыт 4. Составление моделей молекул'),
|
||||||
|
P('§ 39', 'Ионная связь'),
|
||||||
|
P('§ 40', 'Металлическая связь. Межмолекулярное взаимодействие'),
|
||||||
|
P('§ 41', 'Кристаллическое состояние вещества')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'chemistry_8_ch5.html', slug: 'chemistry-8-ch5',
|
||||||
|
kicker: 'Глава 5', title: 'Окислительно-восстановительные реакции',
|
||||||
|
range: '§ 42–45', wm: 'O₂',
|
||||||
|
color: { p:'#ea580c', d:'#c2410c', l:'#fb923c', soft:'#ffedd5', bgd:'#1c1208', cardd:'#2a1c10', textd:'#ffedd5' },
|
||||||
|
items: [
|
||||||
|
P('§ 42', 'Степень окисления'),
|
||||||
|
P('§ 43', 'Процессы окисления и восстановления'),
|
||||||
|
P('§ 44', 'Окислительно-восстановительные реакции'),
|
||||||
|
P('§ 45', 'Окислительно-восстановительные реакции вокруг нас')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'chemistry_8_ch6.html', slug: 'chemistry-8-ch6',
|
||||||
|
kicker: 'Глава 6', title: 'Растворы',
|
||||||
|
range: '§ 46–52', wm: 'aq',
|
||||||
|
color: { p:'#0891b2', d:'#0e7490', l:'#22d3ee', soft:'#cffafe', bgd:'#08191c', cardd:'#10282d', textd:'#cffafe' },
|
||||||
|
items: [
|
||||||
|
P('§ 46', 'Смеси веществ'),
|
||||||
|
P('§ 47', 'Растворение веществ в воде'),
|
||||||
|
P('§ 48', 'Характеристики растворимости веществ'),
|
||||||
|
P('§ 49', 'Качественные характеристики состава растворов'),
|
||||||
|
P('§ 50', 'Количественные характеристики растворённых веществ. Массовая доля растворённого вещества'),
|
||||||
|
P('§ 51', 'Молярная концентрация растворённых веществ'),
|
||||||
|
NOTE('Практическая работа 4. Приготовление раствора с заданной массовой долей и молярной концентрацией'),
|
||||||
|
P('§ 52', 'Вода и растворы в жизни и деятельности человека')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s).replace(/[&<>]/g, c => ({ '&':'&', '<':'<', '>':'>' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function outlineHtml(items) {
|
||||||
|
return items.map(it => {
|
||||||
|
if (it.note) {
|
||||||
|
return ' <li class="ol-note"><span class="ol-note-ic">' +
|
||||||
|
'<svg viewBox="0 0 24 24"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>' +
|
||||||
|
'</span><span>' + esc(it.note) + '</span></li>';
|
||||||
|
}
|
||||||
|
return ' <li class="ol-para"><span class="ol-num">' + esc(it.t) + '</span><span class="ol-name">' + esc(it.n) + '</span></li>';
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageHtml(ch) {
|
||||||
|
const c = ch.color;
|
||||||
|
const wmHeader = ch.kicker.toUpperCase();
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Химия 8 · ${esc(ch.kicker)} · «${esc(ch.title)}»</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
|
<script src="/js/biochem-core.js" defer></script>
|
||||||
|
<script src="/js/chem8_svg.js" defer></script>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#fffbeb; --card:#fff; --text:#1c1917; --muted:#78716c; --border:#e7e5e4;
|
||||||
|
--pri:${c.p}; --pri-d:${c.d}; --pri-l:${c.l}; --pri-soft:${c.soft};
|
||||||
|
--sh:0 4px 16px rgba(0,0,0,.06); --sh-h:0 12px 32px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
html.dark{ --bg:${c.bgd}; --card:${c.cardd}; --text:${c.textd}; --muted:#a8a29e; --border:#3a3026; --pri-soft:rgba(0,0,0,.2); }
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{min-height:100vh}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;transition:background .25s,color .25s}
|
||||||
|
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
|
||||||
|
.hdr{position:relative;background:linear-gradient(110deg,${c.d} 0%,${c.p} 55%,${c.l} 100%);color:#fff;padding:34px 24px 30px;overflow:hidden;border-bottom:2px solid rgba(255,255,255,.18)}
|
||||||
|
.hdr::before{content:'${wmHeader}';position:absolute;right:-12px;top:50%;transform:translateY(-50%);font-family:'Unbounded',sans-serif;font-size:clamp(4rem,13vw,10rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px rgba(255,255,255,.13);line-height:1;pointer-events:none;user-select:none;z-index:0}
|
||||||
|
.hdr-inner{position:relative;z-index:1;max-width:1000px;margin:0 auto;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
||||||
|
.hdr-back{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;background:rgba(255,255,255,.16);border-radius:9px;color:#fff;text-decoration:none;font-size:.85rem;font-weight:600;transition:background .15s}
|
||||||
|
.hdr-back:hover{background:rgba(255,255,255,.26)}
|
||||||
|
.hdr-kicker{font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.14em;opacity:.85}
|
||||||
|
.hdr h1{font-family:'Outfit',sans-serif;font-size:1.55rem;font-weight:900;letter-spacing:-.01em;line-height:1.25;margin-top:3px}
|
||||||
|
.hdr-side{margin-left:auto}
|
||||||
|
.hdr-btn{padding:8px 12px;background:rgba(255,255,255,.16);border:none;color:#fff;border-radius:9px;cursor:pointer;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;font-family:inherit}
|
||||||
|
.hdr-btn:hover{background:rgba(255,255,255,.26)}
|
||||||
|
|
||||||
|
main{max-width:1000px;margin:0 auto;padding:28px 24px 60px}
|
||||||
|
|
||||||
|
.wip{display:flex;gap:14px;align-items:flex-start;background:linear-gradient(135deg,var(--pri-soft),rgba(0,0,0,.02));border:1.5px dashed var(--pri);border-radius:16px;padding:18px 20px;margin-bottom:26px}
|
||||||
|
.wip-ic{width:42px;height:42px;border-radius:11px;background:var(--pri);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||||
|
.wip-ic svg{width:22px;height:22px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
.wip h2{font-family:'Outfit',sans-serif;font-size:1.05rem;color:var(--pri-d);margin-bottom:4px}
|
||||||
|
html.dark .wip h2{color:var(--pri-l)}
|
||||||
|
.wip p{font-size:.9rem;color:var(--muted);line-height:1.55}
|
||||||
|
|
||||||
|
.ol-title{font-family:'Outfit',sans-serif;font-size:1.15rem;font-weight:800;margin:6px 0 14px;display:flex;align-items:center;gap:9px}
|
||||||
|
.ol-title svg{width:20px;height:20px;stroke:var(--pri);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
.ol-list{list-style:none;background:var(--card);border:1px solid var(--border);border-radius:14px;overflow:hidden;box-shadow:var(--sh)}
|
||||||
|
.ol-para,.ol-note{display:flex;gap:12px;align-items:baseline;padding:12px 18px;border-bottom:1px solid var(--border)}
|
||||||
|
.ol-list li:last-child{border-bottom:0}
|
||||||
|
.ol-num{flex-shrink:0;min-width:46px;font-weight:800;color:var(--pri);font-size:.92rem}
|
||||||
|
.ol-name{font-size:.94rem;color:var(--text)}
|
||||||
|
.ol-note{background:var(--pri-soft);align-items:center;gap:10px}
|
||||||
|
.ol-note-ic{display:inline-flex;color:var(--pri-d)}
|
||||||
|
html.dark .ol-note-ic{color:var(--pri-l)}
|
||||||
|
.ol-note-ic svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
.ol-note span:last-child{font-size:.88rem;font-weight:600;color:var(--pri-d)}
|
||||||
|
html.dark .ol-note span:last-child{color:var(--pri-l)}
|
||||||
|
|
||||||
|
.foot{text-align:center;padding:24px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-inner">
|
||||||
|
<a href="/textbook/chemistry-8" class="hdr-back">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
К разделам
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<div class="hdr-kicker">${esc(ch.kicker)} · ${esc(ch.range)}</div>
|
||||||
|
<h1>${esc(ch.title)}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-side">
|
||||||
|
<button id="theme-btn" class="hdr-btn" title="Сменить тему">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
|
<span id="theme-lab">Тёмная</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="wip">
|
||||||
|
<div class="wip-ic">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4l-6.3 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2-2 2.6-2.6z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Раздел в разработке</h2>
|
||||||
|
<p>Интерактивное наглядное наполнение этого раздела (теория, модели, симуляторы, тренажёры и боссы) добавляется поэтапно. Ниже — план параграфов раздела согласно учебнику.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ol-title">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||||
|
Содержание раздела
|
||||||
|
</div>
|
||||||
|
<ul class="ol-list">
|
||||||
|
${outlineHtml(ch.items)}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
Интерактивный учебник «Химия — 8 класс» · ${esc(ch.kicker)} · LearnSpace
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
const _TB_SLUG = '${ch.slug}';
|
||||||
|
(function(){
|
||||||
|
var saved = localStorage.getItem('chemistry8_theme') || localStorage.getItem('theme') || 'light';
|
||||||
|
if (saved === 'dark') document.documentElement.classList.add('dark');
|
||||||
|
var lab = document.getElementById('theme-lab');
|
||||||
|
if (lab) lab.textContent = saved === 'dark' ? 'Светлая' : 'Тёмная';
|
||||||
|
document.getElementById('theme-btn').addEventListener('click', function(){
|
||||||
|
document.documentElement.classList.toggle('dark');
|
||||||
|
var dark = document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem('chemistry8_theme', dark ? 'dark' : 'light');
|
||||||
|
localStorage.setItem('theme', dark ? 'dark' : 'light');
|
||||||
|
if (lab) lab.textContent = dark ? 'Светлая' : 'Тёмная';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --force перезапишет уже существующие файлы; по умолчанию — пропускаем
|
||||||
|
// готовые (наполненные в фазах) страницы, чтобы не затереть контент.
|
||||||
|
const FORCE = process.argv.includes('--force');
|
||||||
|
let count = 0, skipped = 0;
|
||||||
|
for (const ch of CHAPTERS) {
|
||||||
|
const target = path.join(OUT, ch.file);
|
||||||
|
if (!FORCE && fs.existsSync(target)) {
|
||||||
|
skipped++;
|
||||||
|
console.log('skip ', ch.file, '(уже существует — наполнен в фазе)');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(target, pageHtml(ch), 'utf8');
|
||||||
|
count++;
|
||||||
|
console.log('written', ch.file, '(' + ch.items.filter(i => i.t).length + ' §)');
|
||||||
|
}
|
||||||
|
console.log('done:', count, 'written,', skipped, 'skipped');
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
// Генератор stub-файлов разделов Геометрии 10. W0.
|
||||||
|
// Запуск: node backend/scripts/gen_geom10_stubs.js
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ file:'geometry_10_r1.html', num:1, slug:'geometry-10-r1',
|
||||||
|
title:'Введение в стереометрию',
|
||||||
|
sub:'Пространственные фигуры · Аксиомы · Сечения',
|
||||||
|
range:'§1–§3 + Финал', wm:'△',
|
||||||
|
primary:'#2563eb', primaryD:'#1d4ed8', soft:'#dbeafe', dark:'#1e3a8a',
|
||||||
|
paras:[
|
||||||
|
{ n:1, title:'Пространственные фигуры',
|
||||||
|
sub:'Призма, пирамида, цилиндр, конус, шар. Грани, рёбра, вершины.' },
|
||||||
|
{ n:2, title:'Прямые и плоскости',
|
||||||
|
sub:'Аксиомы стереометрии (A1–A3) и их следствия. 4 способа задания плоскости.' },
|
||||||
|
{ n:3, title:'Построения сечений',
|
||||||
|
sub:'Метод следов. Сечения куба, призмы, пирамиды.' }
|
||||||
|
] },
|
||||||
|
{ file:'geometry_10_r2.html', num:2, slug:'geometry-10-r2',
|
||||||
|
title:'Параллельность',
|
||||||
|
sub:'Прямые · Прямая и плоскость · Плоскости',
|
||||||
|
range:'§4–§6 + Финал', wm:'∥',
|
||||||
|
primary:'#059669', primaryD:'#047857', soft:'#d1fae5', dark:'#064e3b',
|
||||||
|
paras:[
|
||||||
|
{ n:4, title:'Расположение прямых в пространстве',
|
||||||
|
sub:'Пересекающиеся, параллельные, скрещивающиеся прямые. Угол между скрещивающимися.' },
|
||||||
|
{ n:5, title:'Прямая и плоскость',
|
||||||
|
sub:'Три случая. Признак параллельности прямой и плоскости.' },
|
||||||
|
{ n:6, title:'Две плоскости',
|
||||||
|
sub:'Пересекаются или параллельны. Признак параллельности плоскостей.' }
|
||||||
|
] },
|
||||||
|
{ file:'geometry_10_r3.html', num:3, slug:'geometry-10-r3',
|
||||||
|
title:'Перпендикулярность',
|
||||||
|
sub:'Прямая ⊥ плоскость · Расстояния · Углы · Двугранный угол',
|
||||||
|
range:'§7–§10 + Финал', wm:'⊥',
|
||||||
|
primary:'#e11d48', primaryD:'#be123c', soft:'#fee2e2', dark:'#7f1d1d',
|
||||||
|
paras:[
|
||||||
|
{ n:7, title:'Перпендикулярность прямой и плоскости',
|
||||||
|
sub:'Определение, признак перпендикулярности.' },
|
||||||
|
{ n:8, title:'Расстояния',
|
||||||
|
sub:'От точки до плоскости, между параллельными плоскостями, между скрещивающимися.' },
|
||||||
|
{ n:9, title:'Угол между прямой и плоскостью',
|
||||||
|
sub:'Наклонная и её проекция. Теорема о трёх перпендикулярах.' },
|
||||||
|
{ n:10, title:'Перпендикулярность плоскостей',
|
||||||
|
sub:'Двугранный угол. Признак перпендикулярности плоскостей.' }
|
||||||
|
] },
|
||||||
|
{ file:'geometry_10_r4.html', num:4, slug:'geometry-10-r4',
|
||||||
|
title:'Координаты и векторы',
|
||||||
|
sub:'ПДСК в пространстве · Векторы · Скалярное произведение',
|
||||||
|
range:'§11–§14 + Финал', wm:'→',
|
||||||
|
primary:'#d97706', primaryD:'#b45309', soft:'#fef3c7', dark:'#78350f',
|
||||||
|
paras:[
|
||||||
|
{ n:11, title:'Координаты в пространстве',
|
||||||
|
sub:'ПДСК. Точка (x; y; z). Расстояние между точками.' },
|
||||||
|
{ n:12, title:'Вектор. Действия над векторами',
|
||||||
|
sub:'Сложение, умножение на число, базис i, j, k. Разложение вектора.' },
|
||||||
|
{ n:13, title:'Скалярное произведение',
|
||||||
|
sub:'a · b = |a||b|cos α = x₁x₂ + y₁y₂ + z₁z₂. Условие перпендикулярности.' },
|
||||||
|
{ n:14, title:'Применение координат и векторов',
|
||||||
|
sub:'Уравнения, углы, расстояния. Векторно-координатный метод.' }
|
||||||
|
] }
|
||||||
|
];
|
||||||
|
|
||||||
|
function html(s){
|
||||||
|
const parasHtml = s.paras.map(p => `
|
||||||
|
<article class="para-card" data-para="${p.n}">
|
||||||
|
<div class="para-num">§ ${p.n}</div>
|
||||||
|
<div class="para-body">
|
||||||
|
<h2 class="para-title">${p.title}</h2>
|
||||||
|
<p class="para-sub">${p.sub}</p>
|
||||||
|
<div class="para-status">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
Будет добавлено в следующей волне реализации
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>`).join('\n');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Геометрия 10 · ${s.title}</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
|
<script src="/js/stereo3d.js?v=1" defer></script>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#f8fafc; --card:#fff;
|
||||||
|
--text:#0f172a; --muted:#475569;
|
||||||
|
--border:#e2e8f0;
|
||||||
|
--pri:${s.primary}; --pri-d:${s.primaryD};
|
||||||
|
--pri-soft:${s.soft};
|
||||||
|
--dark:${s.dark};
|
||||||
|
--sh:0 4px 16px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
html.dark{
|
||||||
|
--bg:#020617; --card:#0a1929;
|
||||||
|
--text:#dbeafe; --muted:#94a3b8;
|
||||||
|
--border:#1e293b;
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{min-height:100vh}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;transition:background .25s,color .25s}
|
||||||
|
|
||||||
|
.hdr{position:relative;background:linear-gradient(110deg,var(--dark) 0%,var(--pri) 55%,var(--pri-soft) 100%);color:#fff;padding:32px 24px 28px;overflow:hidden}
|
||||||
|
.hdr::before{content:'${s.wm}';position:absolute;right:8px;top:-20%;font-family:'Outfit',sans-serif;font-size:clamp(8rem,22vw,18rem);font-weight:900;color:rgba(255,255,255,.10);line-height:1;pointer-events:none;user-select:none}
|
||||||
|
.hdr-inner{position:relative;z-index:1;max-width:1100px;margin:0 auto;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||||||
|
.hdr-back{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;background:rgba(255,255,255,.14);border-radius:9px;color:#fff;text-decoration:none;font-size:.85rem;font-weight:600;transition:background .15s}
|
||||||
|
.hdr-back:hover{background:rgba(255,255,255,.24)}
|
||||||
|
.hdr h1{font-family:'Outfit',sans-serif;font-size:1.7rem;font-weight:900;letter-spacing:-.01em}
|
||||||
|
.hdr-sub{font-size:.92rem;opacity:.85;margin-top:4px}
|
||||||
|
.hdr-side{margin-left:auto;display:flex;gap:8px}
|
||||||
|
.hdr-btn{padding:8px 12px;background:rgba(255,255,255,.14);border:none;color:#fff;border-radius:9px;cursor:pointer;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;font-family:inherit}
|
||||||
|
.hdr-btn:hover{background:rgba(255,255,255,.24)}
|
||||||
|
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
|
||||||
|
main{max-width:980px;margin:0 auto;padding:32px 24px 60px}
|
||||||
|
|
||||||
|
.intro-card{background:var(--card);border:1.5px solid var(--border);border-radius:16px;padding:22px 26px;margin-bottom:28px;box-shadow:var(--sh)}
|
||||||
|
.intro-num{display:inline-block;padding:4px 10px;background:var(--pri-soft);color:var(--pri-d);border-radius:99px;font-size:.72rem;font-weight:800;letter-spacing:.06em;margin-bottom:8px;text-transform:uppercase}
|
||||||
|
.intro-card h2{font-family:'Outfit',sans-serif;font-size:1.4rem;font-weight:800;margin-bottom:6px}
|
||||||
|
.intro-card p{color:var(--muted);font-size:.95rem}
|
||||||
|
|
||||||
|
.para-grid{display:grid;grid-template-columns:1fr;gap:14px}
|
||||||
|
.para-card{background:var(--card);border:1.5px solid var(--border);border-radius:14px;padding:18px 20px;display:flex;gap:16px;align-items:flex-start;transition:transform .15s,box-shadow .15s,border-color .15s}
|
||||||
|
.para-card:hover{transform:translateY(-2px);box-shadow:var(--sh);border-color:var(--pri)}
|
||||||
|
.para-num{font-family:'Outfit',sans-serif;font-size:1rem;font-weight:900;color:#fff;background:linear-gradient(135deg,var(--pri),var(--pri-d));padding:8px 12px;border-radius:9px;min-width:56px;text-align:center;letter-spacing:-.02em;flex-shrink:0}
|
||||||
|
.para-body{flex:1}
|
||||||
|
.para-title{font-family:'Outfit',sans-serif;font-size:1.05rem;font-weight:800;margin-bottom:4px;color:var(--text)}
|
||||||
|
.para-sub{font-size:.88rem;color:var(--muted);margin-bottom:10px;line-height:1.55}
|
||||||
|
.para-status{display:inline-flex;align-items:center;gap:6px;font-size:.78rem;color:var(--muted);background:rgba(0,0,0,.04);padding:6px 10px;border-radius:8px;font-weight:600}
|
||||||
|
html.dark .para-status{background:rgba(255,255,255,.06)}
|
||||||
|
.para-status .ic{width:14px;height:14px}
|
||||||
|
|
||||||
|
.banner-soon{margin-top:30px;text-align:center;padding:20px;background:linear-gradient(135deg,var(--pri-soft),transparent);border:1px dashed var(--pri);border-radius:14px;color:var(--pri-d);font-weight:700;font-size:.92rem}
|
||||||
|
.banner-soon b{font-family:'Outfit',sans-serif}
|
||||||
|
|
||||||
|
.foot{text-align:center;padding:24px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-inner">
|
||||||
|
<div>
|
||||||
|
<a href="/textbook/geometry-10" class="hdr-back">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
К курсу геометрии 10
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Раздел ${s.num}. ${s.title}</h1>
|
||||||
|
<div class="hdr-sub">${s.sub} · ${s.range}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-side">
|
||||||
|
<button id="theme-btn" class="hdr-btn" title="Сменить тему">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
|
<span id="theme-lab">Тёмная</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<div class="intro-card">
|
||||||
|
<span class="intro-num">Раздел ${s.num}</span>
|
||||||
|
<h2>${s.title}</h2>
|
||||||
|
<p>${s.sub}. Раздел содержит ${s.paras.length} параграф${s.paras.length===1?'':(s.paras.length<5?'а':'ов')} и финальный этап с боссами.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="para-grid">
|
||||||
|
${parasHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="banner-soon">
|
||||||
|
<b>Раздел в разработке.</b> Полная реализация — в следующих волнах. Уже доступна 3D-библиотека <code>stereo3d.js</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
Геометрия — 10 класс · Раздел ${s.num} · LearnSpace
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
(function(){
|
||||||
|
var saved = localStorage.getItem('geometry10_theme') || localStorage.getItem('theme') || 'light';
|
||||||
|
if (saved === 'dark') document.documentElement.classList.add('dark');
|
||||||
|
var lab = document.getElementById('theme-lab');
|
||||||
|
if (lab) lab.textContent = saved === 'dark' ? 'Светлая' : 'Тёмная';
|
||||||
|
document.getElementById('theme-btn').addEventListener('click', function(){
|
||||||
|
document.documentElement.classList.toggle('dark');
|
||||||
|
var dark = document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem('geometry10_theme', dark ? 'dark' : 'light');
|
||||||
|
localStorage.setItem('theme', dark ? 'dark' : 'light');
|
||||||
|
if (lab) lab.textContent = dark ? 'Светлая' : 'Тёмная';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
for (const s of sections){
|
||||||
|
const fp = path.join(outDir, s.file);
|
||||||
|
fs.writeFileSync(fp, html(s), 'utf8');
|
||||||
|
console.log('Wrote:', fp);
|
||||||
|
}
|
||||||
|
console.log('Done.');
|
||||||
@@ -0,0 +1,985 @@
|
|||||||
|
// Generator for Geometry 11 chapter files (Phase 0 skeleton).
|
||||||
|
// Produces frontend/textbooks/geometry_11_ch{1..4}.html with all helpers + STUB builders.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const OUT_DIR = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
const CHAPTERS = [
|
||||||
|
{
|
||||||
|
n: 1,
|
||||||
|
title: 'Призма и цилиндр',
|
||||||
|
hdr_sub: 'Призма (правильная, прямая, наклонная, параллелепипед, куб) · цилиндр и его сечения',
|
||||||
|
hero_h2: 'Призма и цилиндр — главные стереометрические тела',
|
||||||
|
hero_p: 'Изучаем призму и цилиндр — главные стереометрические тела. Сечения, развёртки, формулы площадей и объёмов в 3D.',
|
||||||
|
final_id: 'final1',
|
||||||
|
color: {
|
||||||
|
hdr_grad: 'linear-gradient(110deg,#92400e 0%,#d97706 55%,#fbbf24 100%)',
|
||||||
|
hdr_label: 'РАЗДЕЛ 1',
|
||||||
|
hdr_border: 'rgba(251,191,36,.2)',
|
||||||
|
pri: '#d97706', pri2: '#b45309', pri_soft: '#fef3c7',
|
||||||
|
acc: '#f59e0b', acc2: '#d97706', acc_soft: '#fef9c3',
|
||||||
|
dark_bg: '#0a0a0e', dark_card: '#13120a', dark_card_soft: '#18160a', dark_text: '#fef9e7',
|
||||||
|
dark_muted: '#a39070', dark_border: '#2a2512',
|
||||||
|
},
|
||||||
|
paras: [
|
||||||
|
{ id: 'p1', num: '§ 1', name: 'Призма', sub: '$S_{бок}=Pl$, $V=S_{осн}h$', watermark: '\\triangle' },
|
||||||
|
{ id: 'p2', num: '§ 2', name: 'Цилиндр', sub: '$S_{бок}=2\\pi Rh$, $V=\\pi R^2h$', watermark: '\\bigcirc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 2,
|
||||||
|
title: 'Пирамида и конус',
|
||||||
|
hdr_sub: 'Пирамида (правильная, усечённая) · конус (правильный, усечённый) · объёмы через 1/3',
|
||||||
|
hero_h2: 'Пирамида и конус — фигуры с вершиной',
|
||||||
|
hero_p: 'Пирамида и конус — фигуры с вершиной. Правильные и усечённые. Объём через одну треть основания на высоту.',
|
||||||
|
final_id: 'final2',
|
||||||
|
color: {
|
||||||
|
hdr_grad: 'linear-gradient(110deg,#064e3b 0%,#059669 55%,#34d399 100%)',
|
||||||
|
hdr_label: 'РАЗДЕЛ 2',
|
||||||
|
hdr_border: 'rgba(52,211,153,.2)',
|
||||||
|
pri: '#059669', pri2: '#047857', pri_soft: '#d1fae5',
|
||||||
|
acc: '#10b981', acc2: '#059669', acc_soft: '#a7f3d0',
|
||||||
|
dark_bg: '#04140e', dark_card: '#082017', dark_card_soft: '#0a2a1d', dark_text: '#d1fae5',
|
||||||
|
dark_muted: '#6ee7b7', dark_border: '#0f3a28',
|
||||||
|
},
|
||||||
|
paras: [
|
||||||
|
{ id: 'p3', num: '§ 3', name: 'Пирамида', sub: '$V=\\frac{1}{3}S_{осн}h$', watermark: '\\triangledown' },
|
||||||
|
{ id: 'p4', num: '§ 4', name: 'Конус', sub: '$S_{бок}=\\pi Rl$', watermark: '\\nabla' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 3,
|
||||||
|
title: 'Сфера и шар',
|
||||||
|
hdr_sub: 'Сфера и её уравнение · шар, сегменты · 5 платоновых тел',
|
||||||
|
hero_h2: 'Сфера, шар, правильные многогранники',
|
||||||
|
hero_p: 'Сфера, шар, пять платоновых тел. Уравнение сферы в координатах, шаровые сегменты, вписанные и описанные многогранники.',
|
||||||
|
final_id: 'final3',
|
||||||
|
color: {
|
||||||
|
hdr_grad: 'linear-gradient(110deg,#3b0764 0%,#7c3aed 55%,#a78bfa 100%)',
|
||||||
|
hdr_label: 'РАЗДЕЛ 3',
|
||||||
|
hdr_border: 'rgba(167,139,250,.2)',
|
||||||
|
pri: '#7c3aed', pri2: '#6d28d9', pri_soft: '#ede9fe',
|
||||||
|
acc: '#a78bfa', acc2: '#7c3aed', acc_soft: '#f3e8ff',
|
||||||
|
dark_bg: '#0e0521', dark_card: '#1a0a30', dark_card_soft: '#220c3d', dark_text: '#ede9fe',
|
||||||
|
dark_muted: '#c4b5fd', dark_border: '#3a1d5e',
|
||||||
|
},
|
||||||
|
paras: [
|
||||||
|
{ id: 'p5', num: '§ 5', name: 'Сфера', sub: '$(x-a)^2+(y-b)^2+(z-c)^2=R^2$', watermark: 'S^2' },
|
||||||
|
{ id: 'p6', num: '§ 6', name: 'Шар', sub: '$S=4\\pi R^2$, $V=\\frac{4}{3}\\pi R^3$', watermark: 'V' },
|
||||||
|
{ id: 'p7', num: '§ 7', name: 'Правильные многогранники', sub: '5 платоновых тел', watermark: '\\star' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 4,
|
||||||
|
title: 'Повторение',
|
||||||
|
hdr_sub: 'Планиметрия · величины · координаты и векторы в 3D · построения',
|
||||||
|
hero_h2: 'Повторение всей геометрии',
|
||||||
|
hero_p: 'Повторение всей геометрии: планиметрия, площади и объёмы, координаты и векторы в 3D, классические построения.',
|
||||||
|
final_id: 'final4',
|
||||||
|
color: {
|
||||||
|
hdr_grad: 'linear-gradient(110deg,#881337 0%,#e11d48 55%,#fb7185 100%)',
|
||||||
|
hdr_label: 'РАЗДЕЛ 4',
|
||||||
|
hdr_border: 'rgba(251,113,133,.2)',
|
||||||
|
pri: '#e11d48', pri2: '#be123c', pri_soft: '#ffe4e6',
|
||||||
|
acc: '#f43f5e', acc2: '#e11d48', acc_soft: '#fecdd3',
|
||||||
|
dark_bg: '#1a0510', dark_card: '#2a081a', dark_card_soft: '#36102a', dark_text: '#ffe4e6',
|
||||||
|
dark_muted: '#fda4af', dark_border: '#4a1029',
|
||||||
|
},
|
||||||
|
paras: [
|
||||||
|
{ id: 'p8', num: '§ 8', name: 'Геометрические фигуры и их свойства', sub: 'планиметрия', watermark: '\\square' },
|
||||||
|
{ id: 'p9', num: '§ 9', name: 'Геометрические величины', sub: 'площади, объёмы', watermark: 'S=' },
|
||||||
|
{ id: 'p10', num: '§ 10', name: 'Координаты и векторы', sub: '3D: $\\vec{a}=(x;y;z)$', watermark: '\\vec{v}' },
|
||||||
|
{ id: 'p11', num: '§ 11', name: 'Геометрические построения', sub: 'циркуль и линейка', watermark: '\\circlearrowleft' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function chapterTpl(ch) {
|
||||||
|
const allParas = ch.paras.concat([{ id: ch.final_id, num: '★', name: 'Финал раздела', sub: 'Итоги · боссы раздела ' + ch.n, final: true, watermark: '\\star' }]);
|
||||||
|
const PARAS_JS = allParas.map(p => {
|
||||||
|
const sub = p.sub ? `, sub:'${p.sub.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` : '';
|
||||||
|
const fin = p.final ? `, final:true` : '';
|
||||||
|
return ` { id:'${p.id}', num:'${p.num}', name:${JSON.stringify(p.name)}${sub}${fin} }`;
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
// SIDEBARS (basic placeholder per para)
|
||||||
|
const SIDEBARS_JS = allParas.map(p => {
|
||||||
|
const t = p.final ? `Финал раздела ${ch.n}` : `Шпаргалка ${p.num}`;
|
||||||
|
const rows = p.final
|
||||||
|
? `[["${ch.paras[0].num}–${ch.paras[ch.paras.length-1].num}","теория раздела ${ch.n}"],["Награда","+50 XP"]]`
|
||||||
|
: `[["Тема", ${JSON.stringify(p.name)}],["Формула","${(p.sub || '').replace(/\\/g, '\\\\\\\\').replace(/"/g, '\\"')}"]]`;
|
||||||
|
return ` ${p.id}:{title:${JSON.stringify(t)}, rows:${rows}}`;
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
const TIPS_JS = allParas.map(p => {
|
||||||
|
const html = p.final
|
||||||
|
? `Финал раздела ${ch.n} — интегрированные задачи по разделу.`
|
||||||
|
: `${p.num} «${p.name}» — содержание в разработке. ${(p.sub || '').replace(/\\/g, '\\\\\\\\')}`;
|
||||||
|
return ` {sec:'${p.id}',html:${JSON.stringify(html)}}`;
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
const ACH_LABELS_JS = ch.paras.map(p => ` ${p.id}_done:"${p.name} освоено!"`).concat([
|
||||||
|
` start:"Начало раздела ${ch.n}!"`,
|
||||||
|
` ch${ch.n}_done:"Раздел ${ch.n} пройден!"`
|
||||||
|
]).join(',\n');
|
||||||
|
|
||||||
|
// sec[id="sec-pX"] rules for accent colors
|
||||||
|
const SEC_COLOR_RULES = allParas.map(p => {
|
||||||
|
return `.sec[id="sec-${p.id}"]{ --sec-acc:${ch.color.pri}; --sec-acc-d:${ch.color.pri2}; --sec-acc-soft:${ch.color.pri_soft}; }`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
// sec elements in body
|
||||||
|
const SEC_HTML = allParas.map(p => {
|
||||||
|
const w = p.final ? '★' : p.watermark;
|
||||||
|
return ` <section id="sec-${p.id}" class="sec" data-watermark="${w}"><div class="sec-header"><span class="sec-num"${p.final ? ' style="background:linear-gradient(135deg,'+ch.color.pri+','+ch.color.acc+')"' : ''}>${p.final ? '★' : p.num}</span><h2 class="sec-h">${p.name}</h2></div><div id="${p.id}-body"></div></section>`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
// builders map
|
||||||
|
const BUILDERS_JS = allParas.map(p => `${p.id}:()=>buildStub('${p.id}')`).join(', ');
|
||||||
|
|
||||||
|
// search NAMES
|
||||||
|
const NAMES_JS = allParas.map(p => `${p.id}:'${p.final ? 'Финал' : p.num.replace('§', '\\xA7').replace(' ', '')}'`).join(',');
|
||||||
|
|
||||||
|
const TOTAL_PARAS = allParas.length;
|
||||||
|
const SLUG = `geometry-11-ch${ch.n}`;
|
||||||
|
const TITLE = `Геометрия 11 · Раздел ${ch.n} · «${ch.title}»`;
|
||||||
|
const HDR_H1 = `Геометрия 11 · Раздел ${ch.n}`;
|
||||||
|
const HDR_LABEL = ch.color.hdr_label;
|
||||||
|
const HDR_GRAD = ch.color.hdr_grad;
|
||||||
|
const HDR_BORDER = ch.color.hdr_border;
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
<title>${TITLE}</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||||
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false})"></script>
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
|
<script src="/js/g3d.js" defer></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#fafafa; --card:#fff; --card-soft:#f8fafc; --text:#0f172a; --ink:#0f172a; --muted:#64748b;
|
||||||
|
--border:#e2e8f0; --sh:0 1px 3px rgba(0,0,0,.06); --sh2:0 4px 14px rgba(0,0,0,.08);
|
||||||
|
--pri:${ch.color.pri}; --pri2:${ch.color.pri2}; --pri-soft:${ch.color.pri_soft};
|
||||||
|
--acc:${ch.color.acc}; --acc2:${ch.color.acc2}; --acc-soft:${ch.color.acc_soft};
|
||||||
|
--ok:#10b981; --ok-bg:#d1fae5; --warn:#f59e0b; --warn-bg:#fef3c7;
|
||||||
|
--bad:#ef4444; --fail:#dc2626; --fail-bg:#fee2e2;
|
||||||
|
}
|
||||||
|
.dark{--bg:${ch.color.dark_bg}; --card:${ch.color.dark_card}; --card-soft:${ch.color.dark_card_soft}; --text:${ch.color.dark_text}; --ink:${ch.color.dark_text}; --muted:${ch.color.dark_muted}; --border:${ch.color.dark_border}}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||||
|
html,body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;font-size:15px}
|
||||||
|
button,input,select,textarea{font-family:inherit;font-size:inherit}
|
||||||
|
button{cursor:pointer;border:0;background:transparent;color:inherit}
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
.ic{width:16px;height:16px;display:inline-block;flex-shrink:0;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}
|
||||||
|
|
||||||
|
.hdr{position:relative;background:${HDR_GRAD};color:#fff;padding:46px 22px 30px;overflow:hidden;border-bottom:2px solid ${HDR_BORDER};min-height:130px}
|
||||||
|
.hdr::before{content:'${HDR_LABEL}';position:absolute;right:-12px;top:50%;transform:translateY(-50%);font-family:'Unbounded',sans-serif;font-size:clamp(5rem,15vw,11rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px rgba(255,255,255,.12);line-height:1;pointer-events:none;user-select:none;z-index:0}
|
||||||
|
.hdr-row{position:relative;z-index:1;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||||||
|
.hdr h1{font-family:'Unbounded',sans-serif;font-size:1.5rem;font-weight:900;letter-spacing:-.01em;line-height:1.3;padding-top:4px}
|
||||||
|
.hdr-sub{font-size:.85rem;opacity:.88;margin-top:6px;font-weight:500;line-height:1.4}
|
||||||
|
.hdr-side{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.hdr-btn{padding:7px 12px;border-radius:9px;background:rgba(255,255,255,.14);color:#fff;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;text-decoration:none}
|
||||||
|
.hdr-btn:hover{background:rgba(255,255,255,.24)}
|
||||||
|
|
||||||
|
.main{max-width:1240px;margin:0 auto;padding:22px;width:100%;display:grid;grid-template-columns:1fr 280px;gap:24px}
|
||||||
|
@media(max-width:980px){.main{grid-template-columns:1fr;padding:14px}}
|
||||||
|
.col-main{min-width:0}
|
||||||
|
|
||||||
|
.hero{background:linear-gradient(135deg,var(--pri-soft) 0%,var(--acc-soft) 50%,var(--pri-soft) 100%);background-size:200% 200%;animation:heroShift 12s ease-in-out infinite;border:1px solid var(--border);border-radius:18px;padding:24px 22px;margin-bottom:24px;position:relative;overflow:hidden}
|
||||||
|
@keyframes heroShift{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
|
||||||
|
.hero::before{content:'\\25C7';position:absolute;right:0;top:-30px;font-size:clamp(2rem,12vw,8rem);font-weight:900;color:var(--pri);opacity:.10;line-height:1;pointer-events:none;font-family:'Unbounded',sans-serif}
|
||||||
|
.hero h2{font-family:'Unbounded',sans-serif;font-size:1.55rem;font-weight:800;color:var(--pri2);margin-bottom:10px;letter-spacing:-.01em}
|
||||||
|
.hero p{font-size:.95rem;color:var(--text);opacity:.88;margin-bottom:14px;max-width:640px}
|
||||||
|
.hero-row{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
|
||||||
|
.btn-primary{padding:11px 22px;background:linear-gradient(135deg,var(--pri),var(--pri2));color:#fff;border-radius:11px;font-weight:700;font-size:.92rem;display:inline-flex;align-items:center;gap:8px;box-shadow:var(--sh2);transition:transform .15s,box-shadow .15s}
|
||||||
|
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 8px 28px rgba(0,0,0,.18)}
|
||||||
|
.hero-progress{flex:1;min-width:200px;max-width:280px}
|
||||||
|
.hp-label{font-size:.74rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:5px}
|
||||||
|
.hp-bar{height:8px;background:rgba(0,0,0,.12);border-radius:5px;overflow:hidden}
|
||||||
|
.hp-fill{height:100%;background:linear-gradient(90deg,var(--pri),var(--acc));border-radius:5px;width:0%;transition:width .6s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.hp-text{font-size:.78rem;color:var(--muted);font-weight:700;margin-top:4px;display:block}
|
||||||
|
.hero-xp-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:linear-gradient(135deg,var(--warn,#f59e0b),var(--pri));color:#fff;border-radius:99px;font-size:.82rem;font-weight:800;letter-spacing:.02em;box-shadow:0 4px 12px rgba(0,0,0,.18);font-family:'Unbounded',sans-serif}
|
||||||
|
|
||||||
|
.psel{margin-bottom:24px}
|
||||||
|
.psel-title{font-size:.72rem;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}
|
||||||
|
.psel-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
|
||||||
|
.psel-card{background:var(--card);border:1.5px solid var(--border);border-radius:13px;padding:14px;cursor:pointer;transition:transform .2s,box-shadow .2s,border-color .2s;text-align:left;position:relative}
|
||||||
|
.psel-card:hover{transform:translateY(-3px);box-shadow:var(--sh2);border-color:var(--pri)}
|
||||||
|
.psel-card.active{border-color:var(--pri);background:linear-gradient(135deg,var(--pri-soft),var(--card));box-shadow:var(--sh2)}
|
||||||
|
.psel-card.active::after{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--pri),var(--acc));border-radius:13px 13px 0 0}
|
||||||
|
.psel-num{font-family:'Unbounded',sans-serif;font-size:.72rem;font-weight:800;color:var(--pri);text-transform:uppercase;letter-spacing:.08em;margin-bottom:5px}
|
||||||
|
.psel-name{font-size:.86rem;font-weight:700;color:var(--text);line-height:1.3;margin-bottom:8px}
|
||||||
|
.psel-prog{height:4px;background:rgba(0,0,0,.10);border-radius:3px;overflow:hidden}
|
||||||
|
.psel-prog-fill{height:100%;background:var(--pri);width:0%;transition:width .4s}
|
||||||
|
.psel-card.final{background:linear-gradient(135deg,var(--acc-soft),var(--pri-soft))}
|
||||||
|
.psel-card.final .psel-num{color:var(--warn)}
|
||||||
|
|
||||||
|
${SEC_COLOR_RULES}
|
||||||
|
|
||||||
|
.sec{display:none;position:relative;animation:fadeIn .35s ease}
|
||||||
|
.sec.active{display:block}
|
||||||
|
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||||||
|
.sec::before{content:attr(data-watermark);position:absolute;right:-20px;top:10%;font-family:'Unbounded',sans-serif;font-size:clamp(6rem,18vw,14rem);font-weight:900;color:transparent;-webkit-text-stroke:1.5px var(--sec-acc-soft,var(--pri-soft));line-height:1;pointer-events:none;user-select:none;z-index:0;opacity:.35}
|
||||||
|
.sec-header{margin-bottom:22px;padding-bottom:14px;border-bottom:2px solid var(--sec-acc-soft,var(--pri-soft));position:relative;z-index:1}
|
||||||
|
.sec-num{display:inline-block;padding:4px 10px;background:linear-gradient(135deg,var(--sec-acc,var(--pri)),var(--sec-acc-d,var(--pri2)));color:#fff;border-radius:7px;font-family:'Unbounded',sans-serif;font-size:.78rem;font-weight:800;letter-spacing:.04em;margin-bottom:8px}
|
||||||
|
.sec-h{font-family:'Unbounded',sans-serif;font-size:1.6rem;font-weight:800;color:var(--sec-acc-d,var(--pri2));letter-spacing:-.01em;line-height:1.25}
|
||||||
|
|
||||||
|
.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.04);position:relative;z-index:1;transition:transform .25s cubic-bezier(.16,1,.3,1),box-shadow .25s}
|
||||||
|
.card:hover{transform:translateY(-2px);box-shadow:0 4px 10px rgba(0,0,0,.06),0 16px 36px rgba(0,0,0,.08)}
|
||||||
|
.card-header{display:flex;align-items:center;gap:10px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed var(--border)}
|
||||||
|
.card-icon{width:32px;height:32px;border-radius:9px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#fff}
|
||||||
|
.card-icon.repeat{background:#0ea5e9}.card-icon.theory{background:#8b5cf6}.card-icon.algo{background:#f59e0b}.card-icon.rule{background:#ec4899}.card-icon.example{background:#10b981}.card-icon.oral{background:#06b6d4}
|
||||||
|
.card-icon .ic{width:18px;height:18px}
|
||||||
|
.card-title{font-family:'Unbounded',sans-serif;font-size:.82rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex:1}
|
||||||
|
.card-num{font-size:.74rem;font-weight:700;color:var(--muted);background:var(--sec-acc-soft,var(--pri-soft));padding:3px 7px;border-radius:5px}
|
||||||
|
.card-body{font-size:.94rem;line-height:1.65}
|
||||||
|
.card-body p{margin-bottom:8px}
|
||||||
|
.card-body p:last-child{margin-bottom:0}
|
||||||
|
|
||||||
|
.btn{padding:8px 16px;border-radius:8px;background:var(--card);color:var(--text);border:1.5px solid var(--border);font-weight:600;font-size:.88rem;transition:background .15s,border-color .15s,transform .1s}
|
||||||
|
.btn:hover{background:var(--sec-acc-soft,var(--pri-soft));border-color:var(--sec-acc,var(--pri))}
|
||||||
|
.btn:active{transform:scale(.96)}
|
||||||
|
.btn.primary{background:var(--sec-acc,var(--pri));color:#fff;border-color:var(--sec-acc,var(--pri))}
|
||||||
|
.btn.primary:hover{background:var(--sec-acc-d,var(--pri2));border-color:var(--sec-acc-d,var(--pri2))}
|
||||||
|
|
||||||
|
.feedback{padding:10px 14px;border-radius:9px;font-weight:600;font-size:.88rem;margin-top:8px;display:none}
|
||||||
|
.feedback.ok{display:block;background:var(--ok-bg);color:#065f46;border-left:4px solid var(--ok)}
|
||||||
|
.feedback.fail{display:block;background:var(--fail-bg);color:#7f1d1d;border-left:4px solid var(--fail)}
|
||||||
|
|
||||||
|
.wg{background:linear-gradient(135deg,var(--card),var(--sec-acc-soft,var(--pri-soft)));border:1.5px solid var(--sec-acc,var(--pri));border-radius:14px;padding:18px 20px;margin-bottom:18px;box-shadow:var(--sh2);position:relative;z-index:1}
|
||||||
|
.wg-header{display:flex;align-items:center;gap:8px;margin-bottom:14px}
|
||||||
|
.wg-badge{padding:4px 9px;background:var(--sec-acc,var(--pri));color:#fff;border-radius:6px;font-family:'Unbounded',sans-serif;font-size:.68rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em}
|
||||||
|
.wg-title{font-family:'Unbounded',sans-serif;font-size:1.05rem;font-weight:800;color:var(--sec-acc-d,var(--pri2));flex:1}
|
||||||
|
.wg-help{font-size:.88rem;color:var(--text);margin-bottom:12px;line-height:1.55;background:linear-gradient(135deg,var(--warn-bg,#fef3c7),var(--sec-acc-soft,var(--pri-soft)));border-left:4px solid var(--warn,#f59e0b);padding:9px 14px;border-radius:9px}
|
||||||
|
.tinp{padding:8px 12px;border:1.5px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);transition:border-color .15s;font-family:'JetBrains Mono',monospace}
|
||||||
|
.tinp:focus{outline:0;border-color:var(--sec-acc,var(--pri));box-shadow:0 0 0 3px var(--sec-acc-soft,var(--pri-soft))}
|
||||||
|
.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||||
|
.sliders{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;margin-bottom:10px}
|
||||||
|
.sliders label{display:block;font-size:.92rem;color:var(--muted);background:var(--card);padding:8px 12px;border-radius:8px;border:1px solid var(--border);line-height:1.5}
|
||||||
|
.sliders label b{font-family:'JetBrains Mono',monospace;font-size:1.05rem;color:var(--sec-acc-d,var(--pri2));margin-left:4px}
|
||||||
|
.sliders label input[type="range"]{display:block;width:100%;margin-top:6px;accent-color:var(--sec-acc,var(--pri))}
|
||||||
|
.score-display{display:flex;gap:14px;flex-wrap:wrap;align-items:center;padding:10px 14px;background:var(--sec-acc-soft,var(--pri-soft));border-radius:10px;margin-bottom:12px}
|
||||||
|
.score-display b{color:var(--sec-acc-d,var(--pri2));font-size:1.15rem}
|
||||||
|
.spoiler{border:1px solid var(--border);border-radius:10px;background:var(--card);margin:10px 0;overflow:hidden}
|
||||||
|
.spoiler summary{padding:8px 14px;background:var(--sec-acc-soft,var(--pri-soft));font-weight:700;cursor:pointer;font-size:.88rem;color:var(--sec-acc-d,var(--pri2));list-style:none;display:flex;align-items:center;gap:8px}
|
||||||
|
.spoiler summary::-webkit-details-marker{display:none}
|
||||||
|
.spoiler summary::before{content:'+';font-size:1.2rem;font-weight:900;color:var(--sec-acc,var(--pri));width:18px}
|
||||||
|
.spoiler[open] summary::before{content:'\\2212'}
|
||||||
|
.spoiler-body{padding:10px 14px;font-size:.92rem;line-height:1.6}
|
||||||
|
|
||||||
|
.dnd-pool{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px;padding:10px;border:1.5px dashed var(--border);border-radius:10px;min-height:54px;transition:border-color .18s,background .18s}
|
||||||
|
.dnd-pool.over{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft));border-style:solid}
|
||||||
|
.dnd-pool.col{flex-direction:column;align-items:stretch}
|
||||||
|
.dnd-pool.col .dnd-chip{width:auto}
|
||||||
|
.dnd-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--card);border:1.5px solid var(--border);border-radius:10px;cursor:grab;user-select:none;font-size:.92rem;line-height:1.4;transition:transform .12s,box-shadow .12s,border-color .12s;touch-action:none;max-width:100%}
|
||||||
|
.dnd-chip:hover{transform:translateY(-1px);border-color:var(--sec-acc,var(--pri));box-shadow:var(--sh)}
|
||||||
|
.dnd-chip:active{cursor:grabbing}
|
||||||
|
.dnd-chip.armed{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft));box-shadow:0 0 0 3px ${ch.color.pri_soft};transform:translateY(-1px)}
|
||||||
|
.dnd-chip.dragging{opacity:.28}
|
||||||
|
.dnd-chip.placed{background:var(--sec-acc-soft,var(--pri-soft));border-color:var(--sec-acc,var(--pri))}
|
||||||
|
.dnd-chip .dnd-x{padding:0 5px;color:var(--muted);font-weight:700;font-size:1.05rem;border-radius:4px;cursor:pointer}
|
||||||
|
.dnd-chip .dnd-x:hover{color:var(--bad,var(--fail));background:var(--fail-bg)}
|
||||||
|
.drop-box{background:var(--card);border:1.5px dashed var(--border);border-radius:10px;padding:10px;min-height:90px;transition:border-color .15s,background .15s}
|
||||||
|
.drop-box:hover{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft))}
|
||||||
|
.drop-box h5{font-family:'Unbounded',sans-serif;font-size:.78rem;color:var(--sec-acc-d,var(--pri2));margin-bottom:8px;text-transform:uppercase;letter-spacing:.05em}
|
||||||
|
.drop-box.over{border-color:var(--sec-acc,var(--pri));background:var(--sec-acc-soft,var(--pri-soft));border-style:solid;transform:scale(1.015)}
|
||||||
|
.drop-items{display:flex;flex-wrap:wrap;gap:6px;min-height:32px}
|
||||||
|
.dnd-hint{font-size:.78rem;color:var(--muted);margin-bottom:8px;display:flex;align-items:center;gap:6px}
|
||||||
|
.dnd-hint svg{width:14px;height:14px;flex-shrink:0}
|
||||||
|
|
||||||
|
.col-side{position:sticky;top:14px;align-self:start;height:fit-content;max-height:calc(100vh - 28px);overflow-y:auto}
|
||||||
|
.sidecard{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px;margin-bottom:14px;box-shadow:var(--sh)}
|
||||||
|
.sidecard h4{font-family:'Unbounded',sans-serif;font-size:.74rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||||
|
.sidecard-row{margin-bottom:8px;font-size:.86rem;line-height:1.6}
|
||||||
|
.sidecard-row b{color:var(--pri);font-weight:700}
|
||||||
|
.sidecard-row:last-child{margin-bottom:0}
|
||||||
|
@media(max-width:980px){.col-side{position:static;max-height:none}}
|
||||||
|
|
||||||
|
.xp-card{background:linear-gradient(135deg,var(--acc-soft),var(--pri-soft));border:1.5px solid var(--acc);border-radius:12px;padding:14px;margin-bottom:14px}
|
||||||
|
.xp-card-title{font-size:.68rem;font-weight:800;color:var(--acc2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.xp-level{font-size:1.1rem;font-weight:900;color:var(--acc2);font-family:'Unbounded',sans-serif}
|
||||||
|
.xp-bar{height:9px;background:rgba(0,0,0,.10);border-radius:6px;overflow:hidden;margin:7px 0}
|
||||||
|
.xp-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--pri));border-radius:6px;transition:width .5s cubic-bezier(.4,0,.2,1)}
|
||||||
|
.xp-nums{font-size:.74rem;color:var(--muted);display:flex;justify-content:space-between}
|
||||||
|
|
||||||
|
.sec-nav{display:flex;gap:10px;margin-top:24px;padding-top:20px;border-top:1px solid var(--border);justify-content:space-between;flex-wrap:wrap}
|
||||||
|
.foot{text-align:center;padding:30px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
|
||||||
|
|
||||||
|
.ach-popup{position:fixed;top:80px;right:18px;background:linear-gradient(135deg,var(--pri),var(--acc));color:#fff;padding:12px 18px;border-radius:11px;font-weight:700;font-size:.9rem;box-shadow:0 8px 28px rgba(0,0,0,.32);z-index:1002;display:none;align-items:center;gap:8px;max-width:340px}
|
||||||
|
.ach-popup.show{display:flex}
|
||||||
|
|
||||||
|
.col-side-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.42);z-index:9990;display:none}
|
||||||
|
.col-side-backdrop.show{display:block}
|
||||||
|
@media(max-width:980px){
|
||||||
|
.col-side{position:fixed;top:0;right:0;height:100vh;width:300px;max-width:88vw;background:var(--bg);box-shadow:-12px 0 24px rgba(0,0,0,.18);padding:18px 16px;overflow-y:auto;transform:translateX(100%);transition:transform .25s ease;z-index:9991;max-height:none}
|
||||||
|
.col-side.open{transform:none}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-modal{position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(4px);z-index:9993;display:none;align-items:flex-start;justify-content:center;padding-top:14vh}
|
||||||
|
.search-modal.show{display:flex}
|
||||||
|
.search-box{background:var(--bg);border:1px solid var(--border);border-radius:14px;width:560px;max-width:92vw;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.4)}
|
||||||
|
.search-input{padding:14px 16px;font-size:1rem;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--text);outline:none}
|
||||||
|
.search-results{flex:1;overflow-y:auto;padding:6px 0}
|
||||||
|
.search-row{display:block;padding:8px 16px;cursor:pointer;border-bottom:1px solid var(--border);text-align:left;background:transparent;border:0;width:100%;color:var(--text)}
|
||||||
|
.search-row:hover,.search-row.active{background:var(--sec-acc-soft,var(--pri-soft))}
|
||||||
|
.search-row .sr-kind{font-size:.7rem;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:2px}
|
||||||
|
.search-row .sr-title{font-weight:700;font-size:.92rem;color:var(--text)}
|
||||||
|
.search-row .sr-desc{font-size:.8rem;color:var(--muted);margin-top:2px}
|
||||||
|
.search-empty{padding:20px;text-align:center;color:var(--muted);font-size:.88rem}
|
||||||
|
.search-foot{padding:8px 14px;border-top:1px solid var(--border);font-size:.74rem;color:var(--muted);display:flex;gap:14px}
|
||||||
|
.search-foot kbd{padding:2px 6px;background:var(--card);border:1px solid var(--border);border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:.72rem}
|
||||||
|
|
||||||
|
/* === GEOM11 POLISH === */
|
||||||
|
@keyframes wgFadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||||||
|
.sec.active .wg{animation:wgFadeIn .35s cubic-bezier(.16,1,.3,1) backwards}
|
||||||
|
.sec.active .wg:nth-of-type(1){animation-delay:.02s}
|
||||||
|
.sec.active .wg:nth-of-type(2){animation-delay:.08s}
|
||||||
|
.sec.active .wg:nth-of-type(3){animation-delay:.14s}
|
||||||
|
.sec.active .wg:nth-of-type(4){animation-delay:.20s}
|
||||||
|
.sec.active .wg:nth-of-type(5){animation-delay:.26s}
|
||||||
|
.sec.active .wg:nth-of-type(6){animation-delay:.32s}
|
||||||
|
.wg svg{transition:filter .25s ease}
|
||||||
|
.wg:hover svg{filter:drop-shadow(0 4px 16px rgba(0,0,0,.10))}
|
||||||
|
input[type=range]:active{box-shadow:0 0 0 4px var(--pri-soft);border-radius:8px}
|
||||||
|
.wg input[type=range]{cursor:ew-resize}
|
||||||
|
.score-display b{transition:transform .22s cubic-bezier(.16,1,.3,1),color .22s;display:inline-block;transform-origin:center}
|
||||||
|
.score-display b.bump{transform:scale(1.28);color:var(--pri)}
|
||||||
|
.katex{transition:color .2s}
|
||||||
|
.wg-help .katex:hover,.card-body .katex:hover{color:var(--pri2,var(--pri));cursor:help}
|
||||||
|
.hp-fill,.psel-prog-fill,.xp-fill,[id$="-overall-fill"]{transition:width .6s cubic-bezier(.16,1,.3,1)!important}
|
||||||
|
.boss-card,.btn.primary,.btn-primary{position:relative;overflow:hidden}
|
||||||
|
.btn.primary::after,.btn-primary::after{content:'';position:absolute;inset:0;background:radial-gradient(circle at center,rgba(255,255,255,.42) 0%,transparent 60%);opacity:0;transition:opacity .25s;pointer-events:none}
|
||||||
|
.btn.primary:hover::after,.btn-primary:hover::after{opacity:1}
|
||||||
|
.psel-card{position:relative}
|
||||||
|
.psel-card .psel-done{position:absolute;top:6px;right:6px;width:18px;height:18px;border-radius:50%;background:#10b981;display:none;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(16,185,129,.45);z-index:2}
|
||||||
|
.psel-card .psel-done svg{width:11px;height:11px;stroke:#fff;fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
.psel-card.done .psel-done{display:flex}
|
||||||
|
.sec{transition:opacity .25s}
|
||||||
|
|
||||||
|
/* g3d toolbar */
|
||||||
|
.g3d-tools{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
|
||||||
|
.g3d-tools .btn{padding:5px 10px;font-size:.78rem}
|
||||||
|
.stub-note{padding:18px 22px;background:linear-gradient(135deg,var(--pri-soft),var(--sec-acc-soft));border:1.5px dashed var(--pri);border-radius:13px;text-align:center;color:var(--text);margin-bottom:14px}
|
||||||
|
.stub-note h3{font-family:'Unbounded',sans-serif;color:var(--pri2);margin-bottom:8px;font-size:1.05rem}
|
||||||
|
.stub-note p{color:var(--muted);font-size:.9rem;line-height:1.55}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-row">
|
||||||
|
<div>
|
||||||
|
<h1>${HDR_H1}</h1>
|
||||||
|
<div class="hdr-sub">${ch.hdr_sub}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-side">
|
||||||
|
<a href="/textbook/geometry-11" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К геометрии 11</a>
|
||||||
|
<button id="search-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg> Поиск</button>
|
||||||
|
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||||||
|
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="col-main">
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h2>${ch.hero_h2}</h2>
|
||||||
|
<p>${ch.hero_p}</p>
|
||||||
|
<div class="hero-row">
|
||||||
|
<button class="btn-primary" onclick="goTo('${ch.paras[0].id}')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать ${ch.paras[0].num}</button>
|
||||||
|
<div class="hero-progress">
|
||||||
|
<span class="hp-label">Прогресс по разделу</span>
|
||||||
|
<div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div>
|
||||||
|
<span id="hero-hp-text" class="hp-text">0%</span>
|
||||||
|
</div>
|
||||||
|
<div id="hero-xp-badge" class="hero-xp-badge"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="psel">
|
||||||
|
<div class="psel-title">Параграфы раздела</div>
|
||||||
|
<div id="psel-grid" class="psel-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
${SEC_HTML}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<aside class="col-side" id="col-side"><div id="sidebar-content"></div></aside>
|
||||||
|
<div class="col-side-backdrop" id="col-side-backdrop"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">Интерактивный учебник «Геометрия 11» · Раздел ${ch.n} · «${ch.title}» · LearnSpace</footer>
|
||||||
|
|
||||||
|
<div id="ach-popup" class="ach-popup"><svg class="ic" viewBox="0 0 24 24" style="width:22px;height:22px"><polygon points="12,2 22,20 2,20"/></svg><span id="ach-text">Достижение!</span></div>
|
||||||
|
<div id="search-modal" class="search-modal" role="dialog">
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="search-input" class="search-input" placeholder="Поиск…" autocomplete="off">
|
||||||
|
<div id="search-results" class="search-results"></div>
|
||||||
|
<div class="search-foot"><span><kbd>↑↓</kbd> навигация</span><span><kbd>Enter</kbd> открыть</span><span><kbd>Esc</kbd> закрыть</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STATE = { current:'${ch.paras[0].id}', progress:{}, achievements:new Map(), xp:0, level:1 };
|
||||||
|
const TOTAL_PARAS = ${TOTAL_PARAS};
|
||||||
|
const _TB_SLUG = '${SLUG}';
|
||||||
|
|
||||||
|
const PARAS = [
|
||||||
|
${PARAS_JS}
|
||||||
|
];
|
||||||
|
PARAS.forEach(p => { STATE.progress[p.id] = 0; });
|
||||||
|
|
||||||
|
function calcLevel(xp){ return Math.floor(Math.sqrt((xp||0)/100))+1; }
|
||||||
|
function _xpForLevel(lv){ return (lv-1)*(lv-1)*100; }
|
||||||
|
|
||||||
|
const ACH_LABELS = {
|
||||||
|
${ACH_LABELS_JS}
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadProgress(){
|
||||||
|
try{
|
||||||
|
const s=localStorage.getItem('geometry11_ch${ch.n}_progress'); if(s) Object.assign(STATE.progress, JSON.parse(s));
|
||||||
|
const a=localStorage.getItem('geometry11_ch${ch.n}_achievements');
|
||||||
|
if(a){ const p=JSON.parse(a); if(Array.isArray(p)) p.forEach(id=>STATE.achievements.set(id, ACH_LABELS[id]||id)); else if(p&&typeof p==='object'){ for(const[id,t] of Object.entries(p)) STATE.achievements.set(id,(t&&t!==id)?t:(ACH_LABELS[id]||id)); } }
|
||||||
|
STATE.xp=+(localStorage.getItem('geometry11_xp')||0); STATE.level=calcLevel(STATE.xp);
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function saveProgress(){
|
||||||
|
try{
|
||||||
|
localStorage.setItem('geometry11_ch${ch.n}_progress', JSON.stringify(STATE.progress));
|
||||||
|
localStorage.setItem('geometry11_ch${ch.n}_achievements', JSON.stringify(Object.fromEntries(STATE.achievements)));
|
||||||
|
localStorage.setItem('geometry11_xp', String(STATE.xp));
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function bumpProgress(key, delta){
|
||||||
|
STATE.progress[key]=Math.max(0,Math.min(100,(STATE.progress[key]||0)+delta));
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(STATE.progress[key]>=50) markParaRead(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _markedRead=new Set();
|
||||||
|
let _pendingProgressBody=null, _progressTimer=null;
|
||||||
|
function _flushProgress(){
|
||||||
|
const body=_pendingProgressBody; _pendingProgressBody=null; if(!body) return;
|
||||||
|
const tok=(window.LS&&LS.getToken)?LS.getToken():''; if(!tok) return;
|
||||||
|
fetch('/api/textbooks/'+_TB_SLUG+'/progress',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tok},body:JSON.stringify(body),keepalive:true}).catch(()=>{});
|
||||||
|
}
|
||||||
|
function _queueProgress(patch){ _pendingProgressBody=Object.assign(_pendingProgressBody||{},patch); if(_progressTimer) clearTimeout(_progressTimer); _progressTimer=setTimeout(_flushProgress, 600); }
|
||||||
|
function markLastPara(id){ _queueProgress({last_para:id}); }
|
||||||
|
function markParaRead(id){ if(_markedRead.has(id)) return; _markedRead.add(id); _queueProgress({mark_read:id}); }
|
||||||
|
window.addEventListener('beforeunload', _flushProgress);
|
||||||
|
function loadServerReadState(){
|
||||||
|
const tok=(window.LS&&LS.getToken)?LS.getToken():''; if(!tok) return;
|
||||||
|
fetch('/api/textbooks/'+_TB_SLUG,{headers:{'Authorization':'Bearer '+tok}}).then(r=>r.ok?r.json():null).then(d=>{
|
||||||
|
if(!d||!d.progress) return;
|
||||||
|
(d.progress.read||[]).forEach(k=>{_markedRead.add(k); if((STATE.progress[k]||0)<50) STATE.progress[k]=100;});
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addXp(n,src){
|
||||||
|
if(!n) return;
|
||||||
|
const prev=STATE.level; STATE.xp=Math.max(0,(STATE.xp||0)+n); STATE.level=calcLevel(STATE.xp);
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(window.LS&&window.LS.xp) window.LS.xp.add(n,'geometry11-ch${ch.n}-'+(src||'misc'));
|
||||||
|
if(STATE.level>prev){
|
||||||
|
const pop=document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent='Уровень '+STATE.level+'!'; pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'),2600); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProgressUI(){
|
||||||
|
const total=Math.round(Object.values(STATE.progress).reduce((a,b)=>a+b,0)/TOTAL_PARAS);
|
||||||
|
const f=document.getElementById('hero-hp-fill'); if(f) f.style.width=total+'%';
|
||||||
|
const t=document.getElementById('hero-hp-text'); if(t) t.textContent=total+'% пройдено';
|
||||||
|
document.querySelectorAll('[data-prog-card]').forEach(el=>{ const k=el.dataset.progCard; const fl=el.querySelector('.psel-prog-fill'); if(fl) fl.style.width=(STATE.progress[k]||0)+'%'; });
|
||||||
|
const xpBadge=document.getElementById('hero-xp-badge');
|
||||||
|
if(xpBadge){ xpBadge.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polygon points="12 2 22 20 2 20"/></svg> Ур. '+STATE.level+' \\xb7 '+(STATE.xp||0)+' XP'; }
|
||||||
|
if(STATE.current && document.getElementById('sidebar-content')){ try{ buildSidebar(STATE.current); }catch(e){} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function achievement(id,text){
|
||||||
|
if(STATE.achievements.has(id)) return;
|
||||||
|
STATE.achievements.set(id, text||ACH_LABELS[id]||id); saveProgress();
|
||||||
|
const pop=document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent=text||ACH_LABELS[id]||id; pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'),3300); }
|
||||||
|
addXp(20,'ach-'+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParaSelector(){
|
||||||
|
const g=document.getElementById('psel-grid'); g.innerHTML='';
|
||||||
|
PARAS.forEach(p=>{
|
||||||
|
const card=document.createElement('div');
|
||||||
|
card.className='psel-card'+(p.final?' final':'');
|
||||||
|
card.dataset.id=p.id; card.dataset.progCard=p.id;
|
||||||
|
card.innerHTML='<div class="psel-num">'+p.num+'</div><div class="psel-name">'+p.name+'</div><div class="psel-prog"><div class="psel-prog-fill"></div></div>';
|
||||||
|
card.addEventListener('click', ()=>goTo(p.id));
|
||||||
|
g.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILT=new Set();
|
||||||
|
const BUILDERS = { ${BUILDERS_JS} };
|
||||||
|
function ensureBuilt(id){ if(BUILT.has(id)) return; const fn=BUILDERS[id]; if(fn){ fn(); BUILT.add(id); } }
|
||||||
|
function goTo(id){
|
||||||
|
STATE.current=id; ensureBuilt(id);
|
||||||
|
document.querySelectorAll('.sec').forEach(s=>s.classList.remove('active'));
|
||||||
|
const el=document.getElementById('sec-'+id); if(el) el.classList.add('active');
|
||||||
|
document.querySelectorAll('.psel-card').forEach(c=>c.classList.toggle('active', c.dataset.id===id));
|
||||||
|
buildSidebar(id);
|
||||||
|
window.scrollTo({top:0,behavior:'smooth'});
|
||||||
|
if((STATE.progress[id]||0)<10) bumpProgress(id, 10);
|
||||||
|
if(window.renderMathInElement) setTimeout(()=>renderMath(el), 0);
|
||||||
|
markLastPara(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBARS = {
|
||||||
|
${SIDEBARS_JS}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIPS=[
|
||||||
|
${TIPS_JS}
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildSidebar(id){
|
||||||
|
const box=document.getElementById('sidebar-content');
|
||||||
|
const sb=SIDEBARS[id]||SIDEBARS[PARAS[0].id];
|
||||||
|
let html='';
|
||||||
|
const xpForLv=_xpForLevel(STATE.level), xpNext=_xpForLevel(STATE.level+1);
|
||||||
|
const xpInLv=STATE.xp-xpForLv, xpRange=xpNext-xpForLv;
|
||||||
|
const xpPct=xpRange>0?Math.round(xpInLv/xpRange*100):100;
|
||||||
|
html+='<div class="xp-card"><div class="xp-card-title"><span>XP-прогресс</span><span class="xp-level">Ур. '+STATE.level+'</span></div><div class="xp-bar"><div class="xp-fill" style="width:'+xpPct+'%"></div></div><div class="xp-nums"><span>'+STATE.xp+' XP</span><span>'+xpNext+' XP</span></div></div>';
|
||||||
|
html+='<div class="sidecard"><h4>'+sb.title+'</h4>';
|
||||||
|
sb.rows.forEach(([k,v])=>{ html+='<div class="sidecard-row"><b>'+k+'</b>'+(v?' \\u2014 '+v:'')+'</div>'; });
|
||||||
|
html+='</div>';
|
||||||
|
const tip=TIPS.find(t=>t.sec===id)||TIPS[0];
|
||||||
|
if(tip){
|
||||||
|
html+='<div class="sidecard" style="background:linear-gradient(135deg,var(--warn-bg,#fef3c7),var(--pri-soft));border-color:var(--warn,#f59e0b)"><h4 style="color:#92400e;display:flex;align-items:center;gap:6px"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><polygon points="12,2 22,20 2,20"/></svg>Подсказка</h4><div class="sidecard-row" style="margin-bottom:0;font-size:.84rem;line-height:1.55">'+tip.html+'</div></div>';
|
||||||
|
}
|
||||||
|
if(STATE.achievements.size>0){
|
||||||
|
html+='<div class="sidecard"><h4>Достижения <span style="color:var(--warn);float:right">'+STATE.achievements.size+'</span></h4>';
|
||||||
|
[...STATE.achievements.values()].slice(-4).forEach(text=>{ html+='<div class="sidecard-row" style="font-size:.78rem;color:var(--ok)">✓ '+text+'</div>'; });
|
||||||
|
html+='</div>';
|
||||||
|
}
|
||||||
|
box.innerHTML=html;
|
||||||
|
if(window.renderMathInElement) try{ renderMath(box); }catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme(){
|
||||||
|
const t=localStorage.getItem('geometry11_ch${ch.n}_theme')||'light';
|
||||||
|
if(t==='dark') document.documentElement.classList.add('dark');
|
||||||
|
document.getElementById('theme-lab').textContent=t==='dark'?'Светлая':'Тёмная';
|
||||||
|
document.getElementById('theme-btn').addEventListener('click', ()=>{
|
||||||
|
document.documentElement.classList.toggle('dark');
|
||||||
|
const dark=document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem('geometry11_ch${ch.n}_theme', dark?'dark':'light');
|
||||||
|
document.getElementById('theme-lab').textContent=dark?'Светлая':'Тёмная';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMath(root){ if(window.renderMathInElement){ try{ renderMathInElement(root, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false}); }catch(e){} } }
|
||||||
|
function feedback(elm, ok, text){ if(!elm) return; elm.className='feedback '+(ok?'ok':'fail'); elm.innerHTML=text||(ok?'✓ Верно!':'✗ Неверно'); elm.style.display='block'; try{renderMath(elm);}catch(e){} }
|
||||||
|
function fmt(n){ if(!isFinite(n)) return '?'; if(Number.isInteger(n)) return String(n); return Math.abs(n-Math.round(n))<1e-9?String(Math.round(n)):(+n.toFixed(6)).toString(); }
|
||||||
|
function ipow(base, exp){ let r=1; for(let i=0;i<Math.abs(exp);i++) r*=base; return exp<0 ? 1/r : r; }
|
||||||
|
function gcd(a,b){ a=Math.abs(a|0); b=Math.abs(b|0); while(b){ const t=b; b=a%b; a=t; } return a||1; }
|
||||||
|
function makeCard(kind, title, num, body){
|
||||||
|
const labels = {repeat:'Повторение',theory:'Теория',algo:'Алгоритм',rule:'Правило',example:'Пример',oral:'Устно'};
|
||||||
|
return '<div class="card"><div class="card-header"><div class="card-icon '+kind+'">'+ICONS[kind]+'</div><div class="card-title">'+(labels[kind]||'')+(title&&title!==labels[kind]?' \\xb7 '+title:'')+'</div>'+(num?'<div class="card-num">'+num+'</div>':'')+'</div><div class="card-body">'+body+'</div></div>';
|
||||||
|
}
|
||||||
|
function setupSorter(cfg){
|
||||||
|
const placed = {}; const pool = document.getElementById(cfg.poolId); const scope = document.querySelector(cfg.scopeSelector);
|
||||||
|
if(!pool||!scope) return {placed,render:()=>{},reset:()=>{}};
|
||||||
|
pool.classList.add('dnd-pool'); if(cfg.columnLayout) pool.classList.add('col');
|
||||||
|
let armed = null;
|
||||||
|
function buildChip(it,isPlaced){ const e=document.createElement('div'); e.className='dnd-chip'+(isPlaced?' placed':''); e.dataset.id=it.id; e.innerHTML='<span class="dnd-txt">'+it.html+'</span><span class="dnd-x" title="Убрать">\\xd7</span>'; attach(e,it.id); return e; }
|
||||||
|
function attach(elm,itId){ let ghost=null,dragging=false,sx=0,sy=0; elm.addEventListener('pointerdown',ev=>{ if(ev.button!==undefined&&ev.button!==0) return;
|
||||||
|
ev.preventDefault(); if(ev.target.classList&&ev.target.classList.contains('dnd-x')){ ev.stopPropagation(); if(placed[itId]){delete placed[itId];render();}else if(armed===itId){armed=null;render();} return; } sx=ev.clientX;sy=ev.clientY; const r=elm.getBoundingClientRect(); const ox=ev.clientX-r.left,oy=ev.clientY-r.top; try{elm.setPointerCapture(ev.pointerId);}catch(e){} function onMove(e){ const dx=e.clientX-sx,dy=e.clientY-sy; if(!dragging&&Math.hypot(dx,dy)>8){ dragging=true; ghost=elm.cloneNode(true); ghost.classList.remove('armed'); ghost.style.cssText='position:fixed;z-index:9999;pointer-events:none;opacity:.9;transform:rotate(-2.5deg);box-shadow:0 14px 36px rgba(0,0,0,.32);width:'+r.width+'px;left:'+(e.clientX-ox)+'px;top:'+(e.clientY-oy)+'px'; document.body.appendChild(ghost); elm.classList.add('dragging'); } if(dragging&&ghost){ ghost.style.left=(e.clientX-ox)+'px';ghost.style.top=(e.clientY-oy)+'px'; const under=document.elementsFromPoint(e.clientX,e.clientY); scope.querySelectorAll('.drop-box.over,.dnd-pool.over').forEach(n=>n.classList.remove('over')); const tgt=under.find(n=>n.classList&&(n.classList.contains('drop-box')||n.classList.contains('dnd-pool'))); if(tgt)tgt.classList.add('over'); } } function onUp(e){ elm.removeEventListener('pointermove',onMove);elm.removeEventListener('pointerup',onUp);elm.removeEventListener('pointercancel',onUp);elm.classList.remove('dragging'); if(ghost){ghost.remove();ghost=null;} scope.querySelectorAll('.drop-box.over,.dnd-pool.over').forEach(n=>n.classList.remove('over')); if(dragging){ const under=document.elementsFromPoint(e.clientX,e.clientY); const box=under.find(n=>n.classList&&n.classList.contains('drop-box')); const pl=under.find(n=>n.classList&&n.classList.contains('dnd-pool')); if(box){const di=box.querySelector('[data-cat]');if(di){placed[itId]=di.dataset.cat;armed=null;render();return;}}else if(pl){delete placed[itId];armed=null;render();return;} }else{ if(placed[itId]){delete placed[itId];armed=null;render();}else{armed=(armed===itId)?null:itId;render();} } dragging=false; } elm.addEventListener('pointermove',onMove);elm.addEventListener('pointerup',onUp);elm.addEventListener('pointercancel',onUp); }); }
|
||||||
|
function attachBoxTaps(){ scope.querySelectorAll('.drop-box').forEach(box=>{ box.addEventListener('click',ev=>{ if(!armed)return; if(ev.target.closest('.dnd-chip'))return; const di=box.querySelector('[data-cat]'); if(di){placed[armed]=di.dataset.cat;armed=null;render();} }); }); }
|
||||||
|
function render(){ pool.innerHTML=''; cfg.items.forEach(it=>{if(placed[it.id])return;const c=buildChip(it,false);if(armed===it.id)c.classList.add('armed');pool.appendChild(c);}); cfg.cats.forEach(cat=>{const box=scope.querySelector('.drop-items[data-cat="'+cat+'"]');if(!box)return;box.innerHTML='';cfg.items.forEach(it=>{if(placed[it.id]!==cat)return;box.appendChild(buildChip(it,true));});}); if(window.renderMathInElement)try{renderMath(scope);}catch(_){} }
|
||||||
|
attachBoxTaps(); render();
|
||||||
|
return {placed,render,reset(){ for(const k in placed)delete placed[k];armed=null;render(); }};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === SVG-хелперы 2D (axes, plotFunc, pointWithDrop, asymptote, snapToValue, геом.) === */
|
||||||
|
|
||||||
|
function axes2D(W, H, pad, xmin, xmax, ymin, ymax){
|
||||||
|
const ux = (W - 2*pad) / (xmax - xmin);
|
||||||
|
const uy = (H - 2*pad) / (ymax - ymin);
|
||||||
|
const toX = v => pad + (v - xmin) * ux;
|
||||||
|
const toY = v => H - pad - (v - ymin) * uy;
|
||||||
|
let g = '';
|
||||||
|
g += '<g stroke="#e5e7eb" stroke-width="1">';
|
||||||
|
for (let x = Math.ceil(xmin); x <= xmax; x++){
|
||||||
|
g += '<line x1="'+toX(x)+'" y1="'+pad+'" x2="'+toX(x)+'" y2="'+(H-pad)+'"/>';
|
||||||
|
}
|
||||||
|
for (let y = Math.ceil(ymin); y <= ymax; y++){
|
||||||
|
g += '<line x1="'+pad+'" y1="'+toY(y)+'" x2="'+(W-pad)+'" y2="'+toY(y)+'"/>';
|
||||||
|
}
|
||||||
|
g += '</g>';
|
||||||
|
const y0 = toY(0), x0 = toX(0);
|
||||||
|
g += '<line x1="'+pad+'" y1="'+y0+'" x2="'+(W-pad)+'" y2="'+y0+'" stroke="#0f172a" stroke-width="1.5"/>';
|
||||||
|
g += '<line x1="'+x0+'" y1="'+pad+'" x2="'+x0+'" y2="'+(H-pad)+'" stroke="#0f172a" stroke-width="1.5"/>';
|
||||||
|
g += '<text x="'+(W-pad+2)+'" y="'+(y0-4)+'" font-size="11" fill="#0f172a">x</text>';
|
||||||
|
g += '<text x="'+(x0+4)+'" y="'+(pad-2)+'" font-size="11" fill="#0f172a">y</text>';
|
||||||
|
g += '<g font-size="10" fill="#64748b">';
|
||||||
|
for (let x = Math.ceil(xmin); x <= xmax; x++){
|
||||||
|
if (x !== 0) g += '<text x="'+(toX(x)-3)+'" y="'+(y0+12)+'">'+x+'</text>';
|
||||||
|
}
|
||||||
|
for (let y = Math.ceil(ymin); y <= ymax; y++){
|
||||||
|
if (y !== 0) g += '<text x="'+(x0+4)+'" y="'+(toY(y)+3)+'">'+y+'</text>';
|
||||||
|
}
|
||||||
|
g += '<text x="'+(x0+4)+'" y="'+(y0+12)+'">0</text>';
|
||||||
|
g += '</g>';
|
||||||
|
return { content: g, toX, toY, ux, uy };
|
||||||
|
}
|
||||||
|
|
||||||
|
function plotFunc(f, xmin, xmax, toX, toY, color, N){
|
||||||
|
N = N || 200;
|
||||||
|
let d = '';
|
||||||
|
let prevValid = false;
|
||||||
|
for (let i = 0; i <= N; i++){
|
||||||
|
const x = xmin + (xmax - xmin) * i / N;
|
||||||
|
let y;
|
||||||
|
try { y = f(x); } catch(e){ y = NaN; }
|
||||||
|
if (!isFinite(y) || isNaN(y) || y < -1e4 || y > 1e4){ prevValid = false; continue; }
|
||||||
|
d += (prevValid ? ' L' : ' M') + toX(x).toFixed(2) + ',' + toY(y).toFixed(2);
|
||||||
|
prevValid = true;
|
||||||
|
}
|
||||||
|
return '<path d="'+d+'" stroke="'+color+'" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointWithDrop(x, fx, toX, toY, color, label){
|
||||||
|
const px = toX(x), py = toY(fx);
|
||||||
|
let s = '';
|
||||||
|
s += '<line x1="'+px+'" y1="'+py+'" x2="'+px+'" y2="'+toY(0)+'" stroke="'+color+'" stroke-width="1.2" stroke-dasharray="3 3" opacity=".7"/>';
|
||||||
|
s += '<line x1="'+px+'" y1="'+py+'" x2="'+toX(0)+'" y2="'+py+'" stroke="'+color+'" stroke-width="1.2" stroke-dasharray="3 3" opacity=".7"/>';
|
||||||
|
s += '<circle cx="'+px+'" cy="'+py+'" r="4.5" fill="'+color+'" stroke="#fff" stroke-width="2"/>';
|
||||||
|
if (label){
|
||||||
|
s += '<text x="'+(px+8)+'" y="'+(py-8)+'" font-family="Inter,sans-serif" font-size="12" font-weight="700" fill="'+color+'">'+label+'</text>';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asymptote(orientation, value, toX, toY, xmin, xmax, ymin, ymax, color){
|
||||||
|
color = color || '#94a3b8';
|
||||||
|
if (orientation === 'h'){
|
||||||
|
const y = toY(value);
|
||||||
|
return '<line x1="'+toX(xmin)+'" y1="'+y+'" x2="'+toX(xmax)+'" y2="'+y+'" stroke="'+color+'" stroke-width="1.3" stroke-dasharray="6 4"/>';
|
||||||
|
} else {
|
||||||
|
const x = toX(value);
|
||||||
|
return '<line x1="'+x+'" y1="'+toY(ymin)+'" x2="'+x+'" y2="'+toY(ymax)+'" stroke="'+color+'" stroke-width="1.3" stroke-dasharray="6 4"/>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapToValue(value, snapPoints, tolerance){
|
||||||
|
tolerance = tolerance || 0.1;
|
||||||
|
for (const sp of snapPoints){
|
||||||
|
if (Math.abs(value - sp) < tolerance) return sp;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rightAngleMark(V, uIn, wIn, s){
|
||||||
|
s = s || 9;
|
||||||
|
const p1 = {x: V.x + s*uIn.x, y: V.y + s*uIn.y};
|
||||||
|
const c = {x: p1.x + s*wIn.x, y: p1.y + s*wIn.y};
|
||||||
|
const p2 = {x: V.x + s*wIn.x, y: V.y + s*wIn.y};
|
||||||
|
return p1.x+','+p1.y+' '+c.x+','+c.y+' '+p2.x+','+p2.y;
|
||||||
|
}
|
||||||
|
function angleArcAuto(V, uA, uB, R){
|
||||||
|
const sA = {x: V.x + R*uA.x, y: V.y + R*uA.y};
|
||||||
|
const eB = {x: V.x + R*uB.x, y: V.y + R*uB.y};
|
||||||
|
const cross = uA.x*uB.y - uA.y*uB.x;
|
||||||
|
const sweep = cross > 0 ? 1 : 0;
|
||||||
|
return 'M'+sA.x+','+sA.y+' A'+R+','+R+' 0 0,'+sweep+' '+eB.x+','+eB.y;
|
||||||
|
}
|
||||||
|
function unitVec(p1, p2){
|
||||||
|
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
||||||
|
const len = Math.sqrt(dx*dx + dy*dy) || 1;
|
||||||
|
return {x: dx/len, y: dy/len};
|
||||||
|
}
|
||||||
|
function deg2rad(d){ return d * Math.PI / 180; }
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
repeat:'<svg class="ic" viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
|
||||||
|
theory:'<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
|
||||||
|
algo:'<svg class="ic" viewBox="0 0 24 24"><polyline points="17 11 21 7 17 3"/><line x1="21" y1="7" x2="9" y2="7"/><polyline points="7 13 3 17 7 21"/><line x1="3" y1="17" x2="15" y2="17"/></svg>',
|
||||||
|
rule:'<svg class="ic" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>',
|
||||||
|
example:'<svg class="ic" viewBox="0 0 24 24"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 13c1 1 2 2 2 4h4c0-2 1-3 2-4a7 7 0 0 0-4-13z"/></svg>',
|
||||||
|
oral:'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
function secNavFor(curId){
|
||||||
|
const idx = PARAS.findIndex(p => p.id === curId);
|
||||||
|
const prev = idx > 0 ? PARAS[idx-1].id : null;
|
||||||
|
const next = idx < PARAS.length - 1 ? PARAS[idx+1].id : null;
|
||||||
|
return secNav(prev, next);
|
||||||
|
}
|
||||||
|
function secNav(prev, next){
|
||||||
|
const NAMES = {${NAMES_JS}};
|
||||||
|
let h='<div class="sec-nav">';
|
||||||
|
h+=prev?'<button class="btn" onclick="goTo(\\''+prev+'\\')"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> '+NAMES[prev]+'</button>':'<span></span>';
|
||||||
|
h+=next?'<button class="btn primary" onclick="goTo(\\''+next+'\\')">'+NAMES[next]+' <svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></button>':'<span></span>';
|
||||||
|
h+='</div>'; return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readButton(paraId){
|
||||||
|
const p = PARAS.find(x => x.id === paraId);
|
||||||
|
const labelTail = p && p.final ? 'финал' : (p ? p.num : '\\xA7?');
|
||||||
|
return '<div style="margin-top:18px;display:flex;justify-content:center">'
|
||||||
|
+'<button class="btn primary" id="'+paraId+'-read-btn">'
|
||||||
|
+'<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>'
|
||||||
|
+' Я прочитал — '+labelTail+' (+10 XP)'
|
||||||
|
+'</button></div>';
|
||||||
|
}
|
||||||
|
function wireReadBtn(paraId){
|
||||||
|
const btn = document.getElementById(paraId+'-read-btn'); if(!btn) return;
|
||||||
|
btn.addEventListener('click', ()=>{
|
||||||
|
addXp(10, paraId+'-read'); bumpProgress(paraId, 30);
|
||||||
|
btn.textContent='Прочитано! +10 XP'; btn.disabled=true; btn.style.opacity=.6;
|
||||||
|
const aId = paraId+'_done';
|
||||||
|
if(ACH_LABELS[aId]) achievement(aId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STUB BUILDER — единый для всех параграфов раздела (Phase 0) ===== */
|
||||||
|
|
||||||
|
function buildStub(id){
|
||||||
|
const p = PARAS.find(x => x.id === id);
|
||||||
|
if(!p) return;
|
||||||
|
const box = document.getElementById(id + '-body');
|
||||||
|
if(!box) return;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
html += '<div class="stub-note">'
|
||||||
|
+ '<h3>' + p.num + ' «' + p.name + '» — в разработке</h3>'
|
||||||
|
+ '<p>Это параграф раздела ' + ${ch.n} + '. Полное наполнение (теория + 3 интерактива + DnD + тренажёр) появится в Phase 1+. Сейчас доступны только базовая навигация, прогресс-бар и подсказка в боковой панели.</p>'
|
||||||
|
+ '</div>';
|
||||||
|
|
||||||
|
html += makeCard('theory', 'План параграфа', p.num, '<p>Тема: <b>' + p.name + '</b>.</p>' + (p.sub ? '<p>Ключевая формула: ' + p.sub + '</p>' : '') + '<p>Содержание будет реализовано в следующих фазах разработки.</p>');
|
||||||
|
|
||||||
|
/* Демо-интерактив с G3D (если доступен) — показываем разработчику, что движок работает */
|
||||||
|
if(window.G3D && !p.final){
|
||||||
|
html += '<div class="wg" id="' + id + '-iv-demo">'
|
||||||
|
+ '<div class="wg-header"><span class="wg-badge">DEMO 3D</span><div class="wg-title">Превью мини-3D движка</div></div>'
|
||||||
|
+ '<div class="wg-help">Скелет-демо: тело можно вращать мышью. В Phase 1+ здесь появятся полноценные интерактивы с сечениями, развёртками и формулами.</div>'
|
||||||
|
+ '<div class="g3d-tools">'
|
||||||
|
+ '<button class="btn" data-view="iso">Изо</button>'
|
||||||
|
+ '<button class="btn" data-view="front">Спереди</button>'
|
||||||
|
+ '<button class="btn" data-view="top">Сверху</button>'
|
||||||
|
+ '<button class="btn" data-view="side">Сбоку</button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div style="background:var(--card);border-radius:9px;padding:10px;text-align:center"><svg id="' + id + '-iv-svg" viewBox="0 0 480 360" width="100%" style="max-width:480px;height:auto"></svg></div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += secNavFor(id);
|
||||||
|
html += readButton(id);
|
||||||
|
|
||||||
|
box.innerHTML = html;
|
||||||
|
renderMath(box);
|
||||||
|
|
||||||
|
/* Установка демо-3D */
|
||||||
|
if(window.G3D && !p.final){
|
||||||
|
const svg = document.getElementById(id + '-iv-svg');
|
||||||
|
if(svg){
|
||||||
|
const scene = G3D.createScene({W:480, H:360, scale:42, camDist:8, rotX:-0.35, rotY:0.7});
|
||||||
|
/* выбираем фигуру по id параграфа */
|
||||||
|
let mesh;
|
||||||
|
if(id === 'p1') mesh = G3D.prismMesh(4, 1.6, 2.4); /* куб/призма */
|
||||||
|
else if(id === 'p2') mesh = G3D.cylinderMesh(1.5, 2.6, 32);
|
||||||
|
else if(id === 'p3') mesh = G3D.pyramidMesh(4, 1.8, 2.6);
|
||||||
|
else if(id === 'p4') mesh = G3D.coneMesh(1.5, 2.6, 32);
|
||||||
|
else if(id === 'p5' || id === 'p6') mesh = null; /* сфера — отдельный wireframe */
|
||||||
|
else if(id === 'p7') mesh = G3D.prismMesh(3, 1.6, 1.8); /* тетраэдр-подобно */
|
||||||
|
else if(id === 'p10') mesh = G3D.prismMesh(4, 1.6, 2.0); /* куб для координат */
|
||||||
|
else mesh = G3D.prismMesh(6, 1.5, 2.2);
|
||||||
|
|
||||||
|
function draw(){
|
||||||
|
const M = G3D.buildRotMatrix(scene);
|
||||||
|
let inner = '';
|
||||||
|
if(mesh){
|
||||||
|
inner = G3D.renderMesh(mesh, M, scene);
|
||||||
|
} else {
|
||||||
|
/* сфера */
|
||||||
|
const sph = G3D.sphereWireframe(1.7, 5, 10);
|
||||||
|
inner = G3D.renderSphereWireframe(sph, M, scene);
|
||||||
|
}
|
||||||
|
svg.innerHTML = inner;
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
G3D.attachOrbit(svg, scene, draw);
|
||||||
|
const tools = document.querySelectorAll('#' + id + '-iv-demo .g3d-tools .btn');
|
||||||
|
tools.forEach(b => b.addEventListener('click', () => {
|
||||||
|
G3D.presetView(scene, b.dataset.view, draw);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wireReadBtn(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Search ===== */
|
||||||
|
const SEARCH_INDEX = (function(){
|
||||||
|
const arr=[];
|
||||||
|
PARAS.forEach(p=>arr.push({kind:'Параграф',title:p.num+' '+p.name,desc:p.sub||'',sec:p.id}));
|
||||||
|
return arr;
|
||||||
|
})();
|
||||||
|
function initSearch(){
|
||||||
|
const modal=document.getElementById('search-modal'),inp=document.getElementById('search-input'),out=document.getElementById('search-results'),btn=document.getElementById('search-btn');
|
||||||
|
if(!modal||!inp||!out) return;
|
||||||
|
let cur=0,rows=[];
|
||||||
|
function score(q,it){ const t=(it.title+' '+it.desc).toLowerCase(); if(t.includes(q)) return 100+(it.title.toLowerCase().startsWith(q)?50:0); let s=0; q.split(/\\s+/).forEach(w=>{if(w&&t.includes(w))s+=10;}); return s; }
|
||||||
|
function rank(q){ q=q.trim().toLowerCase(); if(!q) return SEARCH_INDEX.slice(0,12); return SEARCH_INDEX.map(it=>({it,s:score(q,it)})).filter(x=>x.s>0).sort((a,b)=>b.s-a.s).slice(0,20).map(x=>x.it); }
|
||||||
|
function render(){ cur=0; if(!rows.length){out.innerHTML='<div class="search-empty">Ничего не найдено</div>';return;} out.innerHTML=rows.map((r,i)=>'<button class="search-row'+(i===0?' active':'')+'" data-i="'+i+'"><div class="sr-kind">'+r.kind+'</div><div class="sr-title">'+r.title+'</div>'+(r.desc?'<div class="sr-desc">'+(r.desc.length>90?r.desc.slice(0,90)+'\\u2026':r.desc)+'</div>':'')+'</button>').join(''); out.querySelectorAll('.search-row').forEach(b=>b.addEventListener('click',()=>{cur=+b.dataset.i;pick();})); }
|
||||||
|
function pick(){ const r=rows[cur]; if(!r) return; close(); goTo(r.sec); }
|
||||||
|
function move(d){ const items=out.querySelectorAll('.search-row'); if(!items.length) return; items[cur]&&items[cur].classList.remove('active'); cur=(cur+d+items.length)%items.length; items[cur].classList.add('active'); items[cur].scrollIntoView({block:'nearest'}); }
|
||||||
|
function open(){ modal.classList.add('show'); inp.value=''; rows=rank(''); render(); setTimeout(()=>inp.focus(),50); }
|
||||||
|
function close(){ modal.classList.remove('show'); }
|
||||||
|
btn&&btn.addEventListener('click',open);
|
||||||
|
modal.addEventListener('click',e=>{if(e.target===modal)close();});
|
||||||
|
inp.addEventListener('input',()=>{rows=rank(inp.value);render();});
|
||||||
|
inp.addEventListener('keydown',e=>{ if(e.key==='ArrowDown'){e.preventDefault();move(1);}else if(e.key==='ArrowUp'){e.preventDefault();move(-1);}else if(e.key==='Enter'){e.preventDefault();pick();}else if(e.key==='Escape'){e.preventDefault();close();} });
|
||||||
|
document.addEventListener('keydown',e=>{ if((e.ctrlKey||e.metaKey)&&(e.key==='k'||e.key==='K')){ e.preventDefault(); if(modal.classList.contains('show')) close(); else open(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSidebarToggle(){
|
||||||
|
const side=document.getElementById('col-side'),back=document.getElementById('col-side-backdrop'),btn=document.getElementById('sidebar-btn');
|
||||||
|
if(!side||!btn) return;
|
||||||
|
function open(){ side.classList.add('open'); back.classList.add('show'); }
|
||||||
|
function close(){ side.classList.remove('open'); back.classList.remove('show'); }
|
||||||
|
btn.addEventListener('click',()=>{ if(side.classList.contains('open')) close(); else open(); });
|
||||||
|
back.addEventListener('click',close);
|
||||||
|
document.addEventListener('keydown',e=>{ if(e.key==='Escape') close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(){
|
||||||
|
loadProgress(); initTheme(); initSidebarToggle(); initSearch();
|
||||||
|
buildParaSelector(); refreshProgressUI(); loadServerReadState(); goTo(PARAS[0].id);
|
||||||
|
setTimeout(()=>achievement('start'), 600);
|
||||||
|
if(window.LS&&window.LS.xp){
|
||||||
|
window.LS.xp.load().then(function(s){ if(s&&s.xp>STATE.xp){ STATE.xp=s.xp; STATE.level=calcLevel(STATE.xp); saveProgress(); refreshProgressUI(); if(STATE.current) buildSidebar(STATE.current); } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
|
/* === GEOM11 POLISH JS === */
|
||||||
|
(function(){
|
||||||
|
function bumpScore(el){
|
||||||
|
if(!el) return;
|
||||||
|
el.classList.remove('bump');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('bump');
|
||||||
|
setTimeout(function(){ try{ el.classList.remove('bump'); }catch(e){} }, 270);
|
||||||
|
}
|
||||||
|
window.__geom11BumpScore = bumpScore;
|
||||||
|
|
||||||
|
function observeScores(root){
|
||||||
|
root = root || document;
|
||||||
|
var nodes = root.querySelectorAll('.score-display b');
|
||||||
|
nodes.forEach(function(b){
|
||||||
|
if(b.__scoreObs) return;
|
||||||
|
b.__scoreObs = true;
|
||||||
|
var last = b.textContent;
|
||||||
|
try{
|
||||||
|
var mo = new MutationObserver(function(){
|
||||||
|
var nv = b.textContent;
|
||||||
|
if(nv !== last){ last = nv; bumpScore(b); }
|
||||||
|
});
|
||||||
|
mo.observe(b, {childList:true, characterData:true, subtree:true});
|
||||||
|
}catch(e){}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function rescanScores(){ try{ observeScores(document); }catch(e){} }
|
||||||
|
if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', rescanScores);
|
||||||
|
else rescanScores();
|
||||||
|
|
||||||
|
try{
|
||||||
|
var rootObs = new MutationObserver(function(muts){
|
||||||
|
var need = false;
|
||||||
|
for(var i=0;i<muts.length && !need;i++){
|
||||||
|
var m = muts[i];
|
||||||
|
for(var j=0;j<m.addedNodes.length;j++){
|
||||||
|
var n = m.addedNodes[j];
|
||||||
|
if(n.nodeType===1){
|
||||||
|
if(n.classList && n.classList.contains('score-display')) { need = true; break; }
|
||||||
|
if(n.querySelector && n.querySelector('.score-display b')) { need = true; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(need) rescanScores();
|
||||||
|
});
|
||||||
|
rootObs.observe(document.body, {childList:true, subtree:true});
|
||||||
|
}catch(e){}
|
||||||
|
|
||||||
|
function refreshDoneMarks(){
|
||||||
|
try{
|
||||||
|
if(typeof STATE === 'undefined' || !STATE.progress) return;
|
||||||
|
document.querySelectorAll('.psel-card').forEach(function(c){
|
||||||
|
var id = c.dataset.id || c.dataset.progCard;
|
||||||
|
if(!id) return;
|
||||||
|
var pct = +STATE.progress[id] || 0;
|
||||||
|
if(!c.querySelector('.psel-done')){
|
||||||
|
var s = document.createElement('span');
|
||||||
|
s.className = 'psel-done';
|
||||||
|
s.setAttribute('title','Прочитано');
|
||||||
|
s.innerHTML = '<svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||||
|
c.appendChild(s);
|
||||||
|
}
|
||||||
|
c.classList.toggle('done', pct >= 50);
|
||||||
|
});
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
if(typeof window.refreshProgressUI === 'function'){
|
||||||
|
var _origRP = window.refreshProgressUI;
|
||||||
|
window.refreshProgressUI = function(){ var r = _origRP.apply(this, arguments); setTimeout(refreshDoneMarks, 0); return r; };
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
setTimeout(refreshDoneMarks, 600);
|
||||||
|
setTimeout(refreshDoneMarks, 1800);
|
||||||
|
window.addEventListener('focus', function(){ setTimeout(refreshDoneMarks, 200); });
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e){
|
||||||
|
var card = e.target.closest && e.target.closest('.psel-card');
|
||||||
|
if(!card) return;
|
||||||
|
var id = card.dataset.id;
|
||||||
|
if(!id) return;
|
||||||
|
setTimeout(function(){
|
||||||
|
var sec = document.getElementById('sec-' + id);
|
||||||
|
if(sec) try{ sec.scrollIntoView({behavior:'smooth', block:'start'}); }catch(e){}
|
||||||
|
}, 60);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Main ===
|
||||||
|
for (const ch of CHAPTERS) {
|
||||||
|
const out = path.join(OUT_DIR, `geometry_11_ch${ch.n}.html`);
|
||||||
|
const content = chapterTpl(ch);
|
||||||
|
fs.writeFileSync(out, content, 'utf8');
|
||||||
|
console.log(`Wrote ${out} (${content.length} bytes)`);
|
||||||
|
}
|
||||||
|
console.log('Done.');
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const OUT_DIR = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
const SPECS = {
|
||||||
|
ch2: {
|
||||||
|
n: 2,
|
||||||
|
title: 'Окружности',
|
||||||
|
subtitle: 'Описанная · вписанная · четырёхугольники',
|
||||||
|
heroH: 'Вписанные и описанные окружности',
|
||||||
|
heroP: 'Здесь мы изучаем <b>описанную</b> и <b>вписанную</b> окружности треугольника, специальные формулы для прямоугольного треугольника $R = c/2$ и $r = (a+b-c)/2$, а также критерии вписанных и описанных четырёхугольников: $\\alpha + \\gamma = 180^\\circ$ и $a+c = b+d$.',
|
||||||
|
heroWm: '○',
|
||||||
|
headerWmName: 'ГЛАВА 2',
|
||||||
|
paras: [
|
||||||
|
{ id: 'p7', num: '§ 7', name: 'Описанная и вписанная окружности треугольника', sub: 'центр $O$, радиус $R$, $r$', watermark: '○' },
|
||||||
|
{ id: 'p8', num: '§ 8', name: 'Окружности прямоугольного треугольника', sub: '$R = c/2$, $r = (a+b-c)/2$', watermark: '⊥' },
|
||||||
|
{ id: 'p9', num: '§ 9', name: 'Вписанные и описанные четырёхугольники', sub: '$\\alpha + \\gamma = 180^\\circ$', watermark: '◇' },
|
||||||
|
{ id: 'final2', num: '★', name: 'Финал главы', sub: 'Итоги главы 2', final: true, watermark: '★' }
|
||||||
|
],
|
||||||
|
palette: {
|
||||||
|
pri: '#059669', pri2: '#047857', priSoft: '#d1fae5',
|
||||||
|
acc: '#34d399', acc2: '#059669', accSoft: '#ecfdf5',
|
||||||
|
darkBg: '#08201b', darkCard: '#0a2b22', darkCardSoft: '#0d3329', darkText: '#d1fae5', darkMuted: '#7aa89a', darkBorder: '#1c463a',
|
||||||
|
hdrGrad: 'linear-gradient(110deg,#064e3b 0%,#059669 55%,#34d399 100%)',
|
||||||
|
hdrStroke: 'rgba(209,250,229,.12)',
|
||||||
|
hdrUnderline: 'rgba(209,250,229,.2)'
|
||||||
|
},
|
||||||
|
sidebars: {
|
||||||
|
p7: { rows: [['Описанная','через все вершины'],['Центр','пересечение серединных перпендикуляров'],['Вписанная','касается всех сторон'],['Центр_in','пересечение биссектрис']] },
|
||||||
|
p8: { rows: [['Описанная','$R = \\tfrac{c}{2}$ — половина гипотенузы'],['Центр','середина гипотенузы'],['Вписанная','$r = \\tfrac{a+b-c}{2}$']] },
|
||||||
|
p9: { rows: [['Вписанный','$\\alpha + \\gamma = 180^\\circ$'],['Описанный','$a+c = b+d$']] },
|
||||||
|
final2: { rows: [['§§7–9','теория главы 2'],['Дальше','глава 3 — теоремы синусов и косинусов']] }
|
||||||
|
},
|
||||||
|
tips: {
|
||||||
|
p7: 'Центр описанной окружности — точка пересечения серединных перпендикуляров. Центр вписанной — точка пересечения биссектрис.',
|
||||||
|
p8: 'В прямоугольном треугольнике гипотенуза — диаметр описанной окружности, отсюда $R = \\tfrac{c}{2}$.',
|
||||||
|
p9: 'Четырёхугольник вписан в окружность ⟺ суммы противоположных углов равны $180^\\circ$. Четырёхугольник описан ⟺ суммы противоположных сторон равны.',
|
||||||
|
final2: 'Главные результаты главы 2: формулы радиусов окружностей треугольника и критерии вписанных и описанных четырёхугольников.'
|
||||||
|
},
|
||||||
|
achLabels: {
|
||||||
|
start: 'Начало главы 2!',
|
||||||
|
p7_done: 'Описанная и вписанная окружности освоены!',
|
||||||
|
p8_done: 'Окружности прямоугольного треугольника освоены!',
|
||||||
|
p9_done: 'Вписанные и описанные четырёхугольники освоены!',
|
||||||
|
ch2_done: 'Глава 2 пройдена! Окружности — финал!'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ch3: {
|
||||||
|
n: 3,
|
||||||
|
title: 'Теоремы синусов и косинусов',
|
||||||
|
subtitle: 'Произвольный треугольник · формула Герона',
|
||||||
|
heroH: 'Теоремы синусов и косинусов',
|
||||||
|
heroP: 'Здесь мы выводим <b>теорему синусов</b> $\\tfrac{a}{\\sin A} = 2R$, <b>теорему косинусов</b> $a^2 = b^2 + c^2 - 2bc\\cos A$ и <b>формулу Герона</b> $S = \\sqrt{p(p-a)(p-b)(p-c)}$. С их помощью решается любой треугольник.',
|
||||||
|
heroWm: '∠',
|
||||||
|
headerWmName: 'ГЛАВА 3',
|
||||||
|
paras: [
|
||||||
|
{ id: 'p10', num: '§ 10', name: 'Теорема синусов', sub: '$\\tfrac{a}{\\sin A} = 2R$', watermark: 'sin' },
|
||||||
|
{ id: 'p11', num: '§ 11', name: 'Теорема косинусов', sub: '$a^2 = b^2 + c^2 - 2bc\\cos A$', watermark: 'cos' },
|
||||||
|
{ id: 'p12', num: '§ 12', name: 'Формула Герона. Решение треугольников', sub: '$S = \\sqrt{p(p-a)(p-b)(p-c)}$', watermark: '√' },
|
||||||
|
{ id: 'final3', num: '★', name: 'Финал главы', sub: 'Итоги главы 3', final: true, watermark: '★' }
|
||||||
|
],
|
||||||
|
palette: {
|
||||||
|
pri: '#7c3aed', pri2: '#6d28d9', priSoft: '#ede9fe',
|
||||||
|
acc: '#a78bfa', acc2: '#7c3aed', accSoft: '#f5f3ff',
|
||||||
|
darkBg: '#160b29', darkCard: '#1d1238', darkCardSoft: '#241646', darkText: '#ede9fe', darkMuted: '#a08fbf', darkBorder: '#352160',
|
||||||
|
hdrGrad: 'linear-gradient(110deg,#3b0764 0%,#7c3aed 55%,#a78bfa 100%)',
|
||||||
|
hdrStroke: 'rgba(237,233,254,.12)',
|
||||||
|
hdrUnderline: 'rgba(237,233,254,.2)'
|
||||||
|
},
|
||||||
|
sidebars: {
|
||||||
|
p10: { rows: [['Теорема','$\\tfrac{a}{\\sin A} = \\tfrac{b}{\\sin B} = \\tfrac{c}{\\sin C} = 2R$'],['Применение','две стороны и угол напротив']] },
|
||||||
|
p11: { rows: [['Теорема','$a^2 = b^2 + c^2 - 2bc\\cos A$'],['Применение','три стороны или две стороны и угол']] },
|
||||||
|
p12: { rows: [['Полупериметр','$p = \\tfrac{a+b+c}{2}$'],['Площадь','$S = \\sqrt{p(p-a)(p-b)(p-c)}$']] },
|
||||||
|
final3: { rows: [['§§10–12','теория главы 3'],['Дальше','глава 4 — правильные многоугольники']] }
|
||||||
|
},
|
||||||
|
tips: {
|
||||||
|
p10: 'Теорема синусов: $\\dfrac{a}{\\sin A} = 2R$, где $R$ — радиус описанной окружности.',
|
||||||
|
p11: 'Теорема косинусов обобщает теорему Пифагора: при $A = 90^\\circ$ получаем $a^2 = b^2 + c^2$.',
|
||||||
|
p12: 'Формула Герона позволяет найти площадь треугольника, зная только три его стороны.',
|
||||||
|
final3: 'Главные результаты главы 3: теоремы синусов и косинусов, формула Герона.'
|
||||||
|
},
|
||||||
|
achLabels: {
|
||||||
|
start: 'Начало главы 3!',
|
||||||
|
p10_done: 'Теорема синусов освоена!',
|
||||||
|
p11_done: 'Теорема косинусов освоена!',
|
||||||
|
p12_done: 'Формула Герона освоена!',
|
||||||
|
ch3_done: 'Глава 3 пройдена! Теоремы синусов и косинусов — финал!'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ch4: {
|
||||||
|
n: 4,
|
||||||
|
title: 'Правильные многоугольники',
|
||||||
|
subtitle: 'Угол · радиусы · длина окружности · площадь круга',
|
||||||
|
heroH: 'Правильные многоугольники',
|
||||||
|
heroP: 'Здесь мы изучаем <b>правильные многоугольники</b>, формулу внутреннего угла $\\beta = \\tfrac{180^\\circ(n-2)}{n}$, связи стороны и радиуса описанной окружности, частные случаи (треугольник, квадрат, шестиугольник) и формулы $C = 2\\pi R$, $S = \\pi R^2$.',
|
||||||
|
heroWm: '⬢',
|
||||||
|
headerWmName: 'ГЛАВА 4',
|
||||||
|
paras: [
|
||||||
|
{ id: 'p13', num: '§ 13', name: 'Правильные многоугольники', sub: '$\\beta = \\tfrac{180^\\circ(n-2)}{n}$', watermark: '⬢' },
|
||||||
|
{ id: 'p14', num: '§ 14', name: 'Формулы радиусов', sub: '$\\tfrac{a}{2} = R\\sin\\tfrac{180^\\circ}{n}$', watermark: 'R' },
|
||||||
|
{ id: 'p15', num: '§ 15', name: 'Треугольник, квадрат, шестиугольник', sub: '$a = R\\sqrt{3}, R\\sqrt{2}, R$', watermark: '△□⬡' },
|
||||||
|
{ id: 'p16', num: '§ 16', name: 'Длина окружности и площадь круга', sub: '$C = 2\\pi R$, $S = \\pi R^2$', watermark: '⊙' },
|
||||||
|
{ id: 'final4', num: '★', name: 'Финал главы', sub: 'Итоги главы 4 · Геометрия 9 пройдена!', final: true, watermark: '★' }
|
||||||
|
],
|
||||||
|
palette: {
|
||||||
|
pri: '#0891b2', pri2: '#0e7490', priSoft: '#cffafe',
|
||||||
|
acc: '#22d3ee', acc2: '#0891b2', accSoft: '#ecfeff',
|
||||||
|
darkBg: '#04141a', darkCard: '#0a1b22', darkCardSoft: '#0d2229', darkText: '#e0fcff', darkMuted: '#7aa8b3', darkBorder: '#163842',
|
||||||
|
hdrGrad: 'linear-gradient(110deg,#164e63 0%,#0891b2 55%,#22d3ee 100%)',
|
||||||
|
hdrStroke: 'rgba(209,250,255,.12)',
|
||||||
|
hdrUnderline: 'rgba(165,243,252,.2)'
|
||||||
|
},
|
||||||
|
sidebars: {
|
||||||
|
p13: { rows: [['Внутренний угол','$\\beta = \\tfrac{180^\\circ(n-2)}{n}$'],['Центральный угол','$\\tfrac{360^\\circ}{n}$']] },
|
||||||
|
p14: { rows: [['Сторона','$a = 2R\\sin\\tfrac{180^\\circ}{n}$'],['Радиус вписанной','$r = R\\cos\\tfrac{180^\\circ}{n}$']] },
|
||||||
|
p15: { rows: [['Треугольник','$a = R\\sqrt{3}$'],['Квадрат','$a = R\\sqrt{2}$'],['Шестиугольник','$a = R$']] },
|
||||||
|
p16: { rows: [['Длина','$C = 2\\pi R$'],['Площадь','$S = \\pi R^2$'],['Сектор','$S = \\tfrac{\\pi R^2 \\alpha}{360^\\circ}$']] },
|
||||||
|
final4: { rows: [['§§13–16','теория главы 4'],['Геометрия 9','полностью пройдена!']] }
|
||||||
|
},
|
||||||
|
tips: {
|
||||||
|
p13: 'В правильном $n$-угольнике все стороны и углы равны. Внутренний угол $\\beta = \\dfrac{180^\\circ(n-2)}{n}$.',
|
||||||
|
p14: '$\\dfrac{a}{2} = R\\sin\\dfrac{180^\\circ}{n}$ — половина стороны через радиус описанной окружности.',
|
||||||
|
p15: 'Запомни: в правильном треугольнике $a = R\\sqrt{3}$, в квадрате $a = R\\sqrt{2}$, в шестиугольнике $a = R$.',
|
||||||
|
p16: '$C = 2\\pi R$ — длина окружности; $S = \\pi R^2$ — площадь круга.',
|
||||||
|
final4: 'Главные результаты главы 4: формулы правильных многоугольников и круга. Вся Геометрия 9 в твоём арсенале!'
|
||||||
|
},
|
||||||
|
achLabels: {
|
||||||
|
start: 'Начало главы 4!',
|
||||||
|
p13_done: 'Правильные многоугольники освоены!',
|
||||||
|
p15_done: 'Треугольник, квадрат, шестиугольник освоены!',
|
||||||
|
p16_done: 'Длина окружности и площадь круга освоены!',
|
||||||
|
ch4_done: 'Глава 4 пройдена! Геометрия 9 — финал!'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function jsStr(s){ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); }
|
||||||
|
function cap(s){ return s[0].toUpperCase() + s.slice(1); }
|
||||||
|
|
||||||
|
function buildChapter(spec){
|
||||||
|
const N = spec.n;
|
||||||
|
const paras = spec.paras;
|
||||||
|
const total = paras.length;
|
||||||
|
const firstP = paras[0].id;
|
||||||
|
const finalId = paras[paras.length-1].id;
|
||||||
|
const lastNonFinal = paras[paras.length-2].id;
|
||||||
|
const p = spec.palette;
|
||||||
|
|
||||||
|
const sectionsHtml = paras.map(par => {
|
||||||
|
const cls = par.final ? ' style="background:linear-gradient(135deg,'+p.pri+','+p.acc+')"' : '';
|
||||||
|
const heading = par.final ? ('Итоги главы '+N) : par.name;
|
||||||
|
return ` <section id="sec-${par.id}" class="sec" data-watermark="${par.watermark||''}"><div class="sec-header"><span class="sec-num"${cls}>${par.num}</span><h2 class="sec-h">${heading}</h2></div><div id="${par.id}-body"></div></section>`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const parasJs = paras.map(par => {
|
||||||
|
return ` { id:'${par.id}', num:'${par.num}', name:'${jsStr(par.name)}', sub:'${jsStr(par.sub||'')}'${par.final?', final:true':''} }`;
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
const buildersJs = paras.map(par => `${par.id}:()=>build${cap(par.id)}()`).join(', ');
|
||||||
|
|
||||||
|
const sidebarsJs = paras.map(par => {
|
||||||
|
const sb = spec.sidebars[par.id] || { rows: [] };
|
||||||
|
const rowsJs = sb.rows.map(([k,v]) => `['${jsStr(k.replace(/_in$/,''))}','${jsStr(v)}']`).join(',');
|
||||||
|
const title = par.final ? 'Финал главы' : ('Шпаргалка \\xA7'+par.num.replace(/§\s*/,''));
|
||||||
|
return ` ${par.id}:{title:'${title}',rows:[${rowsJs}]}`;
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
const tipsJs = paras.map(par => ` {sec:'${par.id}',html:'${jsStr(spec.tips[par.id]||'')}'}`).join(',\n');
|
||||||
|
|
||||||
|
const achJs = Object.entries(spec.achLabels).map(([k,v]) => ` ${k}:'${jsStr(v)}'`).join(',\n');
|
||||||
|
|
||||||
|
const namesJs = paras.map(par => {
|
||||||
|
return par.final ? `${par.id}:'Финал'` : `${par.id}:'\\xA7${par.num.replace(/§\s*/,'')}'`;
|
||||||
|
}).join(',');
|
||||||
|
|
||||||
|
const stubsJs = paras.filter(par => !par.final).map((par, i) => {
|
||||||
|
const idx = paras.indexOf(par);
|
||||||
|
const prev = idx === 0 ? 'null' : `'${paras[idx-1].id}'`;
|
||||||
|
const next = `'${paras[idx+1].id}'`;
|
||||||
|
return `function build${cap(par.id)}(){ _stubBuilder('${par.id}', '${par.num.replace(/§\s*/,'§')}', '${jsStr(par.name)}', ${prev}, ${next}); }`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const finalBuilderJs = `function build${cap(finalId)}(){
|
||||||
|
const body = document.getElementById('${finalId}-body');
|
||||||
|
let html = '';
|
||||||
|
html += makeCard('theory', 'Финал главы ${N}', '★', \`
|
||||||
|
<p>Итоговый раздел главы <b>«${spec.title}»</b> будет добавлен в следующих обновлениях.</p>
|
||||||
|
<p style="color:var(--muted);font-size:.9rem">Раздел Phase 7.</p>\`);
|
||||||
|
html += readButton('${finalId}');
|
||||||
|
html += secNav('${lastNonFinal}', null);
|
||||||
|
body.innerHTML = html;
|
||||||
|
wireReadBtn('${finalId}');
|
||||||
|
if(window.renderMathInElement) renderMath(body);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const progressInit = paras.map(par => `${par.id}:0`).join(',');
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
<title>Геометрия 9 · Глава ${N} · ${spec.title}</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||||
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false})"></script>
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#fafafa; --card:#fff; --card-soft:#f8fafc; --text:#0f172a; --ink:#0f172a; --muted:#64748b;
|
||||||
|
--border:#e2e8f0; --sh:0 1px 3px rgba(0,0,0,.06); --sh2:0 4px 14px rgba(0,0,0,.08);
|
||||||
|
--pri:${p.pri}; --pri2:${p.pri2}; --pri-soft:${p.priSoft};
|
||||||
|
--acc:${p.acc}; --acc2:${p.acc2}; --acc-soft:${p.accSoft};
|
||||||
|
--ok:#10b981; --ok-bg:#d1fae5; --warn:#f59e0b; --warn-bg:#fef3c7;
|
||||||
|
--bad:#ef4444; --fail:#dc2626; --fail-bg:#fee2e2;
|
||||||
|
}
|
||||||
|
.dark{--bg:${p.darkBg}; --card:${p.darkCard}; --card-soft:${p.darkCardSoft}; --text:${p.darkText}; --ink:${p.darkText}; --muted:${p.darkMuted}; --border:${p.darkBorder}}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||||
|
html,body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;font-size:15px}
|
||||||
|
button,input,select,textarea{font-family:inherit;font-size:inherit}
|
||||||
|
button{cursor:pointer;border:0;background:transparent;color:inherit}
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
.ic{width:16px;height:16px;display:inline-block;flex-shrink:0;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}
|
||||||
|
|
||||||
|
.hdr{position:relative;background:${p.hdrGrad};color:#fff;padding:46px 22px 30px;overflow:hidden;border-bottom:2px solid ${p.hdrUnderline};min-height:130px}
|
||||||
|
.hdr::before{content:'${spec.headerWmName}';position:absolute;right:-12px;top:50%;transform:translateY(-50%);font-family:'Unbounded',sans-serif;font-size:clamp(5rem,15vw,11rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px ${p.hdrStroke};line-height:1;pointer-events:none;user-select:none;z-index:0}
|
||||||
|
.hdr-row{position:relative;z-index:1;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||||||
|
.hdr h1{font-family:'Unbounded',sans-serif;font-size:1.5rem;font-weight:900;letter-spacing:-.01em;line-height:1.3;padding-top:4px}
|
||||||
|
.hdr-sub{font-size:.85rem;opacity:.88;margin-top:6px;font-weight:500;line-height:1.4}
|
||||||
|
.hdr-side{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.hdr-btn{padding:7px 12px;border-radius:9px;background:rgba(255,255,255,.14);color:#fff;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;text-decoration:none}
|
||||||
|
.hdr-btn:hover{background:rgba(255,255,255,.24)}
|
||||||
|
|
||||||
|
.main{max-width:1240px;margin:0 auto;padding:22px;width:100%;display:grid;grid-template-columns:1fr 280px;gap:24px}
|
||||||
|
@media(max-width:980px){.main{grid-template-columns:1fr;padding:14px}}
|
||||||
|
.col-main{min-width:0}
|
||||||
|
|
||||||
|
.hero{background:linear-gradient(135deg,var(--pri-soft) 0%,var(--acc-soft) 50%,var(--pri-soft) 100%);background-size:200% 200%;animation:heroShift 12s ease-in-out infinite;border:1px solid var(--border);border-radius:18px;padding:24px 22px;margin-bottom:24px;position:relative;overflow:hidden}
|
||||||
|
@keyframes heroShift{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
|
||||||
|
.hero::before{content:'${spec.heroWm}';position:absolute;right:0;top:-30px;font-size:clamp(2rem,12vw,8rem);font-weight:900;color:var(--pri);opacity:.10;line-height:1;pointer-events:none;font-family:'Unbounded',sans-serif}
|
||||||
|
.hero h2{font-family:'Unbounded',sans-serif;font-size:1.55rem;font-weight:800;color:var(--pri2);margin-bottom:10px;letter-spacing:-.01em}
|
||||||
|
.hero p{font-size:.95rem;color:var(--text);opacity:.88;margin-bottom:14px;max-width:640px}
|
||||||
|
.hero-row{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
|
||||||
|
.btn-primary{padding:11px 22px;background:linear-gradient(135deg,var(--pri),var(--pri2));color:#fff;border-radius:11px;font-weight:700;font-size:.92rem;display:inline-flex;align-items:center;gap:8px;box-shadow:var(--sh2);transition:transform .15s,box-shadow .15s}
|
||||||
|
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 8px 28px rgba(0,0,0,.18)}
|
||||||
|
.hero-progress{flex:1;min-width:200px;max-width:280px}
|
||||||
|
.hp-label{font-size:.74rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:5px}
|
||||||
|
.hp-bar{height:8px;background:rgba(0,0,0,.12);border-radius:5px;overflow:hidden}
|
||||||
|
.hp-fill{height:100%;background:linear-gradient(90deg,var(--pri),var(--acc));border-radius:5px;width:0%;transition:width .6s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.hp-text{font-size:.78rem;color:var(--muted);font-weight:700;margin-top:4px;display:block}
|
||||||
|
.hero-xp-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:linear-gradient(135deg,var(--warn,#f59e0b),var(--pri));color:#fff;border-radius:99px;font-size:.82rem;font-weight:800;letter-spacing:.02em;box-shadow:0 4px 12px rgba(0,0,0,.18);font-family:'Unbounded',sans-serif}
|
||||||
|
|
||||||
|
.psel{margin-bottom:24px}
|
||||||
|
.psel-title{font-size:.72rem;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}
|
||||||
|
.psel-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
|
||||||
|
.psel-card{background:var(--card);border:1.5px solid var(--border);border-radius:13px;padding:14px;cursor:pointer;transition:transform .2s,box-shadow .2s,border-color .2s;text-align:left;position:relative}
|
||||||
|
.psel-card:hover{transform:translateY(-3px);box-shadow:var(--sh2);border-color:var(--pri)}
|
||||||
|
.psel-card.active{border-color:var(--pri);background:linear-gradient(135deg,var(--pri-soft),var(--card));box-shadow:var(--sh2)}
|
||||||
|
.psel-card.active::after{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--pri),var(--acc));border-radius:13px 13px 0 0}
|
||||||
|
.psel-num{font-family:'Unbounded',sans-serif;font-size:.72rem;font-weight:800;color:var(--pri);text-transform:uppercase;letter-spacing:.08em;margin-bottom:5px}
|
||||||
|
.psel-name{font-size:.86rem;font-weight:700;color:var(--text);line-height:1.3;margin-bottom:8px}
|
||||||
|
.psel-prog{height:4px;background:rgba(0,0,0,.10);border-radius:3px;overflow:hidden}
|
||||||
|
.psel-prog-fill{height:100%;background:var(--pri);width:0%;transition:width .4s}
|
||||||
|
.psel-card.final{background:linear-gradient(135deg,#fff5e1,#fef3c7)}
|
||||||
|
.psel-card.final .psel-num{color:var(--warn)}
|
||||||
|
|
||||||
|
.sec{display:none;position:relative;animation:fadeIn .35s ease}
|
||||||
|
.sec.active{display:block}
|
||||||
|
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||||||
|
.sec::before{content:attr(data-watermark);position:absolute;right:-20px;top:10%;font-family:'Unbounded',sans-serif;font-size:clamp(6rem,18vw,14rem);font-weight:900;color:transparent;-webkit-text-stroke:1.5px var(--pri-soft);line-height:1;pointer-events:none;user-select:none;z-index:0;opacity:.35}
|
||||||
|
.sec-header{margin-bottom:22px;padding-bottom:14px;border-bottom:2px solid var(--pri-soft);position:relative;z-index:1}
|
||||||
|
.sec-num{display:inline-block;padding:4px 10px;background:linear-gradient(135deg,var(--pri),var(--pri2));color:#fff;border-radius:7px;font-family:'Unbounded',sans-serif;font-size:.78rem;font-weight:800;letter-spacing:.04em;margin-bottom:8px}
|
||||||
|
.sec-h{font-family:'Unbounded',sans-serif;font-size:1.6rem;font-weight:800;color:var(--pri2);letter-spacing:-.01em;line-height:1.25}
|
||||||
|
|
||||||
|
.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.04);position:relative;z-index:1;transition:transform .25s cubic-bezier(.16,1,.3,1),box-shadow .25s}
|
||||||
|
.card:hover{transform:translateY(-2px);box-shadow:0 4px 10px rgba(0,0,0,.06),0 16px 36px rgba(0,0,0,.08)}
|
||||||
|
.card-header{display:flex;align-items:center;gap:10px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed var(--border)}
|
||||||
|
.card-icon{width:32px;height:32px;border-radius:9px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#fff}
|
||||||
|
.card-icon.repeat{background:#0ea5e9}.card-icon.theory{background:#8b5cf6}.card-icon.algo{background:#f59e0b}.card-icon.rule{background:#ec4899}.card-icon.example{background:#10b981}.card-icon.oral{background:#06b6d4}
|
||||||
|
.card-icon .ic{width:18px;height:18px}
|
||||||
|
.card-title{font-family:'Unbounded',sans-serif;font-size:.82rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex:1}
|
||||||
|
.card-num{font-size:.74rem;font-weight:700;color:var(--muted);background:var(--pri-soft);padding:3px 7px;border-radius:5px}
|
||||||
|
.card-body{font-size:.94rem;line-height:1.65}
|
||||||
|
.card-body p{margin-bottom:8px}
|
||||||
|
.card-body p:last-child{margin-bottom:0}
|
||||||
|
|
||||||
|
.btn{padding:8px 16px;border-radius:8px;background:var(--card);color:var(--text);border:1.5px solid var(--border);font-weight:600;font-size:.88rem;transition:background .15s,border-color .15s,transform .1s}
|
||||||
|
.btn:hover{background:var(--pri-soft);border-color:var(--pri)}
|
||||||
|
.btn:active{transform:scale(.96)}
|
||||||
|
.btn.primary{background:var(--pri);color:#fff;border-color:var(--pri)}
|
||||||
|
.btn.primary:hover{background:var(--pri2);border-color:var(--pri2)}
|
||||||
|
|
||||||
|
.feedback{padding:10px 14px;border-radius:9px;font-weight:600;font-size:.88rem;margin-top:8px;display:none}
|
||||||
|
.feedback.ok{display:block;background:var(--ok-bg);color:#065f46;border-left:4px solid var(--ok)}
|
||||||
|
.feedback.fail{display:block;background:var(--fail-bg);color:#7f1d1d;border-left:4px solid var(--fail)}
|
||||||
|
|
||||||
|
.col-side{position:sticky;top:14px;align-self:start;height:fit-content;max-height:calc(100vh - 28px);overflow-y:auto}
|
||||||
|
.sidecard{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px;margin-bottom:14px;box-shadow:var(--sh)}
|
||||||
|
.sidecard h4{font-family:'Unbounded',sans-serif;font-size:.74rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||||
|
.sidecard-row{margin-bottom:8px;font-size:.86rem;line-height:1.6}
|
||||||
|
.sidecard-row b{color:var(--pri);font-weight:700}
|
||||||
|
.sidecard-row:last-child{margin-bottom:0}
|
||||||
|
@media(max-width:980px){.col-side{position:static;max-height:none}}
|
||||||
|
|
||||||
|
.xp-card{background:linear-gradient(135deg,var(--acc-soft),var(--pri-soft));border:1.5px solid var(--acc);border-radius:12px;padding:14px;margin-bottom:14px}
|
||||||
|
.xp-card-title{font-size:.68rem;font-weight:800;color:var(--acc2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.xp-level{font-size:1.1rem;font-weight:900;color:var(--acc2);font-family:'Unbounded',sans-serif}
|
||||||
|
.xp-bar{height:9px;background:rgba(0,0,0,.10);border-radius:6px;overflow:hidden;margin:7px 0}
|
||||||
|
.xp-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--pri));border-radius:6px;transition:width .5s cubic-bezier(.4,0,.2,1)}
|
||||||
|
.xp-nums{font-size:.74rem;color:var(--muted);display:flex;justify-content:space-between}
|
||||||
|
|
||||||
|
.sec-nav{display:flex;gap:10px;margin-top:24px;padding-top:20px;border-top:1px solid var(--border);justify-content:space-between;flex-wrap:wrap}
|
||||||
|
.foot{text-align:center;padding:30px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
|
||||||
|
|
||||||
|
.ach-popup{position:fixed;top:80px;right:18px;background:linear-gradient(135deg,var(--pri),var(--acc));color:#fff;padding:12px 18px;border-radius:11px;font-weight:700;font-size:.9rem;box-shadow:0 8px 28px rgba(0,0,0,.32);z-index:1002;display:none;align-items:center;gap:8px;max-width:340px}
|
||||||
|
.ach-popup.show{display:flex}
|
||||||
|
|
||||||
|
.col-side-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.42);z-index:9990;display:none}
|
||||||
|
.col-side-backdrop.show{display:block}
|
||||||
|
@media(max-width:980px){
|
||||||
|
.col-side{position:fixed;top:0;right:0;height:100vh;width:300px;max-width:88vw;background:var(--bg);box-shadow:-12px 0 24px rgba(0,0,0,.18);padding:18px 16px;overflow-y:auto;transform:translateX(100%);transition:transform .25s ease;z-index:9991;max-height:none}
|
||||||
|
.col-side.open{transform:none}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-modal{position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(4px);z-index:9993;display:none;align-items:flex-start;justify-content:center;padding-top:14vh}
|
||||||
|
.search-modal.show{display:flex}
|
||||||
|
.search-box{background:var(--bg);border:1px solid var(--border);border-radius:14px;width:560px;max-width:92vw;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.4)}
|
||||||
|
.search-input{padding:14px 16px;font-size:1rem;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--text);outline:none}
|
||||||
|
.search-results{flex:1;overflow-y:auto;padding:6px 0}
|
||||||
|
.search-row{display:block;padding:8px 16px;cursor:pointer;border-bottom:1px solid var(--border);text-align:left;background:transparent;border:0;width:100%;color:var(--text)}
|
||||||
|
.search-row:hover,.search-row.active{background:var(--pri-soft)}
|
||||||
|
.search-row .sr-kind{font-size:.7rem;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:2px}
|
||||||
|
.search-row .sr-title{font-weight:700;font-size:.92rem;color:var(--text)}
|
||||||
|
.search-row .sr-desc{font-size:.8rem;color:var(--muted);margin-top:2px}
|
||||||
|
.search-empty{padding:20px;text-align:center;color:var(--muted);font-size:.88rem}
|
||||||
|
.search-foot{padding:8px 14px;border-top:1px solid var(--border);font-size:.74rem;color:var(--muted);display:flex;gap:14px}
|
||||||
|
.search-foot kbd{padding:2px 6px;background:var(--card);border:1px solid var(--border);border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:.72rem}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-row">
|
||||||
|
<div>
|
||||||
|
<h1>Геометрия 9 · Глава ${N}</h1>
|
||||||
|
<div class="hdr-sub">${spec.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-side">
|
||||||
|
<a href="/textbook/geometry-9" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К геометрии 9</a>
|
||||||
|
<button id="search-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg> Поиск</button>
|
||||||
|
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||||||
|
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="col-main">
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h2>${spec.heroH}</h2>
|
||||||
|
<p>${spec.heroP}</p>
|
||||||
|
<div class="hero-row">
|
||||||
|
<button class="btn-primary" onclick="goTo('${firstP}')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать ${paras[0].num}</button>
|
||||||
|
<div class="hero-progress">
|
||||||
|
<span class="hp-label">Прогресс по главе</span>
|
||||||
|
<div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div>
|
||||||
|
<span id="hero-hp-text" class="hp-text">0%</span>
|
||||||
|
</div>
|
||||||
|
<div id="hero-xp-badge" class="hero-xp-badge"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="psel">
|
||||||
|
<div class="psel-title">Параграфы главы</div>
|
||||||
|
<div id="psel-grid" class="psel-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
${sectionsHtml}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<aside class="col-side" id="col-side"><div id="sidebar-content"></div></aside>
|
||||||
|
<div class="col-side-backdrop" id="col-side-backdrop"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">Интерактивный учебник «Геометрия 9» · Глава ${N} · ${spec.title} · LearnSpace</footer>
|
||||||
|
|
||||||
|
<div id="ach-popup" class="ach-popup"><svg class="ic" viewBox="0 0 24 24" style="width:22px;height:22px"><polygon points="12,2 22,20 2,20"/></svg><span id="ach-text">Достижение!</span></div>
|
||||||
|
<div id="search-modal" class="search-modal" role="dialog">
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="search-input" class="search-input" placeholder="Поиск…" autocomplete="off">
|
||||||
|
<div id="search-results" class="search-results"></div>
|
||||||
|
<div class="search-foot"><span><kbd>↑↓</kbd> навигация</span><span><kbd>Enter</kbd> открыть</span><span><kbd>Esc</kbd> закрыть</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STATE = { current:'${firstP}', progress:{${progressInit}}, achievements:new Map(), xp:0, level:1 };
|
||||||
|
const TOTAL_PARAS = ${total};
|
||||||
|
const _TB_SLUG = 'geometry-9-ch${N}';
|
||||||
|
|
||||||
|
function calcLevel(xp){ return Math.floor(Math.sqrt((xp||0)/100))+1; }
|
||||||
|
function _xpForLevel(lv){ return (lv-1)*(lv-1)*100; }
|
||||||
|
|
||||||
|
const ACH_LABELS = {
|
||||||
|
${achJs}
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadProgress(){
|
||||||
|
try{
|
||||||
|
const s=localStorage.getItem('geometry9_ch${N}_progress'); if(s) Object.assign(STATE.progress, JSON.parse(s));
|
||||||
|
const a=localStorage.getItem('geometry9_ch${N}_achievements');
|
||||||
|
if(a){ const p=JSON.parse(a); if(Array.isArray(p)) p.forEach(id=>STATE.achievements.set(id, ACH_LABELS[id]||id)); else if(p&&typeof p==='object'){ for(const[id,t] of Object.entries(p)) STATE.achievements.set(id,(t&&t!==id)?t:(ACH_LABELS[id]||id)); } }
|
||||||
|
STATE.xp=+(localStorage.getItem('geometry9_xp')||0); STATE.level=calcLevel(STATE.xp);
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function saveProgress(){
|
||||||
|
try{
|
||||||
|
localStorage.setItem('geometry9_ch${N}_progress', JSON.stringify(STATE.progress));
|
||||||
|
localStorage.setItem('geometry9_ch${N}_achievements', JSON.stringify(Object.fromEntries(STATE.achievements)));
|
||||||
|
localStorage.setItem('geometry9_xp', String(STATE.xp));
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function bumpProgress(key, delta){
|
||||||
|
STATE.progress[key]=Math.max(0,Math.min(100,(STATE.progress[key]||0)+delta));
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(STATE.progress[key]>=50) markParaRead(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _markedRead=new Set();
|
||||||
|
let _pendingProgressBody=null, _progressTimer=null;
|
||||||
|
function _flushProgress(){
|
||||||
|
const body=_pendingProgressBody; _pendingProgressBody=null; if(!body) return;
|
||||||
|
const tok=(window.LS&&LS.getToken)?LS.getToken():''; if(!tok) return;
|
||||||
|
fetch('/api/textbooks/'+_TB_SLUG+'/progress',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tok},body:JSON.stringify(body),keepalive:true}).catch(()=>{});
|
||||||
|
}
|
||||||
|
function _queueProgress(patch){ _pendingProgressBody=Object.assign(_pendingProgressBody||{},patch); if(_progressTimer) clearTimeout(_progressTimer); _progressTimer=setTimeout(_flushProgress, 600); }
|
||||||
|
function markLastPara(id){ _queueProgress({last_para:id}); }
|
||||||
|
function markParaRead(id){ if(_markedRead.has(id)) return; _markedRead.add(id); _queueProgress({mark_read:id}); }
|
||||||
|
window.addEventListener('beforeunload', _flushProgress);
|
||||||
|
function loadServerReadState(){
|
||||||
|
const tok=(window.LS&&LS.getToken)?LS.getToken():''; if(!tok) return;
|
||||||
|
fetch('/api/textbooks/'+_TB_SLUG,{headers:{'Authorization':'Bearer '+tok}}).then(r=>r.ok?r.json():null).then(d=>{
|
||||||
|
if(!d||!d.progress) return;
|
||||||
|
(d.progress.read||[]).forEach(k=>{_markedRead.add(k); if((STATE.progress[k]||0)<50) STATE.progress[k]=100;});
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addXp(n,src){
|
||||||
|
if(!n) return;
|
||||||
|
const prev=STATE.level; STATE.xp=Math.max(0,(STATE.xp||0)+n); STATE.level=calcLevel(STATE.xp);
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(window.LS&&window.LS.xp) window.LS.xp.add(n,'geometry9-ch${N}-'+(src||'misc'));
|
||||||
|
if(STATE.level>prev){
|
||||||
|
const pop=document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent='Уровень '+STATE.level+'!'; pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'),2600); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProgressUI(){
|
||||||
|
const total=Math.round(Object.values(STATE.progress).reduce((a,b)=>a+b,0)/TOTAL_PARAS);
|
||||||
|
const f=document.getElementById('hero-hp-fill'); if(f) f.style.width=total+'%';
|
||||||
|
const t=document.getElementById('hero-hp-text'); if(t) t.textContent=total+'% пройдено';
|
||||||
|
document.querySelectorAll('[data-prog-card]').forEach(el=>{ const k=el.dataset.progCard; const fl=el.querySelector('.psel-prog-fill'); if(fl) fl.style.width=(STATE.progress[k]||0)+'%'; });
|
||||||
|
const xpBadge=document.getElementById('hero-xp-badge');
|
||||||
|
if(xpBadge){ xpBadge.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polygon points="12 2 22 20 2 20"/></svg> Ур. '+STATE.level+' \\xb7 '+(STATE.xp||0)+' XP'; }
|
||||||
|
if(STATE.current && document.getElementById('sidebar-content')){ try{ buildSidebar(STATE.current); }catch(e){} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function achievement(id,text){
|
||||||
|
if(STATE.achievements.has(id)) return;
|
||||||
|
STATE.achievements.set(id, text||ACH_LABELS[id]||id); saveProgress();
|
||||||
|
const pop=document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent=text||ACH_LABELS[id]||id; pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'),3300); }
|
||||||
|
addXp(20,'ach-'+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARAS = [
|
||||||
|
${parasJs}
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildParaSelector(){
|
||||||
|
const g=document.getElementById('psel-grid'); g.innerHTML='';
|
||||||
|
PARAS.forEach(p=>{
|
||||||
|
const card=document.createElement('div');
|
||||||
|
card.className='psel-card'+(p.final?' final':'');
|
||||||
|
card.dataset.id=p.id; card.dataset.progCard=p.id;
|
||||||
|
card.innerHTML='<div class="psel-num">'+p.num+'</div><div class="psel-name">'+p.name+'</div><div class="psel-prog"><div class="psel-prog-fill"></div></div>';
|
||||||
|
card.addEventListener('click', ()=>goTo(p.id));
|
||||||
|
g.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILT=new Set();
|
||||||
|
const BUILDERS = { ${buildersJs} };
|
||||||
|
function ensureBuilt(id){ if(BUILT.has(id)) return; const fn=BUILDERS[id]; if(fn){ fn(); BUILT.add(id); } }
|
||||||
|
function goTo(id){
|
||||||
|
STATE.current=id; ensureBuilt(id);
|
||||||
|
document.querySelectorAll('.sec').forEach(s=>s.classList.remove('active'));
|
||||||
|
const el=document.getElementById('sec-'+id); if(el) el.classList.add('active');
|
||||||
|
document.querySelectorAll('.psel-card').forEach(c=>c.classList.toggle('active', c.dataset.id===id));
|
||||||
|
buildSidebar(id);
|
||||||
|
window.scrollTo({top:0,behavior:'smooth'});
|
||||||
|
if((STATE.progress[id]||0)<10) bumpProgress(id, 10);
|
||||||
|
if(window.renderMathInElement) setTimeout(()=>renderMath(el), 0);
|
||||||
|
markLastPara(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBARS = {
|
||||||
|
${sidebarsJs}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIPS=[
|
||||||
|
${tipsJs}
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildSidebar(id){
|
||||||
|
const box=document.getElementById('sidebar-content');
|
||||||
|
const sb=SIDEBARS[id]||SIDEBARS['${firstP}'];
|
||||||
|
let html='';
|
||||||
|
const xpForLv=_xpForLevel(STATE.level), xpNext=_xpForLevel(STATE.level+1);
|
||||||
|
const xpInLv=STATE.xp-xpForLv, xpRange=xpNext-xpForLv;
|
||||||
|
const xpPct=xpRange>0?Math.round(xpInLv/xpRange*100):100;
|
||||||
|
html+='<div class="xp-card"><div class="xp-card-title"><span>XP-прогресс</span><span class="xp-level">Ур. '+STATE.level+'</span></div><div class="xp-bar"><div class="xp-fill" style="width:'+xpPct+'%"></div></div><div class="xp-nums"><span>'+STATE.xp+' XP</span><span>'+xpNext+' XP</span></div></div>';
|
||||||
|
html+='<div class="sidecard"><h4>'+sb.title+'</h4>';
|
||||||
|
sb.rows.forEach(([k,v])=>{ html+='<div class="sidecard-row"><b>'+k+'</b>'+(v?' — '+v:'')+'</div>'; });
|
||||||
|
html+='</div>';
|
||||||
|
const tip=TIPS.find(t=>t.sec===id)||TIPS[0];
|
||||||
|
if(tip){
|
||||||
|
html+='<div class="sidecard" style="background:linear-gradient(135deg,var(--warn-bg,#fef3c7),var(--pri-soft));border-color:var(--warn,#f59e0b)"><h4 style="color:#92400e;display:flex;align-items:center;gap:6px"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><polygon points="12,2 22,20 2,20"/></svg>Подсказка</h4><div class="sidecard-row" style="margin-bottom:0;font-size:.84rem;line-height:1.55">'+tip.html+'</div></div>';
|
||||||
|
}
|
||||||
|
if(STATE.achievements.size>0){
|
||||||
|
html+='<div class="sidecard"><h4>Достижения <span style="color:var(--warn);float:right">'+STATE.achievements.size+'</span></h4>';
|
||||||
|
[...STATE.achievements.values()].slice(-4).forEach(text=>{ html+='<div class="sidecard-row" style="font-size:.78rem;color:var(--ok)">✓ '+text+'</div>'; });
|
||||||
|
html+='</div>';
|
||||||
|
}
|
||||||
|
box.innerHTML=html;
|
||||||
|
if(window.renderMathInElement) try{ renderMath(box); }catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme(){
|
||||||
|
const t=localStorage.getItem('geometry9_ch${N}_theme')||'light';
|
||||||
|
if(t==='dark') document.documentElement.classList.add('dark');
|
||||||
|
document.getElementById('theme-lab').textContent=t==='dark'?'Светлая':'Тёмная';
|
||||||
|
document.getElementById('theme-btn').addEventListener('click', ()=>{
|
||||||
|
document.documentElement.classList.toggle('dark');
|
||||||
|
const dark=document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem('geometry9_ch${N}_theme', dark?'dark':'light');
|
||||||
|
document.getElementById('theme-lab').textContent=dark?'Светлая':'Тёмная';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMath(root){ if(window.renderMathInElement){ try{ renderMathInElement(root, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false}); }catch(e){} } }
|
||||||
|
function feedback(elm, ok, text){ if(!elm) return; elm.className='feedback '+(ok?'ok':'fail'); elm.innerHTML=text||(ok?'✓ Верно!':'✗ Неверно'); elm.style.display='block'; try{renderMath(elm);}catch(e){} }
|
||||||
|
function fmt(n){ if(!isFinite(n)) return '?'; if(Number.isInteger(n)) return String(n); return Math.abs(n-Math.round(n))<1e-9?String(Math.round(n)):(+n.toFixed(6)).toString(); }
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
repeat:'<svg class="ic" viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
|
||||||
|
theory:'<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
|
||||||
|
algo:'<svg class="ic" viewBox="0 0 24 24"><polyline points="17 11 21 7 17 3"/><line x1="21" y1="7" x2="9" y2="7"/><polyline points="7 13 3 17 7 21"/><line x1="3" y1="17" x2="15" y2="17"/></svg>',
|
||||||
|
rule:'<svg class="ic" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>',
|
||||||
|
example:'<svg class="ic" viewBox="0 0 24 24"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 13c1 1 2 2 2 4h4c0-2 1-3 2-4a7 7 0 0 0-4-13z"/></svg>',
|
||||||
|
oral:'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeCard(kind, title, num, body){
|
||||||
|
const labels = {repeat:'Повторение',theory:'Теория',algo:'Алгоритм',rule:'Правило',example:'Пример',oral:'Устно'};
|
||||||
|
return '<div class="card"><div class="card-header"><div class="card-icon '+kind+'">'+ICONS[kind]+'</div><div class="card-title">'+(labels[kind]||'')+(title&&title!==labels[kind]?' \\xb7 '+title:'')+'</div>'+(num?'<div class="card-num">'+num+'</div>':'')+'</div><div class="card-body">'+body+'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function secNav(prev, next){
|
||||||
|
const NAMES={${namesJs}};
|
||||||
|
let h='<div class="sec-nav">';
|
||||||
|
h+=prev?'<button class="btn" onclick="goTo(\\''+prev+'\\')"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> '+NAMES[prev]+'</button>':'<span></span>';
|
||||||
|
h+=next?'<button class="btn primary" onclick="goTo(\\''+next+'\\')">'+NAMES[next]+' <svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></button>':'<span></span>';
|
||||||
|
h+='</div>'; return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readButton(paraId){
|
||||||
|
return '<div style="margin-top:18px;display:flex;justify-content:center">'
|
||||||
|
+'<button class="btn primary" id="'+paraId+'-read-btn">'
|
||||||
|
+'<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>'
|
||||||
|
+' Я прочитал — '+(paraId.startsWith('final')?'финал':'\\xA7'+paraId.replace('p',''))+' (+10 XP)'
|
||||||
|
+'</button></div>';
|
||||||
|
}
|
||||||
|
function wireReadBtn(paraId){
|
||||||
|
const btn = document.getElementById(paraId+'-read-btn'); if(!btn) return;
|
||||||
|
btn.addEventListener('click', ()=>{
|
||||||
|
addXp(10, paraId+'-read'); bumpProgress(paraId, 100);
|
||||||
|
btn.textContent='Прочитано! +10 XP'; btn.disabled=true; btn.style.opacity=.6;
|
||||||
|
if(paraId==='${finalId}') achievement('ch${N}_done');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STUB BUILDERS — наполнение в Phase 7+ ===== */
|
||||||
|
|
||||||
|
function _stubBuilder(paraId, num, name, prev, next){
|
||||||
|
const body = document.getElementById(paraId+'-body');
|
||||||
|
let html = '';
|
||||||
|
html += makeCard('theory', 'В разработке', num, \`
|
||||||
|
<p>Содержание параграфа <b>«\${name}»</b> будет добавлено в следующих обновлениях.</p>
|
||||||
|
<p style="color:var(--muted);font-size:.9rem">Раздел Phase 7.</p>\`);
|
||||||
|
html += readButton(paraId);
|
||||||
|
html += secNav(prev, next);
|
||||||
|
body.innerHTML = html;
|
||||||
|
wireReadBtn(paraId);
|
||||||
|
if(window.renderMathInElement) renderMath(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
${stubsJs}
|
||||||
|
|
||||||
|
${finalBuilderJs}
|
||||||
|
|
||||||
|
/* ===== Search ===== */
|
||||||
|
const SEARCH_INDEX = (function(){
|
||||||
|
const arr=[];
|
||||||
|
PARAS.forEach(p=>arr.push({kind:'Параграф',title:p.num+' '+p.name,desc:p.sub||'',sec:p.id}));
|
||||||
|
return arr;
|
||||||
|
})();
|
||||||
|
function initSearch(){
|
||||||
|
const modal=document.getElementById('search-modal'),inp=document.getElementById('search-input'),out=document.getElementById('search-results'),btn=document.getElementById('search-btn');
|
||||||
|
if(!modal||!inp||!out) return;
|
||||||
|
let cur=0,rows=[];
|
||||||
|
function score(q,it){ const t=(it.title+' '+it.desc).toLowerCase(); if(t.includes(q)) return 100+(it.title.toLowerCase().startsWith(q)?50:0); let s=0; q.split(/\\s+/).forEach(w=>{if(w&&t.includes(w))s+=10;}); return s; }
|
||||||
|
function rank(q){ q=q.trim().toLowerCase(); if(!q) return SEARCH_INDEX.slice(0,12); return SEARCH_INDEX.map(it=>({it,s:score(q,it)})).filter(x=>x.s>0).sort((a,b)=>b.s-a.s).slice(0,20).map(x=>x.it); }
|
||||||
|
function render(){ cur=0; if(!rows.length){out.innerHTML='<div class="search-empty">Ничего не найдено</div>';return;} out.innerHTML=rows.map((r,i)=>'<button class="search-row'+(i===0?' active':'')+'" data-i="'+i+'"><div class="sr-kind">'+r.kind+'</div><div class="sr-title">'+r.title+'</div>'+(r.desc?'<div class="sr-desc">'+(r.desc.length>90?r.desc.slice(0,90)+'…':r.desc)+'</div>':'')+'</button>').join(''); out.querySelectorAll('.search-row').forEach(b=>b.addEventListener('click',()=>{cur=+b.dataset.i;pick();})); }
|
||||||
|
function pick(){ const r=rows[cur]; if(!r) return; close(); goTo(r.sec); }
|
||||||
|
function move(d){ const items=out.querySelectorAll('.search-row'); if(!items.length) return; items[cur]&&items[cur].classList.remove('active'); cur=(cur+d+items.length)%items.length; items[cur].classList.add('active'); items[cur].scrollIntoView({block:'nearest'}); }
|
||||||
|
function open(){ modal.classList.add('show'); inp.value=''; rows=rank(''); render(); setTimeout(()=>inp.focus(),50); }
|
||||||
|
function close(){ modal.classList.remove('show'); }
|
||||||
|
btn&&btn.addEventListener('click',open);
|
||||||
|
modal.addEventListener('click',e=>{if(e.target===modal)close();});
|
||||||
|
inp.addEventListener('input',()=>{rows=rank(inp.value);render();});
|
||||||
|
inp.addEventListener('keydown',e=>{ if(e.key==='ArrowDown'){e.preventDefault();move(1);}else if(e.key==='ArrowUp'){e.preventDefault();move(-1);}else if(e.key==='Enter'){e.preventDefault();pick();}else if(e.key==='Escape'){e.preventDefault();close();} });
|
||||||
|
document.addEventListener('keydown',e=>{ if((e.ctrlKey||e.metaKey)&&(e.key==='k'||e.key==='K')){ e.preventDefault(); if(modal.classList.contains('show')) close(); else open(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSidebarToggle(){
|
||||||
|
const side=document.getElementById('col-side'),back=document.getElementById('col-side-backdrop'),btn=document.getElementById('sidebar-btn');
|
||||||
|
if(!side||!btn) return;
|
||||||
|
function open(){ side.classList.add('open'); back.classList.add('show'); }
|
||||||
|
function close(){ side.classList.remove('open'); back.classList.remove('show'); }
|
||||||
|
btn.addEventListener('click',()=>{ if(side.classList.contains('open')) close(); else open(); });
|
||||||
|
back.addEventListener('click',close);
|
||||||
|
document.addEventListener('keydown',e=>{ if(e.key==='Escape') close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(){
|
||||||
|
loadProgress(); initTheme(); initSidebarToggle(); initSearch();
|
||||||
|
buildParaSelector(); refreshProgressUI(); loadServerReadState(); goTo('${firstP}');
|
||||||
|
setTimeout(()=>achievement('start'), 600);
|
||||||
|
if(window.LS&&window.LS.xp){
|
||||||
|
window.LS.xp.load().then(function(s){ if(s&&s.xp>STATE.xp){ STATE.xp=s.xp; STATE.level=calcLevel(STATE.xp); saveProgress(); refreshProgressUI(); if(STATE.current) buildSidebar(STATE.current); } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
['ch2','ch3','ch4'].forEach(k => {
|
||||||
|
const content = buildChapter(SPECS[k]);
|
||||||
|
const outPath = path.join(OUT_DIR, 'geometry_9_' + k + '.html');
|
||||||
|
fs.writeFileSync(outPath, content, 'utf8');
|
||||||
|
console.log('Wrote', outPath, content.length, 'bytes');
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
|||||||
|
// Генератор physics_10_hub.html на основе algebra_11_hub.html
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'algebra_11_hub.html');
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_10_hub.html');
|
||||||
|
|
||||||
|
let h = fs.readFileSync(SRC, 'utf8');
|
||||||
|
|
||||||
|
// === 1. Palette + dark mode ===
|
||||||
|
h = h.replace(
|
||||||
|
/:root\{[\s\S]*?--bg:#ecfdf5; --card:#fff;[\s\S]*?--sh-h:0 12px 36px rgba\(13,148,136,\.18\);[\s\S]*?\}/,
|
||||||
|
`:root{
|
||||||
|
--bg:#fffbeb; --card:#fff;
|
||||||
|
--text:#0f172a; --muted:#475569;
|
||||||
|
--border:#fde68a;
|
||||||
|
--pri:#ca8a04; --pri-d:#a16207;
|
||||||
|
--pri-soft:#fef3c7;
|
||||||
|
--ch1:#2563eb; --ch1-d:#1d4ed8;
|
||||||
|
--ch2:#059669; --ch2-d:#047857;
|
||||||
|
--ch3:#7c3aed; --ch3-d:#6d28d9;
|
||||||
|
--ch4:#db2777; --ch4-d:#be185d;
|
||||||
|
--ch5:#0891b2; --ch5-d:#0e7490;
|
||||||
|
--ch6:#10b981; --ch6-d:#059669;
|
||||||
|
--sh:0 4px 16px rgba(202,138,4,.10);
|
||||||
|
--sh-h:0 12px 36px rgba(202,138,4,.18);
|
||||||
|
}`);
|
||||||
|
|
||||||
|
h = h.replace(
|
||||||
|
/html\.dark\{[\s\S]*?--pri-soft:rgba\(13,148,136,\.16\);[\s\S]*?\}/,
|
||||||
|
`html.dark{
|
||||||
|
--bg:#1a1500; --card:#2a2410;
|
||||||
|
--text:#fef3c7; --muted:#d4b88f;
|
||||||
|
--border:#3d2f0a;
|
||||||
|
--pri-soft:rgba(202,138,4,.16);
|
||||||
|
}`);
|
||||||
|
|
||||||
|
// === 2. Header ===
|
||||||
|
h = h.replace(
|
||||||
|
/\.hdr\{position:relative;background:linear-gradient\(110deg,#115e59 0%,#0d9488 55%,#5eead4 100%\)[^}]*\}/,
|
||||||
|
`.hdr{position:relative;background:linear-gradient(110deg,#713f12 0%,#ca8a04 55%,#fde047 100%);color:#fff;padding:32px 24px 28px;overflow:hidden;border-bottom:2px solid rgba(254,243,199,.18)}`);
|
||||||
|
|
||||||
|
h = h.replace(/АЛГЕБРА/g, 'ФИЗИКА');
|
||||||
|
h = h.replace(/rgba\(204,251,241,\.12\)/g, 'rgba(254,243,199,.12)');
|
||||||
|
|
||||||
|
// === 3. po-icon gradient ===
|
||||||
|
h = h.replace(
|
||||||
|
/\.po-icon\{[^}]*background:linear-gradient\(135deg,#0d9488,#5eead4\)[^}]*\}/,
|
||||||
|
`.po-icon{width:46px;height:46px;border-radius:12px;background:linear-gradient(135deg,#ca8a04,#fde047);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-family:'Outfit',sans-serif;font-size:1.4rem;font-weight:900;font-style:italic}`);
|
||||||
|
h = h.replace(/\.po-bar\{height:8px;background:rgba\(13,148,136,\.14\)/, '.po-bar{height:8px;background:rgba(202,138,4,.14)');
|
||||||
|
h = h.replace(/\.po-fill\{height:100%;background:linear-gradient\(90deg,var\(--pri\),#5eead4\)/, '.po-fill{height:100%;background:linear-gradient(90deg,var(--pri),#fde047)');
|
||||||
|
h = h.replace(/\.po-xp\{[^}]*background:linear-gradient\(135deg,#f59e0b,var\(--pri\)\)[^}]*\}/,
|
||||||
|
".po-xp{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;background:linear-gradient(135deg,#f59e0b,var(--pri));color:#fff;border-radius:99px;font-size:.8rem;font-weight:800;font-family:'Unbounded',sans-serif;letter-spacing:.02em;box-shadow:0 4px 12px rgba(202,138,4,.24)}");
|
||||||
|
|
||||||
|
// === 4. Chapter grid: 3 → 6 cards ===
|
||||||
|
h = h.replace(
|
||||||
|
/\.ch-grid\{display:grid;grid-template-columns:1fr;gap:18px;margin-bottom:30px\}\s*@media\(min-width:680px\)\{\.ch-grid\{grid-template-columns:1fr 1fr\}\}\s*@media\(min-width:1000px\)\{\.ch-grid\{grid-template-columns:repeat\(3,1fr\)\}\}/,
|
||||||
|
`.ch-grid{display:grid;grid-template-columns:1fr;gap:18px;margin-bottom:30px}
|
||||||
|
@media(min-width:680px){.ch-grid{grid-template-columns:1fr 1fr}}
|
||||||
|
@media(min-width:1000px){.ch-grid{grid-template-columns:repeat(3,1fr)}}`);
|
||||||
|
|
||||||
|
// Replace cover gradients (ch1 ch2 ch3) and add ch4 ch5 ch6
|
||||||
|
h = h.replace(
|
||||||
|
/\.ch-cover\.ch1\{background:[^}]+\}\s*\.ch-cover\.ch2\{background:[^}]+\}\s*\.ch-cover\.ch3\{background:[^}]+\}/,
|
||||||
|
`.ch-cover.ch1{background:linear-gradient(135deg,#1e3a8a,#2563eb 60%,#60a5fa)}
|
||||||
|
.ch-cover.ch2{background:linear-gradient(135deg,#064e3b,#059669 60%,#34d399)}
|
||||||
|
.ch-cover.ch3{background:linear-gradient(135deg,#3b0764,#7c3aed 60%,#a78bfa)}
|
||||||
|
.ch-cover.ch4{background:linear-gradient(135deg,#831843,#db2777 60%,#f472b6)}
|
||||||
|
.ch-cover.ch5{background:linear-gradient(135deg,#164e63,#0891b2 60%,#22d3ee)}
|
||||||
|
.ch-cover.ch6{background:linear-gradient(135deg,#064e3b,#10b981 60%,#6ee7b7)}`);
|
||||||
|
|
||||||
|
// Replace chN-card progress fill and action gradients
|
||||||
|
h = h.replace(
|
||||||
|
/\.ch-card\.ch1-card \.ch-prog-fill\{background:linear-gradient\(90deg,var\(--ch1\),var\(--ch1-d\)\)\}\s*\.ch-card\.ch2-card \.ch-prog-fill\{background:linear-gradient\(90deg,var\(--ch2\),var\(--ch2-d\)\)\}\s*\.ch-card\.ch3-card \.ch-prog-fill\{background:linear-gradient\(90deg,var\(--ch3\),var\(--ch3-d\)\)\}/,
|
||||||
|
`.ch-card.ch1-card .ch-prog-fill{background:linear-gradient(90deg,var(--ch1),var(--ch1-d))}
|
||||||
|
.ch-card.ch2-card .ch-prog-fill{background:linear-gradient(90deg,var(--ch2),var(--ch2-d))}
|
||||||
|
.ch-card.ch3-card .ch-prog-fill{background:linear-gradient(90deg,var(--ch3),var(--ch3-d))}
|
||||||
|
.ch-card.ch4-card .ch-prog-fill{background:linear-gradient(90deg,var(--ch4),var(--ch4-d))}
|
||||||
|
.ch-card.ch5-card .ch-prog-fill{background:linear-gradient(90deg,var(--ch5),var(--ch5-d))}
|
||||||
|
.ch-card.ch6-card .ch-prog-fill{background:linear-gradient(90deg,var(--ch6),var(--ch6-d))}`);
|
||||||
|
|
||||||
|
h = h.replace(
|
||||||
|
/\.ch-card\.ch1-card \.ch-action\{background:linear-gradient\(135deg,var\(--ch1\),#fbbf24\)\}\s*\.ch-card\.ch2-card \.ch-action\{background:linear-gradient\(135deg,var\(--ch2\),#a78bfa\)\}\s*\.ch-card\.ch3-card \.ch-action\{background:linear-gradient\(135deg,var\(--ch3\),#22d3ee\)\}/,
|
||||||
|
`.ch-card.ch1-card .ch-action{background:linear-gradient(135deg,var(--ch1),#60a5fa)}
|
||||||
|
.ch-card.ch2-card .ch-action{background:linear-gradient(135deg,var(--ch2),#34d399)}
|
||||||
|
.ch-card.ch3-card .ch-action{background:linear-gradient(135deg,var(--ch3),#a78bfa)}
|
||||||
|
.ch-card.ch4-card .ch-action{background:linear-gradient(135deg,var(--ch4),#f472b6)}
|
||||||
|
.ch-card.ch5-card .ch-action{background:linear-gradient(135deg,var(--ch5),#22d3ee)}
|
||||||
|
.ch-card.ch6-card .ch-action{background:linear-gradient(135deg,var(--ch6),#6ee7b7)}`);
|
||||||
|
|
||||||
|
// Final header gradient
|
||||||
|
h = h.replace(
|
||||||
|
/\.final-head\{padding:18px 22px;background:linear-gradient\(135deg,#115e59 0%,#0d9488 55%,#0891b2 100%\)/,
|
||||||
|
'.final-head{padding:18px 22px;background:linear-gradient(135deg,#713f12 0%,#ca8a04 55%,#f59e0b 100%)');
|
||||||
|
|
||||||
|
// title
|
||||||
|
h = h.replace(/<title>Алгебра 11 класс — учебник<\/title>/, '<title>Физика 10 класс — учебник</title>');
|
||||||
|
|
||||||
|
// localStorage keys
|
||||||
|
h = h.replace(/algebra11_theme/g, 'physics10_theme');
|
||||||
|
h = h.replace(/algebra11_xp/g, 'physics10_xp');
|
||||||
|
h = h.replace(/algebra11_course_master/g, 'physics10_course_master');
|
||||||
|
h = h.replace(/algebra11_course_bosses/g, 'physics10_course_bosses');
|
||||||
|
h = h.replace(/algebra11-master/g, 'physics10-master');
|
||||||
|
h = h.replace(/'fin-boss-'/g, "'fin-boss-'"); // unchanged
|
||||||
|
|
||||||
|
// Header H1 + subtitle
|
||||||
|
h = h.replace(/<h1>Алгебра — 11 класс<\/h1>/, '<h1>Физика — 10 класс</h1>');
|
||||||
|
h = h.replace(
|
||||||
|
/<div class="hdr-sub">Полный курс: степени и логарифмы, показательная и логарифмическая функции, уравнения и неравенства<\/div>/,
|
||||||
|
'<div class="hdr-sub">Полный курс физики 10 класса: молекулярная физика, термодинамика, электростатика, магнитное поле, ток в средах</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// po-icon "a" → "f"
|
||||||
|
h = h.replace(/<div class="po-icon">a<\/div>/, '<div class="po-icon">f</div>');
|
||||||
|
|
||||||
|
// === 5. Заменяем блок с 3 главами целиком на блок с 6 главами ===
|
||||||
|
const chBlock = `
|
||||||
|
<a href="/textbook/physics-10-ch1" class="ch-card ch1-card" id="ch-1">
|
||||||
|
<div class="ch-cover ch1">
|
||||||
|
<div class="ch-cover-wm">T</div>
|
||||||
|
<div class="ch-num">Глава 1</div>
|
||||||
|
<div class="ch-title">Основы МКТ</div>
|
||||||
|
<div class="ch-range">§1–§10 + Финал</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">Молекулярно-кинетическая теория, идеальный газ, изопроцессы, строение твёрдых тел и жидкостей, влажность воздуха.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-1">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-1" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-1">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/textbook/physics-10-ch2" class="ch-card ch2-card" id="ch-2">
|
||||||
|
<div class="ch-cover ch2">
|
||||||
|
<div class="ch-cover-wm">ΔU</div>
|
||||||
|
<div class="ch-num">Глава 2</div>
|
||||||
|
<div class="ch-title">Термодинамика</div>
|
||||||
|
<div class="ch-range">§11–§15 + Финал</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">Внутренняя энергия, работа и количество теплоты, первый закон термодинамики, тепловые двигатели, цикл Карно.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-2">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-2" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-2">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/textbook/physics-10-ch3" class="ch-card ch3-card" id="ch-3">
|
||||||
|
<div class="ch-cover ch3">
|
||||||
|
<div class="ch-cover-wm">+q</div>
|
||||||
|
<div class="ch-num">Глава 3</div>
|
||||||
|
<div class="ch-title">Электростатика</div>
|
||||||
|
<div class="ch-range">§16–§24 + Финал</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">Электрический заряд, закон Кулона, напряжённость и потенциал электростатического поля, конденсаторы, энергия поля.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-3">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-3" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-3">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/textbook/physics-10-ch4" class="ch-card ch4-card" id="ch-4">
|
||||||
|
<div class="ch-cover ch4">
|
||||||
|
<div class="ch-cover-wm">I</div>
|
||||||
|
<div class="ch-num">Глава 4</div>
|
||||||
|
<div class="ch-title">Постоянный ток</div>
|
||||||
|
<div class="ch-range">§25–§26 + Финал</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">ЭДС источника, закон Ома для полной электрической цепи, КПД источника.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-4">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-4" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-4">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/textbook/physics-10-ch5" class="ch-card ch5-card" id="ch-5">
|
||||||
|
<div class="ch-cover ch5">
|
||||||
|
<div class="ch-cover-wm">B</div>
|
||||||
|
<div class="ch-num">Глава 5</div>
|
||||||
|
<div class="ch-title">Магнитное поле</div>
|
||||||
|
<div class="ch-range">§27–§33 + Финал</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">Магнитное поле, сила Ампера, сила Лоренца, электромагнитная индукция, закон Фарадея, самоиндукция.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-5">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-5" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-5">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/textbook/physics-10-ch6" class="ch-card ch6-card" id="ch-6">
|
||||||
|
<div class="ch-cover ch6">
|
||||||
|
<div class="ch-cover-wm">n/p</div>
|
||||||
|
<div class="ch-num">Глава 6</div>
|
||||||
|
<div class="ch-title">Ток в средах</div>
|
||||||
|
<div class="ch-range">§34–§37 + Финал</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">Электрический ток в металлах и сверхпроводимость, электролиз, разряды в газах и плазма, полупроводники.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-6">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-6" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-6">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Replace the entire <a href="/textbook/algebra-11-ch1"...</a><a ch2></a><a ch3></a> block
|
||||||
|
h = h.replace(/\s*<a href="\/textbook\/algebra-11-ch1"[\s\S]*?<\/a>\s*<a href="\/textbook\/algebra-11-ch2"[\s\S]*?<\/a>\s*<a href="\/textbook\/algebra-11-ch3"[\s\S]*?<\/a>\s*/,
|
||||||
|
chBlock);
|
||||||
|
|
||||||
|
// Final placeholder — заменим cheat-grid + bosses на placeholder
|
||||||
|
h = h.replace(/<div class="fin-section-title">\s*<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"\/><\/svg>\s*Шпаргалка курса\s*<\/div>\s*<div class="cheat-grid">[\s\S]*?<\/div>\s*<div class="fin-section-title">\s*<svg viewBox="0 0 24 24"><path d="M14\.5 3\.5l[^"]+"\/><\/svg>\s*7 интегрированных боссов\s*<\/div>\s*<div class="boss-overall-bar">[\s\S]*?<\/div>\s*<div id="fin-bosses-container"><\/div>/,
|
||||||
|
`<div class="fin-placeholder">
|
||||||
|
<h3>Финал курса — в разработке</h3>
|
||||||
|
<p>Итоговая шпаргалка по всем 37 параграфам и 8–10 интегрированных боссов появятся в Phase 7 (после завершения всех 6 глав).</p>
|
||||||
|
</div>
|
||||||
|
<div id="fin-bosses-container" style="display:none"></div>`);
|
||||||
|
|
||||||
|
// Remove FIN_BOSSES array — заменим на пустой
|
||||||
|
h = h.replace(/var FIN_BOSSES = \[[\s\S]*?\];/, 'var FIN_BOSSES = [];');
|
||||||
|
|
||||||
|
// final-head-sub
|
||||||
|
h = h.replace(
|
||||||
|
/<div class="final-head-sub">Итоговая шпаргалка и интегрированные боссы\. Победи всех — получи «Магистр алгебры 11» и \+50 XP\.<\/div>/,
|
||||||
|
'<div class="final-head-sub">Шпаргалка курса и интегрированные боссы по всем 6 главам. В разработке (Phase 7).</div>'
|
||||||
|
);
|
||||||
|
h = h.replace(/<div class="final-cta-title">Курс Алгебра 11 пройден!<\/div>/, '<div class="final-cta-title">Курс Физика 10 пройден!</div>');
|
||||||
|
h = h.replace(/«Магистр алгебры 11»/g, '«Магистр физики 10»');
|
||||||
|
h = h.replace(/Магистр алгебры 11/g, 'Магистр физики 10');
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
h = h.replace(/Интерактивный учебник «Алгебра — 11 класс»/, 'Интерактивный учебник «Физика — 10 класс»');
|
||||||
|
|
||||||
|
// Achievement strip
|
||||||
|
h = h.replace(/Прочитайте все 10 параграфов трёх глав/, 'Прочитайте все 37 параграфов курса, чтобы получить достижение');
|
||||||
|
|
||||||
|
// === 6. TOTAL + CH_PARA + CH_IDX ===
|
||||||
|
h = h.replace(/var TOTAL = 10;[\s\S]*?var CH_IDX = \{[\s\S]*?\};/, `var TOTAL = 37;
|
||||||
|
var CH_PARA = {
|
||||||
|
'physics-10-ch1': 10,
|
||||||
|
'physics-10-ch2': 5,
|
||||||
|
'physics-10-ch3': 9,
|
||||||
|
'physics-10-ch4': 2,
|
||||||
|
'physics-10-ch5': 7,
|
||||||
|
'physics-10-ch6': 4,
|
||||||
|
};
|
||||||
|
var CH_IDX = {
|
||||||
|
'physics-10-ch1': 1,
|
||||||
|
'physics-10-ch2': 2,
|
||||||
|
'physics-10-ch3': 3,
|
||||||
|
'physics-10-ch4': 4,
|
||||||
|
'physics-10-ch5': 5,
|
||||||
|
'physics-10-ch6': 6,
|
||||||
|
};`);
|
||||||
|
|
||||||
|
// API endpoint slug
|
||||||
|
h = h.replace(/'\/api\/textbooks\/algebra-11\/children'/, "'/api/textbooks/physics-10/children'");
|
||||||
|
|
||||||
|
// На текстах ачивок: "Вы прочитали весь курс алгебры 11 класса."
|
||||||
|
h = h.replace(/Вы прочитали весь курс алгебры 11 класса\./, 'Вы прочитали весь курс физики 10 класса.');
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('OK hub →', DST, 'bytes:', h.length);
|
||||||
|
|
||||||
|
// Quick sanity: extract <script> blocks and check parseable JS
|
||||||
|
const scriptMatches = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
console.log('inline <script> count:', scriptMatches.length);
|
||||||
|
for (const m of scriptMatches) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch(e) { console.error('JS PARSE FAIL:', e.message); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('all inline JS parses OK');
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
/* Генератор stub-файлов для Физики 11 (W0).
|
||||||
|
* Запуск: node backend/scripts/gen_phys11_stubs.js
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const OUT = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
const CHAPTERS = [
|
||||||
|
{ n:1, slug:'physics-11-ch1', name:'Механические колебания и волны',
|
||||||
|
paraRange:'§1–§6', wm:'∿', themeName:'cyan',
|
||||||
|
gradient:['#155e75','#0891b2','#a5f3fc'],
|
||||||
|
pri:'#0891b2', pri2:'#0e7490', priSoft:'#cffafe',
|
||||||
|
desc:'Колебательное движение, гармонические колебания, маятники, превращения энергии, резонанс, продольные и поперечные волны, звук.',
|
||||||
|
paras:[
|
||||||
|
{n:1, title:'Колебательное движение. Гармонические колебания', sub:'$T = \\Delta t / N$, $\\nu = 1/T$, $\\omega = 2\\pi/T$, $x = A\\cos(\\omega t + \\varphi_0)$'},
|
||||||
|
{n:2, title:'Пружинный и математический маятники', sub:'$T_{пр} = 2\\pi\\sqrt{m/k}$, $T_{мат} = 2\\pi\\sqrt{l/g}$'},
|
||||||
|
{n:3, title:'Превращения энергии при гарм. колебаниях', sub:'$W_{мех} = kA^2/2 = m\\omega^2 A^2/2$'},
|
||||||
|
{n:4, title:'Свободные и вынужденные колебания. Резонанс', sub:'Затухание, диссипация, $\\omega_{рез} \\approx \\omega_0$'},
|
||||||
|
{n:5, title:'Распространение колебаний в упругой среде. Продольные и поперечные волны', sub:'$\\lambda = vT$'},
|
||||||
|
{n:6, title:'Звуковые волны', sub:'16 Гц – 20 кГц, $v_{зв}^{возд} \\approx 340$ м/с'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ n:2, slug:'physics-11-ch2', name:'Электромагнитные колебания и волны',
|
||||||
|
paraRange:'§7–§13', wm:'⚡', themeName:'violet',
|
||||||
|
gradient:['#5b21b6','#7c3aed','#c4b5fd'],
|
||||||
|
pri:'#7c3aed', pri2:'#5b21b6', priSoft:'#ede9fe',
|
||||||
|
desc:'Колебательный контур, формула Томсона, переменный ток, трансформатор, передача электроэнергии, ЭМ волны.',
|
||||||
|
paras:[
|
||||||
|
{n:7, title:'Колебательный контур. Свободные ЭМ колебания. Формула Томсона', sub:'$T = 2\\pi\\sqrt{LC}$'},
|
||||||
|
{n:8, title:'Вынужденные ЭМ колебания. Переменный ток', sub:'$i = I_0\\sin(\\omega t)$, $I = I_0/\\sqrt{2}$'},
|
||||||
|
{n:9, title:'Преобразование переменного тока. Трансформатор', sub:'$k = N_1/N_2 = U_1/U_2$'},
|
||||||
|
{n:10, title:'Производство, передача и потребление электроэнергии', sub:'ГЭС, ТЭС, АЭС; потери $P = I^2 R$'},
|
||||||
|
{n:11, title:'Экологические проблемы производства и передачи электроэнергии', sub:'ВЭС, СЭС, гео- и приливные'},
|
||||||
|
{n:12, title:'ЭМ волны. Шкала ЭМ волн', sub:'$c = 3 \\cdot 10^8$ м/с'},
|
||||||
|
{n:13, title:'Действие ЭМ излучения на живые организмы', sub:'Ионизирующее vs неионизирующее'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ n:3, slug:'physics-11-ch3', name:'Оптика',
|
||||||
|
paraRange:'§14–§23', wm:'◇', themeName:'amber',
|
||||||
|
gradient:['#b45309','#d97706','#fcd34d'],
|
||||||
|
pri:'#d97706', pri2:'#b45309', priSoft:'#fef3c7',
|
||||||
|
desc:'Электромагнитная природа света, интерференция, дифракция, отражение, зеркала, преломление, тонкая линза, оптические приборы.',
|
||||||
|
paras:[
|
||||||
|
{n:14, title:'ЭМ природа света. Скорость света', sub:'Опыты Рёмера, Майкельсона'},
|
||||||
|
{n:15, title:'Интерференция света', sub:'$\\Delta = k\\lambda$ (max), $\\Delta = (2k+1)\\lambda/2$ (min)'},
|
||||||
|
{n:16, title:'Принцип Гюйгенса – Френеля. Дифракция. Дифракционная решётка', sub:'$d\\sin\\varphi = k\\lambda$'},
|
||||||
|
{n:17, title:'Прямолинейное распространение и отражение света. Зеркала', sub:'$\\angle_{пад} = \\angle_{отр}$'},
|
||||||
|
{n:18, title:'Сферические зеркала. Построение изображений', sub:'$\\frac{1}{F} = \\frac{1}{d} + \\frac{1}{f}$'},
|
||||||
|
{n:19, title:'Закон преломления света. Полное отражение', sub:'$n_1\\sin\\alpha = n_2\\sin\\beta$, $\\sin\\alpha_{пр} = 1/n$'},
|
||||||
|
{n:20, title:'Прохождение света через оптические элементы', sub:'Призмы, оптоволокно'},
|
||||||
|
{n:21, title:'Формула тонкой линзы', sub:'$D = 1/F$, $\\Gamma = f/d$'},
|
||||||
|
{n:22, title:'Оптические приборы для действительных изображений', sub:'Фотоаппарат, проектор'},
|
||||||
|
{n:23, title:'Оптические приборы для увеличения угла зрения', sub:'Лупа, микроскоп, телескоп'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ n:4, slug:'physics-11-ch4', name:'Основы СТО',
|
||||||
|
paraRange:'§24–§26', wm:'c', themeName:'blue',
|
||||||
|
gradient:['#1e3a8a','#2563eb','#93c5fd'],
|
||||||
|
pri:'#2563eb', pri2:'#1d4ed8', priSoft:'#dbeafe',
|
||||||
|
desc:'Принцип относительности Галилея, постулаты Эйнштейна, преобразования Лоренца, релятивистская динамика, E=mc².',
|
||||||
|
paras:[
|
||||||
|
{n:24, title:'Принцип относ. Галилея и ЭМ явления. Эксп. предпосылки СТО', sub:'Опыт Майкельсона – Морли'},
|
||||||
|
{n:25, title:'Постулаты специальной теории относительности', sub:'$\\Delta t = \\gamma\\Delta t_0$, $l = l_0/\\gamma$'},
|
||||||
|
{n:26, title:'Элементы релятивистской динамики. Взаимосвязь массы и энергии', sub:'$E_0 = mc^2$, $E^2 = (mc^2)^2 + (pc)^2$'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ n:5, slug:'physics-11-ch5', name:'Фотоны. Действия света',
|
||||||
|
paraRange:'§27–§29', wm:'γ', themeName:'pink',
|
||||||
|
gradient:['#831843','#db2777','#fbcfe8'],
|
||||||
|
pri:'#db2777', pri2:'#9d174d', priSoft:'#fce7f3',
|
||||||
|
desc:'Фотоэффект, квантовая гипотеза Планка, фотон, уравнение Эйнштейна, давление света, корпускулярно-волновой дуализм.',
|
||||||
|
paras:[
|
||||||
|
{n:27, title:'Фотоэффект. Эксперим. законы. Квантовая гипотеза Планка', sub:'$E = h\\nu$, $h = 6{,}63 \\cdot 10^{-34}$ Дж·с'},
|
||||||
|
{n:28, title:'Фотон. Уравнение Эйнштейна для фотоэффекта', sub:'$h\\nu = A_{вых} + \\frac{mv_{max}^2}{2}$'},
|
||||||
|
{n:29, title:'Давление света. Корпускулярно-волновой дуализм', sub:'$p_{фот} = h\\nu/c$. Опыт Лебедева'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ n:6, slug:'physics-11-ch6', name:'Физика атома',
|
||||||
|
paraRange:'§30–§34', wm:'⚛', themeName:'emerald',
|
||||||
|
gradient:['#065f46','#10b981','#a7f3d0'],
|
||||||
|
pri:'#10b981', pri2:'#047857', priSoft:'#d1fae5',
|
||||||
|
desc:'Ядерная модель атома Резерфорда, квантовые постулаты Бора, спектры испускания и поглощения, лазеры.',
|
||||||
|
paras:[
|
||||||
|
{n:30, title:'Сложное строение атома. Ядерная модель атома', sub:'Опыт Резерфорда, размер ядра $\\sim 10^{-15}$ м'},
|
||||||
|
{n:31, title:'Квантовые постулаты Бора', sub:'$E_n = -E_1/n^2 = -13{,}6/n^2$ эВ'},
|
||||||
|
{n:32, title:'Излучение и поглощение света атомом. Спектры', sub:'$h\\nu = E_n - E_m$, линейчатые спектры'},
|
||||||
|
{n:33, title:'Спонтанное и индуцированное излучение', sub:'Подготовка к лазерам'},
|
||||||
|
{n:34, title:'Лазеры', sub:'Инверсная населённость, когерентность'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ n:7, slug:'physics-11-ch7', name:'Ядерная физика и элементарные частицы',
|
||||||
|
paraRange:'§35–§44', wm:'☢', themeName:'rose',
|
||||||
|
gradient:['#7f1d1d','#dc2626','#fca5a5'],
|
||||||
|
pri:'#dc2626', pri2:'#991b1b', priSoft:'#fee2e2',
|
||||||
|
desc:'Протонно-нейтронная модель ядра, ядерные реакции, энергия связи, радиоактивность, ядерный реактор, термояд, элементарные частицы.',
|
||||||
|
paras:[
|
||||||
|
{n:35, title:'Протонно-нейтронная модель строения ядра атома', sub:'$A = Z + N$, изотопы'},
|
||||||
|
{n:36, title:'Ядерные реакции. Законы сохранения в ядерных реакциях', sub:'Сохранение заряда, нуклонов, энергии'},
|
||||||
|
{n:37, title:'Энергия связи ядра атома', sub:'$E_{св} = \\Delta m \\cdot c^2$, $\\Delta m = Zm_p + Nm_n - m_я$'},
|
||||||
|
{n:38, title:'Радиоактивность', sub:'$\\alpha$, $\\beta$, $\\gamma$ распады'},
|
||||||
|
{n:39, title:'Закон радиоактивного распада', sub:'$N = N_0 \\cdot 2^{-t/T}$, период полураспада $T$'},
|
||||||
|
{n:40, title:'Деление тяжёлых ядер. Цепные ядерные реакции', sub:'$^{235}$U, $k$ — коэф. размножения'},
|
||||||
|
{n:41, title:'Ядерный реактор', sub:'Управляющие стержни, замедлитель'},
|
||||||
|
{n:42, title:'Реакции ядерного синтеза', sub:'Термояд, $^2$H + $^3$H $\\to ^4$He + n'},
|
||||||
|
{n:43, title:'Ионизирующее излучение. Элементы дозиметрии', sub:'Доза $D$, эквивалент $H$, зиверт'},
|
||||||
|
{n:44, title:'Элементарные частицы и их взаимодействия', sub:'Стандартная модель, 4 фундаментальных взаимодействия'}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ n:8, slug:'physics-11-ch8', name:'Основы единой физической картины мира',
|
||||||
|
paraRange:'§45', wm:'∞', themeName:'indigo',
|
||||||
|
gradient:['#3730a3','#6366f1','#c7d2fe'],
|
||||||
|
pri:'#6366f1', pri2:'#4338ca', priSoft:'#e0e7ff',
|
||||||
|
desc:'Современная естественнонаучная картина мира, эволюция физических теорий, четыре фундаментальных взаимодействия.',
|
||||||
|
paras:[
|
||||||
|
{n:45, title:'Современная естественнонаучная картина мира', sub:'Эволюция представлений: механика → ЭМ → квант'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeChapter(c){
|
||||||
|
/* В какой волне будет реализована эта глава (см. PLAN_FIZIKA_11.md) */
|
||||||
|
const waveOf = {1:'W1-W2', 2:'W3-W4', 3:'W5-W7', 4:'W8', 5:'W9', 6:'W10-W11', 7:'W12-W13', 8:'W14'};
|
||||||
|
const wave = waveOf[c.n] || 'W1+';
|
||||||
|
const parasHtml = c.paras.map(p => `
|
||||||
|
<article class="para-card">
|
||||||
|
<div class="para-num">§ ${p.n}</div>
|
||||||
|
<div class="para-body">
|
||||||
|
<h2 class="para-title">${p.title}</h2>
|
||||||
|
<p class="para-sub">${p.sub}</p>
|
||||||
|
<div class="para-status">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
Будет добавлено в волне ${wave}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>`).join('\n');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Физика 11 · Глава ${c.n} · ${c.name}</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||||
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false})"></script>
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
|
<script src="/js/phys-fx.js?v=1" defer></script>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#f8fafc; --card:#fff;
|
||||||
|
--text:#0f172a; --muted:#475569;
|
||||||
|
--border:#e2e8f0;
|
||||||
|
--pri:${c.pri}; --pri-d:${c.pri2};
|
||||||
|
--pri-soft:${c.priSoft};
|
||||||
|
--dark:${c.gradient[0]};
|
||||||
|
--sh:0 4px 16px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
html.dark{
|
||||||
|
--bg:#020617; --card:#0a1929;
|
||||||
|
--text:#dbeafe; --muted:#94a3b8;
|
||||||
|
--border:#1e293b;
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{min-height:100vh}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;transition:background .25s,color .25s}
|
||||||
|
|
||||||
|
.hdr{position:relative;background:linear-gradient(110deg,${c.gradient[0]} 0%,${c.gradient[1]} 55%,${c.gradient[2]} 100%);color:#fff;padding:32px 24px 28px;overflow:hidden}
|
||||||
|
.hdr::before{content:'${c.wm}';position:absolute;right:8px;top:-20%;font-family:'Outfit',sans-serif;font-size:clamp(8rem,22vw,18rem);font-weight:900;color:rgba(255,255,255,.10);line-height:1;pointer-events:none;user-select:none}
|
||||||
|
.hdr-inner{position:relative;z-index:1;max-width:1100px;margin:0 auto;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||||||
|
.hdr-back{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;background:rgba(255,255,255,.14);border-radius:9px;color:#fff;text-decoration:none;font-size:.85rem;font-weight:600;transition:background .15s}
|
||||||
|
.hdr-back:hover{background:rgba(255,255,255,.24)}
|
||||||
|
.hdr h1{font-family:'Outfit',sans-serif;font-size:1.7rem;font-weight:900;letter-spacing:-.01em}
|
||||||
|
.hdr-sub{font-size:.92rem;opacity:.85;margin-top:4px}
|
||||||
|
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
|
||||||
|
main{max-width:980px;margin:0 auto;padding:32px 24px 60px}
|
||||||
|
|
||||||
|
.intro-card{background:var(--card);border:1.5px solid var(--border);border-radius:16px;padding:22px 26px;margin-bottom:28px;box-shadow:var(--sh)}
|
||||||
|
.intro-num{display:inline-block;padding:4px 10px;background:var(--pri-soft);color:var(--pri-d);border-radius:99px;font-size:.72rem;font-weight:800;letter-spacing:.06em;margin-bottom:8px;text-transform:uppercase}
|
||||||
|
.intro-card h2{font-family:'Outfit',sans-serif;font-size:1.4rem;font-weight:800;margin-bottom:6px}
|
||||||
|
.intro-card p{color:var(--muted);font-size:.95rem}
|
||||||
|
|
||||||
|
.para-grid{display:grid;grid-template-columns:1fr;gap:14px}
|
||||||
|
.para-card{background:var(--card);border:1.5px solid var(--border);border-radius:14px;padding:18px 20px;display:flex;gap:16px;align-items:flex-start;transition:transform .15s,box-shadow .15s,border-color .15s}
|
||||||
|
.para-card:hover{transform:translateY(-2px);box-shadow:var(--sh);border-color:var(--pri)}
|
||||||
|
.para-num{font-family:'Outfit',sans-serif;font-size:1rem;font-weight:900;color:#fff;background:linear-gradient(135deg,var(--pri),var(--pri-d));padding:8px 12px;border-radius:9px;min-width:56px;text-align:center;letter-spacing:-.02em;flex-shrink:0}
|
||||||
|
.para-body{flex:1}
|
||||||
|
.para-title{font-family:'Outfit',sans-serif;font-size:1.05rem;font-weight:800;margin-bottom:4px;color:var(--text)}
|
||||||
|
.para-sub{font-size:.88rem;color:var(--muted);margin-bottom:10px;line-height:1.55}
|
||||||
|
.para-status{display:inline-flex;align-items:center;gap:6px;font-size:.78rem;color:var(--muted);background:rgba(0,0,0,.04);padding:6px 10px;border-radius:8px;font-weight:600}
|
||||||
|
html.dark .para-status{background:rgba(255,255,255,.06)}
|
||||||
|
.para-status .ic{width:14px;height:14px}
|
||||||
|
|
||||||
|
.banner-soon{margin-top:30px;text-align:center;padding:20px;background:linear-gradient(135deg,var(--pri-soft),transparent);border:1px dashed var(--pri);border-radius:14px;color:var(--pri-d);font-weight:700;font-size:.92rem}
|
||||||
|
.banner-soon b{font-family:'Outfit',sans-serif}
|
||||||
|
|
||||||
|
.foot{text-align:center;padding:24px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-inner">
|
||||||
|
<div>
|
||||||
|
<a href="/textbook/physics-11" class="hdr-back">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
К курсу физики 11
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Глава ${c.n}. ${c.name}</h1>
|
||||||
|
<div class="hdr-sub">${c.desc.split('.')[0]} · ${c.paraRange}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<div class="intro-card">
|
||||||
|
<span class="intro-num">Глава ${c.n}</span>
|
||||||
|
<h2>${c.name}</h2>
|
||||||
|
<p>${c.desc} Глава содержит ${c.paras.length} параграф${c.paras.length === 1 ? '' : (c.paras.length < 5 ? 'а' : 'ов')} и финальный этап с боссами.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="para-grid">
|
||||||
|
${parasHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="banner-soon">
|
||||||
|
<b>Глава в разработке.</b> Полная реализация — в следующих волнах. Базовая библиотека <code>phys-fx.js</code> уже доступна.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
Физика — 11 класс · Глава ${c.n} · LearnSpace
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHub(){
|
||||||
|
const cards = CHAPTERS.map((c, i) => `
|
||||||
|
<a href="/textbook/${c.slug}" class="ch-card" style="--ch:${c.pri};--ch-d:${c.pri2};--ch-soft:${c.priSoft}">
|
||||||
|
<div class="ch-cover" style="background:linear-gradient(135deg,${c.gradient[0]},${c.gradient[1]} 60%,${c.gradient[2]})">
|
||||||
|
<div class="ch-cover-wm">${c.wm}</div>
|
||||||
|
<div class="ch-num">Глава ${c.n}</div>
|
||||||
|
<div class="ch-title">${c.name}</div>
|
||||||
|
<div class="ch-range">${c.paraRange} + Финал</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">${c.desc}</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-${c.n}">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-${c.n}" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-${c.n}">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>`).join('\n');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Физика 11 класс — учебник</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||||
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false})"></script>
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#ecfeff; --card:#fff;
|
||||||
|
--text:#0f172a; --muted:#475569;
|
||||||
|
--border:#a5f3fc;
|
||||||
|
--pri:#0891b2; --pri-d:#0e7490;
|
||||||
|
--pri-soft:#cffafe;
|
||||||
|
--sh:0 4px 16px rgba(8,145,178,.10);
|
||||||
|
--sh-h:0 12px 36px rgba(8,145,178,.18);
|
||||||
|
}
|
||||||
|
html.dark{
|
||||||
|
--bg:#062326; --card:#0a2e35;
|
||||||
|
--text:#cffafe; --muted:#67e8f9;
|
||||||
|
--border:#0f4750;
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{min-height:100vh}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;transition:background .25s,color .25s}
|
||||||
|
|
||||||
|
.hdr{position:relative;background:linear-gradient(110deg,#155e75 0%,#0891b2 55%,#67e8f9 100%);color:#fff;padding:32px 24px 28px;overflow:hidden;border-bottom:2px solid rgba(165,243,252,.18)}
|
||||||
|
.hdr::before{content:'ФИЗИКА';position:absolute;right:-14px;top:-18%;font-family:'Outfit',sans-serif;font-size:clamp(5rem,16vw,13rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px rgba(207,250,254,.12);line-height:1;pointer-events:none;user-select:none}
|
||||||
|
.hdr-inner{position:relative;z-index:1;max-width:1180px;margin:0 auto;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||||||
|
.hdr-back{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;background:rgba(255,255,255,.14);border-radius:9px;color:#fff;text-decoration:none;font-size:.85rem;font-weight:600;transition:background .15s}
|
||||||
|
.hdr-back:hover{background:rgba(255,255,255,.24)}
|
||||||
|
.hdr h1{font-family:'Outfit',sans-serif;font-size:1.85rem;font-weight:900;letter-spacing:-.01em}
|
||||||
|
.hdr-sub{font-size:.92rem;opacity:.88;margin-top:4px}
|
||||||
|
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
|
||||||
|
main{max-width:1180px;margin:0 auto;padding:32px 24px 60px}
|
||||||
|
|
||||||
|
.prog-overall{background:linear-gradient(135deg,var(--pri-soft),rgba(103,232,249,.12));border:1px solid var(--border);border-radius:14px;padding:14px 18px;margin-bottom:28px;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
|
||||||
|
.po-icon{width:46px;height:46px;border-radius:12px;background:linear-gradient(135deg,#0891b2,#67e8f9);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-family:'Outfit',sans-serif;font-size:1.4rem;font-weight:900}
|
||||||
|
.po-text{flex:1;min-width:160px}
|
||||||
|
.po-label{font-size:.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px}
|
||||||
|
.po-bar{height:8px;background:rgba(8,145,178,.14);border-radius:5px;overflow:hidden;margin-top:6px}
|
||||||
|
.po-fill{height:100%;background:linear-gradient(90deg,var(--pri),#67e8f9);border-radius:5px;transition:width .5s}
|
||||||
|
|
||||||
|
.ch-grid{display:grid;grid-template-columns:1fr;gap:18px;margin-bottom:30px}
|
||||||
|
@media(min-width:680px){.ch-grid{grid-template-columns:1fr 1fr}}
|
||||||
|
@media(min-width:1100px){.ch-grid{grid-template-columns:repeat(4,1fr)}}
|
||||||
|
|
||||||
|
.ch-card{background:var(--card);border:1.5px solid var(--border);border-radius:18px;overflow:hidden;display:flex;flex-direction:column;transition:transform .2s,box-shadow .2s,border-color .2s;cursor:pointer;text-decoration:none;color:inherit}
|
||||||
|
.ch-card:hover{transform:translateY(-4px);box-shadow:var(--sh-h)}
|
||||||
|
.ch-cover{padding:22px 22px 18px;color:#fff;position:relative;overflow:hidden}
|
||||||
|
.ch-cover-wm{position:absolute;right:-8px;top:-22px;font-size:5.2rem;font-weight:900;font-family:'Outfit',sans-serif;line-height:1;color:rgba(255,255,255,.20);pointer-events:none;letter-spacing:-.04em}
|
||||||
|
.ch-num{display:inline-block;padding:4px 10px;background:rgba(255,255,255,.22);border-radius:99px;font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;position:relative;z-index:1}
|
||||||
|
.ch-title{font-family:'Outfit',sans-serif;font-size:1.05rem;font-weight:800;letter-spacing:-.01em;position:relative;z-index:1;line-height:1.3}
|
||||||
|
.ch-range{font-size:.82rem;opacity:.88;margin-top:4px;position:relative;z-index:1;font-weight:500}
|
||||||
|
|
||||||
|
.ch-body{padding:16px 20px 18px;display:flex;flex-direction:column;flex:1}
|
||||||
|
.ch-desc{font-size:.86rem;color:var(--text);opacity:.84;flex:1;margin-bottom:12px;line-height:1.55}
|
||||||
|
|
||||||
|
.ch-prog{margin-bottom:12px}
|
||||||
|
.ch-prog-label{display:flex;justify-content:space-between;font-size:.74rem;color:var(--muted);font-weight:600;margin-bottom:4px}
|
||||||
|
.ch-prog-bar{height:6px;background:rgba(0,0,0,.07);border-radius:4px;overflow:hidden}
|
||||||
|
.ch-prog-fill{height:100%;border-radius:4px;background:linear-gradient(90deg,var(--ch),var(--ch-d));transition:width .5s}
|
||||||
|
|
||||||
|
.ch-action{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-radius:11px;font-weight:700;font-size:.9rem;color:#fff;background:linear-gradient(135deg,var(--ch),var(--ch-d));transition:filter .15s}
|
||||||
|
.ch-action:hover{filter:brightness(1.08)}
|
||||||
|
|
||||||
|
.banner-soon{margin-top:18px;text-align:center;padding:20px;background:linear-gradient(135deg,var(--pri-soft),transparent);border:1px dashed var(--pri);border-radius:14px;color:var(--pri-d);font-weight:700;font-size:.92rem}
|
||||||
|
.banner-soon b{font-family:'Outfit',sans-serif;display:block;margin-bottom:4px;font-size:1.05rem}
|
||||||
|
|
||||||
|
.foot{text-align:center;padding:24px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-inner">
|
||||||
|
<div>
|
||||||
|
<a href="/textbooks" class="hdr-back">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
К каталогу
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Физика — 11 класс</h1>
|
||||||
|
<div class="hdr-sub">Жилко · Маркович · Сокольский (2021) · 8 глав · 45 параграфов</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<section class="prog-overall">
|
||||||
|
<div class="po-icon">∿</div>
|
||||||
|
<div class="po-text">
|
||||||
|
<div class="po-label">Общий прогресс по курсу</div>
|
||||||
|
<div id="overall-text" style="font-size:1.05rem;font-weight:700">Загрузка...</div>
|
||||||
|
<div class="po-bar"><div id="overall-fill" class="po-fill" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ch-grid">
|
||||||
|
${cards}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="banner-soon">
|
||||||
|
<b>Курс в активной разработке (W0)</b>
|
||||||
|
Инфраструктура готова: миграция БД, библиотека phys-fx.js (Oscillogram, SpringMass, Pendulum) и 8 stub-страниц глав. Реализация по плану PLAN_FIZIKA_11.md — 15 волн (~26 сессий).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
Интерактивный учебник «Физика — 11 класс» · LearnSpace
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
var TOTAL = 45;
|
||||||
|
var CH_PARA = {${CHAPTERS.map(c => "'" + c.slug + "': " + c.paras.length).join(', ')}};
|
||||||
|
var CH_IDX = {${CHAPTERS.map(c => "'" + c.slug + "': " + c.n).join(', ')}};
|
||||||
|
|
||||||
|
function setChProg(idx, readCount, total){
|
||||||
|
var pct = total ? Math.round(readCount * 100 / total) : 0;
|
||||||
|
var labelEl = document.getElementById('prog-' + idx);
|
||||||
|
var fillEl = document.getElementById('fill-' + idx);
|
||||||
|
var btnEl = document.getElementById('btn-' + idx);
|
||||||
|
if (labelEl) labelEl.textContent = pct + '%';
|
||||||
|
if (fillEl) fillEl.style.width = pct + '%';
|
||||||
|
if (btnEl){
|
||||||
|
if (readCount > 0 && readCount < total) btnEl.textContent = 'Продолжить';
|
||||||
|
else if (readCount >= total) btnEl.textContent = 'Открыть снова';
|
||||||
|
else btnEl.textContent = 'Открыть главу';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProgress(children){
|
||||||
|
var totalRead = 0;
|
||||||
|
for (var i = 0; i < children.length; i++){
|
||||||
|
var ch = children[i];
|
||||||
|
var idx = CH_IDX[ch.slug]; if (!idx) continue;
|
||||||
|
var read = ch.progress ? ch.progress.read.length : 0;
|
||||||
|
var total = ch.para_count || CH_PARA[ch.slug] || 1;
|
||||||
|
totalRead += read;
|
||||||
|
setChProg(idx, read, total);
|
||||||
|
}
|
||||||
|
var pct = Math.round(totalRead * 100 / TOTAL);
|
||||||
|
var overallEl = document.getElementById('overall-text');
|
||||||
|
var fillEl = document.getElementById('overall-fill');
|
||||||
|
if (overallEl) overallEl.textContent = totalRead + ' из ' + TOTAL + ' параграфов · ' + pct + '%';
|
||||||
|
if (fillEl) fillEl.style.width = pct + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProgress(){
|
||||||
|
if (typeof window.LS === 'undefined' || typeof window.LS.api !== 'function'){
|
||||||
|
renderProgress([]); return;
|
||||||
|
}
|
||||||
|
window.LS.api('/api/textbooks/physics-11/children')
|
||||||
|
.then(function(data){
|
||||||
|
if (data && data.children) renderProgress(data.children);
|
||||||
|
else renderProgress([]);
|
||||||
|
})
|
||||||
|
.catch(function(){ renderProgress([]); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', loadProgress);
|
||||||
|
else loadProgress();
|
||||||
|
window.addEventListener('focus', loadProgress);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Write all 9 files */
|
||||||
|
fs.writeFileSync(path.join(OUT, 'physics_11_hub.html'), makeHub(), 'utf8');
|
||||||
|
console.log('Wrote: physics_11_hub.html');
|
||||||
|
CHAPTERS.forEach(c => {
|
||||||
|
const fname = 'physics_11_ch' + c.n + '.html';
|
||||||
|
fs.writeFileSync(path.join(OUT, fname), makeChapter(c), 'utf8');
|
||||||
|
console.log('Wrote:', fname);
|
||||||
|
});
|
||||||
|
console.log('Done. 9 stub files generated.');
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Генератор скелетов глав Физики 7. Создаёт physics_7_ch1..ch5.html из единого шаблона.
|
||||||
|
// Phase 0: скелет с инфраструктурой (header, navigator, sidebar, KaTeX, прогресс/XP, goTo),
|
||||||
|
// без §-контента — наполняется в Phase 1+.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const VER = '20260530';
|
||||||
|
const OUT = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
const CHAPTERS = [
|
||||||
|
{
|
||||||
|
n: 1, slug: 'physics-7-ch1',
|
||||||
|
title: 'Физические методы познания природы',
|
||||||
|
range: '§§1–7',
|
||||||
|
accent: '#4f46e5', accentD: '#3730a3', accentSoft: '#e0e7ff',
|
||||||
|
coverGrad: 'linear-gradient(135deg,#312e81,#4f46e5 60%,#a5b4fc)',
|
||||||
|
paras: [
|
||||||
|
{ id:'p1', num:'§ 1', title:'Физика — наука о природе', wm:'?' },
|
||||||
|
{ id:'p2', num:'§ 2', title:'Тело, явление, величина', wm:'×' },
|
||||||
|
{ id:'p3', num:'§ 3', title:'Методы исследования в физике', wm:'⚙' },
|
||||||
|
{ id:'p4', num:'§ 4', title:'Прямые и косвенные измерения', wm:'=' },
|
||||||
|
{ id:'p5', num:'§ 5', title:'Единицы измерения. СИ', wm:'м' },
|
||||||
|
{ id:'p6', num:'§ 6', title:'Действия над физическими величинами', wm:'±' },
|
||||||
|
{ id:'p7', num:'§ 7', title:'Цена деления. Погрешность', wm:'∇' },
|
||||||
|
{ id:'final1', num:'Финал', title:'Итоги главы 1', wm:'★' },
|
||||||
|
],
|
||||||
|
achTitle: 'Юный физик',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 2, slug: 'physics-7-ch2',
|
||||||
|
title: 'Строение вещества',
|
||||||
|
range: '§§8–13',
|
||||||
|
accent: '#7c3aed', accentD: '#5b21b6', accentSoft: '#ede9fe',
|
||||||
|
coverGrad: 'linear-gradient(135deg,#4c1d95,#7c3aed 60%,#c4b5fd)',
|
||||||
|
paras: [
|
||||||
|
{ id:'p8', num:'§ 8', title:'Дискретное строение вещества', wm:'•' },
|
||||||
|
{ id:'p9', num:'§ 9', title:'Тепловое движение частиц', wm:'~' },
|
||||||
|
{ id:'p10', num:'§ 10', title:'Взаимодействие частиц', wm:'⇌' },
|
||||||
|
{ id:'p11', num:'§ 11', title:'Газ, жидкость, твёрдое', wm:'△' },
|
||||||
|
{ id:'p12', num:'§ 12', title:'Тепловое расширение', wm:'↔' },
|
||||||
|
{ id:'p13', num:'§ 13', title:'Температура. Термометры', wm:'°' },
|
||||||
|
{ id:'final2', num:'Финал', title:'Итоги главы 2', wm:'★' },
|
||||||
|
],
|
||||||
|
achTitle: 'Знаток вещества',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 3, slug: 'physics-7-ch3',
|
||||||
|
title: 'Движение и силы',
|
||||||
|
range: '§§14–27',
|
||||||
|
accent: '#dc2626', accentD: '#991b1b', accentSoft: '#fee2e2',
|
||||||
|
coverGrad: 'linear-gradient(135deg,#7f1d1d,#dc2626 60%,#f87171)',
|
||||||
|
paras: [
|
||||||
|
{ id:'p14', num:'§ 14', title:'Механическое движение. Относительность', wm:'→' },
|
||||||
|
{ id:'p15', num:'§ 15', title:'Траектория, путь, время', wm:'s' },
|
||||||
|
{ id:'p16', num:'§ 16', title:'Равномерное движение. Скорость', wm:'v' },
|
||||||
|
{ id:'p17', num:'§ 17', title:'Графики s(t) и v(t)', wm:'∠' },
|
||||||
|
{ id:'p18', num:'§ 18', title:'Средняя скорость', wm:'⟨⟩' },
|
||||||
|
{ id:'p19', num:'§ 19', title:'Инерция', wm:'∞' },
|
||||||
|
{ id:'p20', num:'§ 20', title:'Масса. Плотность', wm:'ρ' },
|
||||||
|
{ id:'p21', num:'§ 21', title:'Сила', wm:'F' },
|
||||||
|
{ id:'p22', num:'§ 22', title:'Сила тяжести', wm:'↓' },
|
||||||
|
{ id:'p23', num:'§ 23', title:'Сила упругости', wm:'≈' },
|
||||||
|
{ id:'p24', num:'§ 24', title:'Вес тела', wm:'P' },
|
||||||
|
{ id:'p25', num:'§ 25', title:'Динамометр', wm:'⊥' },
|
||||||
|
{ id:'p26', num:'§ 26', title:'Сложение сил', wm:'+' },
|
||||||
|
{ id:'p27', num:'§ 27', title:'Сила трения', wm:'~' },
|
||||||
|
{ id:'final3', num:'Финал', title:'Итоги главы 3', wm:'★' },
|
||||||
|
],
|
||||||
|
achTitle: 'Мастер движения',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 4, slug: 'physics-7-ch4',
|
||||||
|
title: 'Давление',
|
||||||
|
range: '§§28–35',
|
||||||
|
accent: '#d97706', accentD: '#92400e', accentSoft: '#fef3c7',
|
||||||
|
coverGrad: 'linear-gradient(135deg,#78350f,#d97706 60%,#fbbf24)',
|
||||||
|
paras: [
|
||||||
|
{ id:'p28', num:'§ 28', title:'Давление. Единицы давления', wm:'p' },
|
||||||
|
{ id:'p29', num:'§ 29', title:'Давление газа', wm:'∴' },
|
||||||
|
{ id:'p30', num:'§ 30', title:'Закон Паскаля', wm:'⊕' },
|
||||||
|
{ id:'p31', num:'§ 31', title:'Гидростатическое давление', wm:'≡' },
|
||||||
|
{ id:'p32', num:'§ 32', title:'Сообщающиеся сосуды', wm:'U' },
|
||||||
|
{ id:'p33', num:'§ 33', title:'Газы и их вес', wm:'⌒' },
|
||||||
|
{ id:'p34', num:'§ 34', title:'Атмосферное давление', wm:'∼' },
|
||||||
|
{ id:'p35', num:'§ 35', title:'Барометры и манометры', wm:'⏚' },
|
||||||
|
{ id:'final4', num:'Финал', title:'Итоги главы 4', wm:'★' },
|
||||||
|
],
|
||||||
|
achTitle: 'Властелин давления',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 5, slug: 'physics-7-ch5',
|
||||||
|
title: 'Работа. Мощность. Энергия',
|
||||||
|
range: '§§36–42',
|
||||||
|
accent: '#10b981', accentD: '#047857', accentSoft: '#d1fae5',
|
||||||
|
coverGrad: 'linear-gradient(135deg,#064e3b,#10b981 60%,#6ee7b7)',
|
||||||
|
paras: [
|
||||||
|
{ id:'p36', num:'§ 36', title:'Механическая работа', wm:'A' },
|
||||||
|
{ id:'p37', num:'§ 37', title:'КПД', wm:'η' },
|
||||||
|
{ id:'p38', num:'§ 38', title:'Мощность', wm:'P' },
|
||||||
|
{ id:'p39', num:'§ 39', title:'Кинетическая энергия',wm:'Eк' },
|
||||||
|
{ id:'p40', num:'§ 40', title:'Потенциальная энергия',wm:'Eп' },
|
||||||
|
{ id:'p41', num:'§ 41', title:'Расчёт Eп = mgh', wm:'h' },
|
||||||
|
{ id:'p42', num:'§ 42', title:'Закон сохранения энергии',wm:'∑' },
|
||||||
|
{ id:'final5', num:'Финал', title:'Итоги главы 5', wm:'★' },
|
||||||
|
],
|
||||||
|
achTitle: 'Энергетик',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeHTML(C) {
|
||||||
|
const parasJs = C.paras.map(p => `{id:'${p.id}',num:'${p.num}',title:${JSON.stringify(p.title)},wm:'${p.wm}'}`).join(',');
|
||||||
|
const sections = C.paras.map(p =>
|
||||||
|
` <section id="sec-${p.id}" class="sec" data-watermark="${p.wm}">
|
||||||
|
<div class="sec-header"><span class="sec-num">${p.num}</span><h2 class="sec-h">${p.title}</h2></div>
|
||||||
|
<div id="${p.id}-body"><div class="placeholder">Содержимое параграфа появится в одной из ближайших фаз разработки.</div></div>
|
||||||
|
</section>`).join('\n');
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
<title>Физика 7 · Глава ${C.n} · ${C.title}</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/phys-textbook-widgets.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||||
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false})"></script>
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
|
<script src="/js/phys.js?v=${VER}" defer></script>
|
||||||
|
<script src="/js/phys7_ch${C.n}_widgets.js?v=${VER}" defer></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#f0f9ff; --card:#fff; --card-soft:#f8fafc; --text:#0f172a; --muted:#475569;
|
||||||
|
--border:#bae6fd; --pri:#0284c7; --pri2:#0c4a6e; --pri-soft:#e0f2fe;
|
||||||
|
--acc:${C.accent}; --acc-d:${C.accentD}; --acc-soft:${C.accentSoft};
|
||||||
|
--ok:#10b981; --ok-bg:#d1fae5; --fail:#dc2626; --fail-bg:#fee2e2; --warn:#f59e0b; --warn-bg:#fef3c7;
|
||||||
|
--sh:0 4px 16px rgba(2,132,199,.08); --sh-h:0 12px 36px rgba(2,132,199,.16);
|
||||||
|
}
|
||||||
|
html.dark{--bg:#0c1e2e;--card:#0e2436;--card-soft:#0b1a28;--text:#e0f2fe;--muted:#7dd3fc;--border:#1e3a5f;--pri-soft:rgba(2,132,199,.18)}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{min-height:100vh}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;transition:background .25s,color .25s}
|
||||||
|
|
||||||
|
.hdr{position:relative;background:${C.coverGrad};color:#fff;padding:24px 22px 22px;overflow:hidden;border-bottom:2px solid rgba(255,255,255,.18)}
|
||||||
|
.hdr-inner{position:relative;z-index:1;max-width:1240px;margin:0 auto;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||||||
|
.hdr h1{font-family:'Unbounded',sans-serif;font-size:1.55rem;font-weight:800;letter-spacing:-.01em}
|
||||||
|
.hdr-sub{font-size:.88rem;opacity:.9;margin-top:3px}
|
||||||
|
.hdr-side{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.hdr-btn{padding:8px 12px;background:rgba(255,255,255,.16);border:none;color:#fff;border-radius:9px;cursor:pointer;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;font-family:inherit;text-decoration:none}
|
||||||
|
.hdr-btn:hover{background:rgba(255,255,255,.26)}
|
||||||
|
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
|
||||||
|
.main{max-width:1240px;margin:0 auto;padding:22px;width:100%;display:grid;grid-template-columns:1fr 280px;gap:24px}
|
||||||
|
@media(max-width:980px){.main{grid-template-columns:1fr;padding:14px}}
|
||||||
|
|
||||||
|
.col-main{min-width:0}
|
||||||
|
.psel{background:var(--card);border:1.5px solid var(--border);border-radius:14px;padding:16px;margin-bottom:18px;box-shadow:var(--sh)}
|
||||||
|
.psel-head{font-family:'Unbounded',sans-serif;font-size:.78rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:10px}
|
||||||
|
.psel-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px}
|
||||||
|
.psel-card{padding:12px;background:var(--card-soft);border:1.5px solid var(--border);border-radius:10px;cursor:pointer;transition:transform .15s,border-color .15s,box-shadow .15s;text-align:left}
|
||||||
|
.psel-card:hover{border-color:var(--acc);transform:translateY(-2px);box-shadow:0 4px 14px rgba(0,0,0,.06)}
|
||||||
|
.psel-card.active{border-color:var(--acc);background:var(--acc-soft)}
|
||||||
|
.psel-num{font-size:.7rem;font-weight:800;color:var(--acc-d);letter-spacing:.04em;text-transform:uppercase;margin-bottom:3px}
|
||||||
|
.psel-title{font-size:.86rem;font-weight:700;line-height:1.35}
|
||||||
|
.psel-prog{height:4px;background:rgba(0,0,0,.07);border-radius:3px;overflow:hidden;margin-top:7px}
|
||||||
|
.psel-prog-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--acc-d));border-radius:3px;transition:width .4s}
|
||||||
|
|
||||||
|
.sec{display:none;background:var(--card);border:1.5px solid var(--border);border-radius:14px;padding:22px;box-shadow:var(--sh);position:relative}
|
||||||
|
.sec.active{display:block}
|
||||||
|
.sec[data-watermark]::before{content:attr(data-watermark);position:absolute;right:18px;top:-12px;font-family:'Unbounded',sans-serif;font-size:5.2rem;font-weight:900;color:var(--acc-soft);pointer-events:none;line-height:1;user-select:none}
|
||||||
|
.sec-header{display:flex;align-items:baseline;gap:14px;margin-bottom:18px;padding-bottom:14px;border-bottom:1.5px solid var(--border);position:relative;z-index:1}
|
||||||
|
.sec-num{background:linear-gradient(135deg,var(--acc),var(--acc-d));color:#fff;padding:5px 12px;border-radius:9px;font-family:'Unbounded',sans-serif;font-weight:800;font-size:.86rem;letter-spacing:.04em}
|
||||||
|
.sec-h{font-family:'Unbounded',sans-serif;font-size:1.35rem;font-weight:800;color:var(--text)}
|
||||||
|
.placeholder{padding:32px 20px;text-align:center;color:var(--muted);font-size:.95rem;background:var(--card-soft);border:1.5px dashed var(--border);border-radius:10px}
|
||||||
|
|
||||||
|
.col-side{position:sticky;top:14px;align-self:start;height:fit-content;max-height:calc(100vh - 28px);overflow-y:auto}
|
||||||
|
.sidecard{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px;margin-bottom:14px;box-shadow:var(--sh)}
|
||||||
|
.sidecard h4{font-family:'Unbounded',sans-serif;font-size:.74rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||||
|
.sidecard-row{margin-bottom:8px;font-size:.86rem;line-height:1.6}
|
||||||
|
.sidecard-row b{color:var(--pri);font-weight:700}
|
||||||
|
@media(max-width:980px){.col-side{position:static;max-height:none}}
|
||||||
|
|
||||||
|
.xp-card{background:linear-gradient(135deg,var(--acc-soft),var(--pri-soft));border:1.5px solid var(--acc);border-radius:12px;padding:14px;margin-bottom:14px}
|
||||||
|
.xp-card-title{font-size:.68rem;font-weight:800;color:var(--acc-d);text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.xp-level{font-size:1.1rem;font-weight:900;color:var(--acc-d);font-family:'Unbounded',sans-serif}
|
||||||
|
.xp-bar{height:9px;background:rgba(245,158,11,.15);border-radius:6px;overflow:hidden;margin:7px 0}
|
||||||
|
.xp-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--pri));border-radius:6px;transition:width .5s cubic-bezier(.4,0,.2,1)}
|
||||||
|
.xp-nums{font-size:.74rem;color:var(--muted);display:flex;justify-content:space-between}
|
||||||
|
|
||||||
|
.col-side-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.42);z-index:9990;display:none}
|
||||||
|
.col-side-backdrop.show{display:block}
|
||||||
|
@media(min-width:981px){#sidebar-btn{display:none}.col-side-backdrop.show{display:none}}
|
||||||
|
@media(max-width:980px){
|
||||||
|
.col-side{position:fixed;top:0;right:0;height:100vh;width:300px;max-width:88vw;background:var(--bg);box-shadow:-12px 0 24px rgba(0,0,0,.18);padding:18px 16px;overflow-y:auto;transform:translateX(100%);transition:transform .25s ease;z-index:9991;max-height:none}
|
||||||
|
.col-side.open{transform:none}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ach-popup{position:fixed;top:80px;right:18px;background:linear-gradient(135deg,var(--acc-d),var(--acc));color:#fff;padding:12px 18px;border-radius:11px;font-weight:700;font-size:.9rem;box-shadow:0 8px 28px rgba(0,0,0,.25);z-index:1002;display:none;align-items:center;gap:8px;max-width:340px}
|
||||||
|
.ach-popup.show{display:flex}
|
||||||
|
|
||||||
|
.foot{text-align:center;padding:30px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
|
||||||
|
|
||||||
|
/* Search modal */
|
||||||
|
.search-modal{position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(4px);z-index:9993;display:none;align-items:flex-start;justify-content:center;padding-top:80px}
|
||||||
|
.search-modal.show{display:flex}
|
||||||
|
.search-box{background:var(--card);border-radius:14px;width:520px;max-width:92vw;padding:14px;box-shadow:0 24px 64px rgba(0,0,0,.35);border:1.5px solid var(--border)}
|
||||||
|
.search-inp{width:100%;padding:11px 14px;background:var(--card-soft);border:1.5px solid var(--border);border-radius:9px;color:var(--text);font-size:.95rem;font-family:inherit;outline:0}
|
||||||
|
.search-inp:focus{border-color:var(--acc)}
|
||||||
|
.search-list{margin-top:12px;max-height:320px;overflow-y:auto}
|
||||||
|
.search-item{padding:10px 12px;border-radius:9px;cursor:pointer;border:1px solid transparent;font-size:.9rem}
|
||||||
|
.search-item:hover,.search-item.cur{background:var(--acc-soft);border-color:var(--acc)}
|
||||||
|
.search-item .num{display:inline-block;padding:2px 8px;background:var(--acc);color:#fff;border-radius:99px;font-size:.7rem;font-weight:700;margin-right:8px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-inner">
|
||||||
|
<div>
|
||||||
|
<a href="/textbook/physics-7" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К физике 7</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Физика 7 · Глава ${C.n}</h1>
|
||||||
|
<div class="hdr-sub">${C.title} · ${C.range}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-side">
|
||||||
|
<button id="search-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg> Поиск</button>
|
||||||
|
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||||||
|
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="col-main">
|
||||||
|
<div class="psel">
|
||||||
|
<div class="psel-head">Параграфы главы ${C.n}</div>
|
||||||
|
<div class="psel-grid" id="psel-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${sections}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="col-side" id="col-side"><div id="sidebar-content"></div></aside>
|
||||||
|
<div class="col-side-backdrop" id="col-side-backdrop"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="ach-popup" id="ach-popup"><svg class="ic" viewBox="0 0 24 24"><polygon points="12,2 15,9 22,9.3 17,14 18.5,21 12,17 5.5,21 7,14 2,9.3 9,9"/></svg><span id="ach-text"></span></div>
|
||||||
|
|
||||||
|
<div class="search-modal" id="search-modal"><div class="search-box">
|
||||||
|
<input type="text" class="search-inp" id="search-inp" placeholder="Поиск по параграфам... (Esc — закрыть, Ctrl+K)">
|
||||||
|
<div class="search-list" id="search-list"></div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<footer class="foot">Интерактивный учебник «Физика 7 класс» · Глава ${C.n} · LearnSpace</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const LS_PREFIX = 'physics7_ch${C.n}';
|
||||||
|
const _TB_SLUG = '${C.slug}';
|
||||||
|
|
||||||
|
const PARAS = [${parasJs}];
|
||||||
|
const TOTAL_PARAS = PARAS.length;
|
||||||
|
|
||||||
|
const SIDEBARS = {};
|
||||||
|
PARAS.forEach(p => { SIDEBARS[p.id] = { title: 'Шпаргалка ' + p.num, rows: [['В разработке','контент появится с волной соответствующего §']] }; });
|
||||||
|
const TIPS = [{ sec: PARAS[0].id, html: 'Скелет главы готов. Контент параграфов выйдет в одной из ближайших фаз.' }];
|
||||||
|
const ACH_LABELS = { start: 'Начало главы ${C.n}', ch_done: '${C.achTitle}' };
|
||||||
|
|
||||||
|
const STATE = { current: null, progress: {}, xp: 0, level: 1, achievements: new Map(), _built: new Set() };
|
||||||
|
|
||||||
|
function _xpForLevel(lv){ return Math.round(100 * Math.pow(lv-1, 1.6)); }
|
||||||
|
function calcLevel(xp){ let lv = 1; while(_xpForLevel(lv+1) <= xp) lv++; return lv; }
|
||||||
|
|
||||||
|
function loadProgress(){
|
||||||
|
try{
|
||||||
|
const s = localStorage.getItem(LS_PREFIX + '_progress'); if(s) Object.assign(STATE.progress, JSON.parse(s));
|
||||||
|
const a = localStorage.getItem(LS_PREFIX + '_achievements');
|
||||||
|
if(a){ const p = JSON.parse(a); if(p && typeof p === 'object'){ for(const [id,t] of Object.entries(p)) STATE.achievements.set(id, (t && t !== id) ? t : (ACH_LABELS[id] || id)); } }
|
||||||
|
STATE.xp = +(localStorage.getItem('physics7_xp') || 0); STATE.level = calcLevel(STATE.xp);
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function saveProgress(){
|
||||||
|
try{
|
||||||
|
localStorage.setItem(LS_PREFIX + '_progress', JSON.stringify(STATE.progress));
|
||||||
|
localStorage.setItem(LS_PREFIX + '_achievements', JSON.stringify(Object.fromEntries(STATE.achievements)));
|
||||||
|
localStorage.setItem('physics7_xp', String(STATE.xp));
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function bumpProgress(key, delta){
|
||||||
|
STATE.progress[key] = Math.max(0, Math.min(100, (STATE.progress[key]||0) + delta));
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(STATE.progress[key] >= 100 && key === PARAS[PARAS.length-1].id) achievement('ch_done', '${C.achTitle}');
|
||||||
|
}
|
||||||
|
function addXp(n, src){
|
||||||
|
if(!n) return;
|
||||||
|
const prev = STATE.level; STATE.xp = Math.max(0, (STATE.xp||0) + n); STATE.level = calcLevel(STATE.xp);
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(window.LS && window.LS.xp) window.LS.xp.add(n, 'physics7-ch${C.n}-' + (src||'misc'));
|
||||||
|
if(STATE.level > prev){
|
||||||
|
const pop = document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent = 'Уровень ' + STATE.level + '!'; pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'), 2600); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function achievement(id, label){
|
||||||
|
if(STATE.achievements.has(id)) return;
|
||||||
|
STATE.achievements.set(id, label || ACH_LABELS[id] || id);
|
||||||
|
saveProgress();
|
||||||
|
const pop = document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent = 'Ачивка: ' + (label || ACH_LABELS[id] || id); pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'), 3000); }
|
||||||
|
addXp(20, 'ach-' + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProgressUI(){
|
||||||
|
document.querySelectorAll('[data-prog-card]').forEach(el => {
|
||||||
|
const k = el.dataset.progCard;
|
||||||
|
const fl = el.querySelector('.psel-prog-fill');
|
||||||
|
if(fl) fl.style.width = (STATE.progress[k]||0) + '%';
|
||||||
|
});
|
||||||
|
if(STATE.current && document.getElementById('sidebar-content')){ try{ buildSidebar(STATE.current); }catch(e){} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParaSelector(){
|
||||||
|
const grid = document.getElementById('psel-grid');
|
||||||
|
if(!grid) return;
|
||||||
|
grid.innerHTML = PARAS.map(p =>
|
||||||
|
'<button class="psel-card" data-id="' + p.id + '" data-prog-card="' + p.id + '">'
|
||||||
|
+ '<div class="psel-num">' + p.num + '</div>'
|
||||||
|
+ '<div class="psel-title">' + p.title + '</div>'
|
||||||
|
+ '<div class="psel-prog"><div class="psel-prog-fill" style="width:' + (STATE.progress[p.id]||0) + '%"></div></div>'
|
||||||
|
+ '</button>'
|
||||||
|
).join('');
|
||||||
|
grid.querySelectorAll('.psel-card').forEach(c => c.addEventListener('click', () => goTo(c.dataset.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBuilt(id){
|
||||||
|
if(STATE._built.has(id)) return;
|
||||||
|
STATE._built.add(id);
|
||||||
|
const W = window['PHYS7_CH${C.n}_WIDGETS'];
|
||||||
|
if(W && typeof W[id] === 'function'){
|
||||||
|
const body = document.getElementById(id + '-body');
|
||||||
|
if(body){
|
||||||
|
const ph = body.querySelector('.placeholder');
|
||||||
|
if(ph) ph.remove();
|
||||||
|
}
|
||||||
|
try{ W[id](); }catch(e){ console.warn('phys7 widget ' + id + ':', e.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(id){
|
||||||
|
STATE.current = id; ensureBuilt(id);
|
||||||
|
document.querySelectorAll('.sec').forEach(s => s.classList.remove('active'));
|
||||||
|
const el = document.getElementById('sec-' + id); if(el) el.classList.add('active');
|
||||||
|
document.querySelectorAll('.psel-card').forEach(c => c.classList.toggle('active', c.dataset.id === id));
|
||||||
|
buildSidebar(id);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
if((STATE.progress[id]||0) < 10) bumpProgress(id, 10);
|
||||||
|
if(window.renderMathInElement && el){
|
||||||
|
setTimeout(() => {
|
||||||
|
try{ renderMathInElement(el, { delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}], throwOnError:false }); }catch(e){}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSidebar(id){
|
||||||
|
const box = document.getElementById('sidebar-content');
|
||||||
|
if(!box) return;
|
||||||
|
const sb = SIDEBARS[id] || SIDEBARS[PARAS[0].id];
|
||||||
|
const xpForLv = _xpForLevel(STATE.level), xpNext = _xpForLevel(STATE.level+1);
|
||||||
|
const xpInLv = STATE.xp - xpForLv, xpRange = xpNext - xpForLv;
|
||||||
|
const xpPct = xpRange > 0 ? Math.round(xpInLv / xpRange * 100) : 100;
|
||||||
|
let html = '';
|
||||||
|
html += '<div class="xp-card"><div class="xp-card-title"><span>XP-прогресс</span><span class="xp-level">Ур. ' + STATE.level + '</span></div><div class="xp-bar"><div class="xp-fill" style="width:' + xpPct + '%"></div></div><div class="xp-nums"><span>' + STATE.xp + ' XP</span><span>' + xpNext + ' XP</span></div></div>';
|
||||||
|
html += '<div class="sidecard"><h4>' + sb.title + '</h4>';
|
||||||
|
sb.rows.forEach(([k,v]) => { html += '<div class="sidecard-row"><b>' + k + '</b>' + (v ? ' — ' + v : '') + '</div>'; });
|
||||||
|
html += '</div>';
|
||||||
|
const tip = TIPS.find(t => t.sec === id) || TIPS[0];
|
||||||
|
if(tip){
|
||||||
|
html += '<div class="sidecard" style="background:linear-gradient(135deg,var(--warn-bg),var(--pri-soft));border-color:var(--warn)"><h4 style="color:#92400e">Подсказка</h4><div class="sidecard-row" style="margin-bottom:0;font-size:.84rem">' + tip.html + '</div></div>';
|
||||||
|
}
|
||||||
|
if(STATE.achievements.size > 0){
|
||||||
|
html += '<div class="sidecard"><h4>Достижения <span style="color:var(--warn);float:right">' + STATE.achievements.size + '</span></h4>';
|
||||||
|
[...STATE.achievements.values()].slice(-4).forEach(t => { html += '<div class="sidecard-row" style="font-size:.78rem;color:var(--ok)">✓ ' + t + '</div>'; });
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
box.innerHTML = html;
|
||||||
|
if(window.renderMathInElement){
|
||||||
|
try{ renderMathInElement(box, { delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}], throwOnError:false }); }catch(e){}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme(){
|
||||||
|
const t = localStorage.getItem(LS_PREFIX + '_theme') || localStorage.getItem('physics7_theme') || 'light';
|
||||||
|
if(t === 'dark') document.documentElement.classList.add('dark');
|
||||||
|
document.getElementById('theme-lab').textContent = t === 'dark' ? 'Светлая' : 'Тёмная';
|
||||||
|
document.getElementById('theme-btn').addEventListener('click', () => {
|
||||||
|
document.documentElement.classList.toggle('dark');
|
||||||
|
const dark = document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem(LS_PREFIX + '_theme', dark ? 'dark' : 'light');
|
||||||
|
localStorage.setItem('physics7_theme', dark ? 'dark' : 'light');
|
||||||
|
document.getElementById('theme-lab').textContent = dark ? 'Светлая' : 'Тёмная';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSidebarToggle(){
|
||||||
|
const side = document.getElementById('col-side'), back = document.getElementById('col-side-backdrop'), btn = document.getElementById('sidebar-btn');
|
||||||
|
if(!side || !btn) return;
|
||||||
|
function open(){ side.classList.add('open'); back.classList.add('show'); }
|
||||||
|
function close(){ side.classList.remove('open'); back.classList.remove('show'); }
|
||||||
|
btn.addEventListener('click', () => { if(side.classList.contains('open')) close(); else open(); });
|
||||||
|
back.addEventListener('click', close);
|
||||||
|
document.addEventListener('keydown', e => { if(e.key === 'Escape') close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSearch(){
|
||||||
|
const btn = document.getElementById('search-btn'), modal = document.getElementById('search-modal'), inp = document.getElementById('search-inp'), list = document.getElementById('search-list');
|
||||||
|
if(!btn || !modal) return;
|
||||||
|
let cur = 0;
|
||||||
|
function render(q){
|
||||||
|
const ql = (q||'').toLowerCase().trim();
|
||||||
|
const items = PARAS.filter(p => !ql || p.title.toLowerCase().includes(ql) || p.num.toLowerCase().includes(ql));
|
||||||
|
list.innerHTML = items.map((p,i) => '<div class="search-item' + (i === cur ? ' cur' : '') + '" data-id="' + p.id + '"><span class="num">' + p.num + '</span>' + p.title + '</div>').join('');
|
||||||
|
list.querySelectorAll('.search-item').forEach(el => el.addEventListener('click', () => { goTo(el.dataset.id); close(); }));
|
||||||
|
}
|
||||||
|
function open(){ modal.classList.add('show'); inp.value = ''; cur = 0; render(''); setTimeout(() => inp.focus(), 50); }
|
||||||
|
function close(){ modal.classList.remove('show'); }
|
||||||
|
btn.addEventListener('click', open);
|
||||||
|
modal.addEventListener('click', e => { if(e.target === modal) close(); });
|
||||||
|
inp.addEventListener('input', () => { cur = 0; render(inp.value); });
|
||||||
|
inp.addEventListener('keydown', e => {
|
||||||
|
const items = list.querySelectorAll('.search-item');
|
||||||
|
if(e.key === 'ArrowDown'){ e.preventDefault(); cur = Math.min(items.length-1, cur+1); render(inp.value); }
|
||||||
|
else if(e.key === 'ArrowUp'){ e.preventDefault(); cur = Math.max(0, cur-1); render(inp.value); }
|
||||||
|
else if(e.key === 'Enter'){ e.preventDefault(); const sel = items[cur]; if(sel){ goTo(sel.dataset.id); close(); } }
|
||||||
|
else if(e.key === 'Escape'){ e.preventDefault(); close(); }
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => { if((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')){ e.preventDefault(); if(modal.classList.contains('show')) close(); else open(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(){
|
||||||
|
loadProgress(); initTheme(); initSidebarToggle(); initSearch();
|
||||||
|
buildParaSelector(); refreshProgressUI(); goTo(PARAS[0].id);
|
||||||
|
setTimeout(() => achievement('start'), 600);
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
CHAPTERS.forEach(C => {
|
||||||
|
const html = makeHTML(C);
|
||||||
|
const file = path.join(OUT, `physics_7_ch${C.n}.html`);
|
||||||
|
fs.writeFileSync(file, html, 'utf8');
|
||||||
|
console.log(`[gen_phys7_ch] ${file} — ${html.split('\n').length} lines`);
|
||||||
|
});
|
||||||
|
console.log('Done.');
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Генератор скелета лабораторного практикума Физики 7. Phase 0: только инфраструктура.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const VER = '20260530';
|
||||||
|
const OUT = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_7_lab.html');
|
||||||
|
|
||||||
|
const LABS = [
|
||||||
|
{ id:'lr1', num:'ЛР 1', title:'Определение цены деления шкалы измерительного прибора', wm:'1', tag:'§ 7' },
|
||||||
|
{ id:'lr2', num:'ЛР 2', title:'Измерение длины', wm:'2', tag:'§ 4 · § 7' },
|
||||||
|
{ id:'lr3', num:'ЛР 3', title:'Измерение объёма', wm:'3', tag:'§ 4' },
|
||||||
|
{ id:'lr4', num:'ЛР 4', title:'Изучение неравномерного движения', wm:'4', tag:'§ 18' },
|
||||||
|
{ id:'lr5', num:'ЛР 5', title:'Измерение плотности вещества', wm:'5', tag:'§ 20' },
|
||||||
|
{ id:'lr6', num:'ЛР 6', title:'Изучение силы трения', wm:'6', tag:'§ 27' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const labsJs = LABS.map(l => `{id:'${l.id}',num:'${l.num}',title:${JSON.stringify(l.title)},wm:'${l.wm}',tag:'${l.tag}'}`).join(',');
|
||||||
|
const sections = LABS.map(l =>
|
||||||
|
` <section id="sec-${l.id}" class="sec" data-watermark="${l.wm}">
|
||||||
|
<div class="sec-header">
|
||||||
|
<span class="sec-num">${l.num}</span>
|
||||||
|
<h2 class="sec-h">${l.title}</h2>
|
||||||
|
<span class="sec-tag">${l.tag}</span>
|
||||||
|
</div>
|
||||||
|
<div id="${l.id}-body"><div class="placeholder">Виртуальная лабораторная работа появится в Phase 7 (после контента глав).</div></div>
|
||||||
|
</section>`).join('\n');
|
||||||
|
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
<title>Физика 7 · Лабораторный практикум</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/phys-textbook-widgets.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||||
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false})"></script>
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
|
<script src="/js/phys.js?v=${VER}" defer></script>
|
||||||
|
<script src="/js/phys7_lab_widgets.js?v=${VER}" defer></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#ecfeff; --card:#fff; --card-soft:#f8fafc; --text:#0f172a; --muted:#475569;
|
||||||
|
--border:#a5f3fc; --pri:#0891b2; --pri2:#0e7490; --pri-soft:#cffafe;
|
||||||
|
--acc:#06b6d4; --acc-d:#0e7490; --acc-soft:#cffafe;
|
||||||
|
--ok:#10b981; --ok-bg:#d1fae5; --fail:#dc2626; --fail-bg:#fee2e2; --warn:#f59e0b; --warn-bg:#fef3c7;
|
||||||
|
--sh:0 4px 16px rgba(8,145,178,.08); --sh-h:0 12px 36px rgba(8,145,178,.16);
|
||||||
|
}
|
||||||
|
html.dark{--bg:#0c2030;--card:#0e2436;--card-soft:#0b1a28;--text:#cffafe;--muted:#67e8f9;--border:#155e75;--pri-soft:rgba(8,145,178,.18)}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{min-height:100vh}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;transition:background .25s,color .25s}
|
||||||
|
|
||||||
|
.hdr{position:relative;background:linear-gradient(135deg,#164e63,#0891b2 60%,#22d3ee);color:#fff;padding:24px 22px 22px;overflow:hidden;border-bottom:2px solid rgba(255,255,255,.18)}
|
||||||
|
.hdr-inner{position:relative;z-index:1;max-width:1240px;margin:0 auto;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||||||
|
.hdr h1{font-family:'Unbounded',sans-serif;font-size:1.55rem;font-weight:800;letter-spacing:-.01em}
|
||||||
|
.hdr-sub{font-size:.88rem;opacity:.9;margin-top:3px}
|
||||||
|
.hdr-side{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.hdr-btn{padding:8px 12px;background:rgba(255,255,255,.16);border:none;color:#fff;border-radius:9px;cursor:pointer;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;font-family:inherit;text-decoration:none}
|
||||||
|
.hdr-btn:hover{background:rgba(255,255,255,.26)}
|
||||||
|
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
|
||||||
|
.main{max-width:1240px;margin:0 auto;padding:22px;width:100%;display:grid;grid-template-columns:1fr 280px;gap:24px}
|
||||||
|
@media(max-width:980px){.main{grid-template-columns:1fr;padding:14px}}
|
||||||
|
|
||||||
|
.col-main{min-width:0}
|
||||||
|
.psel{background:var(--card);border:1.5px solid var(--border);border-radius:14px;padding:16px;margin-bottom:18px;box-shadow:var(--sh)}
|
||||||
|
.psel-head{font-family:'Unbounded',sans-serif;font-size:.78rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:10px}
|
||||||
|
.psel-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px}
|
||||||
|
.psel-card{padding:12px;background:var(--card-soft);border:1.5px solid var(--border);border-radius:10px;cursor:pointer;transition:transform .15s,border-color .15s,box-shadow .15s;text-align:left}
|
||||||
|
.psel-card:hover{border-color:var(--acc);transform:translateY(-2px);box-shadow:0 4px 14px rgba(0,0,0,.06)}
|
||||||
|
.psel-card.active{border-color:var(--acc);background:var(--acc-soft)}
|
||||||
|
.psel-num{font-size:.7rem;font-weight:800;color:var(--acc-d);letter-spacing:.04em;text-transform:uppercase;margin-bottom:3px}
|
||||||
|
.psel-title{font-size:.86rem;font-weight:700;line-height:1.35}
|
||||||
|
.psel-prog{height:4px;background:rgba(0,0,0,.07);border-radius:3px;overflow:hidden;margin-top:7px}
|
||||||
|
.psel-prog-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--acc-d));border-radius:3px;transition:width .4s}
|
||||||
|
|
||||||
|
.sec{display:none;background:var(--card);border:1.5px solid var(--border);border-radius:14px;padding:22px;box-shadow:var(--sh);position:relative}
|
||||||
|
.sec.active{display:block}
|
||||||
|
.sec[data-watermark]::before{content:attr(data-watermark);position:absolute;right:18px;top:-8px;font-family:'Unbounded',sans-serif;font-size:5.6rem;font-weight:900;color:var(--acc-soft);pointer-events:none;line-height:1;user-select:none}
|
||||||
|
.sec-header{display:flex;align-items:baseline;gap:14px;margin-bottom:18px;padding-bottom:14px;border-bottom:1.5px solid var(--border);position:relative;z-index:1;flex-wrap:wrap}
|
||||||
|
.sec-num{background:linear-gradient(135deg,var(--acc),var(--acc-d));color:#fff;padding:5px 12px;border-radius:9px;font-family:'Unbounded',sans-serif;font-weight:800;font-size:.86rem;letter-spacing:.04em}
|
||||||
|
.sec-h{font-family:'Unbounded',sans-serif;font-size:1.3rem;font-weight:800;color:var(--text);flex:1;min-width:0}
|
||||||
|
.sec-tag{font-size:.74rem;font-weight:700;color:var(--pri2);background:var(--pri-soft);padding:3px 9px;border-radius:99px;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
.placeholder{padding:32px 20px;text-align:center;color:var(--muted);font-size:.95rem;background:var(--card-soft);border:1.5px dashed var(--border);border-radius:10px}
|
||||||
|
|
||||||
|
.col-side{position:sticky;top:14px;align-self:start;height:fit-content;max-height:calc(100vh - 28px);overflow-y:auto}
|
||||||
|
.sidecard{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px;margin-bottom:14px;box-shadow:var(--sh)}
|
||||||
|
.sidecard h4{font-family:'Unbounded',sans-serif;font-size:.74rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||||
|
.sidecard-row{margin-bottom:8px;font-size:.86rem;line-height:1.6}
|
||||||
|
.sidecard-row b{color:var(--pri);font-weight:700}
|
||||||
|
@media(max-width:980px){.col-side{position:static;max-height:none}}
|
||||||
|
|
||||||
|
.col-side-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.42);z-index:9990;display:none}
|
||||||
|
.col-side-backdrop.show{display:block}
|
||||||
|
@media(min-width:981px){#sidebar-btn{display:none}.col-side-backdrop.show{display:none}}
|
||||||
|
@media(max-width:980px){
|
||||||
|
.col-side{position:fixed;top:0;right:0;height:100vh;width:300px;max-width:88vw;background:var(--bg);box-shadow:-12px 0 24px rgba(0,0,0,.18);padding:18px 16px;overflow-y:auto;transform:translateX(100%);transition:transform .25s ease;z-index:9991;max-height:none}
|
||||||
|
.col-side.open{transform:none}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ach-popup{position:fixed;top:80px;right:18px;background:linear-gradient(135deg,var(--acc-d),var(--acc));color:#fff;padding:12px 18px;border-radius:11px;font-weight:700;font-size:.9rem;box-shadow:0 8px 28px rgba(0,0,0,.25);z-index:1002;display:none;align-items:center;gap:8px;max-width:340px}
|
||||||
|
.ach-popup.show{display:flex}
|
||||||
|
|
||||||
|
.foot{text-align:center;padding:30px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-inner">
|
||||||
|
<div>
|
||||||
|
<a href="/textbook/physics-7" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К физике 7</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Физика 7 · Лабораторный практикум</h1>
|
||||||
|
<div class="hdr-sub">6 виртуальных лабораторных работ</div>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-side">
|
||||||
|
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||||||
|
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="col-main">
|
||||||
|
<div class="psel">
|
||||||
|
<div class="psel-head">Лабораторные работы</div>
|
||||||
|
<div class="psel-grid" id="psel-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${sections}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="col-side" id="col-side"><div id="sidebar-content"></div></aside>
|
||||||
|
<div class="col-side-backdrop" id="col-side-backdrop"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="ach-popup" id="ach-popup"><svg class="ic" viewBox="0 0 24 24"><polygon points="12,2 15,9 22,9.3 17,14 18.5,21 12,17 5.5,21 7,14 2,9.3 9,9"/></svg><span id="ach-text"></span></div>
|
||||||
|
|
||||||
|
<footer class="foot">Интерактивный учебник «Физика 7 класс» · Лабораторный практикум · LearnSpace</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const LS_PREFIX = 'physics7_lab';
|
||||||
|
const _TB_SLUG = 'physics-7-lab';
|
||||||
|
|
||||||
|
const LABS = [${labsJs}];
|
||||||
|
const TOTAL_LABS = LABS.length;
|
||||||
|
|
||||||
|
const SIDEBARS = {};
|
||||||
|
LABS.forEach(l => { SIDEBARS[l.id] = { title: 'Шпаргалка ' + l.num, rows: [['В разработке','симуляция и таблицы измерений появятся в Phase 7']] }; });
|
||||||
|
const ACH_LABELS = { start: 'Начало практикума', all_labs: 'Лаборант 7 класса' };
|
||||||
|
|
||||||
|
const STATE = { current: null, progress: {}, xp: 0, level: 1, achievements: new Map() };
|
||||||
|
|
||||||
|
function _xpForLevel(lv){ return Math.round(100 * Math.pow(lv-1, 1.6)); }
|
||||||
|
function calcLevel(xp){ let lv = 1; while(_xpForLevel(lv+1) <= xp) lv++; return lv; }
|
||||||
|
|
||||||
|
function loadProgress(){
|
||||||
|
try{
|
||||||
|
const s = localStorage.getItem(LS_PREFIX + '_progress'); if(s) Object.assign(STATE.progress, JSON.parse(s));
|
||||||
|
const a = localStorage.getItem(LS_PREFIX + '_achievements');
|
||||||
|
if(a){ const p = JSON.parse(a); if(p && typeof p === 'object') for(const [id,t] of Object.entries(p)) STATE.achievements.set(id, (t && t !== id) ? t : (ACH_LABELS[id] || id)); }
|
||||||
|
STATE.xp = +(localStorage.getItem('physics7_xp') || 0); STATE.level = calcLevel(STATE.xp);
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function saveProgress(){
|
||||||
|
try{
|
||||||
|
localStorage.setItem(LS_PREFIX + '_progress', JSON.stringify(STATE.progress));
|
||||||
|
localStorage.setItem(LS_PREFIX + '_achievements', JSON.stringify(Object.fromEntries(STATE.achievements)));
|
||||||
|
localStorage.setItem('physics7_xp', String(STATE.xp));
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function bumpProgress(key, delta){
|
||||||
|
STATE.progress[key] = Math.max(0, Math.min(100, (STATE.progress[key]||0) + delta));
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
const done = LABS.every(l => (STATE.progress[l.id]||0) >= 100);
|
||||||
|
if(done && !STATE.achievements.has('all_labs')) achievement('all_labs');
|
||||||
|
}
|
||||||
|
function addXp(n, src){
|
||||||
|
if(!n) return;
|
||||||
|
STATE.xp = Math.max(0, (STATE.xp||0) + n); STATE.level = calcLevel(STATE.xp);
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(window.LS && window.LS.xp) window.LS.xp.add(n, 'physics7-lab-' + (src||'misc'));
|
||||||
|
}
|
||||||
|
function achievement(id, label){
|
||||||
|
if(STATE.achievements.has(id)) return;
|
||||||
|
STATE.achievements.set(id, label || ACH_LABELS[id] || id);
|
||||||
|
saveProgress();
|
||||||
|
const pop = document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent = 'Ачивка: ' + (label || ACH_LABELS[id] || id); pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'), 3000); }
|
||||||
|
addXp(id === 'all_labs' ? 80 : 20, 'ach-' + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProgressUI(){
|
||||||
|
document.querySelectorAll('[data-prog-card]').forEach(el => {
|
||||||
|
const k = el.dataset.progCard;
|
||||||
|
const fl = el.querySelector('.psel-prog-fill');
|
||||||
|
if(fl) fl.style.width = (STATE.progress[k]||0) + '%';
|
||||||
|
});
|
||||||
|
if(STATE.current && document.getElementById('sidebar-content')){ try{ buildSidebar(STATE.current); }catch(e){} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelector(){
|
||||||
|
const grid = document.getElementById('psel-grid');
|
||||||
|
if(!grid) return;
|
||||||
|
grid.innerHTML = LABS.map(l =>
|
||||||
|
'<button class="psel-card" data-id="' + l.id + '" data-prog-card="' + l.id + '">'
|
||||||
|
+ '<div class="psel-num">' + l.num + ' · ' + l.tag + '</div>'
|
||||||
|
+ '<div class="psel-title">' + l.title + '</div>'
|
||||||
|
+ '<div class="psel-prog"><div class="psel-prog-fill" style="width:' + (STATE.progress[l.id]||0) + '%"></div></div>'
|
||||||
|
+ '</button>'
|
||||||
|
).join('');
|
||||||
|
grid.querySelectorAll('.psel-card').forEach(c => c.addEventListener('click', () => goTo(c.dataset.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(id){
|
||||||
|
STATE.current = id;
|
||||||
|
document.querySelectorAll('.sec').forEach(s => s.classList.remove('active'));
|
||||||
|
const el = document.getElementById('sec-' + id); if(el) el.classList.add('active');
|
||||||
|
document.querySelectorAll('.psel-card').forEach(c => c.classList.toggle('active', c.dataset.id === id));
|
||||||
|
buildSidebar(id);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
if((STATE.progress[id]||0) < 10) bumpProgress(id, 10);
|
||||||
|
const W = window['PHYS7_LAB_WIDGETS'];
|
||||||
|
if(W && typeof W[id] === 'function' && !STATE._built){ STATE._built = {}; }
|
||||||
|
if(W && typeof W[id] === 'function' && !STATE._built[id]){
|
||||||
|
STATE._built[id] = true;
|
||||||
|
const body = document.getElementById(id + '-body');
|
||||||
|
if(body){
|
||||||
|
const ph = body.querySelector('.placeholder');
|
||||||
|
if(ph) ph.remove();
|
||||||
|
}
|
||||||
|
try{ W[id](); }catch(e){ console.warn('phys7 lab ' + id + ':', e.message); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSidebar(id){
|
||||||
|
const box = document.getElementById('sidebar-content');
|
||||||
|
if(!box) return;
|
||||||
|
const sb = SIDEBARS[id] || SIDEBARS[LABS[0].id];
|
||||||
|
const xpForLv = _xpForLevel(STATE.level), xpNext = _xpForLevel(STATE.level+1);
|
||||||
|
const xpPct = (xpNext - xpForLv) > 0 ? Math.round((STATE.xp - xpForLv) / (xpNext - xpForLv) * 100) : 100;
|
||||||
|
let html = '';
|
||||||
|
html += '<div class="sidecard" style="background:linear-gradient(135deg,var(--pri-soft),var(--acc-soft));border-color:var(--acc)"><h4>XP-прогресс <span style="float:right">Ур. ' + STATE.level + '</span></h4><div class="sidecard-row"><div style="height:8px;background:rgba(0,0,0,.07);border-radius:5px;overflow:hidden"><div style="height:100%;background:linear-gradient(90deg,var(--acc),var(--pri));width:' + xpPct + '%"></div></div><div style="display:flex;justify-content:space-between;font-size:.78rem;color:var(--muted);margin-top:5px"><span>' + STATE.xp + ' XP</span><span>' + xpNext + ' XP</span></div></div></div>';
|
||||||
|
html += '<div class="sidecard"><h4>' + sb.title + '</h4>';
|
||||||
|
sb.rows.forEach(([k,v]) => { html += '<div class="sidecard-row"><b>' + k + '</b>' + (v ? ' — ' + v : '') + '</div>'; });
|
||||||
|
html += '</div>';
|
||||||
|
if(STATE.achievements.size > 0){
|
||||||
|
html += '<div class="sidecard"><h4>Достижения <span style="color:var(--warn);float:right">' + STATE.achievements.size + '</span></h4>';
|
||||||
|
[...STATE.achievements.values()].slice(-4).forEach(t => { html += '<div class="sidecard-row" style="font-size:.78rem;color:var(--ok)">✓ ' + t + '</div>'; });
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
box.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme(){
|
||||||
|
const t = localStorage.getItem(LS_PREFIX + '_theme') || localStorage.getItem('physics7_theme') || 'light';
|
||||||
|
if(t === 'dark') document.documentElement.classList.add('dark');
|
||||||
|
document.getElementById('theme-lab').textContent = t === 'dark' ? 'Светлая' : 'Тёмная';
|
||||||
|
document.getElementById('theme-btn').addEventListener('click', () => {
|
||||||
|
document.documentElement.classList.toggle('dark');
|
||||||
|
const dark = document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem(LS_PREFIX + '_theme', dark ? 'dark' : 'light');
|
||||||
|
localStorage.setItem('physics7_theme', dark ? 'dark' : 'light');
|
||||||
|
document.getElementById('theme-lab').textContent = dark ? 'Светлая' : 'Тёмная';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSidebarToggle(){
|
||||||
|
const side = document.getElementById('col-side'), back = document.getElementById('col-side-backdrop'), btn = document.getElementById('sidebar-btn');
|
||||||
|
if(!side || !btn) return;
|
||||||
|
function open(){ side.classList.add('open'); back.classList.add('show'); }
|
||||||
|
function close(){ side.classList.remove('open'); back.classList.remove('show'); }
|
||||||
|
btn.addEventListener('click', () => { if(side.classList.contains('open')) close(); else open(); });
|
||||||
|
back.addEventListener('click', close);
|
||||||
|
document.addEventListener('keydown', e => { if(e.key === 'Escape') close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(){
|
||||||
|
loadProgress(); initTheme(); initSidebarToggle();
|
||||||
|
buildSelector(); refreshProgressUI(); goTo(LABS[0].id);
|
||||||
|
setTimeout(() => achievement('start'), 600);
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(OUT, html, 'utf8');
|
||||||
|
console.log(`[gen_phys7_lab] ${OUT} — ${html.split('\n').length} lines`);
|
||||||
@@ -0,0 +1,941 @@
|
|||||||
|
// Генератор physics_9_ch{1..5}.html — Phase 0 skeleton со STUB-builder'ами.
|
||||||
|
// По образцу gen_phys10_ch.js. Главы: ch1..ch4 — параграфы §1..§36, ch5 — ЛР1..ЛР12.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const TBOOKS = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
// === Данные параграфов (§1..§36) ===
|
||||||
|
const PARA_NAMES = {
|
||||||
|
p1:'Механическое движение',
|
||||||
|
p2:'Относительность движения. Система отсчёта',
|
||||||
|
p3:'Скалярные и векторные величины. Действия над векторами',
|
||||||
|
p4:'Проекция вектора на ось',
|
||||||
|
p5:'Путь и перемещение',
|
||||||
|
p6:'Равномерное прямолинейное движение. Скорость',
|
||||||
|
p7:'Графическое представление равномерного движения',
|
||||||
|
p8:'Неравномерное движение. Средняя и мгновенная скорость',
|
||||||
|
p9:'Сложение скоростей',
|
||||||
|
p10:'Ускорение',
|
||||||
|
p11:'Скорость при равноускоренном движении',
|
||||||
|
p12:'Перемещение, координата и путь при равноускоренном движении',
|
||||||
|
p13:'Линейная и угловая скорости',
|
||||||
|
p14:'Ускорение точки при движении по окружности',
|
||||||
|
p15:'Взаимодействие тел. Сила. ИСО. 1-й закон Ньютона',
|
||||||
|
p16:'Масса',
|
||||||
|
p17:'Второй закон Ньютона',
|
||||||
|
p18:'Третий закон Ньютона. Принцип относительности Галилея',
|
||||||
|
p19:'Деформация тел. Сила упругости. Закон Гука',
|
||||||
|
p20:'Силы трения. Силы сопротивления среды',
|
||||||
|
p21:'Движение тела под действием силы тяжести',
|
||||||
|
p22:'Движение тела, брошенного под углом к горизонту',
|
||||||
|
p23:'Закон всемирного тяготения',
|
||||||
|
p24:'Вес. Невесомость и перегрузки',
|
||||||
|
p25:'Условия равновесия тел. Момент силы',
|
||||||
|
p26:'Простые механизмы. Рычаги. Блоки',
|
||||||
|
p27:'Наклонная плоскость. «Золотое правило» механики. КПД',
|
||||||
|
p28:'Центр тяжести. Виды равновесия',
|
||||||
|
p29:'Закон Архимеда. Выталкивающая сила',
|
||||||
|
p30:'Плавание судов. Воздухоплавание',
|
||||||
|
p31:'Импульс тела. Импульс системы тел',
|
||||||
|
p32:'Закон сохранения импульса. Реактивное движение',
|
||||||
|
p33:'Механическая работа. Мощность',
|
||||||
|
p34:'Потенциальная энергия',
|
||||||
|
p35:'Кинетическая энергия. Полная энергия системы тел',
|
||||||
|
p36:'Закон сохранения энергии',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARA_SUBS = {
|
||||||
|
p1:'материальная точка',
|
||||||
|
p2:'СО · относительность',
|
||||||
|
p3:'$\\vec a + \\vec b$',
|
||||||
|
p4:'$a_x = a\\cos\\alpha$',
|
||||||
|
p5:'$s$ vs $\\Delta\\vec r$',
|
||||||
|
p6:'$\\Delta\\vec r = \\vec v t$',
|
||||||
|
p7:'графики $v(t)$, $x(t)$',
|
||||||
|
p8:'$\\langle v\\rangle = s/t$',
|
||||||
|
p9:'$\\vec v_{1,3} = \\vec v_{1,2} + \\vec v_{2,3}$',
|
||||||
|
p10:'$\\vec a = \\Delta\\vec v/\\Delta t$',
|
||||||
|
p11:'$\\vec v = \\vec v_0 + \\vec a t$',
|
||||||
|
p12:'$x = x_0 + v_0 t + at^2/2$',
|
||||||
|
p13:'$v = \\omega R$',
|
||||||
|
p14:'$a_n = v^2/R$',
|
||||||
|
p15:'1-й закон Ньютона',
|
||||||
|
p16:'$m_1/m_2 = a_2/a_1$',
|
||||||
|
p17:'$\\vec F = m\\vec a$',
|
||||||
|
p18:'$\\vec F_{12} = -\\vec F_{21}$',
|
||||||
|
p19:'$F = -kx$',
|
||||||
|
p20:'$F_{тр} = \\mu N$',
|
||||||
|
p21:'$h = gt^2/2$',
|
||||||
|
p22:'$L = v_0^2\\sin 2\\alpha/g$',
|
||||||
|
p23:'$F = Gm_1m_2/r^2$',
|
||||||
|
p24:'$P = m(g \\pm a)$',
|
||||||
|
p25:'$M = Fl$',
|
||||||
|
p26:'$F_1 l_1 = F_2 l_2$',
|
||||||
|
p27:'$\\eta = A_{пол}/A_{сов}$',
|
||||||
|
p28:'ЦТ · равновесие',
|
||||||
|
p29:'$F_A = \\rho g V$',
|
||||||
|
p30:'$\\rho_т \\le \\rho_ж$',
|
||||||
|
p31:'$\\vec p = m\\vec v$',
|
||||||
|
p32:'$\\sum\\vec p = \\text{const}$',
|
||||||
|
p33:'$A = F\\Delta r\\cos\\alpha$',
|
||||||
|
p34:'$E_п = mgh$',
|
||||||
|
p35:'$E_к = mv^2/2$',
|
||||||
|
p36:'$E_к + E_п = \\text{const}$',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARA_WM = {
|
||||||
|
p1:'движ.', p2:'СО', p3:'&vec;a', p4:'a_x', p5:'Δr', p6:'v·t', p7:'v(t)',
|
||||||
|
p8:'⟨v⟩', p9:'v_1+v_2', p10:'a', p11:'v_0+at', p12:'at²/2', p13:'ωR', p14:'v²/R',
|
||||||
|
p15:'ma=F', p16:'m', p17:'F=ma', p18:'F_12=-F_21', p19:'kx', p20:'μN',
|
||||||
|
p21:'g', p22:'∂', p23:'G', p24:'P=mg',
|
||||||
|
p25:'M', p26:'l_1F_1', p27:'η', p28:'ЦТ', p29:'F_A', p30:'ρ',
|
||||||
|
p31:'p=mv', p32:'∑p', p33:'A', p34:'mgh', p35:'mv²/2', p36:'E=const',
|
||||||
|
final1:'★', final2:'★', final3:'★', final4:'★', final5:'★',
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Данные ЛР1..ЛР12 (для Ch5) ===
|
||||||
|
const LR_NAMES = {
|
||||||
|
lr1:'Определение абсолютной и относительной погрешностей прямых измерений',
|
||||||
|
lr2:'Измерение ускорения при равноускоренном движении',
|
||||||
|
lr3:'Изучение движения тела по окружности',
|
||||||
|
lr4:'Проверка закона Гука',
|
||||||
|
lr5:'Измерение коэффициента трения скольжения',
|
||||||
|
lr6:'Изучение движения тела, брошенного горизонтально',
|
||||||
|
lr7:'Проверка условия равновесия рычага',
|
||||||
|
lr8:'Изучение неподвижного и подвижного блоков',
|
||||||
|
lr9:'Изучение наклонной плоскости и измерение её КПД',
|
||||||
|
lr10:'Изучение выталкивающей силы',
|
||||||
|
lr11:'Проверка закона сохранения импульса',
|
||||||
|
lr12:'Проверка закона сохранения механической энергии',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LR_SUBS = {
|
||||||
|
lr1:'$\\Delta t$, $\\varepsilon_t$', lr2:'$a = 2l/t^2$', lr3:'$a_n = 4\\pi^2 R/T^2$',
|
||||||
|
lr4:'$k = F/x$', lr5:'$\\mu = F_{тр}/P$', lr6:'$v_0 = l\\sqrt{g/(2h)}$',
|
||||||
|
lr7:'$F_1 l_1 = F_2 l_2$', lr8:'$P h_1 = F h_2$', lr9:'$\\eta = mgh/A_{сов}$',
|
||||||
|
lr10:'$F_A = F_1 - F_2$', lr11:'$m_1 l_1 = m_1 l_1\' + m_2 l_2\'$',
|
||||||
|
lr12:'$F|x| = ml^2 g/(2h)$',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LR_WM = {
|
||||||
|
lr1:'Δ', lr2:'a', lr3:'ω', lr4:'k', lr5:'μ', lr6:'v_0',
|
||||||
|
lr7:'l_1F_1', lr8:'F=P/2', lr9:'η', lr10:'F_A', lr11:'∑p', lr12:'E',
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Главы ===
|
||||||
|
const CHAPTERS = {
|
||||||
|
ch1: {
|
||||||
|
paras: ['p1','p2','p3','p4','p5','p6','p7','p8','p9','p10','p11','p12','p13','p14'], final: 'final1',
|
||||||
|
title: 'Основы кинематики',
|
||||||
|
headerSub: 'Механическое движение · векторы · путь и перемещение · равноускоренное движение · движение по окружности',
|
||||||
|
hero: { h:'Кинематика — как описывать движение', p:'Раздел физики, изучающий движение тел без выяснения причин, его вызывающих. Изучаем векторы, скорость, ускорение и графики движения.' },
|
||||||
|
pri:'#2563eb', priD:'#1d4ed8', priSoft:'#dbeafe', priLight:'#60a5fa',
|
||||||
|
headerGrad:'linear-gradient(110deg,#1e3a8a 0%,#2563eb 55%,#60a5fa 100%)',
|
||||||
|
chNum:1, watermarkHero:'v',
|
||||||
|
},
|
||||||
|
ch2: {
|
||||||
|
paras: ['p15','p16','p17','p18','p19','p20','p21','p22','p23','p24'], final: 'final2',
|
||||||
|
title: 'Основы динамики',
|
||||||
|
headerSub: 'Законы Ньютона · масса · сила Гука · трение · гравитация · вес и невесомость',
|
||||||
|
hero: { h:'Динамика — почему тела движутся', p:'Динамика выясняет причины движения: силы и массы. Три закона Ньютона, закон всемирного тяготения, силы упругости и трения.' },
|
||||||
|
pri:'#059669', priD:'#047857', priSoft:'#d1fae5', priLight:'#34d399',
|
||||||
|
headerGrad:'linear-gradient(110deg,#064e3b 0%,#059669 55%,#34d399 100%)',
|
||||||
|
chNum:2, watermarkHero:'F',
|
||||||
|
},
|
||||||
|
ch3: {
|
||||||
|
paras: ['p25','p26','p27','p28','p29','p30'], final: 'final3',
|
||||||
|
title: 'Основы статики',
|
||||||
|
headerSub: 'Момент силы · рычаги · блоки · наклонная плоскость · КПД · центр тяжести · закон Архимеда',
|
||||||
|
hero: { h:'Статика — равновесие тел', p:'Статика изучает условия покоя тел. Момент силы, простые механизмы, центр тяжести, закон Архимеда — основа техники.' },
|
||||||
|
pri:'#7c3aed', priD:'#6d28d9', priSoft:'#ede9fe', priLight:'#a78bfa',
|
||||||
|
headerGrad:'linear-gradient(110deg,#3b0764 0%,#7c3aed 55%,#a78bfa 100%)',
|
||||||
|
chNum:3, watermarkHero:'M',
|
||||||
|
},
|
||||||
|
ch4: {
|
||||||
|
paras: ['p31','p32','p33','p34','p35','p36'], final: 'final4',
|
||||||
|
title: 'Законы сохранения',
|
||||||
|
headerSub: 'Импульс · реактивное движение · работа · мощность · кинетическая и потенциальная энергия · закон сохранения энергии',
|
||||||
|
hero: { h:'Законы сохранения — фундамент физики', p:'Импульс и энергия сохраняются в замкнутых системах. Эти законы лежат в основе всего, от движения ракет до колебаний маятников.' },
|
||||||
|
pri:'#db2777', priD:'#be185d', priSoft:'#fce7f3', priLight:'#f472b6',
|
||||||
|
headerGrad:'linear-gradient(110deg,#831843 0%,#db2777 55%,#f472b6 100%)',
|
||||||
|
chNum:4, watermarkHero:'p·E',
|
||||||
|
},
|
||||||
|
ch5: {
|
||||||
|
paras: ['lr1','lr2','lr3','lr4','lr5','lr6','lr7','lr8','lr9','lr10','lr11','lr12'], final: 'final5',
|
||||||
|
title: 'Лабораторный практикум',
|
||||||
|
headerSub: '12 лабораторных работ: погрешности · ускорение · окружность · Гук · трение · бросок · рычаг · блоки · наклонная плоскость · Архимед · импульс · энергия',
|
||||||
|
hero: { h:'Лабораторный практикум — физика руками', p:'12 классических лабораторных работ. Каждая: цель, оборудование, вывод формул, ход работы, таблица измерений, контрольные вопросы и суперзадание.' },
|
||||||
|
pri:'#0891b2', priD:'#0e7490', priSoft:'#cffafe', priLight:'#22d3ee',
|
||||||
|
headerGrad:'linear-gradient(110deg,#164e63 0%,#0891b2 55%,#22d3ee 100%)',
|
||||||
|
chNum:5, watermarkHero:'Δt',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Краткие подсказки в боковой панели (минимальный набор; расширяется в Phase 5) ===
|
||||||
|
const SIDEBAR_ROWS = {
|
||||||
|
p1: [['Кинематика','описывает движение без причин'],['Мат. точка','тело с пренебр. размерами'],['Поступательное','все точки движутся одинаково']],
|
||||||
|
p2: [['СО','тело отсчёта + оси + часы'],['Относ.','скорость, путь, траектория'],['Земля','чаще всего тело отсчёта']],
|
||||||
|
p3: [['Скаляр','число'],['Вектор','число + направление'],['$\\vec a + \\vec b$','правило треугольника / параллелограмма']],
|
||||||
|
p4: [['Проекция','$a_x = a\\cos\\alpha$'],['Знак','зависит от $\\alpha$'],['Сумма','$(\\vec a + \\vec b)_x = a_x + b_x$']],
|
||||||
|
p5: [['Путь','скаляр $s \\ge 0$'],['Перемещ.','вектор $\\Delta\\vec r$'],['$s \\ge |\\Delta\\vec r|$','']],
|
||||||
|
p6: [['$\\vec v = \\text{const}$','равномерное'],['$\\Delta\\vec r = \\vec v t$',''],['$x = x_0 + v_x t$','координата']],
|
||||||
|
p7: [['$v(t)$','прямая'],['$x(t)$','наклонная прямая'],['Площадь','под $v(t)$ = путь']],
|
||||||
|
p8: [['Средняя','$\\langle v\\rangle = s/t$'],['Мгновенная','предел $\\Delta s/\\Delta t$'],['Спидометр','показывает мгн. $v$']],
|
||||||
|
p9: [['$\\vec v_{1,3} = \\vec v_{1,2} + \\vec v_{2,3}$',''],['Лодка/река','$\\vec v_{л,б} = \\vec v_{л,в} + \\vec v_{в,б}$'],['По теч.','скорости складываются']],
|
||||||
|
p10: [['$\\vec a = \\Delta\\vec v/\\Delta t$',''],['Ед.','м/с²'],['Знак','совпадает с $\\Delta\\vec v$']],
|
||||||
|
p11: [['$\\vec v = \\vec v_0 + \\vec a t$',''],['Проекция','$v_x = v_{0x} + a_x t$'],['','']],
|
||||||
|
p12: [['$\\Delta\\vec r = \\vec v_0 t + \\vec a t^2/2$',''],['$v^2 - v_0^2 = 2a_x\\Delta x$','без $t$'],['','']],
|
||||||
|
p13: [['$\\omega = 2\\pi/T$',''],['$v = \\omega R$',''],['$\\omega$','рад/с']],
|
||||||
|
p14: [['$a_n = v^2/R$',''],['$a_n = \\omega^2 R$',''],['К центру','направление']],
|
||||||
|
p15: [['ИСО','системы, в которых выполняется 1-й закон'],['1-й Н.','$\\sum\\vec F = 0 \\Rightarrow \\vec v = \\text{const}$'],['Инерция','свойство сохранять скорость']],
|
||||||
|
p16: [['Масса','мера инертности'],['$m_1/m_2 = a_2/a_1$',''],['Ед.','кг (эталон)']],
|
||||||
|
p17: [['$\\vec a = \\vec F/m$',''],['$\\vec F = m\\vec a$',''],['Принцип суперп.','$\\vec F = \\sum\\vec F_i$']],
|
||||||
|
p18: [['3-й Н.','$\\vec F_{12} = -\\vec F_{21}$'],['Разные тела','силы действуют на разные тела'],['Галилей','законы одинаковы во всех ИСО']],
|
||||||
|
p19: [['Закон Гука','$F = -kx$'],['Жёсткость','$k$, ед. Н/м'],['Лин. упр.','при малых деформациях']],
|
||||||
|
p20: [['Покоя','до начала движения'],['Скольж.','$F_{тр} = \\mu N$'],['$\\mu$','коэф. трения']],
|
||||||
|
p21: [['$g \\approx 9{,}81$ м/с²',''],['$h = gt^2/2$','свободное падение'],['$v = gt$','']],
|
||||||
|
p22: [['$L = v_0^2 \\sin 2\\alpha / g$','дальность'],['$H = v_0^2\\sin^2\\alpha/(2g)$','высота'],['$\\alpha = 45°$','макс. дальность']],
|
||||||
|
p23: [['$F = G m_1 m_2 / r^2$',''],['$G = 6{,}67\\cdot 10^{-11}$ Н·м²/кг²',''],['$g = GM/R^2$','на поверх. Земли']],
|
||||||
|
p24: [['Вес $P$','сила на опору/подвес'],['$P = m(g \\pm a)$',''],['$P = 0$','невесомость']],
|
||||||
|
p25: [['$M = Fl$','момент силы'],['$\\sum\\vec F = 0$ и $\\sum M = 0$',''],['Плечо','$l$ — расст. от оси до линии действия']],
|
||||||
|
p26: [['Рычаг','$F_1 l_1 = F_2 l_2$'],['Неподв. блок','без выигрыша'],['Подв. блок','выигрыш в силе в 2 раза']],
|
||||||
|
p27: [['Накл. пл.','выигрыш = $l/h$'],['«Золотое правило»','выигр. в силе = проигр. в пути'],['$\\eta = A_{пол}/A_{сов}$','КПД']],
|
||||||
|
p28: [['ЦТ','точка прилож. силы тяжести'],['Устойч.','ЦТ при отклонении поднимается'],['Безразл.','ЦТ не меняется']],
|
||||||
|
p29: [['$F_A = \\rho g V_{погр}$',''],['Вверх','направление'],['Архимед','выталкивающая сила']],
|
||||||
|
p30: [['Плав.','$\\rho_т \\le \\rho_ж$'],['Ватерлиния','граница погружения'],['Воздухопл.','подъёмная сила']],
|
||||||
|
p31: [['$\\vec p = m\\vec v$','импульс тела'],['Ед.','кг·м/с'],['Сумма','$\\vec p_{сист} = \\sum \\vec p_i$']],
|
||||||
|
p32: [['ЗСИ','$\\sum\\vec p_{до} = \\sum\\vec p_{после}$'],['Замкн. сист.','без внеш. сил'],['Ракета','$m_р\\vec v_р + m_г\\vec v_г = 0$']],
|
||||||
|
p33: [['$A = F\\Delta r\\cos\\alpha$',''],['Ед.','Дж'],['Мощность','$P = A/\\Delta t$, Вт']],
|
||||||
|
p34: [['$E_п = mgh$','тяжести'],['$E_п = kx^2/2$','упругости'],['$A = -\\Delta E_п$','']],
|
||||||
|
p35: [['$E_к = mv^2/2$',''],['Теорема','$A = \\Delta E_к$'],['$E = E_к + E_п$','полная']],
|
||||||
|
p36: [['ЗСЭ','$E = \\text{const}$ в замкн. консервативной сист.'],['Превращ.','один вид → другой'],['Трение','диссипация $\\to$ тепло']],
|
||||||
|
// ЛР sidebars — краткие
|
||||||
|
lr1: [['Цель','$\\Delta t$, $\\varepsilon_t$'],['Обор.','мерная лента, шарик, секундомер'],['Формула','$\\varepsilon_t = \\Delta t/\\langle t\\rangle \\cdot 100\\%$']],
|
||||||
|
lr2: [['Цель','измерить $a$ при равноускор.'],['Обор.','жёлоб, шарик, секундомер'],['Формула','$a = 2l/t^2$']],
|
||||||
|
lr3: [['Цель','$T$, $a_n$, $\\omega$, $v$'],['Обор.','штатив, нить, шарик'],['Формула','$a_n = 4\\pi^2 R/T^2$']],
|
||||||
|
lr4: [['Цель','$k$ пружины'],['Обор.','штатив, динамометр, грузы'],['Формула','$k = mg/|x|$']],
|
||||||
|
lr5: [['Цель','$\\mu$ дерево/дерево'],['Обор.','брусок, доска, динамометр'],['Формула','$\\mu = F_{упр}/P$']],
|
||||||
|
lr6: [['Цель','$v_0$ гориз. бросок'],['Обор.','лоток, шарик, копир. бумага'],['Формула','$v_0 = l\\sqrt{g/(2h)}$']],
|
||||||
|
lr7: [['Цель','правило рычага'],['Обор.','рычаг, грузы'],['Формула','$F_1 l_1 = F_2 l_2$']],
|
||||||
|
lr8: [['Цель','выигр. подв. блока'],['Обор.','блоки, динамометр'],['Формула','$P h_1 = F h_2$']],
|
||||||
|
lr9: [['Цель','КПД накл. плоскости'],['Обор.','доска, брусок, динамометр'],['Формула','$\\eta = mgh/(F_{упр}l)\\cdot 100\\%$']],
|
||||||
|
lr10: [['Цель','$F_A$ для разных жидк.'],['Обор.','цилиндры, динамометр, вода, соль'],['Формула','$F_A = F_{упр1} - F_{упр2}$']],
|
||||||
|
lr11: [['Цель','проверить ЗСИ'],['Обор.','лоток, два шара, бумага'],['Формула','$m_1 l_1 = m_1 l_1\' + m_2 l_2\'$']],
|
||||||
|
lr12: [['Цель','проверить ЗСЭ'],['Обор.','лоток, шар, пружина, бумага'],['Формула','$F|x| = ml^2g/(2h)$']],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIPS_HTML = {
|
||||||
|
p1: 'Кинематика — раздел физики о движении без причин. Мат. точка — тело, размерами которого можно пренебречь.',
|
||||||
|
p2: 'СО = тело отсчёта + система координат + часы. Скорость, путь и траектория зависят от выбора СО.',
|
||||||
|
p3: 'Скаляры — число (масса, путь). Векторы — число + направление (сила, скорость). Сумма векторов: правило треугольника или параллелограмма.',
|
||||||
|
p4: 'Проекция вектора $\\vec a$ на ось: $a_x = a\\cos\\alpha$. Знак зависит от угла $\\alpha$. Сумма проекций = проекция суммы.',
|
||||||
|
p5: 'Путь $s$ — скаляр $\\ge 0$. Перемещение $\\Delta\\vec r$ — вектор. Всегда $s \\ge |\\Delta\\vec r|$.',
|
||||||
|
p6: 'Равномерное движение: $\\vec v = \\text{const}$. $\\Delta\\vec r = \\vec v t$, координата $x = x_0 + v_x t$.',
|
||||||
|
p7: 'График $v(t)$ — прямая параллельная оси $t$. График $x(t)$ — наклонная прямая. Площадь под $v(t)$ = пройденный путь.',
|
||||||
|
p8: 'Средняя скорость: $\\langle v\\rangle = s/t$. Мгновенная — предел $\\Delta s/\\Delta t$ при $\\Delta t \\to 0$. Спидометр показывает мгновенную.',
|
||||||
|
p9: 'Закон сложения скоростей: $\\vec v_{1,3} = \\vec v_{1,2} + \\vec v_{2,3}$. По течению — скорости складываются, против — вычитаются.',
|
||||||
|
p10: 'Ускорение: $\\vec a = \\Delta\\vec v / \\Delta t$. Единица м/с². Направление совпадает с $\\Delta\\vec v$.',
|
||||||
|
p11: 'При равноускоренном движении: $\\vec v = \\vec v_0 + \\vec a t$. В проекциях: $v_x = v_{0x} + a_x t$.',
|
||||||
|
p12: 'Перемещение: $\\Delta\\vec r = \\vec v_0 t + \\vec a t^2/2$. Без времени: $v^2 - v_0^2 = 2a_x\\Delta x$.',
|
||||||
|
p13: 'Угловая скорость $\\omega = 2\\pi/T = 2\\pi\\nu$ (рад/с). Связь с линейной: $v = \\omega R$.',
|
||||||
|
p14: 'Центростремит. ускорение: $a_n = v^2/R = \\omega^2 R$. Направлено к центру окружности.',
|
||||||
|
p15: 'ИСО — система, в которой выполняется 1-й закон Ньютона. В отсутствие сил тело сохраняет скорость (инерция).',
|
||||||
|
p16: 'Масса — мера инертности. $m_1/m_2 = a_2/a_1$. Единица — килограмм, эталонная.',
|
||||||
|
p17: '2-й закон Ньютона: $\\vec a = \\vec F/m$. Или $\\vec F = m\\vec a$. Принцип суперпозиции: $\\vec F = \\sum \\vec F_i$.',
|
||||||
|
p18: '3-й закон Ньютона: $\\vec F_{12} = -\\vec F_{21}$. Силы приложены к разным телам! Принцип относ. Галилея: законы одинаковы во всех ИСО.',
|
||||||
|
p19: 'Закон Гука: $F_{упр} = -kx$, где $k$ — жёсткость пружины (Н/м). Линейность только при малых деформациях.',
|
||||||
|
p20: 'Сила трения скольжения: $F_{тр} = \\mu N$, где $\\mu$ — коэф. трения. Сила сопротивления среды растёт со скоростью.',
|
||||||
|
p21: 'Свободное падение: $g \\approx 9{,}81$ м/с² у поверхности Земли. $h = gt^2/2$, $v = gt$.',
|
||||||
|
p22: 'Тело, брошенное под углом: $L = v_0^2 \\sin 2\\alpha/g$ — дальность; $H = v_0^2\\sin^2\\alpha/(2g)$ — высота. Макс. $L$ при $\\alpha = 45°$.',
|
||||||
|
p23: 'Закон всемирного тяготения: $F = G m_1 m_2/r^2$. $G = 6{,}67\\cdot 10^{-11}$ Н·м²/кг². У поверхности: $g = GM/R^2$.',
|
||||||
|
p24: 'Вес $P$ — сила, с которой тело давит на опору / тянет подвес. $P = m(g \\pm a)$. При свободном падении $P = 0$ — невесомость.',
|
||||||
|
p25: 'Условия равновесия: $\\sum\\vec F = 0$ И $\\sum M = 0$. Момент силы $M = F \\cdot l$, где $l$ — плечо.',
|
||||||
|
p26: 'Рычаг в равновесии: $F_1 l_1 = F_2 l_2$. Неподвижный блок выигрыша не даёт. Подвижный — выигрыш в силе в 2 раза.',
|
||||||
|
p27: 'Накл. плоскость: выигрыш в силе = $l/h$. «Золотое правило»: выигрываем в силе — проигрываем в пути. КПД: $\\eta = A_{пол}/A_{сов}$.',
|
||||||
|
p28: 'Центр тяжести — точка приложения равнодействующей сил тяжести. Устойчивое равновесие: ЦТ при отклонении поднимается.',
|
||||||
|
p29: 'Закон Архимеда: $F_A = \\rho_ж g V_{погр}$. Направлен вверх. Не зависит от глубины, формы тела или плотности тела.',
|
||||||
|
p30: 'Условие плавания: $\\rho_т \\le \\rho_ж$. Подъёмная сила воздухоплавательного аппарата — разность веса вытесненного воздуха и веса аппарата.',
|
||||||
|
p31: 'Импульс тела: $\\vec p = m\\vec v$ (кг·м/с). Импульс системы — сумма импульсов всех тел.',
|
||||||
|
p32: 'ЗСИ: в замкнутой системе $\\sum\\vec p = \\text{const}$. Реактивное движение: $m_р\\vec v_р + m_г\\vec v_г = 0$.',
|
||||||
|
p33: 'Работа силы: $A = F \\Delta r \\cos\\alpha$ (Дж). Мощность: $P = A/\\Delta t = Fv\\cos\\alpha$ (Вт).',
|
||||||
|
p34: 'Потенц. энергия тяжести: $E_п = mgh$. Упругости: $E_п = kx^2/2$. Работа консерват. силы: $A = -\\Delta E_п$.',
|
||||||
|
p35: 'Кинет. энергия: $E_к = mv^2/2$. Теорема: $A = \\Delta E_к$. Полная мех. энергия: $E = E_к + E_п$.',
|
||||||
|
p36: 'ЗСЭ: в замкнутой консервативной системе $E_к + E_п = \\text{const}$. При трении мех. энергия превращается в тепло.',
|
||||||
|
// ЛР tips
|
||||||
|
lr1: 'ЛР1: погрешности прямых измерений. $\\Delta t = \\Delta t_{сист} + \\Delta t_{случ}$. Результат в интервальной форме: $t = \\langle t\\rangle \\pm \\Delta t$.',
|
||||||
|
lr2: 'ЛР2: ускорение шарика по наклонному жёлобу. $a = 2l/t^2$ (из $s = at^2/2$ при $v_0 = 0$).',
|
||||||
|
lr3: 'ЛР3: движение по окружности. Измеряем $T$, считаем $a_n = 4\\pi^2 R/T^2$, $\\omega = 2\\pi/T$, $v = \\omega R$.',
|
||||||
|
lr4: 'ЛР4: закон Гука. Подвешиваем грузы, строим график $F_{упр}(x)$. Жёсткость $k = mg/|x|$.',
|
||||||
|
lr5: 'ЛР5: коэффициент трения скольжения дерева по дереву. $\\mu = F_{упр}/P$.',
|
||||||
|
lr6: 'ЛР6: тело, брошенное горизонтально. Измеряем дальность $l$ и высоту $h$. $v_0 = l\\sqrt{g/(2h)}$.',
|
||||||
|
lr7: 'ЛР7: условие равновесия рычага. Проверяем $F_1 l_1 = F_2 l_2$.',
|
||||||
|
lr8: 'ЛР8: блоки. Неподв. — без выигрыша; подвижный — выигрыш в силе в 2 раза, проигрыш в пути в 2 раза.',
|
||||||
|
lr9: 'ЛР9: КПД наклонной плоскости. $\\eta = A_{пол}/A_{сов} = mgh/(F_{упр}l)\\cdot 100\\%$. Сравниваем при 30° и 45°.',
|
||||||
|
lr10: 'ЛР10: выталкивающая сила Архимеда. $F_A = F_{упр1} - F_{упр2}$ (вес в воздухе минус вес в жидкости).',
|
||||||
|
lr11: 'ЛР11: ЗСИ. Шар $m_1$ скатывается, сталкивается с покоящимся шаром $m_2$. Проверяем $m_1 l_1 = m_1 l_1\' + m_2 l_2\'$.',
|
||||||
|
lr12: 'ЛР12: ЗСЭ. Сжатая пружина → шар → дальность полёта. $F_{упр}|x| = ml^2 g/(2h)$.',
|
||||||
|
final1: 'Финал главы 1 — интегрированные задачи по §§1–14. В разработке (Phase 1+).',
|
||||||
|
final2: 'Финал главы 2 — интегрированные задачи по §§15–24. В разработке (Phase 2+).',
|
||||||
|
final3: 'Финал главы 3 — интегрированные задачи по §§25–30. В разработке (Phase 3+).',
|
||||||
|
final4: 'Финал главы 4 — интегрированные задачи по §§31–36. В разработке (Phase 4+).',
|
||||||
|
final5: 'Финал главы 5 — итоговый отчёт по 12 ЛР. В разработке (Phase 5+).',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: prefix для номера секции (§ или ЛР или ★ для финала)
|
||||||
|
function numLabel(pid){
|
||||||
|
if (pid.startsWith('final')) return '★';
|
||||||
|
if (pid.startsWith('lr')) return 'ЛР ' + pid.slice(2);
|
||||||
|
return '§ ' + pid.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameOf(pid){
|
||||||
|
if (pid.startsWith('final')) return 'Финал главы';
|
||||||
|
if (pid.startsWith('lr')) return LR_NAMES[pid];
|
||||||
|
return PARA_NAMES[pid];
|
||||||
|
}
|
||||||
|
|
||||||
|
function subOf(pid){
|
||||||
|
if (pid.startsWith('final')) return '';
|
||||||
|
if (pid.startsWith('lr')) return LR_SUBS[pid] || '';
|
||||||
|
return PARA_SUBS[pid] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wmOf(pid){
|
||||||
|
if (pid.startsWith('lr')) return LR_WM[pid] || '?';
|
||||||
|
return PARA_WM[pid] || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Билд одного ch ===
|
||||||
|
function buildCh(chKey) {
|
||||||
|
const C = CHAPTERS[chKey];
|
||||||
|
const slug = 'physics-9-' + chKey;
|
||||||
|
const lsPrefix = 'physics9_' + chKey;
|
||||||
|
const xpKey = 'physics9_xp';
|
||||||
|
const allParas = [...C.paras, C.final];
|
||||||
|
|
||||||
|
// PARAS JS literal
|
||||||
|
const parasArr = allParas.map(pid => {
|
||||||
|
if (pid.startsWith('final')) {
|
||||||
|
return ` { id:${JSON.stringify(pid)}, num:'\\u2605', name:'Финал главы', sub:${JSON.stringify('Итоги · боссы главы ' + C.chNum)}, final:true }`;
|
||||||
|
}
|
||||||
|
const sub = subOf(pid);
|
||||||
|
const num = pid.startsWith('lr') ? `ЛР ${pid.slice(2)}` : `§ ${pid.slice(1)}`;
|
||||||
|
return ` { id:${JSON.stringify(pid)}, num:${JSON.stringify(num)}, name:${JSON.stringify(nameOf(pid))}, sub:${JSON.stringify(sub)} }`;
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
const total = allParas.length;
|
||||||
|
|
||||||
|
// ACH_LABELS
|
||||||
|
const achLabels = [
|
||||||
|
` start:"Начало главы ${C.chNum}!"`,
|
||||||
|
...C.paras.map(pid => ` ${pid}_done:${JSON.stringify(nameOf(pid) + ' освоен!')}`),
|
||||||
|
` ${chKey}_done:"Глава ${C.chNum} пройдена!"`,
|
||||||
|
].join(',\n');
|
||||||
|
|
||||||
|
// SIDEBARS
|
||||||
|
const sidebarObj = allParas.map(pid => {
|
||||||
|
const rows = pid.startsWith('final')
|
||||||
|
? [[`§§${C.paras[0].replace(/^[pl]r?/,'')}–${C.paras[C.paras.length-1].replace(/^[pl]r?/,'')}`, `теория главы ${C.chNum}`],['Награда','+50 XP']]
|
||||||
|
: (SIDEBAR_ROWS[pid] || [['В разработке',`шпаргалка ${pid}`]]);
|
||||||
|
const titleStr = pid.startsWith('final')
|
||||||
|
? `Финал главы ${C.chNum}`
|
||||||
|
: (pid.startsWith('lr') ? `Шпаргалка ЛР ${pid.slice(2)}` : `Шпаргалка §${pid.slice(1)}`);
|
||||||
|
const rowsLit = rows.map(([k,v]) => `[${JSON.stringify(k)},${JSON.stringify(v)}]`).join(',');
|
||||||
|
return ` ${pid}:{title:${JSON.stringify(titleStr)},rows:[${rowsLit}]}`;
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
// TIPS
|
||||||
|
const tipsArr = allParas.map(pid => {
|
||||||
|
const html = TIPS_HTML[pid] || `Подсказка к ${pid} — в разработке.`;
|
||||||
|
return ` {sec:${JSON.stringify(pid)},html:${JSON.stringify(html)}}`;
|
||||||
|
}).join(',\n');
|
||||||
|
|
||||||
|
// STUB-builder для каждого
|
||||||
|
const builders = allParas.map(pid => {
|
||||||
|
const isFinal = pid.startsWith('final');
|
||||||
|
const isLR = pid.startsWith('lr');
|
||||||
|
const name = isFinal ? `Финал главы ${C.chNum}` : nameOf(pid);
|
||||||
|
const num = isFinal ? '★' : (isLR ? `ЛР ${pid.slice(2)}` : `§${pid.slice(1)}`);
|
||||||
|
const idx = allParas.indexOf(pid);
|
||||||
|
const prev = idx > 0 ? allParas[idx-1] : null;
|
||||||
|
const next = idx < allParas.length - 1 ? allParas[idx+1] : null;
|
||||||
|
const prevStr = prev ? `'${prev}'` : 'null';
|
||||||
|
const nextStr = next ? `'${next}'` : 'null';
|
||||||
|
|
||||||
|
const bodyHtml = isLR
|
||||||
|
? `<p><b>${name}</b> — лабораторная работа в разработке (Phase 5+).</p>
|
||||||
|
<p>Здесь появятся: <b>Цель · Оборудование · Проверьте себя · Вывод расчётных формул · Ход работы · Таблица измерений · Контрольные вопросы · Суперзадание</b> — по канве учебника Исаченковой 2019.</p>
|
||||||
|
<p style="margin-top:10px;padding:10px 14px;background:var(--sec-acc-soft);border-radius:9px;font-size:.92rem">
|
||||||
|
<b>Phase 0:</b> создан скелет. <b>Phase 5:</b> наполнение ЛР пошаговой работой с интерактивной таблицей измерений.
|
||||||
|
</p>`
|
||||||
|
: `<p><b>${name}</b> — этот параграф в разработке (Phase ${C.chNum}+).</p>
|
||||||
|
<p>Здесь появятся: теория, формулы, разобранные примеры и 3–4 интерактива в стиле «физики 10» — векторные диаграммы, графики движения, ползунки и автопроверяемые тренажёры.</p>
|
||||||
|
<p style="margin-top:10px;padding:10px 14px;background:var(--sec-acc-soft);border-radius:9px;font-size:.92rem">
|
||||||
|
<b>Phase 0:</b> создан скелет. <b>Phase 5:</b> наполнение по учебнику «Физика 9» (Исаченкова, Сокольский, Захаревич, 2019).
|
||||||
|
</p>`;
|
||||||
|
|
||||||
|
return `function build_${pid}(){
|
||||||
|
const box = document.getElementById('${pid}-body');
|
||||||
|
let html = '';
|
||||||
|
html += makeCard('theory', ${JSON.stringify(name)}, ${JSON.stringify(num)}, \`
|
||||||
|
${bodyHtml}
|
||||||
|
\`);
|
||||||
|
html += secNav(${prevStr}, ${nextStr});
|
||||||
|
html += readButton('${pid}');
|
||||||
|
box.innerHTML = html;
|
||||||
|
renderMath(box);
|
||||||
|
wireReadBtn('${pid}');
|
||||||
|
}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
const buildersMap = allParas.map(pid => `${pid}:()=>build_${pid}()`).join(', ');
|
||||||
|
|
||||||
|
// sec node HTML
|
||||||
|
const secNodes = allParas.map(pid => {
|
||||||
|
const isFinal = pid.startsWith('final');
|
||||||
|
const isLR = pid.startsWith('lr');
|
||||||
|
const num = isFinal ? '★' : (isLR ? `ЛР ${pid.slice(2)}` : `§ ${pid.slice(1)}`);
|
||||||
|
const titleHtml = isFinal ? 'Финал главы' : nameOf(pid);
|
||||||
|
const wm = wmOf(pid);
|
||||||
|
const numHtml = isFinal
|
||||||
|
? `<span class="sec-num" style="background:linear-gradient(135deg,${C.pri},${C.priLight})">★</span>`
|
||||||
|
: `<span class="sec-num">${num}</span>`;
|
||||||
|
return ` <section id="sec-${pid}" class="sec" data-watermark="${wm}"><div class="sec-header">${numHtml}<h2 class="sec-h">${titleHtml}</h2></div><div id="${pid}-body"></div></section>`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const secCss = allParas.map(pid =>
|
||||||
|
`.sec[id="sec-${pid}"]{ --sec-acc:${C.pri}; --sec-acc-d:${C.priD}; --sec-acc-soft:${C.priSoft}; }`
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
// Names for secNav
|
||||||
|
const namesObj = allParas.map(pid => {
|
||||||
|
if (pid.startsWith('final')) return `${pid}:'Финал'`;
|
||||||
|
if (pid.startsWith('lr')) return `${pid}:'ЛР${pid.slice(2)}'`;
|
||||||
|
return `${pid}:'\\xA7${pid.slice(1)}'`;
|
||||||
|
}).join(',');
|
||||||
|
|
||||||
|
const firstParaLabel = C.paras[0].startsWith('lr') ? `ЛР ${C.paras[0].slice(2)}` : `§ ${C.paras[0].slice(1)}`;
|
||||||
|
|
||||||
|
// === Финальный HTML ===
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
|
<meta http-equiv="Expires" content="0">
|
||||||
|
<title>Физика 9 · Глава ${C.chNum} · «${C.title}»</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||||
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false})"></script>
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
|
<script src="/js/phys.js" defer></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#fafafa; --card:#fff; --card-soft:#f8fafc; --text:#0f172a; --ink:#0f172a; --muted:#64748b;
|
||||||
|
--border:#e2e8f0; --sh:0 1px 3px rgba(0,0,0,.06); --sh2:0 4px 14px rgba(0,0,0,.08);
|
||||||
|
--pri:${C.pri}; --pri2:${C.priD}; --pri-soft:${C.priSoft};
|
||||||
|
--acc:${C.priLight}; --acc2:${C.pri}; --acc-soft:${C.priSoft};
|
||||||
|
--ok:#10b981; --ok-bg:#d1fae5; --warn:#f59e0b; --warn-bg:#fef3c7;
|
||||||
|
--bad:#ef4444; --fail:#dc2626; --fail-bg:#fee2e2;
|
||||||
|
}
|
||||||
|
.dark{--bg:#0a0e1a; --card:#0f1727; --card-soft:#13192a; --text:#dbeafe; --ink:#dbeafe; --muted:#7c8fab; --border:#1e2a44}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||||
|
html,body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;font-size:15px}
|
||||||
|
button,input,select,textarea{font-family:inherit;font-size:inherit}
|
||||||
|
button{cursor:pointer;border:0;background:transparent;color:inherit}
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
.ic{width:16px;height:16px;display:inline-block;flex-shrink:0;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}
|
||||||
|
|
||||||
|
.hdr{position:relative;background:${C.headerGrad};color:#fff;padding:46px 22px 30px;overflow:hidden;border-bottom:2px solid rgba(255,255,255,.2);min-height:130px}
|
||||||
|
.hdr::before{content:'ГЛАВА ${C.chNum}';position:absolute;right:-12px;top:50%;transform:translateY(-50%);font-family:'Unbounded',sans-serif;font-size:clamp(5rem,15vw,11rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px rgba(255,255,255,.12);line-height:1;pointer-events:none;user-select:none;z-index:0}
|
||||||
|
.hdr-row{position:relative;z-index:1;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||||||
|
.hdr h1{font-family:'Unbounded',sans-serif;font-size:1.5rem;font-weight:900;letter-spacing:-.01em;line-height:1.3;padding-top:4px}
|
||||||
|
.hdr-sub{font-size:.85rem;opacity:.88;margin-top:6px;font-weight:500;line-height:1.4}
|
||||||
|
.hdr-side{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||||
|
.hdr-btn{padding:7px 12px;border-radius:9px;background:rgba(255,255,255,.14);color:#fff;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;text-decoration:none}
|
||||||
|
.hdr-btn:hover{background:rgba(255,255,255,.24)}
|
||||||
|
|
||||||
|
.main{max-width:1240px;margin:0 auto;padding:22px;width:100%;display:grid;grid-template-columns:1fr 280px;gap:24px}
|
||||||
|
@media(max-width:980px){.main{grid-template-columns:1fr;padding:14px}}
|
||||||
|
.col-main{min-width:0}
|
||||||
|
|
||||||
|
.hero{background:linear-gradient(135deg,var(--pri-soft) 0%,var(--acc-soft) 50%,var(--pri-soft) 100%);background-size:200% 200%;animation:heroShift 12s ease-in-out infinite;border:1px solid var(--border);border-radius:18px;padding:24px 22px;margin-bottom:24px;position:relative;overflow:hidden}
|
||||||
|
@keyframes heroShift{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
|
||||||
|
.hero::before{content:'${C.watermarkHero}';position:absolute;right:0;top:-30px;font-size:clamp(2rem,12vw,8rem);font-weight:900;color:var(--pri);opacity:.10;line-height:1;pointer-events:none;font-family:'Unbounded',sans-serif}
|
||||||
|
.hero h2{font-family:'Unbounded',sans-serif;font-size:1.55rem;font-weight:800;color:var(--pri2);margin-bottom:10px;letter-spacing:-.01em}
|
||||||
|
.hero p{font-size:.95rem;color:var(--text);opacity:.88;margin-bottom:14px;max-width:640px}
|
||||||
|
.hero-row{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
|
||||||
|
.btn-primary{padding:11px 22px;background:linear-gradient(135deg,var(--pri),var(--pri2));color:#fff;border-radius:11px;font-weight:700;font-size:.92rem;display:inline-flex;align-items:center;gap:8px;box-shadow:var(--sh2);transition:transform .15s,box-shadow .15s}
|
||||||
|
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 8px 28px rgba(0,0,0,.18)}
|
||||||
|
.hero-progress{flex:1;min-width:200px;max-width:280px}
|
||||||
|
.hp-label{font-size:.74rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:5px}
|
||||||
|
.hp-bar{height:8px;background:rgba(0,0,0,.12);border-radius:5px;overflow:hidden}
|
||||||
|
.hp-fill{height:100%;background:linear-gradient(90deg,var(--pri),var(--acc));border-radius:5px;width:0%;transition:width .6s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.hp-text{font-size:.78rem;color:var(--muted);font-weight:700;margin-top:4px;display:block}
|
||||||
|
.hero-xp-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:linear-gradient(135deg,var(--warn,#f59e0b),var(--pri));color:#fff;border-radius:99px;font-size:.82rem;font-weight:800;letter-spacing:.02em;box-shadow:0 4px 12px rgba(0,0,0,.18);font-family:'Unbounded',sans-serif}
|
||||||
|
|
||||||
|
.psel{margin-bottom:24px}
|
||||||
|
.psel-title{font-size:.72rem;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}
|
||||||
|
.psel-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px}
|
||||||
|
.psel-card{background:var(--card);border:1.5px solid var(--border);border-radius:13px;padding:14px;cursor:pointer;transition:transform .2s,box-shadow .2s,border-color .2s;text-align:left;position:relative}
|
||||||
|
.psel-card:hover{transform:translateY(-3px);box-shadow:var(--sh2);border-color:var(--pri)}
|
||||||
|
.psel-card.active{border-color:var(--pri);background:linear-gradient(135deg,var(--pri-soft),var(--card));box-shadow:var(--sh2)}
|
||||||
|
.psel-card.active::after{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--pri),var(--acc));border-radius:13px 13px 0 0}
|
||||||
|
.psel-num{font-family:'Unbounded',sans-serif;font-size:.72rem;font-weight:800;color:var(--pri);text-transform:uppercase;letter-spacing:.08em;margin-bottom:5px}
|
||||||
|
.psel-name{font-size:.86rem;font-weight:700;color:var(--text);line-height:1.3;margin-bottom:8px}
|
||||||
|
.psel-prog{height:4px;background:rgba(0,0,0,.10);border-radius:3px;overflow:hidden}
|
||||||
|
.psel-prog-fill{height:100%;background:var(--pri);width:0%;transition:width .4s}
|
||||||
|
.psel-card.final{background:linear-gradient(135deg,var(--acc-soft),var(--pri-soft))}
|
||||||
|
.psel-card.final .psel-num{color:var(--warn)}
|
||||||
|
|
||||||
|
${secCss}
|
||||||
|
|
||||||
|
.sec{display:none;position:relative;animation:fadeIn .35s ease}
|
||||||
|
.sec.active{display:block}
|
||||||
|
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||||||
|
.sec::before{content:attr(data-watermark);position:absolute;right:-20px;top:10%;font-family:'Unbounded',sans-serif;font-size:clamp(6rem,18vw,14rem);font-weight:900;color:transparent;-webkit-text-stroke:1.5px var(--sec-acc-soft,var(--pri-soft));line-height:1;pointer-events:none;user-select:none;z-index:0;opacity:.35}
|
||||||
|
.sec-header{margin-bottom:22px;padding-bottom:14px;border-bottom:2px solid var(--sec-acc-soft,var(--pri-soft));position:relative;z-index:1}
|
||||||
|
.sec-num{display:inline-block;padding:4px 10px;background:linear-gradient(135deg,var(--sec-acc,var(--pri)),var(--sec-acc-d,var(--pri2)));color:#fff;border-radius:7px;font-family:'Unbounded',sans-serif;font-size:.78rem;font-weight:800;letter-spacing:.04em;margin-bottom:8px}
|
||||||
|
.sec-h{font-family:'Unbounded',sans-serif;font-size:1.6rem;font-weight:800;color:var(--sec-acc-d,var(--pri2));letter-spacing:-.01em;line-height:1.25}
|
||||||
|
|
||||||
|
.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.04),0 8px 24px rgba(0,0,0,.04);position:relative;z-index:1;transition:transform .25s cubic-bezier(.16,1,.3,1),box-shadow .25s}
|
||||||
|
.card:hover{transform:translateY(-2px);box-shadow:0 4px 10px rgba(0,0,0,.06),0 16px 36px rgba(0,0,0,.08)}
|
||||||
|
.card-header{display:flex;align-items:center;gap:10px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed var(--border)}
|
||||||
|
.card-icon{width:32px;height:32px;border-radius:9px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#fff}
|
||||||
|
.card-icon.theory{background:#8b5cf6}.card-icon.example{background:#10b981}.card-icon.lab{background:#0891b2}.card-icon.rule{background:#ec4899}
|
||||||
|
.card-icon .ic{width:18px;height:18px}
|
||||||
|
.card-title{font-family:'Unbounded',sans-serif;font-size:.82rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex:1}
|
||||||
|
.card-num{font-size:.74rem;font-weight:700;color:var(--muted);background:var(--sec-acc-soft,var(--pri-soft));padding:3px 7px;border-radius:5px}
|
||||||
|
.card-body{font-size:.94rem;line-height:1.65}
|
||||||
|
.card-body p{margin-bottom:8px}
|
||||||
|
.card-body p:last-child{margin-bottom:0}
|
||||||
|
|
||||||
|
.btn{padding:8px 16px;border-radius:8px;background:var(--card);color:var(--text);border:1.5px solid var(--border);font-weight:600;font-size:.88rem;transition:background .15s,border-color .15s,transform .1s}
|
||||||
|
.btn:hover{background:var(--sec-acc-soft,var(--pri-soft));border-color:var(--sec-acc,var(--pri))}
|
||||||
|
.btn:active{transform:scale(.96)}
|
||||||
|
.btn.primary{background:var(--sec-acc,var(--pri));color:#fff;border-color:var(--sec-acc,var(--pri))}
|
||||||
|
.btn.primary:hover{background:var(--sec-acc-d,var(--pri2));border-color:var(--sec-acc-d,var(--pri2))}
|
||||||
|
|
||||||
|
.col-side{position:sticky;top:14px;align-self:start;height:fit-content;max-height:calc(100vh - 28px);overflow-y:auto}
|
||||||
|
.sidecard{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px;margin-bottom:14px;box-shadow:var(--sh)}
|
||||||
|
.sidecard h4{font-family:'Unbounded',sans-serif;font-size:.74rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||||
|
.sidecard-row{margin-bottom:8px;font-size:.86rem;line-height:1.6}
|
||||||
|
.sidecard-row b{color:var(--pri);font-weight:700}
|
||||||
|
.sidecard-row:last-child{margin-bottom:0}
|
||||||
|
@media(max-width:980px){.col-side{position:static;max-height:none}}
|
||||||
|
|
||||||
|
.xp-card{background:linear-gradient(135deg,var(--acc-soft),var(--pri-soft));border:1.5px solid var(--acc);border-radius:12px;padding:14px;margin-bottom:14px}
|
||||||
|
.xp-card-title{font-size:.68rem;font-weight:800;color:var(--acc2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.xp-level{font-size:1.1rem;font-weight:900;color:var(--acc2);font-family:'Unbounded',sans-serif}
|
||||||
|
.xp-bar{height:9px;background:rgba(0,0,0,.10);border-radius:6px;overflow:hidden;margin:7px 0}
|
||||||
|
.xp-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--pri));border-radius:6px;transition:width .5s cubic-bezier(.4,0,.2,1)}
|
||||||
|
.xp-nums{font-size:.74rem;color:var(--muted);display:flex;justify-content:space-between}
|
||||||
|
|
||||||
|
.sec-nav{display:flex;gap:10px;margin-top:24px;padding-top:20px;border-top:1px solid var(--border);justify-content:space-between;flex-wrap:wrap}
|
||||||
|
.foot{text-align:center;padding:30px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
|
||||||
|
|
||||||
|
.ach-popup{position:fixed;top:80px;right:18px;background:linear-gradient(135deg,var(--pri),var(--acc));color:#fff;padding:12px 18px;border-radius:11px;font-weight:700;font-size:.9rem;box-shadow:0 8px 28px rgba(0,0,0,.32);z-index:1002;display:none;align-items:center;gap:8px;max-width:340px}
|
||||||
|
.ach-popup.show{display:flex}
|
||||||
|
|
||||||
|
.col-side-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.42);z-index:9990;display:none}
|
||||||
|
.col-side-backdrop.show{display:block}
|
||||||
|
@media(max-width:980px){
|
||||||
|
.col-side{position:fixed;top:0;right:0;height:100vh;width:300px;max-width:88vw;background:var(--bg);box-shadow:-12px 0 24px rgba(0,0,0,.18);padding:18px 16px;overflow-y:auto;transform:translateX(100%);transition:transform .25s ease;z-index:9991;max-height:none}
|
||||||
|
.col-side.open{transform:none}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-modal{position:fixed;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(4px);z-index:9993;display:none;align-items:flex-start;justify-content:center;padding-top:14vh}
|
||||||
|
.search-modal.show{display:flex}
|
||||||
|
.search-box{background:var(--bg);border:1px solid var(--border);border-radius:14px;width:560px;max-width:92vw;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.4)}
|
||||||
|
.search-input{padding:14px 16px;font-size:1rem;border:0;border-bottom:1px solid var(--border);background:transparent;color:var(--text);outline:none}
|
||||||
|
.search-results{flex:1;overflow-y:auto;padding:6px 0}
|
||||||
|
.search-row{display:block;padding:8px 16px;cursor:pointer;border-bottom:1px solid var(--border);text-align:left;background:transparent;border:0;width:100%;color:var(--text)}
|
||||||
|
.search-row:hover,.search-row.active{background:var(--sec-acc-soft,var(--pri-soft))}
|
||||||
|
.search-row .sr-kind{font-size:.7rem;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:2px}
|
||||||
|
.search-row .sr-title{font-weight:700;font-size:.92rem;color:var(--text)}
|
||||||
|
.search-row .sr-desc{font-size:.8rem;color:var(--muted);margin-top:2px}
|
||||||
|
.search-empty{padding:20px;text-align:center;color:var(--muted);font-size:.88rem}
|
||||||
|
.search-foot{padding:8px 14px;border-top:1px solid var(--border);font-size:.74rem;color:var(--muted);display:flex;gap:14px}
|
||||||
|
.search-foot kbd{padding:2px 6px;background:var(--card);border:1px solid var(--border);border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:.72rem}
|
||||||
|
|
||||||
|
.psel-card{position:relative}
|
||||||
|
.psel-card .psel-done{position:absolute;top:6px;right:6px;width:18px;height:18px;border-radius:50%;background:#10b981;display:none;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(16,185,129,.45);z-index:2}
|
||||||
|
.psel-card .psel-done svg{width:11px;height:11px;stroke:#fff;fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
.psel-card.done .psel-done{display:flex}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-row">
|
||||||
|
<div>
|
||||||
|
<h1>Физика 9 · Глава ${C.chNum}</h1>
|
||||||
|
<div class="hdr-sub">${C.headerSub}</div>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-side">
|
||||||
|
<a href="/textbook/physics-9" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К физике 9</a>
|
||||||
|
<button id="search-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg> Поиск</button>
|
||||||
|
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||||||
|
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="col-main">
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h2>${C.hero.h}</h2>
|
||||||
|
<p>${C.hero.p}</p>
|
||||||
|
<div class="hero-row">
|
||||||
|
<button class="btn-primary" onclick="goTo('${C.paras[0]}')"><svg class="ic" viewBox="0 0 24 24"><polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/></svg> Начать ${firstParaLabel}</button>
|
||||||
|
<div class="hero-progress">
|
||||||
|
<span class="hp-label">Прогресс по главе</span>
|
||||||
|
<div class="hp-bar"><div id="hero-hp-fill" class="hp-fill"></div></div>
|
||||||
|
<span id="hero-hp-text" class="hp-text">0%</span>
|
||||||
|
</div>
|
||||||
|
<div id="hero-xp-badge" class="hero-xp-badge"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="psel">
|
||||||
|
<div class="psel-title">${chKey === 'ch5' ? 'Лабораторные работы' : 'Параграфы главы'}</div>
|
||||||
|
<div id="psel-grid" class="psel-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
${secNodes}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<aside class="col-side" id="col-side"><div id="sidebar-content"></div></aside>
|
||||||
|
<div class="col-side-backdrop" id="col-side-backdrop"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">Интерактивный учебник «Физика 9» · Глава ${C.chNum} · «${C.title}» · LearnSpace</footer>
|
||||||
|
|
||||||
|
<div id="ach-popup" class="ach-popup"><svg class="ic" viewBox="0 0 24 24" style="width:22px;height:22px"><polygon points="12,2 22,20 2,20"/></svg><span id="ach-text">Достижение!</span></div>
|
||||||
|
<div id="search-modal" class="search-modal" role="dialog">
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="search-input" class="search-input" placeholder="Поиск…" autocomplete="off">
|
||||||
|
<div id="search-results" class="search-results"></div>
|
||||||
|
<div class="search-foot"><span><kbd>↑↓</kbd> навигация</span><span><kbd>Enter</kbd> открыть</span><span><kbd>Esc</kbd> закрыть</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STATE = { current:'${C.paras[0]}', progress:{}, achievements:new Map(), xp:0, level:1 };
|
||||||
|
const TOTAL_PARAS = ${total};
|
||||||
|
const _TB_SLUG = '${slug}';
|
||||||
|
|
||||||
|
const PARAS = [
|
||||||
|
${parasArr}
|
||||||
|
];
|
||||||
|
PARAS.forEach(p => { STATE.progress[p.id] = 0; });
|
||||||
|
|
||||||
|
function calcLevel(xp){ return Math.floor(Math.sqrt((xp||0)/100))+1; }
|
||||||
|
function _xpForLevel(lv){ return (lv-1)*(lv-1)*100; }
|
||||||
|
|
||||||
|
const ACH_LABELS = {
|
||||||
|
${achLabels}
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadProgress(){
|
||||||
|
try{
|
||||||
|
const s=localStorage.getItem('${lsPrefix}_progress'); if(s) Object.assign(STATE.progress, JSON.parse(s));
|
||||||
|
const a=localStorage.getItem('${lsPrefix}_achievements');
|
||||||
|
if(a){ const p=JSON.parse(a); if(Array.isArray(p)) p.forEach(id=>STATE.achievements.set(id, ACH_LABELS[id]||id)); else if(p&&typeof p==='object'){ for(const[id,t] of Object.entries(p)) STATE.achievements.set(id,(t&&t!==id)?t:(ACH_LABELS[id]||id)); } }
|
||||||
|
STATE.xp=+(localStorage.getItem('${xpKey}')||0); STATE.level=calcLevel(STATE.xp);
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function saveProgress(){
|
||||||
|
try{
|
||||||
|
localStorage.setItem('${lsPrefix}_progress', JSON.stringify(STATE.progress));
|
||||||
|
localStorage.setItem('${lsPrefix}_achievements', JSON.stringify(Object.fromEntries(STATE.achievements)));
|
||||||
|
localStorage.setItem('${xpKey}', String(STATE.xp));
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function bumpProgress(key, delta){
|
||||||
|
STATE.progress[key]=Math.max(0,Math.min(100,(STATE.progress[key]||0)+delta));
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(STATE.progress[key]>=50) markParaRead(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _markedRead=new Set();
|
||||||
|
let _pendingProgressBody=null, _progressTimer=null;
|
||||||
|
function _flushProgress(){
|
||||||
|
const body=_pendingProgressBody; _pendingProgressBody=null; if(!body) return;
|
||||||
|
const tok=(window.LS&&LS.getToken)?LS.getToken():''; if(!tok) return;
|
||||||
|
fetch('/api/textbooks/'+_TB_SLUG+'/progress',{method:'POST',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tok},body:JSON.stringify(body),keepalive:true}).catch(()=>{});
|
||||||
|
}
|
||||||
|
function _queueProgress(patch){ _pendingProgressBody=Object.assign(_pendingProgressBody||{},patch); if(_progressTimer) clearTimeout(_progressTimer); _progressTimer=setTimeout(_flushProgress, 600); }
|
||||||
|
function markLastPara(id){ _queueProgress({last_para:id}); }
|
||||||
|
function markParaRead(id){ if(_markedRead.has(id)) return; _markedRead.add(id); _queueProgress({mark_read:id}); }
|
||||||
|
window.addEventListener('beforeunload', _flushProgress);
|
||||||
|
function loadServerReadState(){
|
||||||
|
const tok=(window.LS&&LS.getToken)?LS.getToken():''; if(!tok) return;
|
||||||
|
fetch('/api/textbooks/'+_TB_SLUG,{headers:{'Authorization':'Bearer '+tok}}).then(r=>r.ok?r.json():null).then(d=>{
|
||||||
|
if(!d||!d.progress) return;
|
||||||
|
(d.progress.read||[]).forEach(k=>{_markedRead.add(k); if((STATE.progress[k]||0)<50) STATE.progress[k]=100;});
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
}).catch(()=>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addXp(n,src){
|
||||||
|
if(!n) return;
|
||||||
|
const prev=STATE.level; STATE.xp=Math.max(0,(STATE.xp||0)+n); STATE.level=calcLevel(STATE.xp);
|
||||||
|
saveProgress(); refreshProgressUI();
|
||||||
|
if(window.LS&&window.LS.xp) window.LS.xp.add(n,'physics9-${chKey}-'+(src||'misc'));
|
||||||
|
if(STATE.level>prev){
|
||||||
|
const pop=document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent='Уровень '+STATE.level+'!'; pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'),2600); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProgressUI(){
|
||||||
|
const total=Math.round(Object.values(STATE.progress).reduce((a,b)=>a+b,0)/TOTAL_PARAS);
|
||||||
|
const f=document.getElementById('hero-hp-fill'); if(f) f.style.width=total+'%';
|
||||||
|
const t=document.getElementById('hero-hp-text'); if(t) t.textContent=total+'% пройдено';
|
||||||
|
document.querySelectorAll('[data-prog-card]').forEach(el=>{ const k=el.dataset.progCard; const fl=el.querySelector('.psel-prog-fill'); if(fl) fl.style.width=(STATE.progress[k]||0)+'%'; });
|
||||||
|
const xpBadge=document.getElementById('hero-xp-badge');
|
||||||
|
if(xpBadge){ xpBadge.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px"><polygon points="12 2 22 20 2 20"/></svg> Ур. '+STATE.level+' \\xb7 '+(STATE.xp||0)+' XP'; }
|
||||||
|
if(STATE.current && document.getElementById('sidebar-content')){ try{ buildSidebar(STATE.current); }catch(e){} }
|
||||||
|
refreshDoneMarks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshDoneMarks(){
|
||||||
|
try{
|
||||||
|
document.querySelectorAll('.psel-card').forEach(c=>{
|
||||||
|
const id = c.dataset.id || c.dataset.progCard;
|
||||||
|
if(!id) return;
|
||||||
|
const pct = +STATE.progress[id] || 0;
|
||||||
|
if(!c.querySelector('.psel-done')){
|
||||||
|
const s = document.createElement('span');
|
||||||
|
s.className = 'psel-done';
|
||||||
|
s.setAttribute('title','Прочитано');
|
||||||
|
s.innerHTML = '<svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||||
|
c.appendChild(s);
|
||||||
|
}
|
||||||
|
c.classList.toggle('done', pct >= 50);
|
||||||
|
});
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function achievement(id,text){
|
||||||
|
if(STATE.achievements.has(id)) return;
|
||||||
|
STATE.achievements.set(id, text||ACH_LABELS[id]||id); saveProgress();
|
||||||
|
const pop=document.getElementById('ach-popup');
|
||||||
|
if(pop){ document.getElementById('ach-text').textContent=text||ACH_LABELS[id]||id; pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'),3300); }
|
||||||
|
addXp(20,'ach-'+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParaSelector(){
|
||||||
|
const g=document.getElementById('psel-grid'); g.innerHTML='';
|
||||||
|
PARAS.forEach(p=>{
|
||||||
|
const card=document.createElement('div');
|
||||||
|
card.className='psel-card'+(p.final?' final':'');
|
||||||
|
card.dataset.id=p.id; card.dataset.progCard=p.id;
|
||||||
|
card.innerHTML='<div class="psel-num">'+p.num+'</div><div class="psel-name">'+p.name+'</div><div class="psel-prog"><div class="psel-prog-fill"></div></div>';
|
||||||
|
card.addEventListener('click', ()=>goTo(p.id));
|
||||||
|
g.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILT=new Set();
|
||||||
|
const BUILDERS = { ${buildersMap} };
|
||||||
|
function ensureBuilt(id){ if(BUILT.has(id)) return; const fn=BUILDERS[id]; if(fn){ fn(); BUILT.add(id); } }
|
||||||
|
function goTo(id){
|
||||||
|
STATE.current=id; ensureBuilt(id);
|
||||||
|
document.querySelectorAll('.sec').forEach(s=>s.classList.remove('active'));
|
||||||
|
const el=document.getElementById('sec-'+id); if(el) el.classList.add('active');
|
||||||
|
document.querySelectorAll('.psel-card').forEach(c=>c.classList.toggle('active', c.dataset.id===id));
|
||||||
|
buildSidebar(id);
|
||||||
|
window.scrollTo({top:0,behavior:'smooth'});
|
||||||
|
if((STATE.progress[id]||0)<10) bumpProgress(id, 10);
|
||||||
|
if(window.renderMathInElement) setTimeout(()=>renderMath(el), 0);
|
||||||
|
// Auto-init legacy simulations: call upd<N>() / startAnim<N>() / draw<N>() if defined in phys9_legacy.js.
|
||||||
|
if(id.startsWith('p')){
|
||||||
|
const n = id.slice(1);
|
||||||
|
setTimeout(()=>{
|
||||||
|
['upd','startAnim','init','draw'].forEach(prefix=>{
|
||||||
|
const fn = window[prefix + n];
|
||||||
|
if(typeof fn === 'function'){ try{ fn(); }catch(e){ console.warn(prefix + n + ' init:', e.message); } }
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
} else if(id.startsWith('lr')){
|
||||||
|
const n = id.slice(2);
|
||||||
|
setTimeout(()=>{
|
||||||
|
['updLab','drawLab'].forEach(prefix=>{
|
||||||
|
const fn = window[prefix + n];
|
||||||
|
if(typeof fn === 'function'){ try{ fn(); }catch(e){} }
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
markLastPara(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBARS = {
|
||||||
|
${sidebarObj}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIPS=[
|
||||||
|
${tipsArr}
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildSidebar(id){
|
||||||
|
const box=document.getElementById('sidebar-content');
|
||||||
|
const sb=SIDEBARS[id]||SIDEBARS[PARAS[0].id];
|
||||||
|
let html='';
|
||||||
|
const xpForLv=_xpForLevel(STATE.level), xpNext=_xpForLevel(STATE.level+1);
|
||||||
|
const xpInLv=STATE.xp-xpForLv, xpRange=xpNext-xpForLv;
|
||||||
|
const xpPct=xpRange>0?Math.round(xpInLv/xpRange*100):100;
|
||||||
|
html+='<div class="xp-card"><div class="xp-card-title"><span>XP-прогресс</span><span class="xp-level">Ур. '+STATE.level+'</span></div><div class="xp-bar"><div class="xp-fill" style="width:'+xpPct+'%"></div></div><div class="xp-nums"><span>'+STATE.xp+' XP</span><span>'+xpNext+' XP</span></div></div>';
|
||||||
|
html+='<div class="sidecard"><h4>'+sb.title+'</h4>';
|
||||||
|
sb.rows.forEach(([k,v])=>{ html+='<div class="sidecard-row"><b>'+k+'</b>'+(v?' \\u2014 '+v:'')+'</div>'; });
|
||||||
|
html+='</div>';
|
||||||
|
const tip=TIPS.find(t=>t.sec===id)||TIPS[0];
|
||||||
|
if(tip){
|
||||||
|
html+='<div class="sidecard" style="background:linear-gradient(135deg,var(--warn-bg,#fef3c7),var(--pri-soft));border-color:var(--warn,#f59e0b)"><h4 style="color:#92400e;display:flex;align-items:center;gap:6px"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><polygon points="12,2 22,20 2,20"/></svg>Подсказка</h4><div class="sidecard-row" style="margin-bottom:0;font-size:.84rem;line-height:1.55">'+tip.html+'</div></div>';
|
||||||
|
}
|
||||||
|
if(STATE.achievements.size>0){
|
||||||
|
html+='<div class="sidecard"><h4>Достижения <span style="color:var(--warn);float:right">'+STATE.achievements.size+'</span></h4>';
|
||||||
|
[...STATE.achievements.values()].slice(-4).forEach(text=>{ html+='<div class="sidecard-row" style="font-size:.78rem;color:var(--ok)">✓ '+text+'</div>'; });
|
||||||
|
html+='</div>';
|
||||||
|
}
|
||||||
|
box.innerHTML=html;
|
||||||
|
if(window.renderMathInElement) try{ renderMath(box); }catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme(){
|
||||||
|
const t=localStorage.getItem('${lsPrefix}_theme')||'light';
|
||||||
|
if(t==='dark') document.documentElement.classList.add('dark');
|
||||||
|
document.getElementById('theme-lab').textContent=t==='dark'?'Светлая':'Тёмная';
|
||||||
|
document.getElementById('theme-btn').addEventListener('click', ()=>{
|
||||||
|
document.documentElement.classList.toggle('dark');
|
||||||
|
const dark=document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem('${lsPrefix}_theme', dark?'dark':'light');
|
||||||
|
document.getElementById('theme-lab').textContent=dark?'Светлая':'Тёмная';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMath(root){ if(window.renderMathInElement){ try{ renderMathInElement(root, {delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\\\[',right:'\\\\]',display:true},{left:'\\\\(',right:'\\\\)',display:false}],throwOnError:false}); }catch(e){} } }
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
theory:'<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
|
||||||
|
example:'<svg class="ic" viewBox="0 0 24 24"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 13c1 1 2 2 2 4h4c0-2 1-3 2-4a7 7 0 0 0-4-13z"/></svg>',
|
||||||
|
lab:'<svg class="ic" viewBox="0 0 24 24"><path d="M10 2v7.5L4.5 19a2 2 0 0 0 1.7 3h11.6a2 2 0 0 0 1.7-3L14 9.5V2"/><line x1="9" y1="2" x2="15" y2="2"/></svg>',
|
||||||
|
rule:'<svg class="ic" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeCard(kind, title, num, body){
|
||||||
|
const labels = {theory:'Теория',example:'Пример',lab:'Лабораторная работа',rule:'Правило'};
|
||||||
|
return '<div class="card"><div class="card-header"><div class="card-icon '+kind+'">'+ICONS[kind]+'</div><div class="card-title">'+(labels[kind]||'')+(title&&title!==labels[kind]?' \\xb7 '+title:'')+'</div>'+(num?'<div class="card-num">'+num+'</div>':'')+'</div><div class="card-body">'+body+'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function secNav(prev, next){
|
||||||
|
const NAMES = {${namesObj}};
|
||||||
|
let h='<div class="sec-nav">';
|
||||||
|
h+=prev?'<button class="btn" onclick="goTo(\\''+prev+'\\')"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> '+NAMES[prev]+'</button>':'<span></span>';
|
||||||
|
h+=next?'<button class="btn primary" onclick="goTo(\\''+next+'\\')">'+NAMES[next]+' <svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></button>':'<span></span>';
|
||||||
|
h+='</div>'; return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readButton(paraId){
|
||||||
|
const p = PARAS.find(x => x.id === paraId);
|
||||||
|
const labelTail = p && p.final ? 'финал' : (p ? p.num : '?');
|
||||||
|
return '<div style="margin-top:18px;display:flex;justify-content:center">'
|
||||||
|
+'<button class="btn primary" id="'+paraId+'-read-btn">'
|
||||||
|
+'<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>'
|
||||||
|
+' Я прочитал — '+labelTail+' (+10 XP)'
|
||||||
|
+'</button></div>';
|
||||||
|
}
|
||||||
|
function wireReadBtn(paraId){
|
||||||
|
const btn = document.getElementById(paraId+'-read-btn'); if(!btn) return;
|
||||||
|
btn.addEventListener('click', ()=>{
|
||||||
|
addXp(10, paraId+'-read'); bumpProgress(paraId, 30);
|
||||||
|
btn.textContent='Прочитано! +10 XP'; btn.disabled=true; btn.style.opacity=.6;
|
||||||
|
const aId = paraId+'_done';
|
||||||
|
if(ACH_LABELS[aId]) achievement(aId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STUB BUILDERS — наполнение в Phase 5 ===== */
|
||||||
|
|
||||||
|
${builders}
|
||||||
|
|
||||||
|
/* ===== Search ===== */
|
||||||
|
const SEARCH_INDEX = (function(){
|
||||||
|
const arr=[];
|
||||||
|
PARAS.forEach(p=>arr.push({kind: p.id.startsWith('lr')?'Лабораторная':(p.final?'Финал':'Параграф'),title:p.num+' '+p.name,desc:p.sub||'',sec:p.id}));
|
||||||
|
return arr;
|
||||||
|
})();
|
||||||
|
function initSearch(){
|
||||||
|
const modal=document.getElementById('search-modal'),inp=document.getElementById('search-input'),out=document.getElementById('search-results'),btn=document.getElementById('search-btn');
|
||||||
|
if(!modal||!inp||!out) return;
|
||||||
|
let cur=0,rows=[];
|
||||||
|
function score(q,it){ const t=(it.title+' '+it.desc).toLowerCase(); if(t.includes(q)) return 100+(it.title.toLowerCase().startsWith(q)?50:0); let s=0; q.split(/\\s+/).forEach(w=>{if(w&&t.includes(w))s+=10;}); return s; }
|
||||||
|
function rank(q){ q=q.trim().toLowerCase(); if(!q) return SEARCH_INDEX.slice(0,12); return SEARCH_INDEX.map(it=>({it,s:score(q,it)})).filter(x=>x.s>0).sort((a,b)=>b.s-a.s).slice(0,20).map(x=>x.it); }
|
||||||
|
function render(){ cur=0; if(!rows.length){out.innerHTML='<div class="search-empty">Ничего не найдено</div>';return;} out.innerHTML=rows.map((r,i)=>'<button class="search-row'+(i===0?' active':'')+'" data-i="'+i+'"><div class="sr-kind">'+r.kind+'</div><div class="sr-title">'+r.title+'</div>'+(r.desc?'<div class="sr-desc">'+(r.desc.length>90?r.desc.slice(0,90)+'…':r.desc)+'</div>':'')+'</button>').join(''); out.querySelectorAll('.search-row').forEach(b=>b.addEventListener('click',()=>{cur=+b.dataset.i;pick();})); }
|
||||||
|
function pick(){ const r=rows[cur]; if(!r) return; close(); goTo(r.sec); }
|
||||||
|
function move(d){ const items=out.querySelectorAll('.search-row'); if(!items.length) return; items[cur]&&items[cur].classList.remove('active'); cur=(cur+d+items.length)%items.length; items[cur].classList.add('active'); items[cur].scrollIntoView({block:'nearest'}); }
|
||||||
|
function open(){ modal.classList.add('show'); inp.value=''; rows=rank(''); render(); setTimeout(()=>inp.focus(),50); }
|
||||||
|
function close(){ modal.classList.remove('show'); }
|
||||||
|
btn&&btn.addEventListener('click',open);
|
||||||
|
modal.addEventListener('click',e=>{if(e.target===modal)close();});
|
||||||
|
inp.addEventListener('input',()=>{rows=rank(inp.value);render();});
|
||||||
|
inp.addEventListener('keydown',e=>{ if(e.key==='ArrowDown'){e.preventDefault();move(1);}else if(e.key==='ArrowUp'){e.preventDefault();move(-1);}else if(e.key==='Enter'){e.preventDefault();pick();}else if(e.key==='Escape'){e.preventDefault();close();} });
|
||||||
|
document.addEventListener('keydown',e=>{ if((e.ctrlKey||e.metaKey)&&(e.key==='k'||e.key==='K')){ e.preventDefault(); if(modal.classList.contains('show')) close(); else open(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSidebarToggle(){
|
||||||
|
const side=document.getElementById('col-side'),back=document.getElementById('col-side-backdrop'),btn=document.getElementById('sidebar-btn');
|
||||||
|
if(!side||!btn) return;
|
||||||
|
function open(){ side.classList.add('open'); back.classList.add('show'); }
|
||||||
|
function close(){ side.classList.remove('open'); back.classList.remove('show'); }
|
||||||
|
btn.addEventListener('click',()=>{ if(side.classList.contains('open')) close(); else open(); });
|
||||||
|
back.addEventListener('click',close);
|
||||||
|
document.addEventListener('keydown',e=>{ if(e.key==='Escape') close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(){
|
||||||
|
loadProgress(); initTheme(); initSidebarToggle(); initSearch();
|
||||||
|
buildParaSelector(); refreshProgressUI(); loadServerReadState(); goTo(PARAS[0].id);
|
||||||
|
setTimeout(()=>achievement('start'), 600);
|
||||||
|
if(window.LS&&window.LS.xp){
|
||||||
|
window.LS.xp.load().then(function(s){ if(s&&s.xp>STATE.xp){ STATE.xp=s.xp; STATE.level=calcLevel(STATE.xp); saveProgress(); refreshProgressUI(); if(STATE.current) buildSidebar(STATE.current); } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Run ===
|
||||||
|
for (const chKey of ['ch1','ch2','ch3','ch4','ch5']) {
|
||||||
|
const dst = path.join(TBOOKS, `physics_9_${chKey}.html`);
|
||||||
|
const html = buildCh(chKey);
|
||||||
|
fs.writeFileSync(dst, html);
|
||||||
|
|
||||||
|
const scriptMatches = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scriptMatches) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch(e) {
|
||||||
|
console.error(`JS PARSE FAIL in ${chKey}:`, e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`OK ${chKey} → ${dst} bytes: ${html.length}`);
|
||||||
|
}
|
||||||
|
console.log('All 5 chapters generated.');
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Генератор physics_9_hub.html на основе physics_10_hub.html
|
||||||
|
// Палитра: blue (вместо amber у Phys 10), 5 глав, заголовки/описания Физики 9.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_10_hub.html');
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_9_hub.html');
|
||||||
|
|
||||||
|
let h = fs.readFileSync(SRC, 'utf8');
|
||||||
|
|
||||||
|
// === 1. Primary palette: amber (#ca8a04 / #fde047) → blue (#2563eb / #60a5fa) ===
|
||||||
|
h = h.replace(
|
||||||
|
/:root\{[\s\S]*?--sh-h:0 12px 36px rgba\(202,138,4,\.18\);[\s\S]*?\}/,
|
||||||
|
`:root{
|
||||||
|
--bg:#eff6ff; --card:#fff;
|
||||||
|
--text:#0f172a; --muted:#475569;
|
||||||
|
--border:#bfdbfe;
|
||||||
|
--pri:#2563eb; --pri-d:#1d4ed8;
|
||||||
|
--pri-soft:#dbeafe;
|
||||||
|
--ch1:#2563eb; --ch1-d:#1d4ed8;
|
||||||
|
--ch2:#059669; --ch2-d:#047857;
|
||||||
|
--ch3:#7c3aed; --ch3-d:#6d28d9;
|
||||||
|
--ch4:#db2777; --ch4-d:#be185d;
|
||||||
|
--ch5:#0891b2; --ch5-d:#0e7490;
|
||||||
|
--sh:0 4px 16px rgba(37,99,235,.10);
|
||||||
|
--sh-h:0 12px 36px rgba(37,99,235,.18);
|
||||||
|
}`);
|
||||||
|
|
||||||
|
h = h.replace(
|
||||||
|
/html\.dark\{[\s\S]*?--pri-soft:rgba\(202,138,4,\.16\);[\s\S]*?\}/,
|
||||||
|
`html.dark{
|
||||||
|
--bg:#0a1428; --card:#102137;
|
||||||
|
--text:#dbeafe; --muted:#93c5fd;
|
||||||
|
--border:#1e3a5f;
|
||||||
|
--pri-soft:rgba(37,99,235,.16);
|
||||||
|
}`);
|
||||||
|
|
||||||
|
// === 2. Header gradient: amber → blue ===
|
||||||
|
h = h.replace(
|
||||||
|
/\.hdr\{position:relative;background:linear-gradient\(110deg,#713f12 0%,#ca8a04 55%,#fde047 100%\)[^}]*\}/,
|
||||||
|
`.hdr{position:relative;background:linear-gradient(110deg,#1e3a8a 0%,#2563eb 55%,#60a5fa 100%);color:#fff;padding:32px 24px 28px;overflow:hidden;border-bottom:2px solid rgba(219,234,254,.18)}`);
|
||||||
|
|
||||||
|
h = h.replace(/rgba\(254,243,199,\.12\)/g, 'rgba(219,234,254,.12)');
|
||||||
|
h = h.replace(/rgba\(254,243,199,\.18\)/g, 'rgba(219,234,254,.18)');
|
||||||
|
|
||||||
|
// === 3. po-icon gradient + po-bar/po-fill ===
|
||||||
|
h = h.replace(
|
||||||
|
/\.po-icon\{[^}]*background:linear-gradient\(135deg,#ca8a04,#fde047\)[^}]*\}/,
|
||||||
|
`.po-icon{width:46px;height:46px;border-radius:12px;background:linear-gradient(135deg,#2563eb,#60a5fa);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-family:'Outfit',sans-serif;font-size:1.4rem;font-weight:900;font-style:italic}`);
|
||||||
|
h = h.replace(/\.po-bar\{height:8px;background:rgba\(202,138,4,\.14\)/, '.po-bar{height:8px;background:rgba(37,99,235,.14)');
|
||||||
|
h = h.replace(/\.po-fill\{height:100%;background:linear-gradient\(90deg,var\(--pri\),#fde047\)/, '.po-fill{height:100%;background:linear-gradient(90deg,var(--pri),#60a5fa)');
|
||||||
|
h = h.replace(/\.po-xp\{[^}]*background:linear-gradient\(135deg,#f59e0b,var\(--pri\)\)[^}]*\}/,
|
||||||
|
".po-xp{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;background:linear-gradient(135deg,#3b82f6,var(--pri));color:#fff;border-radius:99px;font-size:.8rem;font-weight:800;font-family:'Unbounded',sans-serif;letter-spacing:.02em;box-shadow:0 4px 12px rgba(37,99,235,.24)}");
|
||||||
|
|
||||||
|
// === 4. Final-head gradient ===
|
||||||
|
h = h.replace(
|
||||||
|
/\.final-head\{padding:18px 22px;background:linear-gradient\(135deg,#713f12 0%,#ca8a04 55%,#f59e0b 100%\)/,
|
||||||
|
'.final-head{padding:18px 22px;background:linear-gradient(135deg,#1e3a8a 0%,#2563eb 55%,#3b82f6 100%)');
|
||||||
|
|
||||||
|
// === 5. Title + H1 + subtitle ===
|
||||||
|
h = h.replace(/<title>Физика 10 класс — учебник<\/title>/, '<title>Физика 9 класс — учебник</title>');
|
||||||
|
h = h.replace(/<h1>Физика — 10 класс<\/h1>/, '<h1>Физика — 9 класс</h1>');
|
||||||
|
h = h.replace(
|
||||||
|
/<div class="hdr-sub">Полный курс физики 10 класса:[^<]+<\/div>/,
|
||||||
|
'<div class="hdr-sub">Полный курс механики: кинематика, динамика, статика, законы сохранения, 12 лабораторных работ</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// === 6. localStorage keys + API endpoint ===
|
||||||
|
h = h.replace(/physics10_theme/g, 'physics9_theme');
|
||||||
|
h = h.replace(/physics10_xp/g, 'physics9_xp');
|
||||||
|
h = h.replace(/physics10_course_master/g, 'physics9_course_master');
|
||||||
|
h = h.replace(/physics10_course_bosses/g, 'physics9_course_bosses');
|
||||||
|
h = h.replace(/physics10-master/g, 'physics9-master');
|
||||||
|
h = h.replace(/'\/api\/textbooks\/physics-10\/children'/, "'/api/textbooks/physics-9/children'");
|
||||||
|
|
||||||
|
// === 7. Заменяем блок с 6 главами целиком на блок с 5 главами ===
|
||||||
|
const chBlock = `
|
||||||
|
<a href="/textbook/physics-9-ch1" class="ch-card ch1-card" id="ch-1">
|
||||||
|
<div class="ch-cover ch1">
|
||||||
|
<div class="ch-cover-wm">v</div>
|
||||||
|
<div class="ch-num">Глава 1</div>
|
||||||
|
<div class="ch-title">Основы кинематики</div>
|
||||||
|
<div class="ch-range">§1–§14</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">Механическое движение, относительность, векторы, путь и перемещение, равномерное и равноускоренное движение, движение по окружности.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-1">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-1" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-1">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/textbook/physics-9-ch2" class="ch-card ch2-card" id="ch-2">
|
||||||
|
<div class="ch-cover ch2">
|
||||||
|
<div class="ch-cover-wm">F</div>
|
||||||
|
<div class="ch-num">Глава 2</div>
|
||||||
|
<div class="ch-title">Основы динамики</div>
|
||||||
|
<div class="ch-range">§15–§24</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">Законы Ньютона, масса, закон Гука, силы трения, движение под силой тяжести, всемирное тяготение, вес и невесомость.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-2">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-2" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-2">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/textbook/physics-9-ch3" class="ch-card ch3-card" id="ch-3">
|
||||||
|
<div class="ch-cover ch3">
|
||||||
|
<div class="ch-cover-wm">M</div>
|
||||||
|
<div class="ch-num">Глава 3</div>
|
||||||
|
<div class="ch-title">Основы статики</div>
|
||||||
|
<div class="ch-range">§25–§30</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">Условия равновесия, момент силы, рычаги, блоки, наклонная плоскость, КПД, центр тяжести, закон Архимеда.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-3">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-3" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-3">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/textbook/physics-9-ch4" class="ch-card ch4-card" id="ch-4">
|
||||||
|
<div class="ch-cover ch4">
|
||||||
|
<div class="ch-cover-wm">p·E</div>
|
||||||
|
<div class="ch-num">Глава 4</div>
|
||||||
|
<div class="ch-title">Законы сохранения</div>
|
||||||
|
<div class="ch-range">§31–§36</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">Импульс тела, закон сохранения импульса, реактивное движение, работа, мощность, кинетическая и потенциальная энергия, закон сохранения энергии.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-4">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-4" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-4">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/textbook/physics-9-ch5" class="ch-card ch5-card" id="ch-5">
|
||||||
|
<div class="ch-cover ch5">
|
||||||
|
<div class="ch-cover-wm">Δt</div>
|
||||||
|
<div class="ch-num">Глава 5</div>
|
||||||
|
<div class="ch-title">Лабораторный практикум</div>
|
||||||
|
<div class="ch-range">ЛР 1–12</div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-body">
|
||||||
|
<div class="ch-desc">12 лабораторных работ: погрешности, ускорение, окружность, закон Гука, трение, брошенное тело, рычаг, блоки, наклонная плоскость, Архимед, импульс, энергия.</div>
|
||||||
|
<div class="ch-prog">
|
||||||
|
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-5">0%</span></div>
|
||||||
|
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-5" style="width:0%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ch-action">
|
||||||
|
<span id="btn-5">Открыть главу</span>
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Replace the entire <a href="/textbook/physics-10-ch1"...</a> ... <a ch6></a> block (6 cards → 5 cards)
|
||||||
|
h = h.replace(/\s*<a href="\/textbook\/physics-10-ch1"[\s\S]*?<a href="\/textbook\/physics-10-ch6"[\s\S]*?<\/a>\s*/,
|
||||||
|
chBlock);
|
||||||
|
|
||||||
|
// === 8. final cta + master text ===
|
||||||
|
h = h.replace(/<div class="final-cta-title">Курс Физика 10 пройден!<\/div>/, '<div class="final-cta-title">Курс Физика 9 пройден!</div>');
|
||||||
|
h = h.replace(/«Магистр физики 10»/g, '«Магистр физики 9»');
|
||||||
|
h = h.replace(/Магистр физики 10/g, 'Магистр физики 9');
|
||||||
|
|
||||||
|
// final-head-sub
|
||||||
|
h = h.replace(
|
||||||
|
/<div class="final-head-sub">Шпаргалка курса и интегрированные боссы по всем 6 главам\. В разработке \(Phase 7\)\.<\/div>/,
|
||||||
|
'<div class="final-head-sub">Шпаргалка курса и интегрированные боссы по всем 5 главам. В разработке (Phase 7).</div>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// fin-placeholder: 37 → 36, 6 → 5
|
||||||
|
h = h.replace(
|
||||||
|
/Итоговая шпаргалка по всем 37 параграфам и 8–10 интегрированных боссов появятся в Phase 7 \(после завершения всех 6 глав\)\./,
|
||||||
|
'Итоговая шпаргалка по всем 36 параграфам и 8–10 интегрированных боссов появятся в Phase 7 (после завершения всех 5 глав).'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
h = h.replace(/Интерактивный учебник «Физика — 10 класс»/, 'Интерактивный учебник «Физика — 9 класс»');
|
||||||
|
|
||||||
|
// Achievement strip
|
||||||
|
h = h.replace(/Прочитайте все 37 параграфов курса, чтобы получить достижение/, 'Прочитайте все 36 параграфов курса, чтобы получить достижение');
|
||||||
|
h = h.replace(/Вы прочитали весь курс физики 10 класса\./, 'Вы прочитали весь курс физики 9 класса.');
|
||||||
|
|
||||||
|
// === 9. TOTAL + CH_PARA + CH_IDX ===
|
||||||
|
h = h.replace(/var TOTAL = 37;[\s\S]*?var CH_IDX = \{[\s\S]*?\};/, `var TOTAL = 36;
|
||||||
|
var CH_PARA = {
|
||||||
|
'physics-9-ch1': 14,
|
||||||
|
'physics-9-ch2': 10,
|
||||||
|
'physics-9-ch3': 6,
|
||||||
|
'physics-9-ch4': 6,
|
||||||
|
'physics-9-ch5': 12,
|
||||||
|
};
|
||||||
|
var CH_IDX = {
|
||||||
|
'physics-9-ch1': 1,
|
||||||
|
'physics-9-ch2': 2,
|
||||||
|
'physics-9-ch3': 3,
|
||||||
|
'physics-9-ch4': 4,
|
||||||
|
'physics-9-ch5': 5,
|
||||||
|
};`);
|
||||||
|
|
||||||
|
// === 10. Chapter grid: 6 cards → 5 cards (2-1-2 на широких экранах смотрится лучше при 5) ===
|
||||||
|
// Оставим CSS как есть — repeat(3,1fr) на >=1000px и repeat(2,1fr) на >=680px.
|
||||||
|
// 5 карточек выстроятся как 3+2 (или 2+2+1). Это нормально.
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('OK hub →', DST, 'bytes:', h.length);
|
||||||
|
|
||||||
|
// Sanity: parse inline scripts
|
||||||
|
const scriptMatches = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
console.log('inline <script> count:', scriptMatches.length);
|
||||||
|
for (const m of scriptMatches) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch(e) { console.error('JS PARSE FAIL:', e.message); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('all inline JS parses OK');
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* import-exam-tasks.js — imports tasks from /frontend/js/exam9/variants/*.js
|
||||||
|
* into the generic exam_tasks table for the exam-prep module.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node backend/scripts/import-exam-tasks.js # all enabled tracks
|
||||||
|
* node backend/scripts/import-exam-tasks.js math9 # one specific track
|
||||||
|
* node backend/scripts/import-exam-tasks.js math9 --dry # don't write, only report parse stats
|
||||||
|
*
|
||||||
|
* Idempotent: deletes existing exam_tasks rows for the target exam_key before inserting.
|
||||||
|
*
|
||||||
|
* For each variant V it produces tasks_per_variant rows in exam_tasks. For each task:
|
||||||
|
* - task_type: 'mc' if has opts; 'open' if sol-ans parses to a clean numeric/short value; 'long' otherwise
|
||||||
|
* - answer: explicit task.answer if present; else autoparsed from <div class="sol-ans">
|
||||||
|
* - text_html / figure_html / opts_json / solution_html — direct
|
||||||
|
*
|
||||||
|
* Reports parse-quality stats at the end:
|
||||||
|
* - per-track: total / mc / open / long / explicit-answer / parsed-answer / unparseable
|
||||||
|
* - lists tasks where autoparse failed but has no opts and no explicit answer
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('../src/db/db');
|
||||||
|
|
||||||
|
const TRACK_VARIANTS_DIR = {
|
||||||
|
math9: path.join(__dirname, '../../frontend/js/exam9/variants'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
||||||
|
const flags = new Set(process.argv.slice(2).filter(a => a.startsWith('--')));
|
||||||
|
const DRY_RUN = flags.has('--dry');
|
||||||
|
const VERBOSE = flags.has('--verbose') || flags.has('-v');
|
||||||
|
|
||||||
|
/* ── HTML-text extraction from sol-ans div ───────────────────────── */
|
||||||
|
function extractAnswerText(solHtml) {
|
||||||
|
if (!solHtml) return null;
|
||||||
|
const m = solHtml.match(/<div class="sol-ans">([\s\S]*?)<\/div>/);
|
||||||
|
if (!m) return null;
|
||||||
|
let raw = m[1]
|
||||||
|
.replace(/<[^>]+>/g, '') // strip HTML tags
|
||||||
|
.replace(/ | | /g, ' ') // common entities
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.trim();
|
||||||
|
raw = raw.replace(/^Ответ[:\s]*/i, '').trim();
|
||||||
|
return raw || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MC letter detector: matches «а)», «б.», «в », etc. ──────────── */
|
||||||
|
function parseMcLetter(answerText) {
|
||||||
|
if (!answerText) return null;
|
||||||
|
const m = answerText.match(/^([а-д])\s*[\)\.]/i);
|
||||||
|
return m ? m[1].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Open answer parser: returns a canonical answer string.
|
||||||
|
Forms supported:
|
||||||
|
"-2" single integer / decimal
|
||||||
|
"7500" positive integer
|
||||||
|
"9/4" fraction (from \dfrac{a}{b})
|
||||||
|
"-2;4" pair of values (from "x=A и x=B")
|
||||||
|
Returns null if the answer is too complex (expressions, multiple vars,
|
||||||
|
inequalities, square roots, intervals). ── */
|
||||||
|
function parseOpenAnswer(answerText) {
|
||||||
|
if (!answerText) return null;
|
||||||
|
|
||||||
|
// Normalize: strip $...$ and trivial LaTeX spacing
|
||||||
|
let s = answerText
|
||||||
|
.replace(/\\;|\\,|\\:|\\ /g, ' ')
|
||||||
|
.replace(/ | /g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// ── Pattern A: \dfrac{a}{b} or \frac{a}{b} as the sole answer
|
||||||
|
const fracMatch = s.match(/^\$?\\d?frac\{(-?\d+)\}\{(-?\d+)\}\$?(?:\s*[а-яА-Яa-zA-Z²³°%\.]*\.?)?$/);
|
||||||
|
if (fracMatch) {
|
||||||
|
return `${fracMatch[1]}/${fracMatch[2]}`;
|
||||||
|
}
|
||||||
|
// Also: "-\dfrac{a}{b}" with sign outside
|
||||||
|
const negFracMatch = s.match(/^\$?-\\d?frac\{(\d+)\}\{(\d+)\}\$?(?:\s*[а-яА-Яa-zA-Z²³°%\.]*\.?)?$/);
|
||||||
|
if (negFracMatch) {
|
||||||
|
return `-${negFracMatch[1]}/${negFracMatch[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pattern B: two roots "$x = A$ и $x = B$" or "x_1=A; x_2=B"
|
||||||
|
const twoRoots = s.match(/x\s*_?\d?\s*=\s*(-?\d+(?:[.,]\d+)?)[\s\$]*(?:\sи\s|;)\s*\$?x\s*_?\d?\s*=\s*(-?\d+(?:[.,]\d+)?)/);
|
||||||
|
if (twoRoots) {
|
||||||
|
const a = twoRoots[1].replace(',', '.');
|
||||||
|
const b = twoRoots[2].replace(',', '.');
|
||||||
|
return `${a};${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip $...$ for further checks (single-number paths)
|
||||||
|
s = s.replace(/\$/g, '').trim();
|
||||||
|
|
||||||
|
// Reject remaining complex forms
|
||||||
|
if (/\\dfrac|\\frac|\\sqrt|\\sum|\\int|\\cdot|\\pi/.test(s)) return null;
|
||||||
|
if (/[<>≤≥]/.test(s)) return null;
|
||||||
|
if (/\(.*[;,].*\)/.test(s)) return null; // intervals/points
|
||||||
|
if (s.split(/\s+или\s+|\s+and\s+|\s+и\s+/i).length > 1) return null;
|
||||||
|
if (/[xyz]\s*_?\d?\s*=.*[xyz]\s*_?\d?\s*=/.test(s)) return null; // multi-var didn't match pattern B
|
||||||
|
|
||||||
|
// "X = NUM" → take RHS
|
||||||
|
const eq = s.match(/=\s*(-?\d+(?:[.,]\d+)?)/);
|
||||||
|
if (eq) return eq[1].replace(',', '.');
|
||||||
|
|
||||||
|
// Single number with optional short unit tail
|
||||||
|
const single = s.match(/^(-?\d+(?:[.,]\d+)?)(\s*[а-яА-Яa-zA-Z\.²³°%]*\.?)?$/);
|
||||||
|
if (single) return single[1].replace(',', '.');
|
||||||
|
|
||||||
|
// Last try: first number iff rest is short suffix
|
||||||
|
const first = s.match(/(-?\d+(?:[.,]\d+)?)/);
|
||||||
|
if (first && first[1].length >= s.length - 8) return first[1].replace(',', '.');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Load a variant from .js via Function constructor ────────────── */
|
||||||
|
function loadVariant(dir, n) {
|
||||||
|
const nn = String(n).padStart(2, '0');
|
||||||
|
const file = path.join(dir, `v${nn}.js`);
|
||||||
|
if (!fs.existsSync(file)) return null;
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
const scope = {};
|
||||||
|
new Function('VARIANTS', src)(scope);
|
||||||
|
return scope[n] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Per-task classification + answer extraction ──────────────────── */
|
||||||
|
function classifyTask(task) {
|
||||||
|
const sol = task.sol || '';
|
||||||
|
const ansText = extractAnswerText(sol);
|
||||||
|
|
||||||
|
if (Array.isArray(task.opts) && task.opts.length) {
|
||||||
|
// MC: explicit answer wins, else parse letter from sol-ans
|
||||||
|
let answer = (typeof task.answer === 'string') ? task.answer.toLowerCase().trim() : null;
|
||||||
|
let source = 'explicit';
|
||||||
|
if (!answer || !/^[а-д]$/.test(answer)) {
|
||||||
|
answer = parseMcLetter(ansText);
|
||||||
|
source = answer ? 'parsed' : 'failed';
|
||||||
|
}
|
||||||
|
return { task_type: 'mc', answer, source, raw_answer: ansText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-MC: try open numeric, then fallback to long
|
||||||
|
let answer = (typeof task.answer === 'string') ? task.answer.trim() : null;
|
||||||
|
let source = answer ? 'explicit' : null;
|
||||||
|
|
||||||
|
if (!answer) {
|
||||||
|
answer = parseOpenAnswer(ansText);
|
||||||
|
source = answer ? 'parsed' : 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answer) return { task_type: 'open', answer, source, raw_answer: ansText };
|
||||||
|
return { task_type: 'long', answer: null, source: 'long', raw_answer: ansText };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Import a single track ────────────────────────────────────────── */
|
||||||
|
function importTrack(examKey) {
|
||||||
|
const dir = TRACK_VARIANTS_DIR[examKey];
|
||||||
|
if (!dir) throw new Error(`Unknown exam_key: ${examKey} (no variants dir mapping)`);
|
||||||
|
|
||||||
|
const track = db.prepare('SELECT variants_count FROM exam_tracks WHERE exam_key = ?').get(examKey);
|
||||||
|
if (!track) throw new Error(`Track not registered in exam_tracks: ${examKey}`);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
examKey,
|
||||||
|
variants: 0,
|
||||||
|
tasks: 0,
|
||||||
|
mc: 0, open: 0, long: 0,
|
||||||
|
mcExplicit: 0, mcParsed: 0, mcFailed: 0,
|
||||||
|
openExplicit: 0, openParsed: 0,
|
||||||
|
failedExamples: [], // tasks classified as long where sol-ans existed (potential miss)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
db.prepare('DELETE FROM exam_tasks WHERE exam_key = ?').run(examKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ins = db.prepare(`
|
||||||
|
INSERT INTO exam_tasks
|
||||||
|
(exam_key, variant, task_idx, task_type, text_html, figure_html, opts_json, answer, solution_html)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const writeAll = db.transaction(() => {
|
||||||
|
for (let n = 1; n <= track.variants_count; n++) {
|
||||||
|
const v = loadVariant(dir, n);
|
||||||
|
if (!v || !Array.isArray(v.tasks) || !v.tasks.length) {
|
||||||
|
if (VERBOSE) console.log(` v${String(n).padStart(2,'0')}: missing/empty — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stats.variants++;
|
||||||
|
|
||||||
|
v.tasks.forEach((task, idx) => {
|
||||||
|
const taskIdx = idx + 1;
|
||||||
|
const cls = classifyTask(task);
|
||||||
|
|
||||||
|
stats[cls.task_type]++;
|
||||||
|
if (cls.task_type === 'mc') {
|
||||||
|
if (cls.source === 'explicit') stats.mcExplicit++;
|
||||||
|
else if (cls.source === 'parsed') stats.mcParsed++;
|
||||||
|
else stats.mcFailed++;
|
||||||
|
} else if (cls.task_type === 'open') {
|
||||||
|
if (cls.source === 'explicit') stats.openExplicit++;
|
||||||
|
else stats.openParsed++;
|
||||||
|
} else if (cls.task_type === 'long' && cls.raw_answer) {
|
||||||
|
// Has an answer but we classified as long → likely autoparser missed something
|
||||||
|
if (stats.failedExamples.length < 20) {
|
||||||
|
stats.failedExamples.push({ v: n, idx: taskIdx, raw: cls.raw_answer.slice(0, 80) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.tasks++;
|
||||||
|
|
||||||
|
if (!DRY_RUN) {
|
||||||
|
ins.run(
|
||||||
|
examKey,
|
||||||
|
n,
|
||||||
|
taskIdx,
|
||||||
|
cls.task_type,
|
||||||
|
task.text || '',
|
||||||
|
task.figure || null,
|
||||||
|
task.opts ? JSON.stringify(task.opts) : null,
|
||||||
|
cls.answer,
|
||||||
|
task.sol || ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
writeAll();
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reporting ────────────────────────────────────────────────────── */
|
||||||
|
function pct(n, total) {
|
||||||
|
if (!total) return '0%';
|
||||||
|
return ((n / total) * 100).toFixed(1) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(stats) {
|
||||||
|
const mcTotal = stats.mc;
|
||||||
|
const openTotal = stats.open;
|
||||||
|
console.log(`\n═══ ${stats.examKey} ═══`);
|
||||||
|
console.log(`Variants imported: ${stats.variants}`);
|
||||||
|
console.log(`Total tasks: ${stats.tasks}`);
|
||||||
|
console.log(` MC : ${stats.mc} (${pct(stats.mc, stats.tasks)})`);
|
||||||
|
console.log(` explicit: ${stats.mcExplicit}, parsed: ${stats.mcParsed}, FAILED: ${stats.mcFailed}`);
|
||||||
|
console.log(` Open : ${stats.open} (${pct(stats.open, stats.tasks)})`);
|
||||||
|
console.log(` explicit: ${stats.openExplicit}, parsed: ${stats.openParsed}`);
|
||||||
|
console.log(` Long : ${stats.long} (${pct(stats.long, stats.tasks)})`);
|
||||||
|
console.log(` ${stats.long - stats.failedExamples.length} truly complex, ${stats.failedExamples.length}+ POTENTIAL autoparse misses`);
|
||||||
|
|
||||||
|
if (stats.failedExamples.length) {
|
||||||
|
console.log(`\nPotential autoparse misses (classified 'long' but had a sol-ans answer):`);
|
||||||
|
stats.failedExamples.forEach(e => {
|
||||||
|
console.log(` v${String(e.v).padStart(2,'0')} t${e.idx}: «${e.raw}»`);
|
||||||
|
});
|
||||||
|
console.log(`(showing first ${stats.failedExamples.length}; fix by adding answer: '...' field in v*.js task, or relax parser in this script)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoSuccess = stats.mcParsed + stats.openParsed + stats.mcExplicit + stats.openExplicit;
|
||||||
|
const checkable = mcTotal + openTotal;
|
||||||
|
console.log(`\nAutocheckable tasks (mc+open): ${checkable} / ${stats.tasks} (${pct(checkable, stats.tasks)})`);
|
||||||
|
console.log(`Of those, answer determined: ${autoSuccess} (${pct(autoSuccess, checkable)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main ─────────────────────────────────────────────────────────── */
|
||||||
|
function main() {
|
||||||
|
const targets = args.length ? args : Object.keys(TRACK_VARIANTS_DIR);
|
||||||
|
console.log(`[import-exam-tasks] Targets: ${targets.join(', ')}${DRY_RUN ? ' (DRY RUN)' : ''}`);
|
||||||
|
|
||||||
|
for (const examKey of targets) {
|
||||||
|
try {
|
||||||
|
const stats = importTrack(examKey);
|
||||||
|
report(stats);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${examKey}] FAILED: ${e.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DRY_RUN) console.log(`\n[DRY RUN] No changes written to DB.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use strict';
|
||||||
|
/* index-textbooks-headless.js — полный RAG-индекс: рендерит каждый учебник
|
||||||
|
* настоящим браузером (puppeteer-core + системный Chrome/Edge) через локальный
|
||||||
|
* сервер и забирает РЕНДЕРНЫЙ текст параграфов. Покрывает и JS-рендеримые
|
||||||
|
* учебники (математика/физика-движки), которых нет в статическом HTML.
|
||||||
|
*
|
||||||
|
* Требует запущенный сервер (localhost:3000). Долгая операция (минуты).
|
||||||
|
* Запуск: node backend/scripts/index-textbooks-headless.js
|
||||||
|
* Дополняет/замещает чанки только для успешно отрендеренных учебников. */
|
||||||
|
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const db = require('../src/db/db');
|
||||||
|
|
||||||
|
/* Текстовые страницы учебника требуют логина — выпускаем служебный JWT. */
|
||||||
|
function authToken() {
|
||||||
|
const u = db.prepare("SELECT id, role, token_version FROM users WHERE is_banned = 0 AND role IN ('admin','teacher') ORDER BY id LIMIT 1").get()
|
||||||
|
|| db.prepare('SELECT id, role, token_version FROM users WHERE is_banned = 0 ORDER BY id LIMIT 1').get();
|
||||||
|
if (!u || !process.env.JWT_SECRET) return null;
|
||||||
|
return jwt.sign({ id: u.id, role: u.role, tv: u.token_version }, process.env.JWT_SECRET, { algorithm: 'HS256', expiresIn: '4h' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = process.env.ASSISTANT_INDEX_BASE || ('http://localhost:' + (process.env.PORT || 3000));
|
||||||
|
const BROWSERS = [
|
||||||
|
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||||
|
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||||
|
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||||
|
'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
|
||||||
|
];
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const puppeteer = require('puppeteer-core');
|
||||||
|
const exe = BROWSERS.find(p => { try { return fs.existsSync(p); } catch (e) { return false; } });
|
||||||
|
if (!exe) { console.error('Браузер не найден (Chrome/Edge)'); process.exit(1); }
|
||||||
|
const books = db.prepare('SELECT slug, title FROM textbooks WHERE is_active = 1 ORDER BY slug').all();
|
||||||
|
const del = db.prepare('DELETE FROM textbook_chunks WHERE slug = ?');
|
||||||
|
const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text, section_ref) VALUES (?, ?, ?, ?, ?)');
|
||||||
|
|
||||||
|
const token = authToken();
|
||||||
|
if (!token) { console.error('Не удалось выпустить токен (нет пользователя или JWT_SECRET)'); process.exit(1); }
|
||||||
|
const browser = await puppeteer.launch({ executablePath: exe, headless: true, args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1100, height: 900 });
|
||||||
|
await page.evaluateOnNewDocument((t) => { try { localStorage.setItem('ls_token', t); } catch (e) {} }, token);
|
||||||
|
let totalChunks = 0, okBooks = 0;
|
||||||
|
|
||||||
|
for (const b of books) {
|
||||||
|
let chunks = [];
|
||||||
|
try {
|
||||||
|
await page.goto(`${BASE}/textbook/${b.slug}`, { waitUntil: 'networkidle2', timeout: 25000 });
|
||||||
|
await page.waitForSelector('.psel-card, .sec', { timeout: 12000 }).catch(() => {});
|
||||||
|
await sleep(400);
|
||||||
|
const ids = await page.$$eval('.psel-card[data-id]', els => els.map(e => ({ id: e.dataset.id, name: ((e.querySelector('.psel-name') || {}).textContent || '').trim() })));
|
||||||
|
if (ids.length) {
|
||||||
|
for (const s of ids) {
|
||||||
|
try {
|
||||||
|
await page.evaluate(id => { const c = document.querySelector('.psel-card[data-id="' + id + '"]'); if (c) c.click(); }, s.id);
|
||||||
|
await sleep(550);
|
||||||
|
const text = await page.evaluate(() => { const a = document.querySelector('.sec.active'); return a ? a.innerText.replace(/\s+/g, ' ').trim() : ''; });
|
||||||
|
if (text && text.length >= 80) chunks.push({ section: s.name.slice(0, 160), text: text.slice(0, 2000), ref: s.id });
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const secs = await page.$$eval('.sec', els => els.map(e => e.innerText.replace(/\s+/g, ' ').trim()));
|
||||||
|
secs.forEach(t => { if (t && t.length >= 80) chunks.push({ section: '', text: t.slice(0, 2000) }); });
|
||||||
|
}
|
||||||
|
} catch (e) { /* книга не отрендерилась — оставляем как было */ }
|
||||||
|
|
||||||
|
if (chunks.length) {
|
||||||
|
del.run(b.slug);
|
||||||
|
for (const c of chunks) ins.run(b.slug, b.title || b.slug, c.section, c.text, c.ref || null);
|
||||||
|
okBooks++; totalChunks += chunks.length;
|
||||||
|
console.log(` ${b.slug}: ${chunks.length}`);
|
||||||
|
} else {
|
||||||
|
console.log(` ${b.slug}: — (нет рендера, оставлено как есть)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(`[headless] готово: ${okBooks}/${books.length} учебников, ${totalChunks} чанков (перезаписаны).`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(e => { console.error('[headless] ошибка:', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use strict';
|
||||||
|
/* index-textbooks.js — наполняет textbook_chunks текстом учебников для RAG
|
||||||
|
* «Спроси Квантика». Парсит HTML учебников (frontend/textbooks/<html_path>) по
|
||||||
|
* параграфам (.sec-h + тело секции), снимает теги, режет на куски.
|
||||||
|
*
|
||||||
|
* Запуск: node backend/scripts/index-textbooks.js (полная переиндексация)
|
||||||
|
* Также вызывается из админки (POST /api/admin/assistant/reindex) через reindex().
|
||||||
|
*
|
||||||
|
* Ограничение: учебники, рендерящие контент через JS-виджеты (напр. physics-9),
|
||||||
|
* в статическом HTML текста почти не содержат — они покрываются контекстом
|
||||||
|
* текущей страницы (getPageContext на клиенте), а не этим индексом. */
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const db = require('../src/db/db');
|
||||||
|
|
||||||
|
const TEXTBOOKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
function stripTags(html) {
|
||||||
|
return String(html || '')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<svg[\s\S]*?<\/svg>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/&[a-z]+;/gi, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunksFromHtml(html) {
|
||||||
|
const body = String(html || '').replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ');
|
||||||
|
const out = [];
|
||||||
|
const re = /<h2[^>]*class="[^"]*sec-h[^"]*"[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2[^>]*class="[^"]*sec-h[^"]*"|<\/body|$)/gi;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(body))) {
|
||||||
|
const title = stripTags(m[1]).slice(0, 160);
|
||||||
|
const text = stripTags(m[2]);
|
||||||
|
if (text.length >= 80) out.push({ section: title, text: text.slice(0, 2000) });
|
||||||
|
}
|
||||||
|
if (!out.length) {
|
||||||
|
const all = stripTags(body);
|
||||||
|
for (let i = 0; i < all.length && out.length < 6; i += 1500) out.push({ section: '', text: all.slice(i, i + 1500) });
|
||||||
|
if (out.length && out[0].text.length < 80) out.length = 0;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reindex() {
|
||||||
|
let books;
|
||||||
|
try { books = db.prepare('SELECT slug, title, html_path FROM textbooks WHERE is_active = 1').all(); }
|
||||||
|
catch (e) { return { error: 'textbooks table missing', chunks: 0 }; }
|
||||||
|
// Замещаем чанки только тех книг, что реально распарсились — не трогаем
|
||||||
|
// данные, наполненные headless-индексатором (JS-рендеримые учебники).
|
||||||
|
const del = db.prepare('DELETE FROM textbook_chunks WHERE slug = ?');
|
||||||
|
const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text) VALUES (?, ?, ?, ?)');
|
||||||
|
let total = 0, files = 0;
|
||||||
|
for (const b of books) {
|
||||||
|
const fp = path.join(TEXTBOOKS_DIR, b.html_path || '');
|
||||||
|
let html;
|
||||||
|
try { html = fs.readFileSync(fp, 'utf8'); } catch (e) { continue; }
|
||||||
|
files++;
|
||||||
|
const chunks = chunksFromHtml(html);
|
||||||
|
if (!chunks.length) continue;
|
||||||
|
del.run(b.slug);
|
||||||
|
for (const c of chunks) { ins.run(b.slug, b.title || b.slug, c.section || '', c.text); total++; }
|
||||||
|
}
|
||||||
|
return { books: books.length, files, chunks: total };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { reindex };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const r = reindex();
|
||||||
|
console.log('[index-textbooks]', JSON.stringify(r));
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Inject IV-5 «Расчётные задачи» widget into build_pN of physics_8_ch1.html
|
||||||
|
// for paragraphs where IV-4 is только MCQ (§1, §2, §3, §4, §5, §8, §10).
|
||||||
|
// §6, §7, §9, §11 already have numeric task trainers.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_ch1.html');
|
||||||
|
let h = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
// === Numeric tasks per paragraph (§1..§11 thermal) ===
|
||||||
|
// Каждая задача: q (вопрос с KaTeX в $..$), ans (число), tol (допуск), why (пошаговое решение)
|
||||||
|
const TASKS = {
|
||||||
|
|
||||||
|
p1: [ // Внутренняя энергия
|
||||||
|
{ q: 'Переведите температуру $t = 27\\,^\\circ$C в кельвины. ($T = t + 273$)', ans: 300, tol: 1, why: '$T = 27 + 273 = 300$ К.' },
|
||||||
|
{ q: 'Температура воды $T = 373$ К. Чему равно $t$ в градусах Цельсия?', ans: 100, tol: 1, why: '$t = T - 273 = 373 - 273 = 100\\,^\\circ$C — кипение воды.' },
|
||||||
|
{ q: 'У стакана воды массой $m_1 = 0{,}5$ кг и у бочки воды массой $m_2 = 50$ кг одинаковая температура. У кого внутренняя энергия больше во сколько раз?', ans: 100, tol: 1, why: '$U \\propto m$ при одинаковой $T$. $U_2/U_1 = m_2/m_1 = 50/0{,}5 = 100$.' },
|
||||||
|
{ q: 'Тело нагрели на $\\Delta T = 30$ К. На сколько градусов Цельсия изменилась его температура?', ans: 30, tol: 0.5, why: 'Шкалы Кельвина и Цельсия отличаются только сдвигом — разность температур одинакова.' },
|
||||||
|
{ q: 'При какой температуре по шкале Цельсия средняя кинетическая энергия молекул равна нулю (абсолютный ноль)?', ans: -273, tol: 1, why: 'Абсолютный ноль $T = 0$ К соответствует $t = 0 - 273 = -273\\,^\\circ$C.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p2: [ // Способы изменения U
|
||||||
|
{ q: 'Газу передали $Q = 200$ Дж теплоты, и он совершил работу $A = 60$ Дж. На сколько увеличилась его внутренняя энергия? ($\\Delta U = Q - A$)', ans: 140, tol: 2, why: '$\\Delta U = Q - A = 200 - 60 = 140$ Дж (первое начало термодинамики).' },
|
||||||
|
{ q: 'Над газом совершили работу $A_{внеш} = 150$ Дж, газ отдал $Q = 50$ Дж тепла. На сколько изменилась $U$?', ans: 100, tol: 2, why: '$\\Delta U = A_{внеш} - Q_{отд} = 150 - 50 = 100$ Дж.' },
|
||||||
|
{ q: 'Газ адиабатно (без теплообмена, $Q = 0$) расширился, совершив $A = 80$ Дж. Найдите $|\\Delta U|$.', ans: 80, tol: 2, why: 'При $Q = 0$: $\\Delta U = -A = -80$ Дж. Модуль изменения $|\\Delta U| = 80$ Дж.' },
|
||||||
|
{ q: 'Молотом массой $0{,}5$ кг, движущимся со скоростью $v = 4$ м/с, ударили по гвоздю. Вся кинетическая энергия перешла в тепло. На сколько Джоулей увеличилась $U$ гвоздя?', ans: 4, tol: 0.1, why: '$E_к = \\dfrac{mv^2}{2} = \\dfrac{0{,}5 \\cdot 16}{2} = 4$ Дж $= \\Delta U$.' },
|
||||||
|
{ q: 'Газу сообщили $Q = 500$ Дж, при этом $\\Delta U = 350$ Дж. Какую работу совершил газ?', ans: 150, tol: 3, why: '$A = Q - \\Delta U = 500 - 350 = 150$ Дж.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p3: [ // Теплопроводность
|
||||||
|
{ q: 'Через стенку площадью $S = 2$ м² с разностью температур $\\Delta T = 20$ К за $t = 100$ с прошло $Q = 200$ Дж. Найдите тепловой поток (Вт): $P = Q/t$.', ans: 2, tol: 0.05, why: '$P = Q/t = 200 / 100 = 2$ Вт.' },
|
||||||
|
{ q: 'Тепловой поток через стенку $P = 50$ Вт. Сколько джоулей теплоты пройдёт через неё за $t = 1$ час?', ans: 180000, tol: 1000, why: '$Q = P \\cdot t = 50 \\cdot 3600 = 180\\,000$ Дж.' },
|
||||||
|
{ q: 'У какого материала теплопроводность больше при прочих равных: у $\\lambda_1 = 400$ Вт/(м·К) (медь) или $\\lambda_2 = 0{,}5$ Вт/(м·К) (вода)? Введите $\\lambda_1/\\lambda_2$.', ans: 800, tol: 5, why: '$\\lambda_1 / \\lambda_2 = 400 / 0{,}5 = 800$ — металлы намного лучше проводят тепло.' },
|
||||||
|
{ q: 'Стенка толщиной $d_1 = 0{,}1$ м заменена на стенку толщиной $d_2 = 0{,}05$ м из того же материала. Во сколько раз вырастет тепловой поток?', ans: 2, tol: 0.05, why: 'Поток $P \\propto 1/d$, поэтому $P_2/P_1 = d_1/d_2 = 0{,}1/0{,}05 = 2$.' },
|
||||||
|
{ q: 'Площадь стенки увеличили в 3 раза. Во сколько раз вырастет тепловой поток (при той же толщине и $\\Delta T$)?', ans: 3, tol: 0.05, why: 'Поток $P \\propto S$, поэтому увеличивается в 3 раза.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p4: [ // Конвекция
|
||||||
|
{ q: 'Плотность тёплого воздуха в $\\rho_1 = 1{,}1$ кг/м³, холодного $\\rho_2 = 1{,}3$ кг/м³. На сколько % холодный плотнее? $((\\rho_2 - \\rho_1)/\\rho_1) \\cdot 100$.', ans: 18, tol: 1, why: '$(1{,}3 - 1{,}1)/1{,}1 \\cdot 100 \\approx 18\\,\\%$.' },
|
||||||
|
{ q: 'Радиатор отдаёт мощность $P = 1500$ Вт, нагревая воздух массой $m = 50$ кг за $t = 60$ с. На сколько $\\Delta T$ нагрелся воздух? ($c_{возд} = 1000$ Дж/(кг·К))', ans: 1.8, tol: 0.1, why: '$\\Delta T = Q/(cm) = (P\\cdot t)/(cm) = (1500 \\cdot 60)/(1000 \\cdot 50) = 1{,}8$ К.' },
|
||||||
|
{ q: 'Вода нагревается снизу. Где будет тёплая вода: $a)$ снизу, $b)$ сверху? Введите 2, если сверху, 1, если снизу.', ans: 2, tol: 0.1, why: 'Тёплая вода легче — поднимается вверх. Это и есть конвекция.' },
|
||||||
|
{ q: 'Холодильник остужает $m = 2$ кг воздуха с $T_1 = 25$ до $T_2 = 5\\,^\\circ$C. Какое тепло (в кДж) он унёс? ($c = 1000$)', ans: 40, tol: 1, why: '$Q = cm\\Delta T = 1000 \\cdot 2 \\cdot 20 = 40\\,000$ Дж $= 40$ кДж.' },
|
||||||
|
{ q: 'Ветер охлаждает кожу. Если без ветра тело отдаёт $P_0 = 50$ Вт, а с ветром $P = 200$ Вт, во сколько раз быстрее идёт теплоотдача?', ans: 4, tol: 0.1, why: '$P/P_0 = 200/50 = 4$ раза.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p5: [ // Излучение
|
||||||
|
{ q: 'Солнце нагревает квадратный метр земной поверхности с мощностью $P = 1000$ Вт. Сколько теплоты получит $S = 5$ м² за $t = 60$ с?', ans: 300000, tol: 5000, why: '$Q = P \\cdot S \\cdot t = 1000 \\cdot 5 \\cdot 60 = 300\\,000$ Дж = $300$ кДж.' },
|
||||||
|
{ q: 'Черное тело излучает в 2 раза эффективнее белого. Если белое тело отдаёт $P_1 = 100$ Вт, сколько отдаст чёрное при той же $T$?', ans: 200, tol: 5, why: '$P_{черн} = 2 \\cdot P_{белого} = 2 \\cdot 100 = 200$ Вт.' },
|
||||||
|
{ q: 'Какая температура (в К) горячей плиты, если её излучение в 16 раз сильнее излучения тела при $T_0 = 300$ К? ($P \\propto T^4$)', ans: 600, tol: 10, why: '$P/P_0 = (T/T_0)^4 = 16$, откуда $T/T_0 = 2$, $T = 600$ К.' },
|
||||||
|
{ q: 'Какой цвет одежды летом холоднее: белый или чёрный? Введите 1, если чёрный, 2, если белый.', ans: 2, tol: 0.1, why: 'Белая отражает солнечное излучение лучше — в ней прохладнее.' },
|
||||||
|
{ q: 'Тело площадью $S = 0{,}5$ м² излучает $P = 200$ Вт. Найдите интенсивность излучения $I = P/S$ (Вт/м²).', ans: 400, tol: 10, why: '$I = P/S = 200/0{,}5 = 400$ Вт/м².' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p8: [ // Плавление (Q = λm)
|
||||||
|
{ q: 'Сколько теплоты (в кДж) нужно для плавления $m = 2$ кг льда при $0\\,^\\circ$C? ($\\lambda_{льда} = 330$ кДж/кг)', ans: 660, tol: 5, why: '$Q = \\lambda m = 330 \\cdot 2 = 660$ кДж.' },
|
||||||
|
{ q: 'Какая масса (в кг) свинца расплавится, получив $Q = 50$ кДж? ($\\lambda_{св} = 25$ кДж/кг)', ans: 2, tol: 0.1, why: '$m = Q/\\lambda = 50/25 = 2$ кг.' },
|
||||||
|
{ q: 'Найдите удельную теплоту плавления вещества (кДж/кг), если на плавление $m = 0{,}5$ кг затрачено $Q = 100$ кДж.', ans: 200, tol: 5, why: '$\\lambda = Q/m = 100/0{,}5 = 200$ кДж/кг.' },
|
||||||
|
{ q: 'Сколько теплоты (кДж) нужно, чтобы расплавить $m = 5$ кг алюминия при $T_{пл}$? ($\\lambda_{Al} = 380$ кДж/кг)', ans: 1900, tol: 20, why: '$Q = \\lambda m = 380 \\cdot 5 = 1900$ кДж.' },
|
||||||
|
{ q: 'Лёд массой $m = 1$ кг при $0\\,^\\circ$C сначала нагрели до $t = 0\\,^\\circ$C (не нужно тепла), затем расплавили. Сколько кДж потратили? ($\\lambda = 330$)', ans: 330, tol: 3, why: '$Q = \\lambda m = 330 \\cdot 1 = 330$ кДж — только на плавление.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p10: [ // Испарение (Q = rm)
|
||||||
|
{ q: 'Сколько теплоты (в кДж) нужно, чтобы испарить $m = 0{,}2$ кг воды при $100\\,^\\circ$C? ($r_{воды} = 2300$ кДж/кг)', ans: 460, tol: 5, why: '$Q = rm = 2300 \\cdot 0{,}2 = 460$ кДж.' },
|
||||||
|
{ q: 'При испарении $m = 5$ кг этилового спирта поглощено $Q = 4500$ кДж. Найдите $r$ (кДж/кг).', ans: 900, tol: 10, why: '$r = Q/m = 4500/5 = 900$ кДж/кг.' },
|
||||||
|
{ q: 'Какая масса (в кг) воды испарится, если ей сообщили $Q = 1150$ кДж при $100\\,^\\circ$C? ($r = 2300$)', ans: 0.5, tol: 0.02, why: '$m = Q/r = 1150/2300 = 0{,}5$ кг.' },
|
||||||
|
{ q: 'Лужа площадью $S = 0{,}5$ м² и толщиной $d = 1$ мм испаряется. Сколько кДж нужно? ($\\rho_{воды} = 1000$ кг/м³, $r = 2300$ кДж/кг)', ans: 1.15, tol: 0.05, why: '$V = Sd = 0{,}5 \\cdot 0{,}001 = 5 \\cdot 10^{-4}$ м³, $m = \\rho V = 0{,}5$ кг $\\cdot 10^{-3} = 0{,}0005$ кг, $Q = rm = 2300 \\cdot 0{,}0005 = 1{,}15$ кДж.' },
|
||||||
|
{ q: 'Почему пот холодит кожу? При испарении пота поглощается теплота. Если испарилось $m = 100$ г пота ($r \\approx 2400$ кДж/кг), сколько кДж унесено с кожи?', ans: 240, tol: 5, why: '$Q = rm = 2400 \\cdot 0{,}1 = 240$ кДж.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Generate iv5 widget HTML + initializer function per pid ===
|
||||||
|
function makeIv5Widget(pid) {
|
||||||
|
const n = pid.slice(1);
|
||||||
|
return `
|
||||||
|
/* IV5 — Расчётные задачи (auto-injected) */
|
||||||
|
h += '<div class="wg">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge">IV-5</span><div class="wg-title">Тренажёр: ${TASKS[pid].length} расчётных задач</div></div>'
|
||||||
|
+'<div class="wg-help">Введи числовой ответ (можно с точкой как разделителем). Решено все верно — +20 XP.</div>'
|
||||||
|
+'<div id="${pid}-tasks5"></div>'
|
||||||
|
+'<div class="score-display" style="margin-top:10px"><span>Задача: <b id="${pid}-tasks5-i">1</b> / ${TASKS[pid].length}</span><span>Правильно: <b id="${pid}-tasks5-ok">0</b></span></div>'
|
||||||
|
+'</div>';
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIv5Init(pid) {
|
||||||
|
const n = pid.slice(1);
|
||||||
|
const tasksLit = JSON.stringify(TASKS[pid]);
|
||||||
|
return `
|
||||||
|
function _init${pid}_iv5(){
|
||||||
|
const TASKS = ${tasksLit};
|
||||||
|
let i = 0, ok = 0, awarded = false;
|
||||||
|
function render(){
|
||||||
|
const t = TASKS[i]; const wrap = document.getElementById('${pid}-tasks5'); if(!wrap) return;
|
||||||
|
wrap.innerHTML =
|
||||||
|
'<div style="padding:10px 14px;background:rgba(15,23,42,.04);border-radius:9px;margin-bottom:10px;font-size:.95rem;line-height:1.55"><b>Задача '+(i+1)+'.</b> '+t.q+'</div>'
|
||||||
|
+'<div class="actions"><input type="number" step="0.001" class="tinp" id="${pid}-iv5-inp" placeholder="число" style="width:140px">'
|
||||||
|
+'<button class="btn primary" id="${pid}-iv5-go">Ответ</button>'
|
||||||
|
+'<button class="btn" id="${pid}-iv5-hint">Подсказка</button>'
|
||||||
|
+'<button class="btn" id="${pid}-iv5-next">Следующая</button></div>'
|
||||||
|
+'<details class="spoiler" id="${pid}-iv5-why-wrap" style="margin-top:8px;display:none"><summary>Решение</summary><div class="spoiler-body">'+t.why+'</div></details>'
|
||||||
|
+'<div class="feedback" id="${pid}-iv5-fb"></div>';
|
||||||
|
if (window.renderMathInElement) try { renderMathInElement(wrap, {delimiters:[{left:'$',right:'$',display:false}],throwOnError:false}); } catch(e){}
|
||||||
|
document.getElementById('${pid}-iv5-go').onclick = () => {
|
||||||
|
const v = parseFloat(document.getElementById('${pid}-iv5-inp').value.replace(',','.'));
|
||||||
|
const fb = document.getElementById('${pid}-iv5-fb');
|
||||||
|
const wh = document.getElementById('${pid}-iv5-why-wrap');
|
||||||
|
if (Math.abs(v - t.ans) <= t.tol) {
|
||||||
|
fb.className = 'feedback ok'; fb.innerHTML = 'Верно!'; ok++;
|
||||||
|
document.getElementById('${pid}-tasks5-ok').textContent = ok;
|
||||||
|
wh.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
fb.className = 'feedback fail'; fb.innerHTML = 'Не совсем. Ожидался $' + t.ans + '$. Загляни в подсказку.';
|
||||||
|
if (window.renderMathInElement) try { renderMathInElement(fb, {delimiters:[{left:'$',right:'$',display:false}],throwOnError:false}); } catch(e){}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById('${pid}-iv5-hint').onclick = () => {
|
||||||
|
const wh = document.getElementById('${pid}-iv5-why-wrap');
|
||||||
|
wh.style.display = wh.style.display === 'block' ? 'none' : 'block';
|
||||||
|
};
|
||||||
|
document.getElementById('${pid}-iv5-next').onclick = () => {
|
||||||
|
i = (i + 1) % TASKS.length;
|
||||||
|
document.getElementById('${pid}-tasks5-i').textContent = i + 1;
|
||||||
|
render();
|
||||||
|
if (ok === TASKS.length && !awarded) { awarded = true; if (typeof addXp === 'function') addXp(20, '${pid}-iv5'); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Patch ch1 file ===
|
||||||
|
let patchedCount = 0;
|
||||||
|
for (const pid of Object.keys(TASKS)) {
|
||||||
|
// 1. Append IV-5 widget HTML inside build_pN before `box.innerHTML = h + secNavFor`
|
||||||
|
// 2. Append init call after wireReadBtn line
|
||||||
|
// 3. Append _initPN_iv5 function after the build_pN function block
|
||||||
|
const widget = makeIv5Widget(pid);
|
||||||
|
const init = makeIv5Init(pid);
|
||||||
|
|
||||||
|
// Marker to insert widget: find `box.innerHTML = h + secNavFor('pN') + readButton('pN');`
|
||||||
|
const insertWidgetBefore = `box.innerHTML = h + secNavFor('${pid}') + readButton('${pid}');`;
|
||||||
|
if (!h.includes(insertWidgetBefore)) {
|
||||||
|
console.warn(`${pid}: insert marker not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
h = h.replace(insertWidgetBefore, widget.trim() + '\n\n ' + insertWidgetBefore);
|
||||||
|
|
||||||
|
// Marker to insert init call: after `wireReadBtn('pN');` add `_initPN_iv5();`
|
||||||
|
const wireMarker = `wireReadBtn('${pid}');`;
|
||||||
|
h = h.replace(wireMarker, wireMarker + `\n _init${pid}_iv5();`);
|
||||||
|
|
||||||
|
// Append the _initPN_iv5 function — insert before the closing }\n of build_pN
|
||||||
|
// Search end of build_pN
|
||||||
|
const fnStart = h.indexOf(`function build_${pid}()`);
|
||||||
|
// Find closing brace of the function (look for `\n}\n` after fnStart, going past the new init call)
|
||||||
|
const fnEnd = h.indexOf('\n}\n', fnStart);
|
||||||
|
// Insert init function AFTER build_pN closing brace
|
||||||
|
const insertPos = fnEnd + 3; // skip "\n}\n"
|
||||||
|
h = h.slice(0, insertPos) + '\n' + init.trim() + '\n' + h.slice(insertPos);
|
||||||
|
patchedCount++;
|
||||||
|
console.log(` ${pid}: patched (${TASKS[pid].length} tasks)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('Patched', patchedCount, '/', Object.keys(TASKS).length, 'paragraphs');
|
||||||
|
console.log('File size:', h.length);
|
||||||
|
|
||||||
|
// Sanity parse
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch(e) { console.error('JS PARSE FAIL:', e.message); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('inline JS parses OK');
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Inject IV-5 «Расчётные задачи» into MCQ-only paragraphs of physics_8_ch2.html
|
||||||
|
// and physics_8_ch3.html — параграфы §12,13,14,16,17,19,21,28,29,30,31,32,39.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const TBOOKS = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
// === Numeric tasks per paragraph ===
|
||||||
|
const TASKS = {
|
||||||
|
|
||||||
|
// === Ch2: Электромагнитные явления ===
|
||||||
|
|
||||||
|
p12: [ // Электризация
|
||||||
|
{ q: 'Два одинаковых шарика имели заряды $q_1 = +6$ мкКл и $q_2 = -2$ мкКл. После соприкосновения и разделения, какой заряд (мкКл) остался на каждом?', ans: 2, tol: 0.05, why: 'При контакте одинаковых шаров заряды выравниваются: $q = (q_1 + q_2)/2 = (+6 - 2)/2 = +2$ мкКл.' },
|
||||||
|
{ q: 'У эбонитовой палочки $-30$ нКл, у шерсти $+30$ нКл. Какой суммарный заряд (нКл) системы по закону сохранения заряда?', ans: 0, tol: 0.5, why: 'Заряд изолированной системы сохраняется. Если до трения было $0$ — и после $0$: $-30 + 30 = 0$.' },
|
||||||
|
{ q: 'У шарика заряд $q = +4$ мкКл. Сколько электронов нужно добавить, чтобы он стал нейтральным? ($e = 1{,}6 \\cdot 10^{-19}$ Кл). Ответ дайте в единицах $\\times 10^{13}$.', ans: 2.5, tol: 0.1, why: '$n = q/e = 4 \\cdot 10^{-6} / 1{,}6 \\cdot 10^{-19} = 2{,}5 \\cdot 10^{13}$ электронов.' },
|
||||||
|
{ q: 'Тело потеряло $n = 5 \\cdot 10^{12}$ электронов. Каков стал его заряд (мкКл)? ($e = 1{,}6 \\cdot 10^{-19}$ Кл)', ans: 0.8, tol: 0.02, why: '$q = n \\cdot e = 5 \\cdot 10^{12} \\cdot 1{,}6 \\cdot 10^{-19} = 8 \\cdot 10^{-7}$ Кл $= 0{,}8$ мкКл (положительный, т. к. потерял электроны).' },
|
||||||
|
{ q: 'Если соединить шары с зарядами $q_1 = +10$ нКл и $q_2 = +6$ нКл одинакового размера, какой заряд (нКл) будет на каждом после разделения?', ans: 8, tol: 0.2, why: '$q = (q_1 + q_2)/2 = 16/2 = 8$ нКл.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p13: [ // Проводники и диэлектрики
|
||||||
|
{ q: 'У проводника плотность свободных электронов $n = 10^{29}$ м⁻³, у диэлектрика $\\sim 10^{17}$ м⁻³. Во сколько раз больше носителей у проводника? (степень 10)', ans: 12, tol: 0.5, why: '$n_{пр}/n_{ди} = 10^{29}/10^{17} = 10^{12}$.' },
|
||||||
|
{ q: 'Стержень проводника $L = 10$ см заряжен. Если соединить с таким же незаряженным, какая часть (в %) заряда уйдёт на второй?', ans: 50, tol: 1, why: 'Заряды выравниваются, на каждом — половина исходного: $50\\,\\%$.' },
|
||||||
|
{ q: 'Какой материал лучший проводник: $1$ — стекло, $2$ — сухое дерево, $3$ — медь, $4$ — пластик? Введите номер.', ans: 3, tol: 0.1, why: 'Металлы (медь) — лучшие проводники из перечисленных.' },
|
||||||
|
{ q: 'У диэлектрика свободных зарядов нет, но связанные могут поляризоваться. Сколько свободных носителей в идеальном диэлектрике?', ans: 0, tol: 0.1, why: 'В идеальном диэлектрике $n_{своб} = 0$ — есть только связанные заряды атомов и молекул.' },
|
||||||
|
{ q: 'Через тело прошло за $t = 2$ с $q = 4$ Кл. Какова сила тока $I$ (А)?', ans: 2, tol: 0.05, why: '$I = q/t = 4/2 = 2$ А.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p14: [ // Электростатическая индукция
|
||||||
|
{ q: 'К незаряженному проводнику поднесли $+$-заряженный шар. Какой заряд индуцируется на ближнем конце проводника?', ans: -1, tol: 0.1, why: 'Свободные электроны притянутся к $+$ — на ближнем конце $-1$ (отрицательный заряд). Введите $-1$.' },
|
||||||
|
{ q: 'Шар с зарядом $q = +20$ нКл коснулся незаряженного такого же шара. Заряд (нКл) на одном шаре после разделения?', ans: 10, tol: 0.2, why: 'Заряды выравниваются: $q/2 = 10$ нКл.' },
|
||||||
|
{ q: 'Электроскоп заряжен зарядом $q = 5$ нКл. Что произойдёт с углом отклонения лепестков, если к нему поднести заряд того же знака? ($1$ — уменьшится, $2$ — увеличится, $3$ — не изменится)', ans: 2, tol: 0.1, why: 'Одноимённые заряды отталкиваются — лепестки разойдутся сильнее.' },
|
||||||
|
{ q: 'Если поднести $+$-заряд к нейтральному шару и заземлить его (отвести электроны), какой заряд останется на шаре после удаления $+$-заряда? ($1$ — положительный, $-1$ — отрицательный)', ans: -1, tol: 0.1, why: 'Электроны притянулись и не ушли — шар стал отрицательным. Ответ $-1$.' },
|
||||||
|
{ q: 'Заряженная палочка приближается к листку фольги. Лепесток отклоняется на угол $\\alpha$. Если палочку удалить, какой будет $\\alpha$?', ans: 0, tol: 0.1, why: 'Без внешнего поля индуцированные заряды перераспределяются обратно — отклонение $0$.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p16: [ // Строение атома
|
||||||
|
{ q: 'У атома водорода $1$ электрон. Каков заряд электронной оболочки (в единицах $e$)? Введите модуль.', ans: 1, tol: 0.1, why: 'Один электрон с зарядом $-e$. Модуль $|q| = e = 1$ в этих единицах.' },
|
||||||
|
{ q: 'Электрон имеет заряд $e = 1{,}6 \\cdot 10^{-19}$ Кл. Какой суммарный заряд (Кл) у $n = 10^{20}$ электронов? Дайте ответ в виде $\\times 10^{1}$ Кл.', ans: 16, tol: 0.5, why: '$q = ne = 10^{20} \\cdot 1{,}6 \\cdot 10^{-19} = 16$ Кл.' },
|
||||||
|
{ q: 'Какой заряд имеет атомное ядро углерода $^{12}_{6}$C (в единицах $e$)?', ans: 6, tol: 0.1, why: 'У углерода $Z = 6$ протонов, каждый с зарядом $+e$. Заряд ядра $= +6e$.' },
|
||||||
|
{ q: 'Атом нейтрален. Сколько электронов вокруг ядра кислорода $^{16}_{8}$O?', ans: 8, tol: 0.1, why: 'В нейтральном атоме число электронов равно числу протонов: $Z = 8$.' },
|
||||||
|
{ q: 'Сколько Кл составляет заряд $5$ протонов? ($e = 1{,}6 \\cdot 10^{-19}$ Кл). Ответ $\\times 10^{-19}$.', ans: 8, tol: 0.2, why: '$q = 5e = 5 \\cdot 1{,}6 \\cdot 10^{-19} = 8 \\cdot 10^{-19}$ Кл.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p17: [ // Электрическое поле
|
||||||
|
{ q: 'В точке поля на заряд $q = 2$ нКл действует сила $F = 4 \\cdot 10^{-5}$ Н. Найдите модуль напряжённости $E$ (В/м). $E = F/q$.', ans: 20000, tol: 500, why: '$E = F/q = 4 \\cdot 10^{-5} / 2 \\cdot 10^{-9} = 2 \\cdot 10^{4} = 20\\,000$ В/м.' },
|
||||||
|
{ q: 'Напряжённость поля $E = 1000$ В/м. Какая сила (мкН) действует на заряд $q = 5$ нКл?', ans: 5, tol: 0.2, why: '$F = qE = 5 \\cdot 10^{-9} \\cdot 1000 = 5 \\cdot 10^{-6}$ Н $= 5$ мкН.' },
|
||||||
|
{ q: 'Однородное поле напряжённостью $E = 200$ В/м. Какая работа поля (мкДж) при перемещении заряда $q = 1$ мкКл вдоль линии поля на $d = 10$ см?', ans: 20, tol: 1, why: '$A = qEd = 10^{-6} \\cdot 200 \\cdot 0{,}1 = 2 \\cdot 10^{-5}$ Дж $= 20$ мкДж.' },
|
||||||
|
{ q: 'На пробный заряд $q_0$ в поле действует сила $F$. Если заряд $q_0$ удвоить, во сколько раз изменится $E$ в этой точке?', ans: 1, tol: 0.05, why: '$E$ — характеристика поля, не зависит от пробного заряда: $E$ не меняется (коэффициент $= 1$).' },
|
||||||
|
{ q: 'Силовые линии однородного поля идут параллельно с плотностью $5$ линий/см. У сильного поля плотность $20$ линий/см. Во сколько раз больше $E$?', ans: 4, tol: 0.2, why: 'Густота линий пропорциональна $E$: $20/5 = 4$ раза.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p19: [ // Источники тока
|
||||||
|
{ q: 'Батарея делает работу $A = 12$ Дж по перемещению заряда $q = 4$ Кл. Найдите ЭДС $\\mathcal{E}$ (В). $\\mathcal{E} = A/q$.', ans: 3, tol: 0.05, why: '$\\mathcal{E} = A/q = 12/4 = 3$ В.' },
|
||||||
|
{ q: 'ЭДС источника $\\mathcal{E} = 9$ В. Какую работу (Дж) совершат сторонние силы по переносу заряда $q = 5$ Кл?', ans: 45, tol: 1, why: '$A = \\mathcal{E} \\cdot q = 9 \\cdot 5 = 45$ Дж.' },
|
||||||
|
{ q: 'Аккумулятор отдал заряд $q = 0{,}5$ Кл при ЭДС $\\mathcal{E} = 12$ В. Сколько Дж работы он совершил?', ans: 6, tol: 0.1, why: '$A = \\mathcal{E} q = 12 \\cdot 0{,}5 = 6$ Дж.' },
|
||||||
|
{ q: 'У батарейки ЭДС $1{,}5$ В. Сколько Кл нужно перенести, чтобы получить $3$ Дж?', ans: 2, tol: 0.05, why: '$q = A/\\mathcal{E} = 3/1{,}5 = 2$ Кл.' },
|
||||||
|
{ q: 'Гальванический элемент работает $t = 60$ с с током $I = 0{,}1$ А и ЭДС $\\mathcal{E} = 1{,}5$ В. Какую работу (Дж) он совершил? ($A = \\mathcal{E} I t$)', ans: 9, tol: 0.2, why: '$A = \\mathcal{E} \\cdot I \\cdot t = 1{,}5 \\cdot 0{,}1 \\cdot 60 = 9$ Дж.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p21: [ // Электрическая цепь
|
||||||
|
{ q: 'За $t = 2$ с через сечение проводника прошёл заряд $q = 6$ Кл. Найдите силу тока $I$ (А). $I = q/t$.', ans: 3, tol: 0.05, why: '$I = q/t = 6/2 = 3$ А.' },
|
||||||
|
{ q: 'В цепи течёт ток $I = 0{,}5$ А. Какой заряд (Кл) пройдёт за минуту?', ans: 30, tol: 0.5, why: '$q = It = 0{,}5 \\cdot 60 = 30$ Кл.' },
|
||||||
|
{ q: 'Через лампочку прошло $q = 60$ Кл за $t = 5$ мин. Найдите $I$ (мА). Внимание: переведите минуты в секунды.', ans: 200, tol: 5, why: '$t = 300$ с, $I = q/t = 60/300 = 0{,}2$ А $= 200$ мА.' },
|
||||||
|
{ q: 'В лампе сила тока $I = 0{,}3$ А. Сколько электронов проходит через сечение нити за $t = 1$ с? ($e = 1{,}6 \\cdot 10^{-19}$ Кл). Ответ $\\times 10^{18}$.', ans: 1.875, tol: 0.05, why: '$n = q/e = It/e = 0{,}3 / 1{,}6 \\cdot 10^{-19} \\approx 1{,}88 \\cdot 10^{18}$.' },
|
||||||
|
{ q: 'Какой ток (А) в цепи, где за $0{,}5$ часа прошло $q = 900$ Кл?', ans: 0.5, tol: 0.02, why: '$t = 1800$ с, $I = q/t = 900/1800 = 0{,}5$ А.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p28: [ // Постоянные магниты
|
||||||
|
{ q: 'Сколько полюсов у любого магнита?', ans: 2, tol: 0.1, why: 'У любого магнита всегда $2$ полюса: северный N и южный S. Магнитного «монополя» не существует.' },
|
||||||
|
{ q: 'Если разрезать магнит на $2$ части, сколько магнитов получится?', ans: 2, tol: 0.1, why: 'Каждая часть будет иметь свои $2$ полюса — получаем $2$ магнита.' },
|
||||||
|
{ q: 'Магнит разрезали на $5$ кусков. Сколько всего полюсов у всех кусков?', ans: 10, tol: 0.1, why: 'Каждый магнит имеет $2$ полюса. Всего: $5 \\cdot 2 = 10$.' },
|
||||||
|
{ q: 'У одного магнита N-полюс, у другого S-полюс. Они притягиваются или отталкиваются? ($1$ — притягиваются, $0$ — отталкиваются)', ans: 1, tol: 0.1, why: 'Разноимённые магнитные полюса притягиваются (как и разноимённые заряды).' },
|
||||||
|
{ q: 'Какой полюс у Земли находится около географического Северного полюса? ($1$ — северный магнитный, $2$ — южный магнитный)', ans: 2, tol: 0.1, why: 'Около географ. севера — южный магнитный полюс Земли (поэтому стрелка компаса N притягивается к нему).' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p29: [ // Магнитное поле тока
|
||||||
|
{ q: 'Опыт Эрстеда: магнитная стрелка отклоняется при включении тока в проводнике. Это значит, что вокруг тока есть... ($1$ — электрическое поле, $2$ — магнитное поле, $3$ — гравитационное)', ans: 2, tol: 0.1, why: 'Вокруг любого тока существует магнитное поле — ключевое открытие Эрстеда (1820).' },
|
||||||
|
{ q: 'Линии магнитного поля прямого тока — это: ($1$ — прямые, $2$ — концентрические окружности вокруг проводника, $3$ — параболы)', ans: 2, tol: 0.1, why: 'Силовые линии магнитного поля прямого тока — концентрические окружности в плоскостях, перпендикулярных проводнику.' },
|
||||||
|
{ q: 'У одного провода ток $I_1 = 2$ А, у другого $I_2 = 8$ А. У какого индукция магнитного поля на одинаковом расстоянии больше во сколько раз? ($B \\propto I$)', ans: 4, tol: 0.1, why: '$B \\propto I$, поэтому $B_2/B_1 = I_2/I_1 = 8/2 = 4$ раза.' },
|
||||||
|
{ q: 'Сила, действующая на проводник с током в магнитном поле, при $I = 5$ А, $B = 0{,}2$ Тл, $L = 0{,}1$ м: $F = BIL$ (Н)?', ans: 0.1, tol: 0.005, why: '$F = BIL = 0{,}2 \\cdot 5 \\cdot 0{,}1 = 0{,}1$ Н.' },
|
||||||
|
{ q: 'Если ток в проводнике увеличить в $3$ раза, во сколько раз увеличится сила в магнитном поле?', ans: 3, tol: 0.1, why: '$F = BIL \\propto I$, поэтому $F$ увеличится в $3$ раза.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p30: [ // Опыт Эрстеда (в этом учебнике — продолжение)
|
||||||
|
{ q: 'В каком году Эрстед обнаружил магнитное действие тока?', ans: 1820, tol: 5, why: 'Х. Эрстед открыл связь электрического тока и магнетизма в 1820 году.' },
|
||||||
|
{ q: 'Стрелка компаса находится над проводником. Какой угол (в градусах) к проводнику она составит при отсутствии тока? ($1$ — $0°$, $2$ — $90°$)', ans: 1, tol: 0.1, why: 'Без тока стрелка направлена вдоль магнитного поля Земли. Над проводником, протянутым с севера на юг, стрелка $\\parallel$ проводнику ($0°$).' },
|
||||||
|
{ q: 'При включении тока стрелка отклоняется. Когда ток отключают, что произойдёт? ($1$ — останется, $2$ — вернётся в исходное положение)', ans: 2, tol: 0.1, why: 'Без тока магнитное поле проводника исчезает, на стрелку действует только поле Земли — она возвращается.' },
|
||||||
|
{ q: 'Правило буравчика: если ток течёт вверх, в каком направлении вращаются силовые линии магн. поля? ($1$ — по часовой при взгляде сверху, $2$ — против часовой при взгляде сверху)', ans: 2, tol: 0.1, why: 'Правило правой руки: большой палец — направление тока, согнутые пальцы показывают направление поля. При токе вверх — против часовой при взгляде сверху.' },
|
||||||
|
{ q: 'Сила тока удвоилась. Во сколько раз сильнее отклонится магнитная стрелка (приближённо)?', ans: 2, tol: 0.1, why: 'Магнитное поле $B \\propto I$, отклонение стрелки тоже растёт примерно линейно — в $2$ раза.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p31: [ // Электромагнит
|
||||||
|
{ q: 'У электромагнита было $N_1 = 100$ витков, его магнитное поле $B_1$. После добавления стало $N_2 = 500$ витков (тот же ток). Во сколько раз вырастет $B$?', ans: 5, tol: 0.2, why: '$B \\propto N$, поэтому $B_2/B_1 = N_2/N_1 = 5$.' },
|
||||||
|
{ q: 'Ток в катушке вырос с $I_1 = 0{,}2$ А до $I_2 = 1$ А. Во сколько раз увеличилось магнитное поле электромагнита?', ans: 5, tol: 0.2, why: '$B \\propto I$, поэтому $B_2/B_1 = I_2/I_1 = 5$.' },
|
||||||
|
{ q: 'Без сердечника поле электромагнита $B_0 = 1$ мТл. С железным сердечником стало $B = 1000$ мТл. Во сколько раз сердечник усилил поле?', ans: 1000, tol: 10, why: 'Железо имеет магнитную проницаемость $\\mu \\sim 1000$. $B/B_0 = 1000$.' },
|
||||||
|
{ q: 'Электромагнит подняет груз массой $m = 50$ кг с силой $F = 500$ Н. Какова перегрузка $F/(mg)$? ($g = 10$ м/с²)', ans: 1, tol: 0.05, why: '$F/(mg) = 500/(50 \\cdot 10) = 500/500 = 1$ — сила в точности уравновешивает вес.' },
|
||||||
|
{ q: 'Если ток отключить, что произойдёт с магн. полем электромагнита? ($1$ — останется, $0$ — исчезнет)', ans: 0, tol: 0.1, why: 'Магнитное поле электромагнита создаётся током. Нет тока — нет поля. (В отличие от постоянного магнита.)' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// === Ch3: Световые явления ===
|
||||||
|
|
||||||
|
p32: [ // Источники света
|
||||||
|
{ q: 'Скорость света в вакууме $c = 3 \\cdot 10^{8}$ м/с. За какое время (мкс) свет пройдёт $L = 300$ км?', ans: 1000, tol: 10, why: '$t = L/c = 3 \\cdot 10^{5} / 3 \\cdot 10^{8} = 10^{-3}$ с $= 1000$ мкс.' },
|
||||||
|
{ q: 'Свет от Солнца достигает Земли за $t = 500$ с. Какое расстояние (в км)? Ответ $\\times 10^{8}$.', ans: 1.5, tol: 0.05, why: '$L = ct = 3 \\cdot 10^{8} \\cdot 500 = 1{,}5 \\cdot 10^{11}$ м $= 1{,}5 \\cdot 10^{8}$ км.' },
|
||||||
|
{ q: 'Сколько секунд лётит свет от Луны до Земли, если расстояние $L = 384\\,000$ км?', ans: 1.28, tol: 0.05, why: '$t = L/c = 3{,}84 \\cdot 10^{8} / 3 \\cdot 10^{8} = 1{,}28$ с.' },
|
||||||
|
{ q: 'Свет звезды доходит до нас за $4$ года. Сколько $4$ световых лет в км? Ответ $\\times 10^{13}$.', ans: 3.78, tol: 0.05, why: '$1$ год $\\approx 3{,}15 \\cdot 10^{7}$ с. $L = c \\cdot 4 \\cdot 3{,}15 \\cdot 10^{7} = 3{,}78 \\cdot 10^{16}$ м $= 3{,}78 \\cdot 10^{13}$ км.' },
|
||||||
|
{ q: 'Какой из источников света — точечный с практической точки зрения: ($1$ — Солнце на небе для нас, $2$ — лампа в комнате с метра, $3$ — звезда)?', ans: 3, tol: 0.1, why: 'Звёзды настолько далеки, что их можно считать точечными источниками света. Солнце и лампа — нет.' },
|
||||||
|
],
|
||||||
|
|
||||||
|
p39: [ // §39 в ch3 — обычно «Глаз / Дисперсия / Оптические приборы»
|
||||||
|
{ q: 'Из скольких основных цветов состоит спектр белого света (радуга)?', ans: 7, tol: 0.1, why: '$7$ цветов: красный, оранжевый, жёлтый, зелёный, голубой, синий, фиолетовый.' },
|
||||||
|
{ q: 'У какого цвета света наибольшая длина волны: ($1$ — красный, $2$ — синий, $3$ — фиолетовый, $4$ — зелёный)?', ans: 1, tol: 0.1, why: 'Красный свет имеет наибольшую длину волны ($\\sim 700$ нм) среди видимого спектра.' },
|
||||||
|
{ q: 'У какого цвета света наименьшая длина волны: ($1$ — красный, $2$ — жёлтый, $3$ — зелёный, $4$ — фиолетовый)?', ans: 4, tol: 0.1, why: 'Фиолетовый свет имеет наименьшую длину волны ($\\sim 400$ нм).' },
|
||||||
|
{ q: 'Линза с фокусом $F = 25$ см. Оптическая сила $D = 1/F$ (дптр). Найдите $D$ при $F$ в метрах.', ans: 4, tol: 0.1, why: '$F = 0{,}25$ м, $D = 1/F = 1/0{,}25 = 4$ дптр.' },
|
||||||
|
{ q: 'У близорукого человека очки $-2$ дптр. Найдите фокусное расстояние линзы $F$ в м.', ans: -0.5, tol: 0.02, why: '$F = 1/D = 1/(-2) = -0{,}5$ м (рассеивающая линза).' },
|
||||||
|
],
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Generate IV-5 widget HTML + initializer function per pid ===
|
||||||
|
function makeIv5Widget(pid) {
|
||||||
|
return `
|
||||||
|
/* IV5 — Расчётные задачи (auto-injected) */
|
||||||
|
h += '<div class="wg">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge">IV-5</span><div class="wg-title">Тренажёр: ${TASKS[pid].length} расчётных задач</div></div>'
|
||||||
|
+'<div class="wg-help">Введи числовой ответ (точка как разделитель). Решено все верно — +20 XP.</div>'
|
||||||
|
+'<div id="${pid}-tasks5"></div>'
|
||||||
|
+'<div class="score-display" style="margin-top:10px"><span>Задача: <b id="${pid}-tasks5-i">1</b> / ${TASKS[pid].length}</span><span>Правильно: <b id="${pid}-tasks5-ok">0</b></span></div>'
|
||||||
|
+'</div>';
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIv5Init(pid) {
|
||||||
|
const tasksLit = JSON.stringify(TASKS[pid]);
|
||||||
|
return `
|
||||||
|
function _init${pid}_iv5(){
|
||||||
|
const TASKS = ${tasksLit};
|
||||||
|
let i = 0, ok = 0, awarded = false;
|
||||||
|
function render(){
|
||||||
|
const t = TASKS[i]; const wrap = document.getElementById('${pid}-tasks5'); if(!wrap) return;
|
||||||
|
wrap.innerHTML =
|
||||||
|
'<div style="padding:10px 14px;background:rgba(15,23,42,.04);border-radius:9px;margin-bottom:10px;font-size:.95rem;line-height:1.55"><b>Задача '+(i+1)+'.</b> '+t.q+'</div>'
|
||||||
|
+'<div class="actions"><input type="number" step="0.001" class="tinp" id="${pid}-iv5-inp" placeholder="число" style="width:140px">'
|
||||||
|
+'<button class="btn primary" id="${pid}-iv5-go">Ответ</button>'
|
||||||
|
+'<button class="btn" id="${pid}-iv5-hint">Подсказка</button>'
|
||||||
|
+'<button class="btn" id="${pid}-iv5-next">Следующая</button></div>'
|
||||||
|
+'<details class="spoiler" id="${pid}-iv5-why-wrap" style="margin-top:8px;display:none"><summary>Решение</summary><div class="spoiler-body">'+t.why+'</div></details>'
|
||||||
|
+'<div class="feedback" id="${pid}-iv5-fb"></div>';
|
||||||
|
if (window.renderMathInElement) try { renderMathInElement(wrap, {delimiters:[{left:'$',right:'$',display:false}],throwOnError:false}); } catch(e){}
|
||||||
|
document.getElementById('${pid}-iv5-go').onclick = () => {
|
||||||
|
const v = parseFloat(document.getElementById('${pid}-iv5-inp').value.replace(',','.'));
|
||||||
|
const fb = document.getElementById('${pid}-iv5-fb');
|
||||||
|
const wh = document.getElementById('${pid}-iv5-why-wrap');
|
||||||
|
if (Math.abs(v - t.ans) <= t.tol) {
|
||||||
|
fb.className = 'feedback ok'; fb.innerHTML = 'Верно!'; ok++;
|
||||||
|
document.getElementById('${pid}-tasks5-ok').textContent = ok;
|
||||||
|
wh.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
fb.className = 'feedback fail'; fb.innerHTML = 'Не совсем. Ожидался $' + t.ans + '$. Загляни в подсказку.';
|
||||||
|
if (window.renderMathInElement) try { renderMathInElement(fb, {delimiters:[{left:'$',right:'$',display:false}],throwOnError:false}); } catch(e){}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById('${pid}-iv5-hint').onclick = () => {
|
||||||
|
const wh = document.getElementById('${pid}-iv5-why-wrap');
|
||||||
|
wh.style.display = wh.style.display === 'block' ? 'none' : 'block';
|
||||||
|
};
|
||||||
|
document.getElementById('${pid}-iv5-next').onclick = () => {
|
||||||
|
i = (i + 1) % TASKS.length;
|
||||||
|
document.getElementById('${pid}-tasks5-i').textContent = i + 1;
|
||||||
|
render();
|
||||||
|
if (ok === TASKS.length && !awarded) { awarded = true; if (typeof addXp === 'function') addXp(20, '${pid}-iv5'); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Patch chapter file ===
|
||||||
|
function patchChapter(fname, pids) {
|
||||||
|
const dst = path.join(TBOOKS, fname);
|
||||||
|
let h = fs.readFileSync(dst, 'utf8');
|
||||||
|
let patched = 0;
|
||||||
|
for (const pid of pids) {
|
||||||
|
if (!TASKS[pid]) { console.warn(` ${pid}: no task data`); continue; }
|
||||||
|
const widget = makeIv5Widget(pid);
|
||||||
|
const init = makeIv5Init(pid);
|
||||||
|
|
||||||
|
const insertWidgetBefore = `box.innerHTML = h + secNavFor('${pid}') + readButton('${pid}');`;
|
||||||
|
if (!h.includes(insertWidgetBefore)) {
|
||||||
|
console.warn(` ${pid}: insert marker not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Check if already injected
|
||||||
|
if (h.includes(`id="${pid}-tasks5"`)) {
|
||||||
|
console.log(` ${pid}: already injected, skip`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
h = h.replace(insertWidgetBefore, widget.trim() + '\n\n ' + insertWidgetBefore);
|
||||||
|
const wireMarker = `wireReadBtn('${pid}');`;
|
||||||
|
h = h.replace(wireMarker, wireMarker + `\n _init${pid}_iv5();`);
|
||||||
|
|
||||||
|
const fnStart = h.indexOf(`function build_${pid}()`);
|
||||||
|
const fnEnd = h.indexOf('\n}\n', fnStart);
|
||||||
|
const insertPos = fnEnd + 3;
|
||||||
|
h = h.slice(0, insertPos) + '\n' + init.trim() + '\n' + h.slice(insertPos);
|
||||||
|
patched++;
|
||||||
|
console.log(` ${pid}: patched (${TASKS[pid].length} tasks)`);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(dst, h);
|
||||||
|
console.log(`${fname}: ${patched}/${pids.length} patched, ${h.length} bytes`);
|
||||||
|
// Sanity parse
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch(e) { console.error(`JS PARSE FAIL in ${fname}:`, e.message); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log(`${fname}: inline JS parses OK`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== physics_8_ch2.html ===');
|
||||||
|
patchChapter('physics_8_ch2.html', ['p12','p13','p14','p16','p17','p19','p21','p28','p29','p30','p31']);
|
||||||
|
console.log('=== physics_8_ch3.html ===');
|
||||||
|
patchChapter('physics_8_ch3.html', ['p32','p39']);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const root = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
const STUBS = [
|
||||||
|
{ f:'algebra_7_ch1.html', hub:'/textbook/algebra-7', hubName:'Алгебра 7', ch:'Глава 1 · Степень', range:'§1–§3', color:'#d97706', colorD:'#b45309', wm:'aⁿ' },
|
||||||
|
{ f:'algebra_7_ch2.html', hub:'/textbook/algebra-7', hubName:'Алгебра 7', ch:'Глава 2 · Выражения и их преобразования', range:'§4–§14', color:'#059669', colorD:'#047857', wm:'P(x)' },
|
||||||
|
{ f:'algebra_7_ch3.html', hub:'/textbook/algebra-7', hubName:'Алгебра 7', ch:'Глава 3 · Линейные уравнения. Неравенства. Функция', range:'§15–§20', color:'#7c3aed', colorD:'#6d28d9', wm:'y=kx' },
|
||||||
|
{ f:'algebra_7_ch4.html', hub:'/textbook/algebra-7', hubName:'Алгебра 7', ch:'Глава 4 · Системы линейных уравнений', range:'§21–§25', color:'#0891b2', colorD:'#0e7490', wm:'{' },
|
||||||
|
{ f:'geometry_7_ch1.html', hub:'/textbook/geometry-7', hubName:'Геометрия 7', ch:'Глава 1 · Начальные понятия', range:'§1–§7', color:'#d97706', colorD:'#b45309', wm:'●' },
|
||||||
|
{ f:'geometry_7_ch2.html', hub:'/textbook/geometry-7', hubName:'Геометрия 7', ch:'Глава 2 · Признаки равенства треугольников', range:'§8–§14', color:'#059669', colorD:'#047857', wm:'△' },
|
||||||
|
{ f:'geometry_7_ch3.html', hub:'/textbook/geometry-7', hubName:'Геометрия 7', ch:'Глава 3 · Параллельность прямых', range:'§15–§18', color:'#7c3aed', colorD:'#6d28d9', wm:'∥' },
|
||||||
|
{ f:'geometry_7_ch4.html', hub:'/textbook/geometry-7', hubName:'Геометрия 7', ch:'Глава 4 · Сумма углов треугольника', range:'§19–§26', color:'#0891b2', colorD:'#0e7490', wm:'∠' },
|
||||||
|
{ f:'geometry_7_ch5.html', hub:'/textbook/geometry-7', hubName:'Геометрия 7', ch:'Глава 5 · Задачи на построение', range:'§27–§31', color:'#db2777', colorD:'#9d174d', wm:'◯' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tpl = ({ hub, hubName, ch, range, color, colorD, wm }) => `<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>${ch}</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@600;800;900&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#fafafa;--card:#fff;--text:#0f172a;--muted:#64748b;--border:#e2e8f0;--pri:${color};--pri-d:${colorD};--pri-soft:${color}1a}
|
||||||
|
html.dark{--bg:#0a0a0e;--card:#13120a;--text:#fef9e7;--muted:#a39070;--border:#2a2512}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{min-height:100vh}
|
||||||
|
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55}
|
||||||
|
.hdr{position:relative;background:linear-gradient(110deg,${colorD},${color} 60%,${color}cc);color:#fff;padding:46px 22px 30px;overflow:hidden;border-bottom:2px solid ${color}33}
|
||||||
|
.hdr::before{content:'${wm}';position:absolute;right:-12px;top:50%;transform:translateY(-50%);font-family:'Outfit',sans-serif;font-size:clamp(5rem,15vw,11rem);font-weight:900;color:transparent;-webkit-text-stroke:1.5px rgba(255,255,255,.12);line-height:1;pointer-events:none}
|
||||||
|
.hdr-inner{position:relative;z-index:1;max-width:1100px;margin:0 auto;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
||||||
|
.hdr-back{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;background:rgba(255,255,255,.14);border-radius:9px;color:#fff;text-decoration:none;font-size:.85rem;font-weight:600}
|
||||||
|
.hdr-back:hover{background:rgba(255,255,255,.24)}
|
||||||
|
.hdr h1{font-family:'Outfit',sans-serif;font-size:1.6rem;font-weight:900}
|
||||||
|
.hdr-sub{font-size:.92rem;opacity:.85;margin-top:4px}
|
||||||
|
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;display:inline-block;vertical-align:middle}
|
||||||
|
main{max-width:740px;margin:0 auto;padding:48px 22px 80px}
|
||||||
|
.coming{background:var(--card);border:1.5px solid var(--border);border-radius:18px;padding:32px 28px;text-align:center;box-shadow:0 4px 18px rgba(0,0,0,.05)}
|
||||||
|
.coming-icon{width:72px;height:72px;border-radius:20px;background:var(--pri-soft);display:flex;align-items:center;justify-content:center;margin:0 auto 18px;color:var(--pri-d)}
|
||||||
|
.coming-icon svg{width:36px;height:36px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
.coming h2{font-family:'Outfit',sans-serif;font-size:1.5rem;color:var(--pri-d);margin-bottom:12px}
|
||||||
|
.coming p{font-size:1rem;color:var(--muted);margin-bottom:8px}
|
||||||
|
.coming p b{color:var(--text)}
|
||||||
|
.coming-cta{margin-top:24px;display:inline-flex;align-items:center;gap:8px;padding:12px 22px;background:linear-gradient(135deg,var(--pri),var(--pri-d));color:#fff;border-radius:12px;font-weight:700;text-decoration:none;box-shadow:0 6px 22px ${color}33}
|
||||||
|
.coming-cta:hover{filter:brightness(1.08)}
|
||||||
|
.range-pill{display:inline-block;padding:5px 13px;background:var(--pri-soft);color:var(--pri-d);border-radius:99px;font-size:.84rem;font-weight:700;margin-top:6px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="hdr-inner">
|
||||||
|
<div>
|
||||||
|
<a href="${hub}" class="hdr-back">
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
К ${hubName}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>${ch}</h1>
|
||||||
|
<div class="hdr-sub">${range}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="coming">
|
||||||
|
<div class="coming-icon">
|
||||||
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
</div>
|
||||||
|
<h2>Глава в разработке</h2>
|
||||||
|
<p>Эта глава — часть нового курса <b>${hubName}</b>.</p>
|
||||||
|
<p>Содержание (${range}) уже спланировано — теория, интерактивы и финальный босс появятся в одной из ближайших волн реализации.</p>
|
||||||
|
<div class="range-pill">${range}</div>
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<a href="${hub}" class="coming-cta">
|
||||||
|
Вернуться к учебнику
|
||||||
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const s of STUBS) {
|
||||||
|
fs.writeFileSync(path.join(root, s.f), tpl(s), 'utf8');
|
||||||
|
console.log('wrote ' + s.f);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Перенос §31-36 из монолитного physics_9.html в physics_9_ch4.html.
|
||||||
|
// - Извлекает CSS-блок монолита и инжектит в ch4 (стили .para-hero, .formula-grid, .fcard, .def-box, .remember-box и т.д. нужны)
|
||||||
|
// - Извлекает HTML-тело каждого §31..§36
|
||||||
|
// - Убирает emoji (нарушают правило проекта) и Font Awesome <i> теги
|
||||||
|
// - Подключает Font Awesome CDN для совместимости (на случай если внутри остались)
|
||||||
|
// - Заменяет STUB-builder в physics_9_ch4.html на реальный контент
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_9.html');
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_9_ch4.html');
|
||||||
|
|
||||||
|
const src = fs.readFileSync(SRC, 'utf8');
|
||||||
|
let ch4 = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
// === 1. Извлекаем CSS-блок монолита ===
|
||||||
|
const styleStart = src.indexOf('<style>') + '<style>'.length;
|
||||||
|
const styleEnd = src.indexOf('</style>', styleStart);
|
||||||
|
const monolithCss = src.slice(styleStart, styleEnd);
|
||||||
|
console.log('monolith CSS:', monolithCss.length, 'bytes');
|
||||||
|
|
||||||
|
// === 2. Извлекаем тела §31..§36 ===
|
||||||
|
const PARAS = {};
|
||||||
|
const REF_END_36 = src.indexOf('Проверка закона сохранения импульса');
|
||||||
|
const refEnd = src.lastIndexOf('<!-- ═', REF_END_36 > 0 ? REF_END_36 : src.length);
|
||||||
|
|
||||||
|
for (let n = 31; n <= 36; n++) {
|
||||||
|
const tag = `id="tab-ref${n}"`;
|
||||||
|
const i = src.indexOf(tag);
|
||||||
|
if (i < 0) { console.log('miss', n); continue; }
|
||||||
|
// Найти конец: следующий tab-ref или (для §36) refEnd
|
||||||
|
let j;
|
||||||
|
if (n < 36) {
|
||||||
|
j = src.indexOf(`id="tab-ref${n+1}"`, i);
|
||||||
|
// Откатимся к комментарию ═══ перед следующим
|
||||||
|
const cm = src.lastIndexOf('<!--', j);
|
||||||
|
if (cm > i + 1000) j = cm;
|
||||||
|
} else {
|
||||||
|
j = refEnd;
|
||||||
|
}
|
||||||
|
// Найти позицию открывающего <div class="content ..." id="tab-refN">
|
||||||
|
const divStart = src.lastIndexOf('<div class="content', i);
|
||||||
|
// Извлекаем сырое тело — от <div class="content" id="tab-refN"> до boundary j
|
||||||
|
const raw = src.slice(divStart, j);
|
||||||
|
PARAS[n] = raw;
|
||||||
|
console.log(`§${n}: ${raw.length} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 3. Очистка: убрать emoji и Font Awesome <i> теги ===
|
||||||
|
function clean(s) {
|
||||||
|
return s
|
||||||
|
// Emoji (Unicode supplementary + dingbats + misc symbols)
|
||||||
|
.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{27BF}]|[\u{1F000}-\u{1F2FF}]|[\u{FE0F}]/gu, '')
|
||||||
|
// Font Awesome icons - заменяем на пусто (либо можно на SVG ниже)
|
||||||
|
.replace(/<i\s+class="fa[s ][^"]*"[^>]*>\s*<\/i>/g, '')
|
||||||
|
// Лишние пробелы после удалений
|
||||||
|
.replace(/(\s)\s+/g, '$1')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 4. Преобразуем тело каждого § в формат builder'а ===
|
||||||
|
// Builder ожидает: html += makeCard('theory', name, '§N', `BODY`);
|
||||||
|
// Поскольку наше body уже — большой готовый HTML с собственными классами, оборачиваем напрямую в <div>.
|
||||||
|
const PARA_NAMES = {
|
||||||
|
31:'Импульс тела. Импульс системы тел',
|
||||||
|
32:'Закон сохранения импульса. Реактивное движение',
|
||||||
|
33:'Механическая работа. Мощность',
|
||||||
|
34:'Потенциальная энергия',
|
||||||
|
35:'Кинетическая энергия. Полная энергия системы тел',
|
||||||
|
36:'Закон сохранения энергии',
|
||||||
|
};
|
||||||
|
|
||||||
|
// === 5. Заменяем STUB-builder каждого pN в ch4 файле ===
|
||||||
|
for (let n = 31; n <= 36; n++) {
|
||||||
|
const pid = 'p' + n;
|
||||||
|
let body = clean(PARAS[n]);
|
||||||
|
// Удаляем внешний <div class="content..." id="tab-refN"> и закрывающий </div>
|
||||||
|
body = body.replace(/^<div\s+class="content[^"]*"\s+id="tab-ref\d+">/, '');
|
||||||
|
// Найти и удалить ровно один соответствующий закрывающий </div> в конце
|
||||||
|
// (поскольку HTML может быть несбалансированным, безопаснее сделать regex по последнему </div>\s* в строке)
|
||||||
|
body = body.replace(/<\/div>\s*$/, '');
|
||||||
|
// Экранируем backticks и ${...} для template literal
|
||||||
|
const esc = body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
||||||
|
|
||||||
|
// Найти стандартный stub-блок для pN
|
||||||
|
// Stub имеет вид: makeCard('theory', "<name>", "§<n>", `\n <p>...в разработке...</p>\n <p>...</p>\n <p style="margin-top:10px;...">\n <b>Phase 0:</b>...<b>Phase 4+:</b>...\n </p>\n `);
|
||||||
|
// Используем regex с захватом до закрывающего `);
|
||||||
|
const stubRegex = new RegExp(
|
||||||
|
`makeCard\\('theory', "${PARA_NAMES[n].replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}", "\\u00a7${n}", \`[\\s\\S]*?\`\\);`
|
||||||
|
);
|
||||||
|
const match = ch4.match(stubRegex);
|
||||||
|
if (!match) {
|
||||||
|
console.error(`STUB not found for ${pid}`);
|
||||||
|
// Try simpler matcher
|
||||||
|
const simpleStub = `makeCard('theory', "${PARA_NAMES[n]}", "§${n}", `;
|
||||||
|
const idx = ch4.indexOf(simpleStub);
|
||||||
|
console.log(` simple-match for "${simpleStub.slice(0,50)}..." at`, idx);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const replacement = `makeCard('theory', ${JSON.stringify(PARA_NAMES[n])}, "§${n}", \`\n${esc}\n \`);`;
|
||||||
|
ch4 = ch4.replace(stubRegex, () => replacement);
|
||||||
|
console.log(`§${n} → builder replaced (${body.length} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 6. Инжектим CSS монолита в ch4 (перед </style>) ===
|
||||||
|
// Чтобы не дублировать — проверим, не уже ли инжекчено
|
||||||
|
if (!ch4.includes('/* === MONOLITH CSS (migrated from physics_9.html) === */')) {
|
||||||
|
const inject = `\n/* === MONOLITH CSS (migrated from physics_9.html) === */\n${monolithCss}\n/* === END MONOLITH CSS === */\n`;
|
||||||
|
ch4 = ch4.replace('</style>', inject + '</style>');
|
||||||
|
console.log('Monolith CSS injected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 7. Подключим Font Awesome CDN (на случай оставшихся <i>) ===
|
||||||
|
if (!ch4.includes('font-awesome')) {
|
||||||
|
ch4 = ch4.replace(
|
||||||
|
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">',
|
||||||
|
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">\n<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">'
|
||||||
|
);
|
||||||
|
console.log('Font Awesome CDN linked');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 8. phys9_legacy.js ===
|
||||||
|
if (!ch4.includes('phys9_legacy.js')) {
|
||||||
|
ch4 = ch4.replace(
|
||||||
|
'<script src="/js/phys.js" defer></script>',
|
||||||
|
'<script src="/js/phys.js" defer></script>\n<script src="/js/phys9_legacy.js" defer></script>'
|
||||||
|
);
|
||||||
|
console.log('phys9_legacy.js linked');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, ch4);
|
||||||
|
console.log('OK ch4 →', DST, 'bytes:', ch4.length);
|
||||||
|
|
||||||
|
// Sanity: parse inline scripts
|
||||||
|
const scriptMatches = [...ch4.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
console.log('inline <script> count:', scriptMatches.length);
|
||||||
|
for (const m of scriptMatches) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch(e) { console.error('JS PARSE FAIL:', e.message); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('all inline JS parses OK');
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
// Перенос всего содержимого physics_9.html в physics_9_ch1..ch5.html.
|
||||||
|
// - Извлекает CSS-блок монолита, инжектит в каждую ch-файл (стили нужны для рендера)
|
||||||
|
// - Извлекает HTML-тело каждого §1..§36 + лабораторного блока
|
||||||
|
// - Чистит emoji и Font Awesome <i>
|
||||||
|
// - Подключает FA CDN для совместимости
|
||||||
|
// - Заменяет STUB-builder для каждого pid на реальный контент
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const TBOOKS = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
const SRC = path.join(TBOOKS, 'physics_9.html');
|
||||||
|
|
||||||
|
const src = fs.readFileSync(SRC, 'utf8');
|
||||||
|
|
||||||
|
// === Распределение §N → главе ===
|
||||||
|
const CH_OF = {};
|
||||||
|
for (let n = 1; n <= 14; n++) CH_OF[n] = 1;
|
||||||
|
for (let n = 15; n <= 24; n++) CH_OF[n] = 2;
|
||||||
|
for (let n = 25; n <= 30; n++) CH_OF[n] = 3;
|
||||||
|
for (let n = 31; n <= 36; n++) CH_OF[n] = 4;
|
||||||
|
|
||||||
|
// Заголовки § (для матчинга STUB) — должны точно совпадать с PARA_NAMES в gen_phys9_ch.js
|
||||||
|
const PARA_NAMES = {
|
||||||
|
1:'Механическое движение',
|
||||||
|
2:'Относительность движения. Система отсчёта',
|
||||||
|
3:'Скалярные и векторные величины. Действия над векторами',
|
||||||
|
4:'Проекция вектора на ось',
|
||||||
|
5:'Путь и перемещение',
|
||||||
|
6:'Равномерное прямолинейное движение. Скорость',
|
||||||
|
7:'Графическое представление равномерного движения',
|
||||||
|
8:'Неравномерное движение. Средняя и мгновенная скорость',
|
||||||
|
9:'Сложение скоростей',
|
||||||
|
10:'Ускорение',
|
||||||
|
11:'Скорость при равноускоренном движении',
|
||||||
|
12:'Перемещение, координата и путь при равноускоренном движении',
|
||||||
|
13:'Линейная и угловая скорости',
|
||||||
|
14:'Ускорение точки при движении по окружности',
|
||||||
|
15:'Взаимодействие тел. Сила. ИСО. 1-й закон Ньютона',
|
||||||
|
16:'Масса',
|
||||||
|
17:'Второй закон Ньютона',
|
||||||
|
18:'Третий закон Ньютона. Принцип относительности Галилея',
|
||||||
|
19:'Деформация тел. Сила упругости. Закон Гука',
|
||||||
|
20:'Силы трения. Силы сопротивления среды',
|
||||||
|
21:'Движение тела под действием силы тяжести',
|
||||||
|
22:'Движение тела, брошенного под углом к горизонту',
|
||||||
|
23:'Закон всемирного тяготения',
|
||||||
|
24:'Вес. Невесомость и перегрузки',
|
||||||
|
25:'Условия равновесия тел. Момент силы',
|
||||||
|
26:'Простые механизмы. Рычаги. Блоки',
|
||||||
|
27:'Наклонная плоскость. «Золотое правило» механики. КПД',
|
||||||
|
28:'Центр тяжести. Виды равновесия',
|
||||||
|
29:'Закон Архимеда. Выталкивающая сила',
|
||||||
|
30:'Плавание судов. Воздухоплавание',
|
||||||
|
31:'Импульс тела. Импульс системы тел',
|
||||||
|
32:'Закон сохранения импульса. Реактивное движение',
|
||||||
|
33:'Механическая работа. Мощность',
|
||||||
|
34:'Потенциальная энергия',
|
||||||
|
35:'Кинетическая энергия. Полная энергия системы тел',
|
||||||
|
36:'Закон сохранения энергии',
|
||||||
|
};
|
||||||
|
|
||||||
|
// === Извлекаем CSS ===
|
||||||
|
const styleStart = src.indexOf('<style>') + '<style>'.length;
|
||||||
|
const styleEnd = src.indexOf('</style>', styleStart);
|
||||||
|
const monolithCss = src.slice(styleStart, styleEnd);
|
||||||
|
|
||||||
|
// === Извлекаем §1..§36 ===
|
||||||
|
// Boundary для §36 — позиция h2 лабораторной секции
|
||||||
|
const labH2Pos = src.indexOf('Проверка закона сохранения импульса');
|
||||||
|
const labBoundary = labH2Pos > 0 ? src.lastIndexOf('<!-- ═', labH2Pos) : src.length;
|
||||||
|
|
||||||
|
const PARAS = {};
|
||||||
|
for (let n = 1; n <= 36; n++) {
|
||||||
|
const tag = `id="tab-ref${n}"`;
|
||||||
|
const i = src.indexOf(tag);
|
||||||
|
if (i < 0) { console.warn('miss §' + n); continue; }
|
||||||
|
let j;
|
||||||
|
if (n < 36) {
|
||||||
|
j = src.indexOf(`id="tab-ref${n+1}"`, i);
|
||||||
|
const cm = src.lastIndexOf('<!--', j);
|
||||||
|
if (cm > i + 1000) j = cm;
|
||||||
|
} else {
|
||||||
|
j = labBoundary;
|
||||||
|
}
|
||||||
|
const divStart = src.lastIndexOf('<div class="content', i);
|
||||||
|
PARAS[n] = src.slice(divStart, j);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Извлекаем лабораторный блок ЛР11 (для Ch5) ===
|
||||||
|
// В монолите есть одна секция id="tab-lab11" — "Проверка закона сохранения импульса".
|
||||||
|
let LAB_BLOCK = null;
|
||||||
|
{
|
||||||
|
const labStart = src.indexOf('id="tab-lab11"');
|
||||||
|
if (labStart >= 0) {
|
||||||
|
const divStart = src.lastIndexOf('<div class="content', labStart);
|
||||||
|
const labEnd = src.indexOf('<!-- ═', labStart + 200);
|
||||||
|
if (labEnd > 0) {
|
||||||
|
LAB_BLOCK = src.slice(divStart, labEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Очистка от emoji + FA ===
|
||||||
|
function clean(s) {
|
||||||
|
return s
|
||||||
|
.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{27BF}]|[\u{1F000}-\u{1F2FF}]|[\u{FE0F}]/gu, '')
|
||||||
|
.replace(/<i\s+class="fa[s ][^"]*"[^>]*>\s*<\/i>/g, '')
|
||||||
|
.replace(/(\s)\s+/g, '$1')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Замена STUB в ch-файле ===
|
||||||
|
function migrateChapter(chN, paraNums) {
|
||||||
|
const dstPath = path.join(TBOOKS, `physics_9_ch${chN}.html`);
|
||||||
|
let h = fs.readFileSync(dstPath, 'utf8');
|
||||||
|
const before = h.length;
|
||||||
|
|
||||||
|
for (const n of paraNums) {
|
||||||
|
const pid = 'p' + n;
|
||||||
|
if (!PARAS[n]) { console.warn(`skip ${pid} — no source`); continue; }
|
||||||
|
let body = clean(PARAS[n]);
|
||||||
|
// Удаляем внешний контейнер
|
||||||
|
body = body.replace(/^<div\s+class="content[^"]*"\s+id="tab-ref\d+">/, '');
|
||||||
|
body = body.replace(/<\/div>\s*$/, '');
|
||||||
|
// Экранируем для template literal
|
||||||
|
const esc = body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
||||||
|
|
||||||
|
// Найти STUB makeCard блок для этого pid
|
||||||
|
const titleEsc = PARA_NAMES[n].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const stubRegex = new RegExp(
|
||||||
|
`makeCard\\('theory', "${titleEsc}", "\\u00a7${n}", \`[\\s\\S]*?\`\\);`
|
||||||
|
);
|
||||||
|
const match = h.match(stubRegex);
|
||||||
|
if (!match) {
|
||||||
|
console.error(`STUB not found for ${pid} (ch${chN})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const replacement = `makeCard('theory', ${JSON.stringify(PARA_NAMES[n])}, "§${n}", \`\n${esc}\n \`);`;
|
||||||
|
h = h.replace(stubRegex, () => replacement);
|
||||||
|
console.log(` §${n} → ${body.length} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инжект CSS монолита
|
||||||
|
if (!h.includes('/* === MONOLITH CSS (migrated from physics_9.html) === */')) {
|
||||||
|
const inject = `\n/* === MONOLITH CSS (migrated from physics_9.html) === */\n${monolithCss}\n/* === END MONOLITH CSS === */\n`;
|
||||||
|
h = h.replace('</style>', inject + '</style>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// FA CDN + widget CSS
|
||||||
|
if (!h.includes('font-awesome')) {
|
||||||
|
h = h.replace(
|
||||||
|
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">',
|
||||||
|
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">\n<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!h.includes('phys-textbook-widgets.css')) {
|
||||||
|
h = h.replace(
|
||||||
|
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">',
|
||||||
|
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">\n<link rel="stylesheet" href="/css/phys-textbook-widgets.css">'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// phys9_legacy.js (provides startAnim1, lab11*, checkNum, togglePend36, etc.)
|
||||||
|
if (!h.includes('phys9_legacy.js')) {
|
||||||
|
h = h.replace(
|
||||||
|
'<script src="/js/phys.js" defer></script>',
|
||||||
|
'<script src="/js/phys.js" defer></script>\n<script src="/js/phys9_legacy.js" defer></script>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(dstPath, h);
|
||||||
|
console.log(`ch${chN}: ${before} → ${h.length} bytes`);
|
||||||
|
|
||||||
|
// Sanity: parse inline scripts
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch(e) { console.error(`JS PARSE FAIL in ch${chN}:`, e.message); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log(`ch${chN}: inline JS parses OK`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Ch5: лабораторный блок целиком в первую ЛР (lr11 — единственная описанная) ===
|
||||||
|
function migrateCh5(chN = 5) {
|
||||||
|
if (!LAB_BLOCK) {
|
||||||
|
console.log('ch5: no lab block found in source, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dstPath = path.join(TBOOKS, `physics_9_ch${chN}.html`);
|
||||||
|
let h = fs.readFileSync(dstPath, 'utf8');
|
||||||
|
const before = h.length;
|
||||||
|
|
||||||
|
// Очищаем лаб-блок
|
||||||
|
let body = clean(LAB_BLOCK);
|
||||||
|
const esc = body.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
||||||
|
|
||||||
|
// В монолите есть единственная ЛР "Проверка закона сохранения импульса" — ставим её в lr11.
|
||||||
|
// Остальные 11 ЛР остаются STUB.
|
||||||
|
const stubRegex = /makeCard\('theory', "Проверка закона сохранения импульса", "ЛР 11", `[\s\S]*?`\);/;
|
||||||
|
const match = h.match(stubRegex);
|
||||||
|
if (match) {
|
||||||
|
const replacement = `makeCard('lab', "Проверка закона сохранения импульса", "ЛР 11", \`\n${esc}\n \`);`;
|
||||||
|
h = h.replace(stubRegex, () => replacement);
|
||||||
|
console.log(` ЛР11 → ${body.length} bytes`);
|
||||||
|
} else {
|
||||||
|
console.warn('ЛР11 stub not found — leaving Ch5 untouched');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инжект CSS и FA как в других
|
||||||
|
if (!h.includes('/* === MONOLITH CSS (migrated from physics_9.html) === */')) {
|
||||||
|
const inject = `\n/* === MONOLITH CSS (migrated from physics_9.html) === */\n${monolithCss}\n/* === END MONOLITH CSS === */\n`;
|
||||||
|
h = h.replace('</style>', inject + '</style>');
|
||||||
|
}
|
||||||
|
if (!h.includes('font-awesome')) {
|
||||||
|
h = h.replace(
|
||||||
|
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">',
|
||||||
|
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">\n<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!h.includes('phys9_legacy.js')) {
|
||||||
|
h = h.replace(
|
||||||
|
'<script src="/js/phys.js" defer></script>',
|
||||||
|
'<script src="/js/phys.js" defer></script>\n<script src="/js/phys9_legacy.js" defer></script>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(dstPath, h);
|
||||||
|
console.log(`ch5: ${before} → ${h.length} bytes`);
|
||||||
|
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch(e) { console.error(`JS PARSE FAIL in ch5:`, e.message); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log(`ch5: inline JS parses OK`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Run ===
|
||||||
|
console.log('=== ch1 (§1-14) ===');
|
||||||
|
migrateChapter(1, [1,2,3,4,5,6,7,8,9,10,11,12,13,14]);
|
||||||
|
console.log('=== ch2 (§15-24) ===');
|
||||||
|
migrateChapter(2, [15,16,17,18,19,20,21,22,23,24]);
|
||||||
|
console.log('=== ch3 (§25-30) ===');
|
||||||
|
migrateChapter(3, [25,26,27,28,29,30]);
|
||||||
|
// ch4 — уже мигрирована migrate_phys9_ch4.js, не трогаем повторно
|
||||||
|
console.log('=== ch5 (lab) ===');
|
||||||
|
migrateCh5();
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Inject task panels (ptab-pN scaffold + JS auto-render) into physics_9_ch4.html
|
||||||
|
// for §31..§36 — the only paragraphs with TASKS_PN arrays in the monolith.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const TBOOKS = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
const SRC = path.join(TBOOKS, 'physics_9.html');
|
||||||
|
const DST = path.join(TBOOKS, 'physics_9_ch4.html');
|
||||||
|
|
||||||
|
const src = fs.readFileSync(SRC, 'utf8');
|
||||||
|
let ch4 = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
// === Extract each ptab-pN block (N in 31..36) ===
|
||||||
|
function clean(s) {
|
||||||
|
return s
|
||||||
|
.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{27BF}]|[\u{1F000}-\u{1F2FF}]|[\u{FE0F}]/gu, '')
|
||||||
|
.replace(/<i\s+class="fa[s ][^"]*"[^>]*>\s*<\/i>/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPtab(n) {
|
||||||
|
const tag = `id="ptab-p${n}"`;
|
||||||
|
const i = src.indexOf(tag);
|
||||||
|
if (i < 0) return null;
|
||||||
|
// Откатываемся к открывающему div
|
||||||
|
const divStart = src.lastIndexOf('<div', i);
|
||||||
|
// Ищем следующий ptab или конец tab-tasks
|
||||||
|
let endTag = src.indexOf(`id="ptab-p${n+1}"`, i);
|
||||||
|
if (endTag < 0) endTag = src.indexOf(`id="ptab-hard"`, i);
|
||||||
|
if (endTag < 0) endTag = i + 2000;
|
||||||
|
// Закрывающий </div> непосредственно перед следующим ptab
|
||||||
|
let closingDiv = src.lastIndexOf('</div>', endTag);
|
||||||
|
if (closingDiv < divStart) closingDiv = endTag;
|
||||||
|
return src.slice(divStart, closingDiv + 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PTABS = {};
|
||||||
|
for (let n = 31; n <= 36; n++) {
|
||||||
|
const b = extractPtab(n);
|
||||||
|
if (b) PTABS[n] = clean(b);
|
||||||
|
}
|
||||||
|
console.log('Extracted ptabs:', Object.keys(PTABS).map(k => `p${k}:${PTABS[k].length}b`).join(' '));
|
||||||
|
|
||||||
|
// === Inject ptab block + auto-render call into each build_pN in ch4 ===
|
||||||
|
// Pattern: each build_pN ends with `box.innerHTML = html; renderMath(box); wireReadBtn('pN');`
|
||||||
|
// We append the ptab HTML to the same box, then call window.goToTask('pN', 0) to start.
|
||||||
|
|
||||||
|
for (let n = 31; n <= 36; n++) {
|
||||||
|
if (!PTABS[n]) continue;
|
||||||
|
const pid = 'p' + n;
|
||||||
|
// Escape for template literal
|
||||||
|
const esc = PTABS[n].replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
||||||
|
|
||||||
|
// Find build_pN function end
|
||||||
|
const fnRegex = new RegExp(
|
||||||
|
`(function\\s+build_${pid}\\(\\)\\s*\\{[\\s\\S]*?wireReadBtn\\('${pid}'\\);)\\s*\\}`,
|
||||||
|
'm'
|
||||||
|
);
|
||||||
|
const match = ch4.match(fnRegex);
|
||||||
|
if (!match) {
|
||||||
|
console.warn(`build_${pid}: not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new function body: append ptab HTML + setup call
|
||||||
|
const injectedBlock = `
|
||||||
|
// === Задачи §${n} — task panel (auto-injected from monolith) ===
|
||||||
|
const tasksBlock = document.createElement('div');
|
||||||
|
tasksBlock.className = 'wg';
|
||||||
|
tasksBlock.style.marginTop = '20px';
|
||||||
|
tasksBlock.innerHTML = '<div class="wg-header"><span class="wg-badge">Задачи</span><div class="wg-title">Тренажёр §${n}</div></div>' + \`${esc}\`;
|
||||||
|
box.appendChild(tasksBlock);
|
||||||
|
// Auto-render first task
|
||||||
|
setTimeout(() => {
|
||||||
|
try { if (typeof goToTask === 'function') goToTask('${pid}', 0); }
|
||||||
|
catch(e) { console.warn('${pid} goToTask:', e.message); }
|
||||||
|
}, 80);`;
|
||||||
|
|
||||||
|
const newBody = match[1] + injectedBlock + '\n}';
|
||||||
|
ch4 = ch4.replace(fnRegex, () => newBody);
|
||||||
|
console.log(` build_${pid}: injected ptab (${esc.length} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, ch4);
|
||||||
|
console.log('ch4 size:', ch4.length);
|
||||||
|
|
||||||
|
// Sanity parse inline scripts
|
||||||
|
const scripts = [...ch4.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch(e) { console.error('JS PARSE FAIL:', e.message); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('inline JS parses OK');
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import sys, os
|
||||||
|
|
||||||
|
src = os.path.join(os.path.dirname(__file__), '../../frontend/lab.html')
|
||||||
|
|
||||||
|
with open(src, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# PATCH 1: Add animated ray buttons + lens-maker controls to lens panel
|
||||||
|
# Insert BEFORE the Aberrации section
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
OLD_LENS = (
|
||||||
|
' <div class="pp-hint">Тащи стрелку-предмет или фокус мышью</div>\n'
|
||||||
|
' <div style="margin-top:8px"></div>\n'
|
||||||
|
' <div class="gp-section-title" style="margin-bottom:6px">Аберрации</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
NEW_LENS = (
|
||||||
|
' <div class="pp-hint">Тащи стрелку-предмет или фокус мышью</div>\n'
|
||||||
|
' <div style="margin-top:8px"></div>\n'
|
||||||
|
' <!-- Feature 1: animated ray buttons -->\n'
|
||||||
|
' <div style="display:flex;gap:4px;margin-bottom:8px">\n'
|
||||||
|
' <button onclick="if(lensSim)lensSim.buildRays()" style="flex:1;padding:5px 0;border-radius:6px;border:none;background:linear-gradient(135deg,#06D6E0,#9B5DE5);color:#fff;font-size:.72rem;font-weight:700;cursor:pointer">Построить лучи</button>\n'
|
||||||
|
' <button onclick="if(lensSim)lensSim.resetRays()" style="padding:5px 9px;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#888;font-size:.78rem;cursor:pointer" title="Сбросить лучи">↺</button>\n'
|
||||||
|
' </div>\n'
|
||||||
|
' <!-- Feature 3: lens-maker toggle -->\n'
|
||||||
|
' <div style="margin-bottom:6px">\n'
|
||||||
|
' <label style="display:flex;align-items:center;gap:6px;font-size:.72rem;color:#ccc;cursor:pointer">\n'
|
||||||
|
' <input type="checkbox" id="ltog-lensmaker" onchange="lensToggleLM(this.checked)">\n'
|
||||||
|
' Подробный (R1/R2/n)\n'
|
||||||
|
' </label>\n'
|
||||||
|
' </div>\n'
|
||||||
|
' <!-- LM sliders (hidden by default) -->\n'
|
||||||
|
' <div id="ob-lm-sliders" style="display:none">\n'
|
||||||
|
' <div class="proj-slider-row" style="margin-bottom:6px">\n'
|
||||||
|
' <label style="font-size:.72rem;color:#ccc;width:60px">R1 = <span id="lm-r1-val" style="color:#FFD166;font-weight:700">200</span></label>\n'
|
||||||
|
' <input type="range" id="sl-lm-r1" min="-300" max="300" step="5" value="200" oninput="lensLMParam(\'R1\',this.value)" style="flex:1">\n'
|
||||||
|
' </div>\n'
|
||||||
|
' <div class="proj-slider-row" style="margin-bottom:6px">\n'
|
||||||
|
' <label style="font-size:.72rem;color:#ccc;width:60px">R2 = <span id="lm-r2-val" style="color:#FFD166;font-weight:700">-200</span></label>\n'
|
||||||
|
' <input type="range" id="sl-lm-r2" min="-300" max="300" step="5" value="-200" oninput="lensLMParam(\'R2\',this.value)" style="flex:1">\n'
|
||||||
|
' </div>\n'
|
||||||
|
' <div class="proj-slider-row" style="margin-bottom:8px">\n'
|
||||||
|
' <label style="font-size:.72rem;color:#ccc;width:60px">n = <span id="lm-n-val" style="color:#9B5DE5;font-weight:700">1.50</span></label>\n'
|
||||||
|
' <input type="range" id="sl-lm-n" min="1.3" max="2.4" step="0.05" value="1.5" oninput="lensLMParam(\'n\',this.value)" style="flex:1">\n'
|
||||||
|
' </div>\n'
|
||||||
|
' <div style="font-size:.68rem;color:#888;margin-bottom:6px">f = 1/((n-1)*(1/R1 - 1/R2))</div>\n'
|
||||||
|
' </div>\n'
|
||||||
|
' <div style="margin-top:0"></div>\n'
|
||||||
|
' <div class="gp-section-title" style="margin-bottom:6px">Аберрации</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if OLD_LENS not in content:
|
||||||
|
print('ERROR: lens panel marker not found'); exit(1)
|
||||||
|
|
||||||
|
content = content.replace(OLD_LENS, NEW_LENS, 1)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# PATCH 2: Add R slider + parabolic toggle to mirror panel
|
||||||
|
# Insert BEFORE the "Отображение" section
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
OLD_MIRROR = (
|
||||||
|
' <div class="gp-section-title" style="margin-bottom:6px">Отображение</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
NEW_MIRROR = (
|
||||||
|
' <!-- Feature 2: R-slider + parabolic toggle -->\n'
|
||||||
|
' <div style="margin-bottom:6px">\n'
|
||||||
|
' <label style="display:flex;align-items:center;gap:6px;font-size:.72rem;color:#ccc;cursor:pointer">\n'
|
||||||
|
' <input type="checkbox" id="mtog-useR" onchange="mirrorToggleR(this.checked)">\n'
|
||||||
|
' Радиус R (непрерывный)\n'
|
||||||
|
' </label>\n'
|
||||||
|
' </div>\n'
|
||||||
|
' <div id="ob-mirror-R-row" class="proj-slider-row" style="margin-bottom:6px;display:none">\n'
|
||||||
|
' <label style="font-size:.72rem;color:#ccc;width:60px">R = <span id="mirror-R-val" style="color:var(--cyan);font-weight:700">240</span></label>\n'
|
||||||
|
' <input type="range" id="sl-mirror-R" min="-250" max="250" step="5" value="240" oninput="mirrorRParam(this.value)" style="flex:1">\n'
|
||||||
|
' </div>\n'
|
||||||
|
' <div style="display:flex;gap:4px;margin-bottom:8px">\n'
|
||||||
|
' <button id="mirror-parab-btn" onclick="mirrorToggleParabolic(this)" style="flex:1;padding:5px 0;border-radius:6px;border:1px solid #333;background:#1a1a2e;color:#888;font-size:.72rem;cursor:pointer">Сферическое</button>\n'
|
||||||
|
' </div>\n'
|
||||||
|
' <div class="gp-section-title" style="margin-bottom:6px">Отображение</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if OLD_MIRROR not in content:
|
||||||
|
print('ERROR: mirror panel Отображение marker not found'); exit(1)
|
||||||
|
|
||||||
|
content = content.replace(OLD_MIRROR, NEW_MIRROR, 1)
|
||||||
|
|
||||||
|
with open(src, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print('OK')
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const targetFile = path.join(__dirname, '../../frontend/js/labs/opticsbench.js');
|
||||||
|
|
||||||
|
const ifSimCode = `/* ─────────────────────────────────────────────────────────────
|
||||||
|
4d. INTERFERENCE SIM — Newton's rings / Thin film / Polarization
|
||||||
|
Agent C — additive only, class InterferenceSim
|
||||||
|
─────────────────────────────────────────────────────────────*/
|
||||||
|
class InterferenceSim {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.W = 0; this.H = 0;
|
||||||
|
this.subMode = 'newton';
|
||||||
|
// Newton rings
|
||||||
|
this.nR = 200;
|
||||||
|
this.nNmax = 12;
|
||||||
|
// Thin film
|
||||||
|
this.tfT = 400;
|
||||||
|
this.tfN = 1.33;
|
||||||
|
this.tfTheta = 0;
|
||||||
|
this.tfPreset = 'soap';
|
||||||
|
// Polarization
|
||||||
|
this.polTheta = 45;
|
||||||
|
this.polSrc = 'unpolarized';
|
||||||
|
this._polTick = 0;
|
||||||
|
this._polRaf = null;
|
||||||
|
this.onUpdate = null;
|
||||||
|
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement || canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
fit() {
|
||||||
|
const p = this.canvas.parentElement;
|
||||||
|
if (!p) return;
|
||||||
|
const r = p.getBoundingClientRect();
|
||||||
|
this.W = this.canvas.width = r.width || p.offsetWidth || 600;
|
||||||
|
this.H = this.canvas.height = r.height || p.offsetHeight || 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubMode(sm) {
|
||||||
|
this.subMode = sm;
|
||||||
|
if (sm === 'polarization') {
|
||||||
|
this._polStart();
|
||||||
|
} else {
|
||||||
|
this._polStop();
|
||||||
|
}
|
||||||
|
this.draw();
|
||||||
|
if (this.onUpdate) this.onUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Newton Rings ──────────────────────────────────────── */
|
||||||
|
_drawNewton() {
|
||||||
|
const { ctx, W, H } = this;
|
||||||
|
const nm = window._obWavelength || 550;
|
||||||
|
const R = this.nR;
|
||||||
|
const nMax = this.nNmax;
|
||||||
|
const white = window._obWhiteLight;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.fillStyle = '#08081a';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const topH = Math.floor(H * 0.60);
|
||||||
|
const cx = W / 2, cy = topH / 2;
|
||||||
|
const maxR_mm = Math.sqrt(nMax * nm * 1e-6 * R);
|
||||||
|
const scale = Math.min(cx * 0.85, cy * 0.85) / (maxR_mm || 1);
|
||||||
|
|
||||||
|
for (let n = nMax; n >= 0; n--) {
|
||||||
|
const lambdas = white ? [420, 470, 510, 550, 590, 620, 680] : [nm];
|
||||||
|
for (const lam of lambdas) {
|
||||||
|
const rDark = Math.sqrt(n * lam * 1e-6 * R) * scale;
|
||||||
|
const rBright = Math.sqrt((n + 0.5) * lam * 1e-6 * R) * scale;
|
||||||
|
if (rDark > 0.5) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, rDark, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = white
|
||||||
|
? wavelengthToRGB(lam).replace(')', ',0.5)').replace('rgb', 'rgba')
|
||||||
|
: '#000000';
|
||||||
|
ctx.lineWidth = white ? 1.2 : 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
if (rBright > 0.5) {
|
||||||
|
const al = white ? 0.22 : 0.55;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, rBright, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = wavelengthToRGB(lam).replace(')', ',' + al + ')').replace('rgb', 'rgba');
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#000000'; ctx.fill();
|
||||||
|
|
||||||
|
if (window.LabFX && LabFX.glow && !white) {
|
||||||
|
const r1b = Math.sqrt(0.5 * nm * 1e-6 * R) * scale;
|
||||||
|
LabFX.glow.drawGlow(ctx, cx, cy, r1b, wavelengthToRGB(nm), 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, maxR_mm * scale * 1.05, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = '#334455'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
|
|
||||||
|
const crossY0 = topH + 8;
|
||||||
|
const crossH = H - crossY0 - 40;
|
||||||
|
if (crossH < 30) return;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0d0d20';
|
||||||
|
ctx.fillRect(0, crossY0, W, crossH + 36);
|
||||||
|
|
||||||
|
const glassY = crossY0 + crossH - 10;
|
||||||
|
ctx.fillStyle = '#1a3a5c';
|
||||||
|
ctx.fillRect(cx - maxR_mm * scale * 1.1, glassY, maxR_mm * scale * 2.2, 10);
|
||||||
|
|
||||||
|
const sagitta = (maxR_mm * maxR_mm) / (2 * R);
|
||||||
|
const sagPx = sagitta * scale;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, glassY - 1 - sagPx, maxR_mm * scale * 1.1, sagPx + 6, 0, 0, Math.PI);
|
||||||
|
ctx.fillStyle = 'rgba(100,180,255,0.15)'; ctx.fill();
|
||||||
|
ctx.strokeStyle = '#4499cc'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||||
|
|
||||||
|
for (let n = 0; n <= nMax; n++) {
|
||||||
|
const rD = Math.sqrt(n * nm * 1e-6 * R) * scale;
|
||||||
|
if (rD < 1) continue;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + rD, glassY); ctx.lineTo(cx + rD, glassY + 8);
|
||||||
|
ctx.moveTo(cx - rD, glassY); ctx.lineTo(cx - rD, glassY + 8);
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.font = '600 11px monospace'; ctx.fillStyle = '#667788'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Cross-section', cx, crossY0 + 14);
|
||||||
|
|
||||||
|
const r1d = Math.sqrt(nm * 1e-6 * R).toFixed(3);
|
||||||
|
this._drawHUD(ctx, W, H,
|
||||||
|
'r1 = sqrt(lam*R) = ' + r1d + ' mm | R=' + R + 'mm | lam=' + nm + 'nm');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Thin Film ─────────────────────────────────────────── */
|
||||||
|
_thinFilmColor(t_nm, n_film, theta_deg) {
|
||||||
|
const sinR = Math.sin(theta_deg * Math.PI / 180) / n_film;
|
||||||
|
const cosR = Math.sqrt(Math.max(0, 1 - sinR * sinR));
|
||||||
|
const opd = 2 * n_film * t_nm * cosR;
|
||||||
|
let rS = 0, gS = 0, bS = 0;
|
||||||
|
for (let lam = 380; lam <= 780; lam += 5) {
|
||||||
|
const phase = Math.PI * opd / lam;
|
||||||
|
const I = Math.cos(phase) * Math.cos(phase);
|
||||||
|
const rgb = wavelengthToRGB(lam);
|
||||||
|
const m = rgb.match(/\d+/g);
|
||||||
|
if (!m) continue;
|
||||||
|
rS += I * +m[0]; gS += I * +m[1]; bS += I * +m[2];
|
||||||
|
}
|
||||||
|
const sc = 255 / Math.max(rS, gS, bS, 1);
|
||||||
|
return 'rgb(' + Math.round(rS * sc) + ',' + Math.round(gS * sc) + ',' + Math.round(bS * sc) + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawThinFilm() {
|
||||||
|
const { ctx, W, H } = this;
|
||||||
|
const t = this.tfT;
|
||||||
|
const nf = this.tfN;
|
||||||
|
const theta = this.tfTheta;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.fillStyle = '#08081a';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const midY = H * 0.40;
|
||||||
|
const filmH = Math.max(28, H * 0.12);
|
||||||
|
const margin = W * 0.10;
|
||||||
|
const ang = theta * Math.PI / 180;
|
||||||
|
const skew = Math.tan(ang) * filmH * 0.5;
|
||||||
|
|
||||||
|
const grad = ctx.createLinearGradient(margin, 0, W - margin, 0);
|
||||||
|
for (let i = 0; i <= 20; i++) {
|
||||||
|
const frac = i / 20;
|
||||||
|
grad.addColorStop(frac, this._thinFilmColor(t * (0.3 + 0.7 * frac), nf, theta));
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(margin - skew, midY - filmH / 2);
|
||||||
|
ctx.lineTo(W - margin - skew, midY - filmH / 2);
|
||||||
|
ctx.lineTo(W - margin + skew, midY + filmH / 2);
|
||||||
|
ctx.lineTo(margin + skew, midY + filmH / 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = grad; ctx.fill();
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.font = '700 11px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('t=' + t + 'nm n=' + nf.toFixed(2), W / 2, midY);
|
||||||
|
|
||||||
|
const ax2 = W * 0.25, ay2 = midY - filmH / 2;
|
||||||
|
const ax1 = ax2 - Math.cos(ang) * 40, ay1 = ay2 - Math.sin(ang) * 40 - 20;
|
||||||
|
ctx.beginPath(); ctx.moveTo(ax1, ay1); ctx.lineTo(ax2, ay2);
|
||||||
|
ctx.strokeStyle = '#8ab4e8'; ctx.lineWidth = 1.5; ctx.stroke();
|
||||||
|
|
||||||
|
const col = this._thinFilmColor(t, nf, theta);
|
||||||
|
ctx.beginPath(); ctx.moveTo(ax2, ay2); ctx.lineTo(ax2 - Math.cos(ang) * 40, ay1);
|
||||||
|
ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.stroke();
|
||||||
|
|
||||||
|
const dx2 = Math.sin(ang) * filmH / nf;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ax2 + dx2, ay2 + filmH);
|
||||||
|
ctx.lineTo(ax2 + dx2 - Math.cos(ang) * 40, ay1 + filmH - 20);
|
||||||
|
ctx.strokeStyle = col; ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
|
||||||
|
const tvX0 = W * 0.55, tvW2 = W * 0.38;
|
||||||
|
const tvY0 = H * 0.05, tvH2 = H * 0.60;
|
||||||
|
ctx.fillStyle = '#0d0d22'; ctx.strokeStyle = '#2a2a4a'; ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
if (ctx.roundRect) ctx.roundRect(tvX0, tvY0, tvW2, tvH2, 8);
|
||||||
|
else ctx.rect(tvX0, tvY0, tvW2, tvH2);
|
||||||
|
ctx.fill(); ctx.stroke();
|
||||||
|
ctx.font = '600 10px sans-serif'; ctx.fillStyle = '#555'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Top view', tvX0 + tvW2 / 2, tvY0 + 14);
|
||||||
|
|
||||||
|
const tvRows = 28, tvCols = 36;
|
||||||
|
const cW = tvW2 / tvCols, cH = (tvH2 - 20) / tvRows;
|
||||||
|
for (let r = 0; r < tvRows; r++) {
|
||||||
|
for (let c = 0; c < tvCols; c++) {
|
||||||
|
ctx.fillStyle = this._thinFilmColor(t * (0.5 + c / tvCols), nf, theta * (r / tvRows));
|
||||||
|
ctx.fillRect(tvX0 + c * cW, tvY0 + 20 + r * cH, cW + 0.5, cH + 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sinR2 = Math.sin(ang) / nf;
|
||||||
|
const cosR2 = Math.sqrt(Math.max(0, 1 - sinR2 * sinR2));
|
||||||
|
const opd2 = (2 * nf * t * cosR2).toFixed(0);
|
||||||
|
this._drawHUD(ctx, W, H,
|
||||||
|
'2nt*cos(th_r)=' + opd2 + 'nm | t=' + t + 'nm n=' + nf.toFixed(2) + ' th=' + theta + 'deg');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Polarization ──────────────────────────────────────── */
|
||||||
|
_polStart() {
|
||||||
|
if (this._polRaf) return;
|
||||||
|
const loop = () => { this._polTick++; this.draw(); this._polRaf = requestAnimationFrame(loop); };
|
||||||
|
this._polRaf = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
_polStop() {
|
||||||
|
if (this._polRaf) { cancelAnimationFrame(this._polRaf); this._polRaf = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawPolarization() {
|
||||||
|
const { ctx, W, H } = this;
|
||||||
|
const theta = this.polTheta * Math.PI / 180;
|
||||||
|
const I_rel = Math.cos(theta) * Math.cos(theta);
|
||||||
|
const tick = this._polTick;
|
||||||
|
const white = window._obWhiteLight;
|
||||||
|
const nm = window._obWavelength || 550;
|
||||||
|
const beamCol = white ? '#ffffff' : wavelengthToRGB(nm);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.fillStyle = '#08081a';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const axisY = H * 0.45;
|
||||||
|
const stH = H * 0.38;
|
||||||
|
const st = [
|
||||||
|
{ x: W * 0.12, label: 'Источник', isFilter: false },
|
||||||
|
{ x: W * 0.38, label: 'Поляризатор P1', isFilter: true, angle: 0 },
|
||||||
|
{ x: W * 0.64, label: 'Анализатор P2', isFilter: true, angle: this.polTheta },
|
||||||
|
{ x: W * 0.88, label: 'Детектор', isFilter: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(st[0].x - 20, axisY); ctx.lineTo(st[3].x + 20, axisY);
|
||||||
|
ctx.strokeStyle = '#1a1a35'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
|
|
||||||
|
const segs = [
|
||||||
|
{ x0: st[0].x, x1: st[1].x, amp: 1, unpol: this.polSrc === 'unpolarized', ang: 0 },
|
||||||
|
{ x0: st[1].x, x1: st[2].x, amp: 1, unpol: false, ang: 0 },
|
||||||
|
{ x0: st[2].x, x1: st[3].x, amp: I_rel, unpol: false, ang: this.polTheta },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const seg of segs) {
|
||||||
|
const nA = 20;
|
||||||
|
const sdx = (seg.x1 - seg.x0) / nA;
|
||||||
|
for (let i = 0; i <= nA; i++) {
|
||||||
|
const bx = seg.x0 + i * sdx;
|
||||||
|
const phase = (bx * 0.08 - tick * 0.04) % (Math.PI * 2);
|
||||||
|
const bAmp = stH * 0.28 * seg.amp;
|
||||||
|
if (seg.unpol) {
|
||||||
|
for (let d = 0; d < 4; d++) {
|
||||||
|
const a = d * Math.PI / 4;
|
||||||
|
const oy = Math.sin(phase + d * 0.7) * bAmp;
|
||||||
|
ctx.beginPath(); ctx.moveTo(bx, axisY);
|
||||||
|
ctx.lineTo(bx + oy * Math.sin(a) * 0.25, axisY + oy * Math.cos(a));
|
||||||
|
ctx.strokeStyle = 'rgba(200,200,255,0.22)'; ctx.lineWidth = 1; ctx.stroke();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const oy = Math.sin(phase) * bAmp;
|
||||||
|
const a = seg.ang * Math.PI / 180;
|
||||||
|
const py = oy * Math.cos(a), px = oy * Math.sin(a) * 0.35;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(bx - px, axisY - py); ctx.lineTo(bx + px, axisY + py);
|
||||||
|
ctx.strokeStyle = (I_rel < 0.01 && seg.amp < 0.5)
|
||||||
|
? 'rgba(80,80,120,0.5)'
|
||||||
|
: beamCol.replace(')', ',0.75)').replace('rgb', 'rgba');
|
||||||
|
ctx.lineWidth = 1.5; ctx.stroke();
|
||||||
|
if (i % 3 === 0 && bAmp > 2) {
|
||||||
|
ctx.beginPath(); ctx.arc(bx + px, axisY + py, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = beamCol; ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of st) {
|
||||||
|
if (!s.isFilter) continue;
|
||||||
|
const a = s.angle * Math.PI / 180;
|
||||||
|
ctx.save(); ctx.translate(s.x, axisY);
|
||||||
|
ctx.fillStyle = 'rgba(80,120,200,0.18)';
|
||||||
|
ctx.fillRect(-4, -stH / 2, 8, stH);
|
||||||
|
ctx.strokeStyle = '#4466aa'; ctx.lineWidth = 1.5; ctx.strokeRect(-4, -stH / 2, 8, stH);
|
||||||
|
const axLen = stH * 0.45;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-Math.sin(a) * axLen, -Math.cos(a) * axLen);
|
||||||
|
ctx.lineTo( Math.sin(a) * axLen, Math.cos(a) * axLen);
|
||||||
|
ctx.strokeStyle = '#7aaeff'; ctx.lineWidth = 2; ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
ctx.font = '700 10px monospace'; ctx.fillStyle = '#7aaeff'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(s.angle + 'deg', s.x, axisY + stH / 2 + 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of st) {
|
||||||
|
ctx.font = '600 10px sans-serif'; ctx.fillStyle = '#667788'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(s.label, s.x, axisY - stH / 2 - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
const barX = W * 0.91, barW = 16;
|
||||||
|
const barY0 = axisY - stH / 2;
|
||||||
|
ctx.fillStyle = '#111122'; ctx.fillRect(barX, barY0, barW, stH);
|
||||||
|
const fillH2 = stH * I_rel;
|
||||||
|
if (fillH2 > 0) {
|
||||||
|
const bg = ctx.createLinearGradient(barX, barY0 + stH - fillH2, barX, barY0 + stH);
|
||||||
|
bg.addColorStop(0, beamCol); bg.addColorStop(1, 'rgba(0,0,0,0.2)');
|
||||||
|
ctx.fillStyle = bg; ctx.fillRect(barX, barY0 + stH - fillH2, barW, fillH2);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = '#334455'; ctx.lineWidth = 1; ctx.strokeRect(barX, barY0, barW, stH);
|
||||||
|
ctx.font = '600 9px monospace'; ctx.fillStyle = '#aaaaaa'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('I', barX + barW / 2, barY0 - 5);
|
||||||
|
|
||||||
|
if (this.polTheta >= 88) {
|
||||||
|
ctx.font = '700 13px sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Полное гашение', W / 2, H * 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.font = '10px sans-serif'; ctx.fillStyle = '#444466'; ctx.textAlign = 'right';
|
||||||
|
ctx.fillText('Угол Брюстера: отражённый свет поляризован (см. Преломление)', W - 10, H - 10);
|
||||||
|
|
||||||
|
const pct = (I_rel * 100).toFixed(1);
|
||||||
|
this._drawHUD(ctx, W, H,
|
||||||
|
'I/I0=cos2(th)=cos2(' + this.polTheta + 'deg)=' + I_rel.toFixed(3) + ' (' + pct + '%)');
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawHUD(ctx, W, H, text) {
|
||||||
|
const pad = 8, fs = 11;
|
||||||
|
ctx.font = '600 ' + fs + 'px monospace';
|
||||||
|
const tw = ctx.measureText(text).width;
|
||||||
|
const bx = (W - tw) / 2 - pad, by = H - 32;
|
||||||
|
const bw = tw + pad * 2, bh = fs + pad * 2;
|
||||||
|
ctx.fillStyle = 'rgba(10,10,30,0.82)';
|
||||||
|
ctx.beginPath();
|
||||||
|
if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 5);
|
||||||
|
else ctx.rect(bx, by, bw, bh);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = '#c8d8ff';
|
||||||
|
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(text, bx + pad, by + bh / 2);
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
if (this.subMode === 'newton') this._drawNewton();
|
||||||
|
else if (this.subMode === 'thinfilm') this._drawThinFilm();
|
||||||
|
else if (this.subMode === 'polarization') this._drawPolarization();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
const src = fs.readFileSync(targetFile, 'utf-8');
|
||||||
|
const markerStr = '4c. SPECTROMETER PANEL';
|
||||||
|
const markerIdx = src.indexOf(markerStr);
|
||||||
|
if (markerIdx < 0) {
|
||||||
|
console.error('ERROR: marker not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const insertIdx = src.lastIndexOf('/*', markerIdx);
|
||||||
|
if (insertIdx < 0) {
|
||||||
|
console.error('ERROR: comment start not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check InterferenceSim not already present
|
||||||
|
if (src.indexOf('class InterferenceSim') >= 0) {
|
||||||
|
console.log('InterferenceSim already present — skipping JS insertion');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = src.slice(0, insertIdx) + ifSimCode + src.slice(insertIdx);
|
||||||
|
fs.writeFileSync(targetFile, result, 'utf-8');
|
||||||
|
console.log('JS insertion OK. New size:', result.length);
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const targetFile = path.join(__dirname, '../../frontend/js/labs/opticsbench.js');
|
||||||
|
let src = fs.readFileSync(targetFile, 'utf-8');
|
||||||
|
|
||||||
|
// --- 1. Add ifSim variable declaration after freeSim ---
|
||||||
|
if (src.indexOf('var ifSim') < 0) {
|
||||||
|
src = src.replace(
|
||||||
|
'var freeSim = null; /* multi-lens free-build (Agent OB-A3) */',
|
||||||
|
'var freeSim = null; /* multi-lens free-build (Agent OB-A3) */\r\nvar ifSim = null; /* interference/polarization (Agent C) */'
|
||||||
|
);
|
||||||
|
console.log('Added ifSim declaration');
|
||||||
|
} else {
|
||||||
|
console.log('ifSim declaration already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Add 'interf' to tab array in obSwitchMode ---
|
||||||
|
const tabArrOld = "['lens', 'mirror', 'refraction', 'prism', 'freebuild', 'waves'].forEach(m => {";
|
||||||
|
const tabArrNew = "['lens', 'mirror', 'refraction', 'prism', 'freebuild', 'waves', 'interf'].forEach(m => {";
|
||||||
|
if (src.indexOf(tabArrNew) < 0) {
|
||||||
|
if (src.indexOf(tabArrOld) >= 0) {
|
||||||
|
src = src.replace(tabArrOld, tabArrNew);
|
||||||
|
console.log('Updated tab array');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: tab array old pattern not found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Tab array already updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Add 'ob-ctrl-interf' to control panels array ---
|
||||||
|
const ctrlArrOld = "['ob-ctrl-lens', 'ob-ctrl-mirror', 'ob-ctrl-refraction', 'ob-ctrl-prism', 'ob-ctrl-freebuild', 'ob-ctrl-waves'].forEach(id => {";
|
||||||
|
const ctrlArrNew = "['ob-ctrl-lens', 'ob-ctrl-mirror', 'ob-ctrl-refraction', 'ob-ctrl-prism', 'ob-ctrl-freebuild', 'ob-ctrl-waves', 'ob-ctrl-interf'].forEach(id => {";
|
||||||
|
if (src.indexOf(ctrlArrNew) < 0) {
|
||||||
|
if (src.indexOf(ctrlArrOld) >= 0) {
|
||||||
|
src = src.replace(ctrlArrOld, ctrlArrNew);
|
||||||
|
console.log('Updated ctrl panel array');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: ctrl panel array old pattern not found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Ctrl panel array already updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Add 'ob-stats-interf' to stats array ---
|
||||||
|
const statsArrOld = "['ob-stats-lens', 'ob-stats-mirror', 'ob-stats-refr', 'ob-stats-prism', 'ob-stats-freebuild', 'ob-stats-waves'].forEach(id => {";
|
||||||
|
const statsArrNew = "['ob-stats-lens', 'ob-stats-mirror', 'ob-stats-refr', 'ob-stats-prism', 'ob-stats-freebuild', 'ob-stats-waves', 'ob-stats-interf'].forEach(id => {";
|
||||||
|
if (src.indexOf(statsArrNew) < 0) {
|
||||||
|
if (src.indexOf(statsArrOld) >= 0) {
|
||||||
|
src = src.replace(statsArrOld, statsArrNew);
|
||||||
|
console.log('Updated stats array');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: stats array old pattern not found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Stats array already updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. Add 'ob-interf-canvas' to canvas arrays ---
|
||||||
|
const canvasIdsOld = "const canvasIds = ['ob-lens-canvas', 'ob-mirror-canvas', 'ob-refr-canvas', 'ob-prism-canvas', 'ob-free-canvas', 'ob-waves-canvas'];";
|
||||||
|
const canvasIdsNew = "const canvasIds = ['ob-lens-canvas', 'ob-mirror-canvas', 'ob-refr-canvas', 'ob-prism-canvas', 'ob-free-canvas', 'ob-waves-canvas', 'ob-interf-canvas'];";
|
||||||
|
if (src.indexOf(canvasIdsNew) < 0) {
|
||||||
|
if (src.indexOf(canvasIdsOld) >= 0) {
|
||||||
|
src = src.replace(canvasIdsOld, canvasIdsNew);
|
||||||
|
console.log('Updated canvasIds');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: canvasIds old pattern not found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('canvasIds already updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeCanvasOld = "const modeCanvas = { lens: 'ob-lens-canvas', mirror: 'ob-mirror-canvas', refraction: 'ob-refr-canvas', prism: 'ob-prism-canvas', freebuild: 'ob-free-canvas', waves: 'ob-waves-canvas' };";
|
||||||
|
const modeCanvasNew = "const modeCanvas = { lens: 'ob-lens-canvas', mirror: 'ob-mirror-canvas', refraction: 'ob-refr-canvas', prism: 'ob-prism-canvas', freebuild: 'ob-free-canvas', waves: 'ob-waves-canvas', interf: 'ob-interf-canvas' };";
|
||||||
|
if (src.indexOf(modeCanvasNew) < 0) {
|
||||||
|
if (src.indexOf(modeCanvasOld) >= 0) {
|
||||||
|
src = src.replace(modeCanvasOld, modeCanvasNew);
|
||||||
|
console.log('Updated modeCanvas');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: modeCanvas old pattern not found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('modeCanvas already updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. Add 'interf' case in obSwitchMode, before closing brace ---
|
||||||
|
const interfCaseStr = ` } else if (mode === 'interf') { /* Agent C — interference / polarization */\r\n if (!ifSim) {\r\n const cv = document.getElementById('ob-interf-canvas');\r\n if (cv) { ifSim = new InterferenceSim(cv); ifSim.onUpdate = _ifUpdateUI; }\r\n }\r\n if (ifSim) { ifSim.fit(); ifSim.draw(); }\r\n _ifUpdateUI();\r\n }\r\n}`;
|
||||||
|
|
||||||
|
const oldModeEnd = ` } else if (mode === 'waves') { /* Agent B1 — diffraction & interference */\r\n if (!diffrSim) {\r\n const cv = document.getElementById('ob-waves-canvas');\r\n if (cv) diffrSim = new DiffractionSim(cv);\r\n }\r\n if (diffrSim) {\r\n diffrSim.fit();\r\n diffrSim.draw();\r\n diffrSim._updateHUD();\r\n }\r\n }\r\n}`;
|
||||||
|
|
||||||
|
const newModeEnd = ` } else if (mode === 'waves') { /* Agent B1 — diffraction & interference */\r\n if (!diffrSim) {\r\n const cv = document.getElementById('ob-waves-canvas');\r\n if (cv) diffrSim = new DiffractionSim(cv);\r\n }\r\n if (diffrSim) {\r\n diffrSim.fit();\r\n diffrSim.draw();\r\n diffrSim._updateHUD();\r\n }\r\n } else if (mode === 'interf') { /* Agent C — interference / polarization */\r\n if (!ifSim) {\r\n const cv = document.getElementById('ob-interf-canvas');\r\n if (cv) { ifSim = new InterferenceSim(cv); ifSim.onUpdate = _ifUpdateUI; }\r\n }\r\n if (ifSim) { ifSim.fit(); ifSim.draw(); }\r\n _ifUpdateUI();\r\n }\r\n}`;
|
||||||
|
|
||||||
|
if (src.indexOf('else if (mode === \'interf\')') < 0) {
|
||||||
|
if (src.indexOf(oldModeEnd) >= 0) {
|
||||||
|
src = src.replace(oldModeEnd, newModeEnd);
|
||||||
|
console.log('Added interf case in obSwitchMode');
|
||||||
|
} else {
|
||||||
|
// Try with LF only
|
||||||
|
const oldLF = oldModeEnd.replace(/\r\n/g, '\n');
|
||||||
|
const newLF = newModeEnd.replace(/\r\n/g, '\n');
|
||||||
|
if (src.indexOf(oldLF) >= 0) {
|
||||||
|
src = src.replace(oldLF, newLF);
|
||||||
|
console.log('Added interf case (LF variant)');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: waves mode end pattern not found - trying fallback');
|
||||||
|
// Fallback: find closing brace of obSwitchMode after diffrSim block
|
||||||
|
const marker2 = "diffrSim._updateHUD();\n }\n }\n}";
|
||||||
|
const marker2cr = "diffrSim._updateHUD();\r\n }\r\n }\r\n}";
|
||||||
|
const repl2 = "diffrSim._updateHUD();\n }\n } else if (mode === 'interf') {\n if (!ifSim) {\n const cv = document.getElementById('ob-interf-canvas');\n if (cv) { ifSim = new InterferenceSim(cv); ifSim.onUpdate = _ifUpdateUI; }\n }\n if (ifSim) { ifSim.fit(); ifSim.draw(); }\n _ifUpdateUI();\n }\n}";
|
||||||
|
const repl2cr = "diffrSim._updateHUD();\r\n }\r\n } else if (mode === 'interf') {\r\n if (!ifSim) {\r\n const cv = document.getElementById('ob-interf-canvas');\r\n if (cv) { ifSim = new InterferenceSim(cv); ifSim.onUpdate = _ifUpdateUI; }\r\n }\r\n if (ifSim) { ifSim.fit(); ifSim.draw(); }\r\n _ifUpdateUI();\r\n }\r\n}";
|
||||||
|
if (src.indexOf(marker2) >= 0) {
|
||||||
|
src = src.replace(marker2, repl2);
|
||||||
|
console.log('Added interf case (fallback LF)');
|
||||||
|
} else if (src.indexOf(marker2cr) >= 0) {
|
||||||
|
src = src.replace(marker2cr, repl2cr);
|
||||||
|
console.log('Added interf case (fallback CRLF)');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: all fallbacks failed for interf case');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('interf case already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 7. Add _ifUpdateUI function and control functions ---
|
||||||
|
const ifUICode = `
|
||||||
|
/* ── Interference mode UI callbacks (Agent C) ── */
|
||||||
|
function _ifUpdateUI() {
|
||||||
|
if (!ifSim) return;
|
||||||
|
const subMode = ifSim.subMode;
|
||||||
|
['if-ctrl-newton', 'if-ctrl-thinfilm', 'if-ctrl-polarization'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
});
|
||||||
|
const active = document.getElementById('if-ctrl-' + subMode);
|
||||||
|
if (active) active.style.display = '';
|
||||||
|
['if-sub-newton', 'if-sub-thinfilm', 'if-sub-polarization'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.classList.toggle('active', id === 'if-sub-' + subMode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifSwitchSub(sub) {
|
||||||
|
if (window.LabFX) LabFX.sound.play('chime');
|
||||||
|
if (!ifSim) return;
|
||||||
|
ifSim.setSubMode(sub);
|
||||||
|
_ifUpdateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifNewtParam(key, val) {
|
||||||
|
if (!ifSim) return;
|
||||||
|
const v = parseFloat(val);
|
||||||
|
if (key === 'R') { ifSim.nR = v; document.getElementById('if-newton-r-val').textContent = v; }
|
||||||
|
else if (key === 'nmax') { ifSim.nNmax = Math.round(v); document.getElementById('if-newton-n-val').textContent = Math.round(v); }
|
||||||
|
ifSim.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifThinFilmParam(key, val) {
|
||||||
|
if (!ifSim) return;
|
||||||
|
const v = parseFloat(val);
|
||||||
|
if (key === 't') { ifSim.tfT = v; document.getElementById('if-tf-t-val').textContent = v; }
|
||||||
|
else if (key === 'n') { ifSim.tfN = v; document.getElementById('if-tf-n-val').textContent = v.toFixed(2); }
|
||||||
|
else if (key === 'theta') { ifSim.tfTheta = v; document.getElementById('if-tf-th-val').textContent = v; }
|
||||||
|
ifSim.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifThinFilmPreset(name) {
|
||||||
|
if (!ifSim) return;
|
||||||
|
const presets = {
|
||||||
|
soap: { n: 1.33, label: 'Мыльная плёнка' },
|
||||||
|
oil: { n: 1.50, label: 'Масло на воде' },
|
||||||
|
coating: { n: 1.38, label: 'Антибликовое покрытие' },
|
||||||
|
};
|
||||||
|
const p = presets[name];
|
||||||
|
if (!p) return;
|
||||||
|
ifSim.tfN = p.n;
|
||||||
|
ifSim.tfPreset = name;
|
||||||
|
const slN = document.getElementById('sl-if-tf-n');
|
||||||
|
if (slN) slN.value = p.n;
|
||||||
|
const lbN = document.getElementById('if-tf-n-val');
|
||||||
|
if (lbN) lbN.textContent = p.n.toFixed(2);
|
||||||
|
ifSim.draw();
|
||||||
|
if (window.LabFX) LabFX.sound.play('chime');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifPolParam(key, val) {
|
||||||
|
if (!ifSim) return;
|
||||||
|
const v = parseFloat(val);
|
||||||
|
if (key === 'theta') { ifSim.polTheta = v; document.getElementById('if-pol-th-val').textContent = v; }
|
||||||
|
ifSim.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifPolSrc(val) {
|
||||||
|
if (!ifSim) return;
|
||||||
|
ifSim.polSrc = val;
|
||||||
|
ifSim.draw();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (src.indexOf('function _ifUpdateUI') < 0) {
|
||||||
|
// Insert before the closing of the file or before _obRedraw
|
||||||
|
const insertBefore = 'function _obRedraw()';
|
||||||
|
const insertIdx = src.indexOf(insertBefore);
|
||||||
|
if (insertIdx >= 0) {
|
||||||
|
src = src.slice(0, insertIdx) + ifUICode + '\r\n' + src.slice(insertIdx);
|
||||||
|
console.log('Added _ifUpdateUI and control functions');
|
||||||
|
} else {
|
||||||
|
src = src + '\r\n' + ifUICode;
|
||||||
|
console.log('Appended _ifUpdateUI at end');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('_ifUpdateUI already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 8. Make _obRedraw also redraw ifSim if active ---
|
||||||
|
const redrawOld = 'function _obRedraw() {';
|
||||||
|
const redrawNew = 'function _obRedraw() {';
|
||||||
|
// Find _obRedraw body and add ifSim redraw
|
||||||
|
if (src.indexOf('if (ifSim && _obMode') < 0) {
|
||||||
|
const marker3 = "if (prismSim && _obMode === 'prism')";
|
||||||
|
const repl3 = "if (ifSim && _obMode === 'interf') { ifSim.draw(); }\r\n if (prismSim && _obMode === 'prism')";
|
||||||
|
const marker3lf = "if (prismSim && _obMode === 'prism')";
|
||||||
|
if (src.indexOf(marker3) >= 0) {
|
||||||
|
src = src.replace(marker3, repl3);
|
||||||
|
console.log('Added ifSim redraw to _obRedraw');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: could not find _obRedraw prism marker');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(targetFile, src, 'utf-8');
|
||||||
|
console.log('Done. New size:', src.length);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const targetFile = path.join(__dirname, '../../frontend/js/labs/opticsbench.js');
|
||||||
|
let src = fs.readFileSync(targetFile, 'utf-8');
|
||||||
|
|
||||||
|
// Add ifSim to _obRedraw
|
||||||
|
const oldRedrawLine = " if (_obMode === 'waves' && diffrSim) { diffrSim.draw(); diffrSim._updateHUD(); }";
|
||||||
|
const newRedrawLine = " if (_obMode === 'waves' && diffrSim) { diffrSim.draw(); diffrSim._updateHUD(); }\r\n if (_obMode === 'interf' && ifSim) { ifSim.draw(); }";
|
||||||
|
|
||||||
|
if (src.indexOf(newRedrawLine) < 0 && src.indexOf(oldRedrawLine) >= 0) {
|
||||||
|
src = src.replace(oldRedrawLine, newRedrawLine);
|
||||||
|
console.log('Added ifSim to _obRedraw');
|
||||||
|
} else {
|
||||||
|
console.log('ifSim redraw already present or old line not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(targetFile, src, 'utf-8');
|
||||||
|
console.log('Done. Size:', src.length);
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const targetFile = path.join(__dirname, '../../frontend/lab.html');
|
||||||
|
let src = fs.readFileSync(targetFile, 'utf-8');
|
||||||
|
|
||||||
|
// --- 1. Add «Интерференция» tab button after Призма ---
|
||||||
|
const tabPrism = '<button id="ob-tab-prism" onclick="obSwitchMode(\'prism\')" class="ob-tab" style="flex:1;padding:8px 0;border:none;background:transparent;color:#ccc;font-size:.78rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s">Призма</button>';
|
||||||
|
const tabInterf = '\n <button id="ob-tab-interf" onclick="obSwitchMode(\'interf\')" class="ob-tab" style="flex:1;padding:8px 0;border:none;background:transparent;color:#ccc;font-size:.78rem;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s">Интерференция</button>';
|
||||||
|
|
||||||
|
if (src.indexOf('ob-tab-interf') < 0) {
|
||||||
|
if (src.indexOf(tabPrism) >= 0) {
|
||||||
|
src = src.replace(tabPrism, tabPrism + tabInterf);
|
||||||
|
console.log('Added Интерференция tab button');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: Prizm tab not found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Интерференция tab already present');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Add ob-ctrl-interf control panel after ob-ctrl-freebuild ---
|
||||||
|
// Find end of ob-ctrl-freebuild div (find the closing tag)
|
||||||
|
const ctrlFreeEnd = ' <div class="pp-hint">Тащи линзы или предмет по оси мышью</div>\n </div>';
|
||||||
|
const ctrlFreeEndCR = ' <div class="pp-hint">Тащи линзы или предмет по оси мышью</div>\r\n </div>';
|
||||||
|
|
||||||
|
const ctrlInterfHTML = `
|
||||||
|
<!-- ── Interference control panel (Agent C) ── -->
|
||||||
|
<div id="ob-ctrl-interf" class="proj-panel" style="width:240px;gap:0;flex-shrink:0;display:none">
|
||||||
|
<!-- Sub-mode buttons -->
|
||||||
|
<div class="gp-section-title" style="margin-bottom:6px">Эксперимент</div>
|
||||||
|
<div style="display:flex;gap:3px;margin-bottom:10px;flex-wrap:wrap">
|
||||||
|
<button id="if-sub-newton" class="preset-btn active" onclick="ifSwitchSub('newton')" style="font-size:.7rem;flex:1">Кольца Ньютона</button>
|
||||||
|
<button id="if-sub-thinfilm" class="preset-btn" onclick="ifSwitchSub('thinfilm')" style="font-size:.7rem;flex:1">Тонкая плёнка</button>
|
||||||
|
<button id="if-sub-polarization" class="preset-btn" onclick="ifSwitchSub('polarization')" style="font-size:.7rem;flex:1">Поляризация</button>
|
||||||
|
</div>
|
||||||
|
<!-- Newton rings controls -->
|
||||||
|
<div id="if-ctrl-newton">
|
||||||
|
<div class="gp-section-title" style="margin-bottom:6px">Кольца Ньютона</div>
|
||||||
|
<div class="proj-slider-row" style="margin-bottom:8px">
|
||||||
|
<label style="font-size:.78rem;color:#ccc;width:65px">R = <span id="if-newton-r-val" style="color:var(--cyan);font-weight:700">200</span> мм</label>
|
||||||
|
<input type="range" id="sl-if-newton-r" min="50" max="500" step="10" value="200" oninput="ifNewtParam('R',this.value)" style="flex:1">
|
||||||
|
</div>
|
||||||
|
<div class="proj-slider-row" style="margin-bottom:8px">
|
||||||
|
<label style="font-size:.78rem;color:#ccc;width:65px">n = <span id="if-newton-n-val" style="color:#FFD166;font-weight:700">12</span></label>
|
||||||
|
<input type="range" id="sl-if-newton-n" min="4" max="20" step="1" value="12" oninput="ifNewtParam('nmax',this.value)" style="flex:1">
|
||||||
|
</div>
|
||||||
|
<div class="pp-hint">r_n(dark) = sqrt(n*lambda*R)</div>
|
||||||
|
</div>
|
||||||
|
<!-- Thin film controls -->
|
||||||
|
<div id="if-ctrl-thinfilm" style="display:none">
|
||||||
|
<div class="gp-section-title" style="margin-bottom:6px">Тонкая плёнка</div>
|
||||||
|
<div class="proj-slider-row" style="margin-bottom:8px">
|
||||||
|
<label style="font-size:.78rem;color:#ccc;width:60px">t = <span id="if-tf-t-val" style="color:var(--cyan);font-weight:700">400</span></label>
|
||||||
|
<input type="range" id="sl-if-tf-t" min="50" max="2000" step="10" value="400" oninput="ifThinFilmParam('t',this.value)" style="flex:1">
|
||||||
|
</div>
|
||||||
|
<div class="proj-slider-row" style="margin-bottom:8px">
|
||||||
|
<label style="font-size:.78rem;color:#ccc;width:60px">n = <span id="if-tf-n-val" style="color:#FFD166;font-weight:700">1.33</span></label>
|
||||||
|
<input type="range" id="sl-if-tf-n" min="1.0" max="2.5" step="0.01" value="1.33" oninput="ifThinFilmParam('n',this.value)" style="flex:1">
|
||||||
|
</div>
|
||||||
|
<div class="proj-slider-row" style="margin-bottom:8px">
|
||||||
|
<label style="font-size:.78rem;color:#ccc;width:60px">θ = <span id="if-tf-th-val" style="color:#EF476F;font-weight:700">0</span>°</label>
|
||||||
|
<input type="range" id="sl-if-tf-th" min="0" max="60" step="1" value="0" oninput="ifThinFilmParam('theta',this.value)" style="flex:1">
|
||||||
|
</div>
|
||||||
|
<div class="gp-section-title" style="margin-bottom:4px">Пресет</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px">
|
||||||
|
<button class="preset-btn" onclick="ifThinFilmPreset('soap')" style="font-size:.68rem">Мыльная n=1.33</button>
|
||||||
|
<button class="preset-btn" onclick="ifThinFilmPreset('oil')" style="font-size:.68rem">Масло n=1.50</button>
|
||||||
|
<button class="preset-btn" onclick="ifThinFilmPreset('coating')" style="font-size:.68rem">Покрытие n=1.38</button>
|
||||||
|
</div>
|
||||||
|
<div class="pp-hint">2nt·cosθr = (m+0.5)λ — максимум</div>
|
||||||
|
</div>
|
||||||
|
<!-- Polarization controls -->
|
||||||
|
<div id="if-ctrl-polarization" style="display:none">
|
||||||
|
<div class="gp-section-title" style="margin-bottom:6px">Поляризация (Малюс)</div>
|
||||||
|
<div class="proj-slider-row" style="margin-bottom:8px">
|
||||||
|
<label style="font-size:.78rem;color:#ccc;width:60px">θ = <span id="if-pol-th-val" style="color:var(--cyan);font-weight:700">45</span>°</label>
|
||||||
|
<input type="range" id="sl-if-pol-th" min="0" max="90" step="1" value="45" oninput="ifPolParam('theta',this.value)" style="flex:1">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:8px">
|
||||||
|
<label style="font-size:.72rem;color:#ccc;display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||||
|
<input type="radio" name="if-pol-src" value="unpolarized" checked onchange="ifPolSrc(this.value)" style="accent-color:var(--violet)">
|
||||||
|
Неполяризованный
|
||||||
|
</label>
|
||||||
|
<label style="font-size:.72rem;color:#ccc;display:flex;align-items:center;gap:6px;cursor:pointer;margin-top:4px">
|
||||||
|
<input type="radio" name="if-pol-src" value="polarized" onchange="ifPolSrc(this.value)" style="accent-color:var(--violet)">
|
||||||
|
Поляризованный
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="pp-hint">I = I₀·cos²(θ)</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (src.indexOf('ob-ctrl-interf') < 0) {
|
||||||
|
let replaced = false;
|
||||||
|
if (src.indexOf(ctrlFreeEnd) >= 0) {
|
||||||
|
src = src.replace(ctrlFreeEnd, ctrlFreeEnd + ctrlInterfHTML);
|
||||||
|
replaced = true;
|
||||||
|
} else if (src.indexOf(ctrlFreeEndCR) >= 0) {
|
||||||
|
src = src.replace(ctrlFreeEndCR, ctrlFreeEndCR + ctrlInterfHTML);
|
||||||
|
replaced = true;
|
||||||
|
}
|
||||||
|
if (replaced) {
|
||||||
|
console.log('Added ob-ctrl-interf panel');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: freebuild ctrl end not found, trying alternate pattern');
|
||||||
|
// Try finding the shared canvas area comment
|
||||||
|
const canvasAreaMarker = '<!-- ── Shared canvas area';
|
||||||
|
const idx = src.indexOf(canvasAreaMarker);
|
||||||
|
if (idx >= 0) {
|
||||||
|
src = src.slice(0, idx) + ctrlInterfHTML + '\n ' + src.slice(idx);
|
||||||
|
console.log('Added ob-ctrl-interf before canvas area');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: canvas area marker not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('ob-ctrl-interf already present');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Add ob-interf-canvas after ob-waves-canvas ---
|
||||||
|
const wavesCanvas = '<canvas id="ob-waves-canvas"';
|
||||||
|
const interfCanvas = '\n <canvas id="ob-interf-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%;display:none"></canvas>';
|
||||||
|
if (src.indexOf('ob-interf-canvas') < 0) {
|
||||||
|
// find the waves canvas line
|
||||||
|
const wIdx = src.indexOf(wavesCanvas);
|
||||||
|
if (wIdx >= 0) {
|
||||||
|
// find end of that line
|
||||||
|
const eol = src.indexOf('>', wIdx) + 1;
|
||||||
|
const afterLine = src.indexOf('\n', eol);
|
||||||
|
src = src.slice(0, afterLine) + interfCanvas + src.slice(afterLine);
|
||||||
|
console.log('Added ob-interf-canvas');
|
||||||
|
} else {
|
||||||
|
// fallback: add after ob-free-canvas
|
||||||
|
const freeCanvas = '<canvas id="ob-free-canvas"';
|
||||||
|
const fIdx = src.indexOf(freeCanvas);
|
||||||
|
if (fIdx >= 0) {
|
||||||
|
const eol2 = src.indexOf('\n', src.indexOf('>', fIdx));
|
||||||
|
src = src.slice(0, eol2) + interfCanvas + src.slice(eol2);
|
||||||
|
console.log('Added ob-interf-canvas (after free)');
|
||||||
|
} else {
|
||||||
|
console.log('WARN: canvas insertion point not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('ob-interf-canvas already present');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Add ob-stats-interf in stats bar (after ob-stats-prism) ---
|
||||||
|
const prismStats = '</div>\n </div>\n </div>\n\n <!-- ── ISOPROCESS';
|
||||||
|
const prismStatsCR = '</div>\r\n </div>\r\n </div>\r\n\r\n <!-- ── ISOPROCESS';
|
||||||
|
const interfStats = `
|
||||||
|
<div id="ob-stats-interf" style="display:none;flex:1;gap:0">
|
||||||
|
<div class="pstat"><div class="pstat-label">Режим</div><div class="pstat-val" id="ifbar-sub" style="color:var(--cyan)">Кольца</div></div>
|
||||||
|
<div class="pstat"><div class="pstat-label">λ</div><div class="pstat-val" id="ifbar-wl" style="color:#FFFFFF">550 нм</div></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
if (src.indexOf('ob-stats-interf') < 0) {
|
||||||
|
// find the closing of ob-stats-prism section
|
||||||
|
const prismStatsBlock = '<div id="ob-stats-prism"';
|
||||||
|
const pIdx = src.indexOf(prismStatsBlock);
|
||||||
|
if (pIdx >= 0) {
|
||||||
|
// find the closing </div> of this section then the closing </div> of statsbar div
|
||||||
|
let depth = 0, i = pIdx;
|
||||||
|
while (i < src.length) {
|
||||||
|
if (src[i] === '<' && src.slice(i, i+5) === '<div ') depth++;
|
||||||
|
if (src[i] === '<' && src.slice(i, i+6) === '</div>') {
|
||||||
|
if (depth <= 1) {
|
||||||
|
// found end of ob-stats-prism
|
||||||
|
const afterDiv = src.indexOf('>', i) + 1;
|
||||||
|
src = src.slice(0, afterDiv) + interfStats + src.slice(afterDiv);
|
||||||
|
console.log('Added ob-stats-interf');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
depth--;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('WARN: ob-stats-prism not found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('ob-stats-interf already present');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(targetFile, src, 'utf-8');
|
||||||
|
console.log('HTML patching done. New size:', src.length);
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import sys, os
|
||||||
|
|
||||||
|
src = os.path.join(os.path.dirname(__file__), '../../frontend/js/labs/opticsbench.js')
|
||||||
|
|
||||||
|
with open(src, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# PATCH 1: Insert _drawRaysAnimated + _drawArrowLabels before _bindEvents
|
||||||
|
# in ThinLensSim
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
MARKER_BIND = ' _bindEvents() {\n const cv = this.canvas;\n const getPos = (e) => {'
|
||||||
|
idx = content.find(MARKER_BIND)
|
||||||
|
if idx == -1:
|
||||||
|
print('ERROR: _bindEvents marker not found'); sys.exit(1)
|
||||||
|
|
||||||
|
NEW_METHODS_1 = (
|
||||||
|
' /* === _drawRaysAnimated: principal rays with per-ray progress === */\n'
|
||||||
|
' _drawRaysAnimated(ctx, lx, ay, d, h, f, dPrime, hPrime) {\n'
|
||||||
|
' const T = this._rayAnimT;\n'
|
||||||
|
' if (T[0] >= 1 && T[1] >= 1 && T[2] >= 1) { this._drawRays(ctx, lx, ay, d, h, f, dPrime, hPrime); return; }\n'
|
||||||
|
' const objX = lx - d, objY = ay - h;\n'
|
||||||
|
' const hasImage = dPrime !== null && isFinite(dPrime);\n'
|
||||||
|
' const isVirtual = hasImage && dPrime < 0;\n'
|
||||||
|
' const COLORS = [\'#06D6E0\', \'#7BF5A4\', \'#FFD166\'];\n'
|
||||||
|
' ctx.lineWidth = 1.8;\n'
|
||||||
|
' const lerp = (a, b, t) => a + (b - a) * Math.min(1, Math.max(0, t));\n'
|
||||||
|
' const drawPts = (color, pts, t) => {\n'
|
||||||
|
' if (t <= 0 || pts.length < 2) return;\n'
|
||||||
|
' const totalLen = pts.reduce((s, p, i) => i === 0 ? 0 : s + Math.hypot(p[0]-pts[i-1][0], p[1]-pts[i-1][1]), 0);\n'
|
||||||
|
' const target = totalLen * t;\n'
|
||||||
|
' const draw = () => {\n'
|
||||||
|
' ctx.strokeStyle = color; ctx.setLineDash([]);\n'
|
||||||
|
' ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);\n'
|
||||||
|
' let drawn = 0;\n'
|
||||||
|
' for (let i = 1; i < pts.length; i++) {\n'
|
||||||
|
' const segLen = Math.hypot(pts[i][0]-pts[i-1][0], pts[i][1]-pts[i-1][1]);\n'
|
||||||
|
' if (drawn + segLen <= target) { ctx.lineTo(pts[i][0], pts[i][1]); drawn += segLen; }\n'
|
||||||
|
' else { const fr = segLen > 0 ? (target - drawn) / segLen : 0; ctx.lineTo(lerp(pts[i-1][0], pts[i][0], fr), lerp(pts[i-1][1], pts[i][1], fr)); break; }\n'
|
||||||
|
' }\n'
|
||||||
|
' ctx.stroke();\n'
|
||||||
|
' };\n'
|
||||||
|
' if (window.LabFX) LabFX.glow.drawGlow(ctx, draw, { color, intensity: 10 });\n'
|
||||||
|
' else draw();\n'
|
||||||
|
' };\n'
|
||||||
|
' const FAR = lx + 360;\n'
|
||||||
|
' const imgX = hasImage ? lx + dPrime : null, imgY = hasImage ? ay - hPrime : null;\n'
|
||||||
|
' // Ray 1: parallel to axis -> through F\'\n'
|
||||||
|
' if (T[0] > 0) {\n'
|
||||||
|
' let pts;\n'
|
||||||
|
' if (!hasImage) { pts = [[objX, objY], [lx, objY], [FAR, objY]]; }\n'
|
||||||
|
' else if (!isVirtual) { pts = [[objX, objY], [lx, objY], [imgX, imgY]]; }\n'
|
||||||
|
' else { const s = (objY - ay) / f; pts = [[objX, objY], [lx, objY], [FAR, objY + s*(FAR-lx)]]; }\n'
|
||||||
|
' drawPts(COLORS[0], pts, T[0]);\n'
|
||||||
|
' }\n'
|
||||||
|
' // Ray 2: through optical center (straight)\n'
|
||||||
|
' if (T[1] > 0) {\n'
|
||||||
|
' const s = (objY - ay) / (objX - lx);\n'
|
||||||
|
' drawPts(COLORS[1], [[objX, objY], [FAR, ay + s*(FAR-lx)]], T[1]);\n'
|
||||||
|
' }\n'
|
||||||
|
' // Ray 3: through front focus F -> parallel after lens\n'
|
||||||
|
' if (T[2] > 0) {\n'
|
||||||
|
' const fx = lx - f, s = (objY - ay) / (objX - fx);\n'
|
||||||
|
' const hitY = objY + s * (lx - objX);\n'
|
||||||
|
' const endX = hasImage && !isVirtual ? Math.max(imgX + 60, FAR) : FAR;\n'
|
||||||
|
' drawPts(COLORS[2], [[objX, objY], [lx, hitY], [endX, hitY]], T[2]);\n'
|
||||||
|
' }\n'
|
||||||
|
' }\n'
|
||||||
|
'\n'
|
||||||
|
' /* === Arrow labels: h_o, h_i, magnification Gamma === */\n'
|
||||||
|
' _drawArrowLabels(ctx, lx, ay, d, h, dPrime, hPrime) {\n'
|
||||||
|
' const objX = lx - d;\n'
|
||||||
|
' ctx.font = \'11px Manrope, system-ui, sans-serif\'; ctx.textBaseline = \'middle\';\n'
|
||||||
|
' ctx.fillStyle = \'rgba(155,93,229,0.85)\'; ctx.textAlign = \'right\';\n'
|
||||||
|
' ctx.fillText(\'ho=\' + h.toFixed(0), objX - 6, ay - h / 2);\n'
|
||||||
|
' if (dPrime !== null && isFinite(dPrime)) {\n'
|
||||||
|
' const imgX = lx + dPrime, isVirtual = dPrime < 0;\n'
|
||||||
|
' const M = -dPrime / d;\n'
|
||||||
|
' const Gstr = isFinite(M) ? (M >= 0 ? \'+\' : \'\') + M.toFixed(2) : \'---\';\n'
|
||||||
|
' const imgColor = isVirtual ? \'rgba(255,133,162,0.85)\' : \'rgba(6,214,224,0.85)\';\n'
|
||||||
|
' ctx.fillStyle = imgColor; ctx.textAlign = \'left\';\n'
|
||||||
|
' ctx.fillText("hi=" + Math.abs(hPrime).toFixed(0), imgX + 6, ay - hPrime / 2);\n'
|
||||||
|
' ctx.fillStyle = \'#FFD166\'; ctx.textAlign = \'center\';\n'
|
||||||
|
' ctx.fillText(\'G=\' + Gstr, (lx + imgX) / 2, ay + 60);\n'
|
||||||
|
' }\n'
|
||||||
|
' }\n'
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
new_content = content[:idx] + NEW_METHODS_1 + content[idx:]
|
||||||
|
content = new_content
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# PATCH 2: Add R slider + parabolic/spherical toggle to MirrorSim constructor
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Find MirrorSim constructor and add _R and _parabolic fields
|
||||||
|
OLD_MIRROR_CTOR = ' this._photonPaths = [];\n\n this._prevType = \'concave\';\n this._transT = 1.0;\n this._transRaf = null;\n\n this._drag = null;\n this._hoverX = -999;\n this._hoverY = -999;\n\n this.onUpdate = null;\n this.onAnimate = null;\n\n this._bindEvents();\n new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);\n }'
|
||||||
|
|
||||||
|
NEW_MIRROR_CTOR = (
|
||||||
|
' this._photonPaths = [];\n\n'
|
||||||
|
' this._prevType = \'concave\';\n'
|
||||||
|
' this._transT = 1.0;\n'
|
||||||
|
' this._transRaf = null;\n\n'
|
||||||
|
' this._drag = null;\n'
|
||||||
|
' this._hoverX = -999;\n'
|
||||||
|
' this._hoverY = -999;\n\n'
|
||||||
|
' this.onUpdate = null;\n'
|
||||||
|
' this.onAnimate = null;\n\n'
|
||||||
|
' /* Feature 2: R slider + spherical aberration toggle */\n'
|
||||||
|
' this._R = 240; // radius of curvature (positive=concave, negative=convex)\n'
|
||||||
|
' this._useR = false; // true = R-slider mode; false = classic type+f mode\n'
|
||||||
|
' this._parabolic = false; // false = spherical mirror; true = perfect parabolic\n'
|
||||||
|
'\n'
|
||||||
|
' this._bindEvents();\n'
|
||||||
|
' new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);\n'
|
||||||
|
' }'
|
||||||
|
)
|
||||||
|
|
||||||
|
if OLD_MIRROR_CTOR not in content:
|
||||||
|
print('ERROR: MirrorSim ctor block not found'); sys.exit(1)
|
||||||
|
|
||||||
|
content = content.replace(OLD_MIRROR_CTOR, NEW_MIRROR_CTOR, 1)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# PATCH 3: Add setMirrorR, setMirrorParabolic, _drawAberrationFan
|
||||||
|
# before MirrorSim._bindEvents
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Find the _bindEvents in MirrorSim (it's further down, after chk method)
|
||||||
|
MIRROR_BIND_MARKER = ' _bindEvents() {\n const cv = this.canvas;\n const getPos = e => {'
|
||||||
|
|
||||||
|
idx2 = content.find(MIRROR_BIND_MARKER)
|
||||||
|
if idx2 == -1:
|
||||||
|
print('ERROR: chk marker not found'); sys.exit(1)
|
||||||
|
|
||||||
|
NEW_METHODS_2 = (
|
||||||
|
' /* === Feature 2: R-slider mode for MirrorSim === */\n'
|
||||||
|
' setMirrorR(R) {\n'
|
||||||
|
' this._useR = true;\n'
|
||||||
|
' this._R = +R;\n'
|
||||||
|
' // Derive type and f from R\n'
|
||||||
|
' const absR = Math.abs(this._R);\n'
|
||||||
|
' if (absR < 5) { this.type = \'flat\'; }\n'
|
||||||
|
' else if (this._R > 0) { this.type = \'concave\'; this.f = absR / 2; }\n'
|
||||||
|
' else { this.type = \'convex\'; this.f = absR / 2; }\n'
|
||||||
|
' this.draw(); this._emit();\n'
|
||||||
|
' }\n'
|
||||||
|
'\n'
|
||||||
|
' setMirrorParabolic(on) {\n'
|
||||||
|
' this._parabolic = !!on;\n'
|
||||||
|
' this.draw();\n'
|
||||||
|
' }\n'
|
||||||
|
'\n'
|
||||||
|
' /* Draw 5 parallel rays showing spherical vs parabolic aberration */\n'
|
||||||
|
' _drawAberrationFan(ctx, mx, ay, f) {\n'
|
||||||
|
' if (!isFinite(f) || Math.abs(f) < 5) return;\n'
|
||||||
|
' const mH = Math.min(this.H * 0.38, 140);\n'
|
||||||
|
' const heights = [-0.85, -0.45, 0, 0.45, 0.85];\n'
|
||||||
|
' const COLORS = [\'#FF6B6B\', \'#FFD166\', \'#7BF5A4\', \'#06D6E0\', \'#B8A4FF\'];\n'
|
||||||
|
' ctx.save(); ctx.lineWidth = 1.4;\n'
|
||||||
|
' heights.forEach((fr, i) => {\n'
|
||||||
|
' const rayH = fr * mH;\n'
|
||||||
|
' // For parabolic mirror: all parallel rays focus exactly at f\n'
|
||||||
|
' // For spherical: marginal rays (fr != 0) focus closer by h^2/(2R) approx\n'
|
||||||
|
' const fEff = this._parabolic ? f : f - (rayH * rayH) / (2 * Math.abs(f) * 2);\n'
|
||||||
|
' const startX = mx - this.d - 40;\n'
|
||||||
|
' const hitY = ay - rayH; // hits mirror at height rayH\n'
|
||||||
|
' // Incident ray: horizontal from left to mirror\n'
|
||||||
|
' ctx.strokeStyle = COLORS[i]; ctx.globalAlpha = 0.75;\n'
|
||||||
|
' ctx.setLineDash([]);\n'
|
||||||
|
' ctx.beginPath(); ctx.moveTo(startX, ay - rayH); ctx.lineTo(mx, hitY); ctx.stroke();\n'
|
||||||
|
' // Reflected ray: goes toward focal point fEff\n'
|
||||||
|
' const focX = mx - fEff;\n'
|
||||||
|
' if (focX > 0 && focX < this.W) {\n'
|
||||||
|
' ctx.beginPath(); ctx.moveTo(mx, hitY); ctx.lineTo(focX, ay);\n'
|
||||||
|
' // extend a bit past focus\n'
|
||||||
|
' const dx = focX - mx, dy = ay - hitY, len = Math.hypot(dx, dy);\n'
|
||||||
|
' if (len > 1) ctx.lineTo(focX + dx/len*50, ay + dy/len*50);\n'
|
||||||
|
' ctx.stroke();\n'
|
||||||
|
' }\n'
|
||||||
|
' });\n'
|
||||||
|
' ctx.globalAlpha = 1;\n'
|
||||||
|
' // label\n'
|
||||||
|
' const label = this._parabolic ? \'Параболическое (идеальный фокус)\' : \'Сферическое (аберрация)\';\n'
|
||||||
|
' const col = this._parabolic ? \'#7BF5A4\' : \'#FF6B6B\';\n'
|
||||||
|
' const bx = 12, by = this.H - 36;\n'
|
||||||
|
' ctx.fillStyle = \'rgba(13,13,26,0.85)\';\n'
|
||||||
|
' ctx.beginPath(); ctx.roundRect(bx, by, 250, 24, 6); ctx.fill();\n'
|
||||||
|
' ctx.font = \'bold 11px Manrope, system-ui, sans-serif\';\n'
|
||||||
|
' ctx.textAlign = \'left\'; ctx.textBaseline = \'middle\';\n'
|
||||||
|
' ctx.fillStyle = col;\n'
|
||||||
|
' ctx.fillText(label, bx + 8, by + 12);\n'
|
||||||
|
' ctx.restore();\n'
|
||||||
|
' }\n'
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
new_content2 = content[:idx2] + NEW_METHODS_2 + content[idx2:]
|
||||||
|
content = new_content2
|
||||||
|
|
||||||
|
with open(src, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print('OK lines:', content.count('\n'))
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import sys, os
|
||||||
|
|
||||||
|
src = os.path.join(os.path.dirname(__file__), '../../frontend/js/labs/opticsbench.js')
|
||||||
|
|
||||||
|
with open(src, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# PATCH: In MirrorSim.draw(), after _drawFanRays, add aberration fan for _useR mode
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
OLD = (' this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill);\n'
|
||||||
|
' /* spherical aberration overlay (Agent OB-A3) */\n'
|
||||||
|
' if (this._showSpherical && this.type !== \'flat\' && isFinite(f))\n'
|
||||||
|
' this._drawMirrorSphericalAberration(ctx, mx, ay, f);\n'
|
||||||
|
' this._drawMirror(ctx, mx, ay);')
|
||||||
|
|
||||||
|
NEW = (' this._drawFanRays(ctx, mx, ay, f, dPrime, hPrime, showRay, showFill);\n'
|
||||||
|
' /* spherical aberration overlay (Agent OB-A3) */\n'
|
||||||
|
' if (this._showSpherical && this.type !== \'flat\' && isFinite(f))\n'
|
||||||
|
' this._drawMirrorSphericalAberration(ctx, mx, ay, f);\n'
|
||||||
|
' /* Feature 2: parabolic/spherical aberration fan */\n'
|
||||||
|
' if (this._useR && this.type !== \'flat\' && isFinite(f))\n'
|
||||||
|
' this._drawAberrationFan(ctx, mx, ay, f);\n'
|
||||||
|
' this._drawMirror(ctx, mx, ay);\n'
|
||||||
|
' /* Feature 2: R and f labels on mirror */\n'
|
||||||
|
' if (this._useR && this.type !== \'flat\' && isFinite(f)) {\n'
|
||||||
|
' ctx.save();\n'
|
||||||
|
' ctx.font = \'bold 11px Manrope, system-ui, sans-serif\';\n'
|
||||||
|
' ctx.fillStyle = \'rgba(6,214,224,0.9)\';\n'
|
||||||
|
' ctx.textAlign = \'right\'; ctx.textBaseline = \'bottom\';\n'
|
||||||
|
' ctx.fillText(\'R=\' + this._R.toFixed(0) + \' f=\' + f.toFixed(0), mx - 4, ay - 6);\n'
|
||||||
|
' ctx.restore();\n'
|
||||||
|
' }')
|
||||||
|
|
||||||
|
if OLD not in content:
|
||||||
|
print('ERROR: draw fan/mirror block not found'); sys.exit(1)
|
||||||
|
|
||||||
|
content = content.replace(OLD, NEW, 1)
|
||||||
|
|
||||||
|
with open(src, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print('OK lines:', content.count('\n'))
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import sys, os
|
||||||
|
|
||||||
|
src = os.path.join(os.path.dirname(__file__), '../../frontend/js/labs/opticsbench.js')
|
||||||
|
|
||||||
|
with open(src, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# PATCH: Insert new glue functions after lensPreset, before _lensUpdateUI
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
MARKER = 'function _lensUpdateUI(info) {'
|
||||||
|
|
||||||
|
idx = content.find(MARKER)
|
||||||
|
if idx == -1:
|
||||||
|
print('ERROR: _lensUpdateUI not found'); sys.exit(1)
|
||||||
|
|
||||||
|
NEW_FUNCS = (
|
||||||
|
'/* ── Lens animated ray + LM controls (Feature 1 & 3) ── */\n'
|
||||||
|
'function lensToggleLM(on) {\n'
|
||||||
|
' const sliders = document.getElementById(\'ob-lm-sliders\');\n'
|
||||||
|
' const fRow = document.querySelector(\'#ob-ctrl-lens .proj-slider-row\');\n'
|
||||||
|
' if (sliders) sliders.style.display = on ? \'\' : \'none\';\n'
|
||||||
|
' // hide/show simple f slider\n'
|
||||||
|
' const fSlRow = document.getElementById(\'sl-lens-f\');\n'
|
||||||
|
' if (fSlRow && fSlRow.parentElement) fSlRow.parentElement.style.display = on ? \'none\' : \'\';\n'
|
||||||
|
' if (lensSim) lensSim.setLensMode(!on);\n'
|
||||||
|
' if (on && lensSim) {\n'
|
||||||
|
' // sync sliders to current LM params\n'
|
||||||
|
' const r1 = lensSim._lmR1, r2 = lensSim._lmR2, n = lensSim._lmN;\n'
|
||||||
|
' const s1 = document.getElementById(\'sl-lm-r1\'), l1 = document.getElementById(\'lm-r1-val\');\n'
|
||||||
|
' const s2 = document.getElementById(\'sl-lm-r2\'), l2 = document.getElementById(\'lm-r2-val\');\n'
|
||||||
|
' const sn = document.getElementById(\'sl-lm-n\'), ln = document.getElementById(\'lm-n-val\');\n'
|
||||||
|
' if (s1) s1.value = r1; if (l1) l1.textContent = r1.toFixed(0);\n'
|
||||||
|
' if (s2) s2.value = r2; if (l2) l2.textContent = r2.toFixed(0);\n'
|
||||||
|
' if (sn) sn.value = n; if (ln) ln.textContent = n.toFixed(2);\n'
|
||||||
|
' }\n'
|
||||||
|
'}\n'
|
||||||
|
'\n'
|
||||||
|
'function lensLMParam(name, val) {\n'
|
||||||
|
' const v = parseFloat(val);\n'
|
||||||
|
' const lblMap = { R1: \'lm-r1-val\', R2: \'lm-r2-val\', n: \'lm-n-val\' };\n'
|
||||||
|
' const el = document.getElementById(lblMap[name]);\n'
|
||||||
|
' if (el) el.textContent = name === \'n\' ? v.toFixed(2) : v.toFixed(0);\n'
|
||||||
|
' if (lensSim) {\n'
|
||||||
|
' lensSim.setLMParam(name, v);\n'
|
||||||
|
' // update f display\n'
|
||||||
|
' const fl = document.getElementById(\'lens-f-val\');\n'
|
||||||
|
' if (fl) fl.textContent = lensSim.f.toFixed(0);\n'
|
||||||
|
' }\n'
|
||||||
|
'}\n'
|
||||||
|
'\n'
|
||||||
|
'/* ── Mirror R-slider + parabolic controls (Feature 2) ── */\n'
|
||||||
|
'function mirrorToggleR(on) {\n'
|
||||||
|
' const rRow = document.getElementById(\'ob-mirror-R-row\');\n'
|
||||||
|
' if (rRow) rRow.style.display = on ? \'\' : \'none\';\n'
|
||||||
|
' const pbtn = document.getElementById(\'mirror-parab-btn\');\n'
|
||||||
|
' if (pbtn) pbtn.style.display = on ? \'\' : \'none\';\n'
|
||||||
|
' if (mirrorSim) mirrorSim._useR = !!on;\n'
|
||||||
|
' if (on && mirrorSim) {\n'
|
||||||
|
' const sv = document.getElementById(\'sl-mirror-R\');\n'
|
||||||
|
' const lv = document.getElementById(\'mirror-R-val\');\n'
|
||||||
|
' if (sv) sv.value = mirrorSim._R;\n'
|
||||||
|
' if (lv) lv.textContent = mirrorSim._R;\n'
|
||||||
|
' mirrorSim.setMirrorR(mirrorSim._R);\n'
|
||||||
|
' } else if (mirrorSim) { mirrorSim.draw(); }\n'
|
||||||
|
'}\n'
|
||||||
|
'\n'
|
||||||
|
'function mirrorRParam(val) {\n'
|
||||||
|
' const v = parseFloat(val);\n'
|
||||||
|
' const el = document.getElementById(\'mirror-R-val\');\n'
|
||||||
|
' if (el) el.textContent = v;\n'
|
||||||
|
' if (mirrorSim) mirrorSim.setMirrorR(v);\n'
|
||||||
|
'}\n'
|
||||||
|
'\n'
|
||||||
|
'function mirrorToggleParabolic(btn) {\n'
|
||||||
|
' if (!mirrorSim) return;\n'
|
||||||
|
' mirrorSim._parabolic = !mirrorSim._parabolic;\n'
|
||||||
|
' if (btn) btn.textContent = mirrorSim._parabolic ? \'Параболическое\' : \'Сферическое\';\n'
|
||||||
|
' if (btn) btn.style.color = mirrorSim._parabolic ? \'#7BF5A4\' : \'#888\';\n'
|
||||||
|
' mirrorSim.draw();\n'
|
||||||
|
'}\n'
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
new_content = content[:idx] + NEW_FUNCS + content[idx:]
|
||||||
|
|
||||||
|
with open(src, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
|
||||||
|
print('OK lines:', new_content.count('\n'))
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Phase 1 — визуальный редизайн ch1 (Тепловые явления):
|
||||||
|
// 1. Hero: заменяет старый <header class="hdr"> на p8-hero с
|
||||||
|
// eyebrow, title, sub, live meter, watermark (огонь SVG).
|
||||||
|
// 2. Section watermarks: в каждом <section id="sec-pN"> добавляет
|
||||||
|
// тематический SVG-watermark (пламя/термометр/снежинка).
|
||||||
|
// 3. Inject IV-6 (drag-thermometer) в §1 — flagship интерактив.
|
||||||
|
// Остальные §2-11 получают IV-6 stub-placeholder с заголовком.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_ch1.html');
|
||||||
|
let h = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
// === 1. Replace .hdr block with p8-hero ===
|
||||||
|
const FIRE_WM = `<svg viewBox="0 0 100 100" aria-hidden="true">
|
||||||
|
<path d="M50 8 C 52 22 65 30 64 46 C 63 56 56 60 55 48 C 53 56 48 60 42 58 C 36 56 32 50 34 42 C 30 52 22 60 24 72 C 26 84 36 92 50 92 C 64 92 76 84 76 70 C 76 50 60 40 56 22 C 54 14 52 10 50 8 Z"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const NEW_HERO = `<header class="p8-hero">
|
||||||
|
<div class="p8-hero-wm">${FIRE_WM}</div>
|
||||||
|
<div class="p8-hero-meter" id="p8-meter-ch1"><span id="p8-meter-val">37</span>°C</div>
|
||||||
|
<div class="p8-hero-inner">
|
||||||
|
<div class="p8-hero-eyebrow">Глава 1 · 11 параграфов</div>
|
||||||
|
<h1 class="p8-hero-title">Тепловые явления</h1>
|
||||||
|
<div class="p8-hero-sub">Внутренняя энергия, способы теплопередачи, плавление и кипение. Перетаскивайте термометры, нагреватели и материалы — наблюдайте поведение тепла в реальном времени.</div>
|
||||||
|
<div class="hdr-side" style="margin-top:18px;display:flex;gap:8px;flex-wrap:wrap;position:relative;z-index:1">
|
||||||
|
<a href="/textbook/physics-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К физике 8</a>
|
||||||
|
<button id="search-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg> Поиск</button>
|
||||||
|
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||||||
|
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>`;
|
||||||
|
|
||||||
|
const oldHdrRegex = /<header class="hdr">[\s\S]*?<\/header>/;
|
||||||
|
if (h.match(oldHdrRegex)) {
|
||||||
|
h = h.replace(oldHdrRegex, NEW_HERO);
|
||||||
|
console.log('Hero replaced');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 2. Update meter live: добавим скрипт, который анимирует значение в углу ===
|
||||||
|
const METER_SCRIPT = `
|
||||||
|
<script>
|
||||||
|
/* P8 hero meter — анимированный счётчик в углу (Phase 1 thermal) */
|
||||||
|
(function(){
|
||||||
|
function init(){
|
||||||
|
const el = document.getElementById('p8-meter-val');
|
||||||
|
if (!el || !window.P8Anim) return;
|
||||||
|
const targets = [37, 100, 0, -10, 25, 80];
|
||||||
|
let i = 0;
|
||||||
|
function step(){
|
||||||
|
const from = parseInt(el.textContent) || 0;
|
||||||
|
const to = targets[i % targets.length];
|
||||||
|
P8Anim.tween({
|
||||||
|
from, to, duration: 1400, easing: 'cubicInOut',
|
||||||
|
onUpdate: v => { el.textContent = Math.round(v); },
|
||||||
|
onComplete: () => { i++; setTimeout(step, 1800); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTimeout(step, 1200);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||||
|
else init();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
if (!h.includes('P8 hero meter')) {
|
||||||
|
h = h.replace('</body>', METER_SCRIPT + '\n</body>');
|
||||||
|
console.log('Meter animation script added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 3. Section watermarks — добавить data-attribute, CSS подхватит ===
|
||||||
|
// Используем pseudo-element через inline стиль не получится; вместо этого
|
||||||
|
// инжектим <div class="p8-sec-wm"> в каждую секцию.
|
||||||
|
|
||||||
|
// Watermark SVG-символы по §
|
||||||
|
const SEC_SYMBOLS = {
|
||||||
|
p1: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="28" stroke="currentColor" stroke-width="6" fill="none"/><path d="M50 22 v56 M22 50 h56" stroke="currentColor" stroke-width="3"/></svg>', // атом
|
||||||
|
p2: '<svg viewBox="0 0 100 100"><path d="M50 12 v76 M50 12 l-14 16 M50 12 l14 16 M50 88 l-14-16 M50 88 l14-16" stroke="currentColor" stroke-width="4" fill="none"/></svg>', // вверх/вниз
|
||||||
|
p3: '<svg viewBox="0 0 100 100"><path d="M14 50 h72 M86 50 l-14-14 M86 50 l-14 14" stroke="currentColor" stroke-width="5" fill="none"/></svg>', // правая стрелка
|
||||||
|
p4: '<svg viewBox="0 0 100 100"><path d="M30 80 C 30 50, 70 50, 70 30 M30 30 C 30 60, 70 60, 70 80" stroke="currentColor" stroke-width="4" fill="none"/></svg>', // спирали (конвекция)
|
||||||
|
p5: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="14" fill="currentColor"/><g stroke="currentColor" stroke-width="4" fill="none"><line x1="50" y1="6" x2="50" y2="22"/><line x1="50" y1="78" x2="50" y2="94"/><line x1="6" y1="50" x2="22" y2="50"/><line x1="78" y1="50" x2="94" y2="50"/><line x1="18" y1="18" x2="30" y2="30"/><line x1="70" y1="70" x2="82" y2="82"/><line x1="82" y1="18" x2="70" y2="30"/><line x1="30" y1="70" x2="18" y2="82"/></g></svg>', // солнце
|
||||||
|
p6: '<svg viewBox="0 0 100 100"><rect x="20" y="35" width="60" height="35" rx="4" stroke="currentColor" stroke-width="4" fill="none"/><path d="M28 35 v-8 M50 35 v-8 M72 35 v-8" stroke="currentColor" stroke-width="3"/></svg>', // сосуд
|
||||||
|
p7: '<svg viewBox="0 0 100 100"><path d="M28 78 L50 22 L72 78 Z" stroke="currentColor" stroke-width="4" fill="none"/><path d="M40 60 L60 60" stroke="currentColor" stroke-width="3"/></svg>', // пламя/огонь
|
||||||
|
p8: '<svg viewBox="0 0 100 100"><path d="M30 30 L70 30 L70 70 L30 70 Z" stroke="currentColor" stroke-width="4" fill="none"/><path d="M30 50 L70 50" stroke="currentColor" stroke-width="3" stroke-dasharray="4 3"/></svg>', // фазовый переход
|
||||||
|
p9: '<svg viewBox="0 0 100 100"><path d="M50 14 L70 50 L50 86 L30 50 Z" stroke="currentColor" stroke-width="4" fill="none"/></svg>', // ромб
|
||||||
|
p10: '<svg viewBox="0 0 100 100"><path d="M20 70 Q 35 50, 50 65 T 80 60" stroke="currentColor" stroke-width="4" fill="none"/><circle cx="78" cy="32" r="6" fill="currentColor"/></svg>', // капля + пар
|
||||||
|
p11: '<svg viewBox="0 0 100 100"><circle cx="35" cy="55" r="6" fill="currentColor"/><circle cx="55" cy="45" r="8" fill="currentColor"/><circle cx="65" cy="65" r="5" fill="currentColor"/><circle cx="50" cy="75" r="4" fill="currentColor"/></svg>', // пузыри
|
||||||
|
};
|
||||||
|
|
||||||
|
let secWmInjected = 0;
|
||||||
|
for (const pid of Object.keys(SEC_SYMBOLS)) {
|
||||||
|
const symbol = SEC_SYMBOLS[pid];
|
||||||
|
const secOpenRegex = new RegExp(`(<section[^>]+id="sec-${pid}"[^>]*>)`);
|
||||||
|
if (h.match(secOpenRegex) && !h.includes(`p8-sec-wm-${pid}`)) {
|
||||||
|
const wmDiv = `<div class="p8-sec-wm" id="p8-sec-wm-${pid}" aria-hidden="true">${symbol}</div>`;
|
||||||
|
h = h.replace(secOpenRegex, '$1\n ' + wmDiv);
|
||||||
|
secWmInjected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Section watermarks injected:', secWmInjected);
|
||||||
|
|
||||||
|
// Обеспечиваем что section имеет position:relative — добавляем inline-стиль
|
||||||
|
// (или полагаемся на существующий CSS .sec, который позиционирован)
|
||||||
|
// Проверим: ищем `.sec{` в style
|
||||||
|
// (Это уже есть в существующем CSS, sec — позиционирован относительно)
|
||||||
|
|
||||||
|
// === 4. IV-6 flagship: для §1 добавляем drag-thermometer интерактив ===
|
||||||
|
// Patch build_p1 — добавим IV-6 widget HTML + _initP1_iv6 функцию.
|
||||||
|
|
||||||
|
const IV6_P1_WIDGET = `
|
||||||
|
/* IV6 — Drag thermometer (Phase 1 flagship interactive) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Перетащи термометр</div></div>'
|
||||||
|
+'<div class="wg-help">Перетащи термометр на одно из четырёх тел и наблюдай, как меняется его температурный отсчёт. Тела разной массы и при разных условиях — оцени, в каком из них больше внутренней энергии.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p1-iv6-sandbox" style="height:320px"></div>'
|
||||||
|
+'<div class="actions" style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">T</span><span class="p8-readout-value" id="p1-iv6-T">—</span><span class="p8-readout-unit">°C</span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">U отн.</span><span class="p8-readout-value" id="p1-iv6-U">—</span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';
|
||||||
|
`;
|
||||||
|
|
||||||
|
const IV6_P1_INIT = `
|
||||||
|
function _initP1_iv6(){
|
||||||
|
const sandbox = document.getElementById('p1-iv6-sandbox');
|
||||||
|
if (!sandbox || !window.P8Helpers || !window.P8Drag) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 320);
|
||||||
|
svg.setAttribute('width', '100%');
|
||||||
|
svg.setAttribute('height', '100%');
|
||||||
|
svg.style.display = 'block';
|
||||||
|
sandbox.appendChild(svg);
|
||||||
|
/* 4 тела: имя, T (°C), относительная U */
|
||||||
|
const bodies = [
|
||||||
|
{ name:'Лёд 1 кг', cx: 95, cy: 200, T: -10, U: 14, color:'#bfdbfe' },
|
||||||
|
{ name:'Вода 1 кг', cx: 230, cy: 200, T: 20, U: 100, color:'#7dd3fc' },
|
||||||
|
{ name:'Чай 0,3 кг',cx: 365, cy: 200, T: 80, U: 80, color:'#fb923c' },
|
||||||
|
{ name:'Пар 0,5 кг',cx: 500, cy: 200, T: 110, U: 200, color:'#ef4444' }
|
||||||
|
];
|
||||||
|
bodies.forEach(b => {
|
||||||
|
const g = P8Helpers.svg.el('g', { transform: 'translate('+b.cx+','+b.cy+')' });
|
||||||
|
g.appendChild(P8Helpers.svg.el('rect', {
|
||||||
|
x: -50, y: -55, width: 100, height: 110, rx: 12,
|
||||||
|
fill: b.color, stroke: '#0f172a', 'stroke-width': 1.5, opacity: 0.88
|
||||||
|
}));
|
||||||
|
g.appendChild(P8Helpers.svg.el('text', {
|
||||||
|
x: 0, y: -68,
|
||||||
|
'font-family': "'JetBrains Mono', monospace",
|
||||||
|
'font-size': 11, 'font-weight': 700,
|
||||||
|
fill: '#0f172a', 'text-anchor': 'middle',
|
||||||
|
text: b.name
|
||||||
|
}));
|
||||||
|
g.dataset = b;
|
||||||
|
svg.appendChild(g);
|
||||||
|
});
|
||||||
|
/* Термометр (draggable group) */
|
||||||
|
let thermoX = 50, thermoY = 70;
|
||||||
|
const thermoG = P8Helpers.svg.el('g', { transform: 'translate('+thermoX+','+thermoY+')', 'class': 'p8-draggable' });
|
||||||
|
/* Drop shadow rect */
|
||||||
|
thermoG.appendChild(P8Helpers.svg.el('rect', { x: -22, y: -10, width: 44, height: 130, fill: 'transparent' }));
|
||||||
|
/* Tube */
|
||||||
|
thermoG.appendChild(P8Helpers.svg.el('rect', {
|
||||||
|
x: -5, y: 0, width: 10, height: 100, rx: 5,
|
||||||
|
fill: '#f3f4f6', stroke: '#475569', 'stroke-width': 1.5
|
||||||
|
}));
|
||||||
|
const fill = P8Helpers.svg.el('rect', {
|
||||||
|
x: -3, y: 70, width: 6, height: 30, rx: 2, fill: '#f97316'
|
||||||
|
});
|
||||||
|
thermoG.appendChild(fill);
|
||||||
|
/* Bulb */
|
||||||
|
const bulb = P8Helpers.svg.el('circle', { cx: 0, cy: 110, r: 12, fill: '#f97316', stroke: '#475569', 'stroke-width': 1.5 });
|
||||||
|
thermoG.appendChild(bulb);
|
||||||
|
thermoG.appendChild(P8Helpers.svg.el('text', {
|
||||||
|
x: 0, y: -2,
|
||||||
|
'font-family': "'Inter', sans-serif", 'font-size': 10, 'font-weight': 700,
|
||||||
|
fill: '#0f172a', 'text-anchor': 'middle', text: 'Drag'
|
||||||
|
}));
|
||||||
|
svg.appendChild(thermoG);
|
||||||
|
/* Show current readout */
|
||||||
|
const tEl = document.getElementById('p1-iv6-T');
|
||||||
|
const uEl = document.getElementById('p1-iv6-U');
|
||||||
|
function checkHit(cx, cy){
|
||||||
|
for (const b of bodies){
|
||||||
|
if (Math.abs(cx - b.cx) < 50 && Math.abs(cy - b.cy) < 55){
|
||||||
|
const tColor = P8Helpers.thermal.tempColor((b.T + 20) / 130);
|
||||||
|
fill.setAttribute('fill', tColor);
|
||||||
|
bulb.setAttribute('fill', tColor);
|
||||||
|
if (tEl) tEl.textContent = b.T;
|
||||||
|
if (uEl) uEl.textContent = b.U;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fill.setAttribute('fill', '#94a3b8');
|
||||||
|
bulb.setAttribute('fill', '#94a3b8');
|
||||||
|
if (tEl) tEl.textContent = '—';
|
||||||
|
if (uEl) uEl.textContent = '—';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
P8Drag.attach(thermoG, {
|
||||||
|
container: svg,
|
||||||
|
onMove: (ev, pos) => {
|
||||||
|
thermoX = Math.max(20, Math.min(540, pos.x));
|
||||||
|
thermoY = Math.max(10, Math.min(200, pos.y));
|
||||||
|
thermoG.setAttribute('transform', 'translate('+thermoX+','+thermoY+')');
|
||||||
|
checkHit(thermoX, thermoY + 110);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (window.addXp) {
|
||||||
|
setTimeout(() => addXp(5, 'p1-iv6-explore'), 12000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const insertMarker = `box.innerHTML = h + secNavFor('p1') + readButton('p1');`;
|
||||||
|
if (!h.includes('p1-iv6-sandbox') && h.includes(insertMarker)) {
|
||||||
|
h = h.replace(insertMarker, IV6_P1_WIDGET.trim() + '\n\n ' + insertMarker);
|
||||||
|
// Add init call after wireReadBtn
|
||||||
|
h = h.replace(`wireReadBtn('p1');`, `wireReadBtn('p1');\n _initP1_iv6();`);
|
||||||
|
// Append init function after build_p1
|
||||||
|
const p1Start = h.indexOf('function build_p1()');
|
||||||
|
const p1End = h.indexOf('\n}\n', p1Start);
|
||||||
|
h = h.slice(0, p1End + 3) + '\n' + IV6_P1_INIT.trim() + '\n' + h.slice(p1End + 3);
|
||||||
|
console.log('IV-6 §1 drag-thermometer injected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 5. Stub IV-6 placeholders для §2-11 ===
|
||||||
|
for (let n = 2; n <= 11; n++) {
|
||||||
|
const pid = 'p' + n;
|
||||||
|
const stubHtml = `
|
||||||
|
/* IV6 — flagship интерактив (заглушка Phase 1, наполнение в Phase 1.${n}) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Новый интерактив §${n}</div></div>'
|
||||||
|
+'<div class="wg-help">Готовится: интерактивная визуализация с drag-and-drop для углубления темы. Скоро будет доступна.</div>'
|
||||||
|
+'<div style="padding:30px;text-align:center;color:var(--p8-muted);font-style:italic">'
|
||||||
|
+'<svg viewBox="0 0 24 24" style="width:32px;height:32px;stroke:currentColor;fill:none;stroke-width:1.5;opacity:.4"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||||
|
+'<div style="margin-top:8px;font-size:.86rem">Phase 1.${n} — coming soon</div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';
|
||||||
|
`;
|
||||||
|
const marker = `box.innerHTML = h + secNavFor('${pid}') + readButton('${pid}');`;
|
||||||
|
if (!h.includes(`p8-iv6-${pid}`) && !h.includes(`Новый интерактив §${n}`) && h.includes(marker)) {
|
||||||
|
h = h.replace(marker, stubHtml.trim() + '\n\n ' + marker);
|
||||||
|
console.log(` ${pid}: IV-6 stub added`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('ch1 final size:', h.length);
|
||||||
|
|
||||||
|
// Sanity parse
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch (e) { console.error('JS PARSE FAIL:', e.message.slice(0, 100)); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('inline JS parses OK');
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
// Phase 1.2 — Заменяет IV-6 stubs в §3, §6, §8 на полноценные интерактивы.
|
||||||
|
// Использует точный per-paragraph anchor — текст 'Новый интерактив §N' — для
|
||||||
|
// замены ровно одного стуба за раз. Без greedy match через границы.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_ch1.html');
|
||||||
|
let h = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
// Stub-HTML per paragraph N (та же форма что в redesign_p8_ch1.cjs).
|
||||||
|
// Заменяем эту точную строку на новый widgetHtml.
|
||||||
|
function makeStubText(n) {
|
||||||
|
return `/* IV6 — flagship интерактив (заглушка Phase 1, наполнение в Phase 1.${n}) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Новый интерактив §${n}</div></div>'
|
||||||
|
+'<div class="wg-help">Готовится: интерактивная визуализация с drag-and-drop для углубления темы. Скоро будет доступна.</div>'
|
||||||
|
+'<div style="padding:30px;text-align:center;color:var(--p8-muted);font-style:italic">'
|
||||||
|
+'<svg viewBox="0 0 24 24" style="width:32px;height:32px;stroke:currentColor;fill:none;stroke-width:1.5;opacity:.4"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||||
|
+'<div style="margin-top:8px;font-size:.86rem">Phase 1.${n} — coming soon</div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceStub(pid, n, widgetHtml, initFn) {
|
||||||
|
// File uses CRLF, my template uses LF — normalize stub to file's EOL style.
|
||||||
|
const stubLF = makeStubText(n);
|
||||||
|
const stubCRLF = stubLF.replace(/\n/g, '\r\n');
|
||||||
|
let stubText = null;
|
||||||
|
if (h.includes(stubLF)) stubText = stubLF;
|
||||||
|
else if (h.includes(stubCRLF)) stubText = stubCRLF;
|
||||||
|
if (!stubText) {
|
||||||
|
console.warn(`${pid}: stub text not found in file`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const eol = stubText === stubCRLF ? '\r\n' : '\n';
|
||||||
|
const widget = widgetHtml.trim().replace(/\n/g, eol);
|
||||||
|
h = h.replace(stubText, widget);
|
||||||
|
// Add init call after wireReadBtn
|
||||||
|
h = h.replace(`wireReadBtn('${pid}');`, `wireReadBtn('${pid}');\n _init${pid.toUpperCase()}_iv6();`);
|
||||||
|
// Append init function after build_pN
|
||||||
|
const fnStart = h.indexOf(`function build_${pid}()`);
|
||||||
|
const fnEnd = h.indexOf('\n}\n', fnStart);
|
||||||
|
h = h.slice(0, fnEnd + 3) + '\n' + initFn.trim() + '\n' + h.slice(fnEnd + 3);
|
||||||
|
console.log(`${pid}: replaced stub with real IV-6`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === §3 — Heat Conductor Bench ===
|
||||||
|
const P3_HTML = `/* IV6 — Heat Conductor Bench (Phase 1.2) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Тепловая лавочка — какой материал быстрее проводит тепло?</div></div>'
|
||||||
|
+'<div class="wg-help">Перетащи один из стержней (медь, дерево, стекло, серебро) на горелку. Цветовая карта покажет, как тепло движется по стержню. Чем больше λ — тем быстрее.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p3-iv6-sandbox" style="height:300px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">Материал</span><span class="p8-readout-value" id="p3-iv6-mat">—</span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">λ</span><span class="p8-readout-value" id="p3-iv6-lam">—</span><span class="p8-readout-unit">Вт/(м·К)</span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">T дальнего конца</span><span class="p8-readout-value" id="p3-iv6-tend">—</span><span class="p8-readout-unit">°C</span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P3_INIT = `
|
||||||
|
function _initP3_iv6(){
|
||||||
|
const sb = document.getElementById('p3-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers || !window.P8Drag || !window.P8Anim) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 300);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const burner = P8Helpers.svg.el('g', { transform: 'translate(80, 240)' });
|
||||||
|
burner.appendChild(P8Helpers.svg.el('rect', { x:-32, y:-8, width:64, height:32, rx:4, fill:'#475569' }));
|
||||||
|
burner.appendChild(P8Helpers.svg.el('rect', { x:-26, y:-22, width:52, height:14, rx:7, fill:'#dc2626' }));
|
||||||
|
burner.appendChild(P8Helpers.svg.el('text', { x:0, y:48, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#475569', 'text-anchor':'middle', text:'Горелка (drop)' }));
|
||||||
|
svg.appendChild(burner);
|
||||||
|
const rods = [
|
||||||
|
{ name:'Медь', lam:400, color:'#b45309', x:200, y:50 },
|
||||||
|
{ name:'Серебро', lam:430, color:'#9ca3af', x:300, y:50 },
|
||||||
|
{ name:'Стекло', lam:0.8, color:'#bae6fd', x:400, y:50 },
|
||||||
|
{ name:'Дерево', lam:0.15,color:'#a16207', x:500, y:50 }
|
||||||
|
];
|
||||||
|
const rodEls = [];
|
||||||
|
rods.forEach(rod => {
|
||||||
|
const g = P8Helpers.svg.el('g', { transform: 'translate('+rod.x+','+rod.y+')' });
|
||||||
|
const segments = 12;
|
||||||
|
const segs = [];
|
||||||
|
for (let s = 0; s < segments; s++) {
|
||||||
|
const r = P8Helpers.svg.el('rect', {
|
||||||
|
x: -55 + s * (110/segments), y: -10, width: 110/segments, height: 20,
|
||||||
|
fill: rod.color, stroke: 'none'
|
||||||
|
});
|
||||||
|
g.appendChild(r);
|
||||||
|
segs.push(r);
|
||||||
|
}
|
||||||
|
g.appendChild(P8Helpers.svg.el('rect', { x:-55, y:-10, width:110, height:20, rx:3, fill:'none', stroke:'#0f172a', 'stroke-width':1.5 }));
|
||||||
|
g.appendChild(P8Helpers.svg.el('text', { x:0, y:-18, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: rod.name }));
|
||||||
|
g.appendChild(P8Helpers.svg.el('text', { x:0, y:30, 'font-family':"'JetBrains Mono',monospace", 'font-size':9, 'font-weight':600, fill:'var(--p8-muted, #64748b)', 'text-anchor':'middle', text: 'λ='+rod.lam }));
|
||||||
|
svg.appendChild(g);
|
||||||
|
rodEls.push({ rod, g, segs, x: rod.x, y: rod.y });
|
||||||
|
});
|
||||||
|
let simLoop = null;
|
||||||
|
let simTime = 0;
|
||||||
|
const matEl = document.getElementById('p3-iv6-mat');
|
||||||
|
const lamEl = document.getElementById('p3-iv6-lam');
|
||||||
|
const tendEl = document.getElementById('p3-iv6-tend');
|
||||||
|
function resetColors(rodObj){ rodObj.segs.forEach(s => s.setAttribute('fill', rodObj.rod.color)); }
|
||||||
|
function startSim(rodObj){
|
||||||
|
if (simLoop) simLoop.stop();
|
||||||
|
simTime = 0;
|
||||||
|
const lamNorm = Math.min(1, Math.log10(rodObj.rod.lam + 1) / Math.log10(500));
|
||||||
|
simLoop = P8Anim.raf(dt => {
|
||||||
|
simTime += dt;
|
||||||
|
const speed = lamNorm * 0.8 + 0.04;
|
||||||
|
rodObj.segs.forEach((seg, i) => {
|
||||||
|
const pos = i / (rodObj.segs.length - 1);
|
||||||
|
const heat = Math.max(0, Math.min(1, speed * simTime - pos));
|
||||||
|
seg.setAttribute('fill', P8Helpers.thermal.tempColor(heat * 0.85 + 0.1));
|
||||||
|
});
|
||||||
|
const endHeat = Math.max(0, Math.min(1, speed * simTime - 0.95));
|
||||||
|
const tEnd = Math.round(20 + endHeat * 80);
|
||||||
|
if (tendEl) tendEl.textContent = tEnd;
|
||||||
|
if (simTime > 30) simLoop.stop();
|
||||||
|
});
|
||||||
|
simLoop.start();
|
||||||
|
if (matEl) matEl.textContent = rodObj.rod.name;
|
||||||
|
if (lamEl) lamEl.textContent = rodObj.rod.lam;
|
||||||
|
}
|
||||||
|
rodEls.forEach((rodObj, i) => {
|
||||||
|
P8Drag.attach(rodObj.g, {
|
||||||
|
container: svg,
|
||||||
|
onMove: (ev, pos) => {
|
||||||
|
rodObj.x = pos.x;
|
||||||
|
rodObj.y = pos.y;
|
||||||
|
rodObj.g.setAttribute('transform', 'translate('+rodObj.x+','+rodObj.y+')');
|
||||||
|
},
|
||||||
|
onEnd: (ev, pos) => {
|
||||||
|
if (Math.abs(pos.x - 80) < 70 && Math.abs(pos.y - 240) < 50) {
|
||||||
|
rodEls.forEach((other, j) => { if (j !== i) resetColors(other); });
|
||||||
|
startSim(rodObj);
|
||||||
|
if (window.addXp) addXp(10, 'p3-iv6-conduct');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', {
|
||||||
|
x: 280, y: 290,
|
||||||
|
'font-family': "'Inter', sans-serif", 'font-size': 10,
|
||||||
|
fill: 'var(--p8-muted, #64748b)', 'text-anchor': 'middle',
|
||||||
|
text: 'Перетащи стержень на горелку • Чем выше λ — тем быстрее цвет дойдёт до конца'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p3', 3, P3_HTML, P3_INIT);
|
||||||
|
|
||||||
|
// === §6 — Heat Mixer ===
|
||||||
|
const P6_HTML = `/* IV6 — Heat Mixer (Phase 1.2) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Смесь двух жидкостей — рассчитай конечную T</div></div>'
|
||||||
|
+'<div class="wg-help">Установи массы и начальные T двух ёмкостей скрубберами, нажми «Смешать» и наблюдай за итоговой температурой по уравнению теплового баланса $c m_1 (T_1 - T) = c m_2 (T - T_2)$.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p6-iv6-sandbox" style="height:240px"></div>'
|
||||||
|
+'<div style="margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:10px">'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">m₁</span><input type="range" id="p6-iv6-m1" min="0.1" max="2" step="0.1" value="0.5"><span class="p8-scrubber-value"><span id="p6-iv6-m1-val">0.5</span><span class="p8-unit">кг</span></span></div>'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">T₁</span><input type="range" id="p6-iv6-t1" min="0" max="100" step="1" value="80"><span class="p8-scrubber-value"><span id="p6-iv6-t1-val">80</span><span class="p8-unit">°C</span></span></div>'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">m₂</span><input type="range" id="p6-iv6-m2" min="0.1" max="2" step="0.1" value="1"><span class="p8-scrubber-value"><span id="p6-iv6-m2-val">1.0</span><span class="p8-unit">кг</span></span></div>'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">T₂</span><input type="range" id="p6-iv6-t2" min="0" max="100" step="1" value="20"><span class="p8-scrubber-value"><span id="p6-iv6-t2-val">20</span><span class="p8-unit">°C</span></span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">T_итог</span><span class="p8-readout-value" id="p6-iv6-tf">—</span><span class="p8-readout-unit">°C</span></div>'
|
||||||
|
+'<button class="btn primary" id="p6-iv6-mix">Смешать</button>'
|
||||||
|
+'<button class="btn" id="p6-iv6-reset">Сброс</button>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P6_INIT = `
|
||||||
|
function _initP6_iv6(){
|
||||||
|
const sb = document.getElementById('p6-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers || !window.P8Anim) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const v1 = { x: 140, y: 130, m: 0.5, T: 80 };
|
||||||
|
const v2 = { x: 420, y: 130, m: 1.0, T: 20 };
|
||||||
|
const finalState = { active: false, T: 50 };
|
||||||
|
function drawVessel(x, y, m, T){
|
||||||
|
const g = P8Helpers.svg.el('g', { transform: 'translate('+x+','+y+')' });
|
||||||
|
const ht = 30 + m * 50; const w = 70;
|
||||||
|
g.appendChild(P8Helpers.svg.el('rect', { x:-w/2, y:-ht, width:w, height:ht, rx:6, fill:'rgba(255,255,255,.6)', stroke:'#0f172a', 'stroke-width':1.5 }));
|
||||||
|
g.appendChild(P8Helpers.svg.el('rect', { x:-w/2+3, y:-ht+5, width:w-6, height:ht-8, rx:4, fill: P8Helpers.thermal.tempColor(T/100) }));
|
||||||
|
g.appendChild(P8Helpers.svg.el('text', { x:0, y:18, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'm='+m.toFixed(1)+' кг' }));
|
||||||
|
g.appendChild(P8Helpers.svg.el('text', { x:0, y:32, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'T='+Math.round(T)+'°C' }));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
function redraw(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
if (!finalState.active) {
|
||||||
|
svg.appendChild(drawVessel(v1.x, v1.y, v1.m, v1.T));
|
||||||
|
svg.appendChild(drawVessel(v2.x, v2.y, v2.m, v2.T));
|
||||||
|
} else {
|
||||||
|
svg.appendChild(drawVessel(280, 130, v1.m + v2.m, finalState.T));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x:280, y:60, 'font-family':"'Unbounded',sans-serif", 'font-size':14, 'font-weight':800, fill:'var(--th-mid,#f97316)', 'text-anchor':'middle', text: 'T_итог = '+Math.round(finalState.T)+' °C' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bindScrub(inputId, valId, obj, prop){
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const lab = document.getElementById(valId);
|
||||||
|
if (!input || !lab) return;
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const v = parseFloat(input.value);
|
||||||
|
obj[prop] = v;
|
||||||
|
lab.textContent = v.toFixed(prop === 'm' ? 1 : 0);
|
||||||
|
if (finalState.active) { finalState.active = false; document.getElementById('p6-iv6-tf').textContent = '—'; }
|
||||||
|
redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bindScrub('p6-iv6-m1', 'p6-iv6-m1-val', v1, 'm');
|
||||||
|
bindScrub('p6-iv6-t1', 'p6-iv6-t1-val', v1, 'T');
|
||||||
|
bindScrub('p6-iv6-m2', 'p6-iv6-m2-val', v2, 'm');
|
||||||
|
bindScrub('p6-iv6-t2', 'p6-iv6-t2-val', v2, 'T');
|
||||||
|
document.getElementById('p6-iv6-mix').onclick = () => {
|
||||||
|
const T = (v1.m * v1.T + v2.m * v2.T) / (v1.m + v2.m);
|
||||||
|
finalState.active = true;
|
||||||
|
P8Anim.tween({
|
||||||
|
from: v1.T, to: T, duration: 1200, easing: 'cubicInOut',
|
||||||
|
onUpdate: t => { finalState.T = t; redraw(); document.getElementById('p6-iv6-tf').textContent = Math.round(t); }
|
||||||
|
});
|
||||||
|
if (window.addXp) addXp(10, 'p6-iv6-mix');
|
||||||
|
};
|
||||||
|
document.getElementById('p6-iv6-reset').onclick = () => {
|
||||||
|
finalState.active = false; document.getElementById('p6-iv6-tf').textContent = '—'; redraw();
|
||||||
|
};
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p6', 6, P6_HTML, P6_INIT);
|
||||||
|
|
||||||
|
// === §8 — Phase Diagram T(t) ===
|
||||||
|
const P8_HTML = `/* IV6 — Phase Diagram T(t) (Phase 1.2) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">График плавления — почему T не растёт?</div></div>'
|
||||||
|
+'<div class="wg-help">Запусти нагрев льда и наблюдай T(t). При плавлении энергия идёт на разрушение решётки — T держится постоянной (плато при 0°C). Двигай мощность нагревателя — крутизна меняется.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p8-iv6-sandbox" style="height:280px"></div>'
|
||||||
|
+'<div style="margin-top:12px;display:flex;gap:14px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-scrubber" style="flex:1;min-width:200px"><span class="p8-scrubber-label">Мощность</span><input type="range" id="p8-iv6-pwr" min="100" max="2000" step="50" value="500"><span class="p8-scrubber-value"><span id="p8-iv6-pwr-val">500</span><span class="p8-unit">Вт</span></span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">Фаза</span><span class="p8-readout-value" id="p8-iv6-phase">лёд</span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">T</span><span class="p8-readout-value" id="p8-iv6-temp">-20</span><span class="p8-readout-unit">°C</span></div>'
|
||||||
|
+'<button class="btn primary" id="p8-iv6-play">Старт</button>'
|
||||||
|
+'<button class="btn" id="p8-iv6-reset">Сброс</button>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P8_INIT = `
|
||||||
|
function _initP8_iv6(){
|
||||||
|
const sb = document.getElementById('p8-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers || !window.P8Anim) return;
|
||||||
|
const W = 560, H = 280;
|
||||||
|
const svg = P8Helpers.svg.create(W, H);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const m = 0.5;
|
||||||
|
const c_ice = 2100, c_water = 4200, lambda = 330000, r_vap = 2300000;
|
||||||
|
let power = 500, energyAccumulated = 0, running = false;
|
||||||
|
let points = [{ t: 0, T: -20 }];
|
||||||
|
const pad = { l: 50, r: 18, t: 22, b: 32 };
|
||||||
|
const plotW = W - pad.l - pad.r;
|
||||||
|
const plotH = H - pad.t - pad.b;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: pad.l, y: pad.t, width: plotW, height: plotH, fill: '#fafafa', stroke: '#e5e7eb' }));
|
||||||
|
const yMin = -20, yMax = 120;
|
||||||
|
function yToPx(T) { return pad.t + plotH * (1 - (T - yMin) / (yMax - yMin)); }
|
||||||
|
function tToPx(t) { return pad.l + plotW * Math.min(1, t / 300); }
|
||||||
|
[-20, 0, 20, 40, 60, 80, 100, 120].forEach(t => {
|
||||||
|
const y = yToPx(t);
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: pad.l, y1: y, x2: pad.l + plotW, y2: y, stroke: '#e5e7eb' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: pad.l - 6, y: y + 3, 'font-family':"'JetBrains Mono',monospace", 'font-size': 10, fill: 'var(--p8-muted,#64748b)', 'text-anchor':'end', text: t+'°' }));
|
||||||
|
});
|
||||||
|
const phaseRegions = [
|
||||||
|
{ from: -20, to: 0, fill: '#bfdbfe', name: 'лёд' },
|
||||||
|
{ from: 0, to: 100, fill: '#7dd3fc', name: 'вода' },
|
||||||
|
{ from: 100, to: 120, fill: '#fde68a', name: 'пар' }
|
||||||
|
];
|
||||||
|
phaseRegions.forEach(r => {
|
||||||
|
const y1 = yToPx(r.from), y2 = yToPx(r.to);
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: pad.l, y: y2, width: plotW, height: y1 - y2, fill: r.fill, opacity: 0.18 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: pad.l + plotW - 6, y: (y1 + y2) / 2 + 3, 'font-family':"'Inter',sans-serif", 'font-size': 10, 'font-weight': 700, fill: 'var(--p8-text)', 'text-anchor': 'end', text: r.name }));
|
||||||
|
});
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: pad.l, y1: yToPx(0), x2: pad.l + plotW, y2: yToPx(0), stroke: '#0f172a', 'stroke-width': 1, 'stroke-dasharray': '3 3' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: pad.l, y1: yToPx(100), x2: pad.l + plotW, y2: yToPx(100), stroke: '#0f172a', 'stroke-width': 1, 'stroke-dasharray': '3 3' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: pad.l, y1: pad.t + plotH, x2: pad.l + plotW, y2: pad.t + plotH, stroke: '#0f172a' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: pad.l + plotW / 2, y: H - 6, 'font-family':"'Inter',sans-serif", 'font-size': 11, 'font-weight': 700, fill: 'var(--p8-text)', 'text-anchor': 'middle', text: 'Время, с' }));
|
||||||
|
const path = P8Helpers.svg.el('path', { d: '', fill: 'none', stroke: 'var(--th-mid, #f97316)', 'stroke-width': 3, 'stroke-linejoin': 'round', 'stroke-linecap': 'round' });
|
||||||
|
svg.appendChild(path);
|
||||||
|
function updatePath(){
|
||||||
|
if (!points.length) return;
|
||||||
|
const d = points.map((p, i) => (i === 0 ? 'M' : 'L') + tToPx(p.t).toFixed(1) + ',' + yToPx(p.T).toFixed(1)).join(' ');
|
||||||
|
path.setAttribute('d', d);
|
||||||
|
}
|
||||||
|
function currentT(){ return points[points.length-1].T; }
|
||||||
|
function currentPhase(T){
|
||||||
|
if (T < 0) return 'лёд';
|
||||||
|
if (T < 0.5 && energyAccumulated < lambda * m) return 'плавление';
|
||||||
|
if (T < 100) return 'вода';
|
||||||
|
if (T < 100.5 && energyAccumulated < (lambda + r_vap) * m) return 'кипение';
|
||||||
|
return 'пар';
|
||||||
|
}
|
||||||
|
function tick(dt){
|
||||||
|
if (!running) return;
|
||||||
|
const energy = power * dt;
|
||||||
|
let T = currentT();
|
||||||
|
let newT = T;
|
||||||
|
if (T < 0) {
|
||||||
|
const dT = energy / (c_ice * m);
|
||||||
|
newT = T + dT;
|
||||||
|
if (newT > 0) newT = 0;
|
||||||
|
} else if (T < 0.5 && energyAccumulated < lambda * m) {
|
||||||
|
energyAccumulated += energy;
|
||||||
|
newT = 0;
|
||||||
|
if (energyAccumulated >= lambda * m) newT = 0.5;
|
||||||
|
} else if (T < 100) {
|
||||||
|
const dT = energy / (c_water * m);
|
||||||
|
newT = T + dT;
|
||||||
|
if (newT > 100) newT = 100;
|
||||||
|
} else if (T < 100.5 && energyAccumulated < (lambda + r_vap) * m) {
|
||||||
|
energyAccumulated += energy;
|
||||||
|
newT = 100;
|
||||||
|
if (energyAccumulated >= (lambda + r_vap) * m) newT = 100.5;
|
||||||
|
} else if (T < 120) {
|
||||||
|
const dT = energy / (c_water * m);
|
||||||
|
newT = T + dT;
|
||||||
|
if (newT > 120) newT = 120;
|
||||||
|
} else { running = false; }
|
||||||
|
const lastP = points[points.length-1];
|
||||||
|
points.push({ t: lastP.t + dt, T: newT });
|
||||||
|
if (points.length > 600) points.shift();
|
||||||
|
updatePath();
|
||||||
|
document.getElementById('p8-iv6-temp').textContent = Math.round(newT);
|
||||||
|
document.getElementById('p8-iv6-phase').textContent = currentPhase(newT);
|
||||||
|
if (lastP.t > 300) running = false;
|
||||||
|
}
|
||||||
|
const raf = P8Anim.raf(dt => tick(Math.min(dt * 4, 0.5)));
|
||||||
|
const pwrInp = document.getElementById('p8-iv6-pwr');
|
||||||
|
const pwrLab = document.getElementById('p8-iv6-pwr-val');
|
||||||
|
pwrInp.oninput = () => { power = +pwrInp.value; pwrLab.textContent = power; };
|
||||||
|
document.getElementById('p8-iv6-play').onclick = () => {
|
||||||
|
if (!running) { running = true; raf.start(); if (window.addXp) addXp(10, 'p8-iv6-melt'); }
|
||||||
|
};
|
||||||
|
document.getElementById('p8-iv6-reset').onclick = () => {
|
||||||
|
running = false; raf.stop();
|
||||||
|
energyAccumulated = 0;
|
||||||
|
points = [{ t: 0, T: -20 }];
|
||||||
|
updatePath();
|
||||||
|
document.getElementById('p8-iv6-temp').textContent = '-20';
|
||||||
|
document.getElementById('p8-iv6-phase').textContent = 'лёд';
|
||||||
|
};
|
||||||
|
updatePath();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p8', 8, P8_HTML, P8_INIT);
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('ch1 final size:', h.length);
|
||||||
|
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch (e) { console.error('JS PARSE FAIL:', e.message.slice(0, 150)); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('inline JS parses OK');
|
||||||
|
|
||||||
|
// Verify all 11 builders still present
|
||||||
|
const fns = [...h.matchAll(/function build_p(\d+)\(\)/g)].map(m => parseInt(m[1]));
|
||||||
|
console.log('Builders after:', fns.length, fns);
|
||||||
|
if (fns.length !== 11) { console.error('LOST BUILDERS!'); process.exit(1); }
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
// Phase 1.3 — заменяет оставшиеся IV-6 stubs §2, §4, §5, §7, §9, §10, §11
|
||||||
|
// на реальные интерактивы.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_ch1.html');
|
||||||
|
let h = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
function makeStubText(n) {
|
||||||
|
return `/* IV6 — flagship интерактив (заглушка Phase 1, наполнение в Phase 1.${n}) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Новый интерактив §${n}</div></div>'
|
||||||
|
+'<div class="wg-help">Готовится: интерактивная визуализация с drag-and-drop для углубления темы. Скоро будет доступна.</div>'
|
||||||
|
+'<div style="padding:30px;text-align:center;color:var(--p8-muted);font-style:italic">'
|
||||||
|
+'<svg viewBox="0 0 24 24" style="width:32px;height:32px;stroke:currentColor;fill:none;stroke-width:1.5;opacity:.4"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||||
|
+'<div style="margin-top:8px;font-size:.86rem">Phase 1.${n} — coming soon</div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceStub(pid, n, widgetHtml, initFn) {
|
||||||
|
const stubLF = makeStubText(n);
|
||||||
|
const stubCRLF = stubLF.replace(/\n/g, '\r\n');
|
||||||
|
let stubText = null;
|
||||||
|
if (h.includes(stubLF)) stubText = stubLF;
|
||||||
|
else if (h.includes(stubCRLF)) stubText = stubCRLF;
|
||||||
|
if (!stubText) { console.warn(`${pid}: stub not found`); return false; }
|
||||||
|
const eol = stubText === stubCRLF ? '\r\n' : '\n';
|
||||||
|
const widget = widgetHtml.trim().replace(/\n/g, eol);
|
||||||
|
h = h.replace(stubText, widget);
|
||||||
|
h = h.replace(`wireReadBtn('${pid}');`, `wireReadBtn('${pid}');\n _init${pid.toUpperCase()}_iv6();`);
|
||||||
|
const fnStart = h.indexOf(`function build_${pid}()`);
|
||||||
|
const fnEnd = h.indexOf('\n}\n', fnStart);
|
||||||
|
h = h.slice(0, fnEnd + 3) + '\n' + initFn.trim() + '\n' + h.slice(fnEnd + 3);
|
||||||
|
console.log(`${pid}: replaced`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §2 — Drag-piston (compress gas / supply heat)
|
||||||
|
// ============================================================
|
||||||
|
const P2_HTML = `/* IV6 — Piston (Phase 1.3) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Поршень — два способа изменить U</div></div>'
|
||||||
|
+'<div class="wg-help">Двигай поршень — газ сжимается и нагревается (работа над газом → ΔU > 0). Или подавай тепло — U растёт без работы. Сравни графики.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p2-iv6-sandbox" style="height:240px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:14px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-scrubber" style="flex:1;min-width:180px"><span class="p8-scrubber-label">Сжатие</span><input type="range" id="p2-iv6-comp" min="0" max="100" step="1" value="0"><span class="p8-scrubber-value"><span id="p2-iv6-comp-val">0</span><span class="p8-unit">%</span></span></div>'
|
||||||
|
+'<div class="p8-scrubber" style="flex:1;min-width:180px"><span class="p8-scrubber-label">Q</span><input type="range" id="p2-iv6-q" min="0" max="500" step="10" value="0"><span class="p8-scrubber-value"><span id="p2-iv6-q-val">0</span><span class="p8-unit">Дж</span></span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">T</span><span class="p8-readout-value" id="p2-iv6-t">20</span><span class="p8-readout-unit">°C</span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">U</span><span class="p8-readout-value" id="p2-iv6-u">100</span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P2_INIT = `
|
||||||
|
function _initP2_iv6(){
|
||||||
|
const sb = document.getElementById('p2-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const state = { comp: 0, q: 0 };
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
/* Cylinder */
|
||||||
|
const compFraction = state.comp / 100;
|
||||||
|
const cylX = 80, cylY = 60, cylW = 320, cylH = 120;
|
||||||
|
const pistonX = cylX + cylW * (0.2 + compFraction * 0.5);
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: cylX, y: cylY, width: cylW, height: cylH, rx: 8, fill: 'none', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
/* Gas region (compressed = brighter, hotter) */
|
||||||
|
const T = 20 + compFraction * 60 + state.q * 0.1;
|
||||||
|
const gasColor = P8Helpers.thermal.tempColor(T / 200);
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: cylX + 2, y: cylY + 2, width: pistonX - cylX - 4, height: cylH - 4, rx: 6, fill: gasColor, opacity: 0.6 }));
|
||||||
|
/* Molecules — count grows with T */
|
||||||
|
const numMol = Math.round(8 + T * 0.3);
|
||||||
|
for (let i = 0; i < numMol; i++) {
|
||||||
|
const px = cylX + 8 + Math.random() * (pistonX - cylX - 16);
|
||||||
|
const py = cylY + 8 + Math.random() * (cylH - 16);
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: px, cy: py, r: 3, fill: '#fff', opacity: 0.7 }));
|
||||||
|
}
|
||||||
|
/* Piston */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: pistonX - 6, y: cylY - 8, width: 12, height: cylH + 16, fill: '#475569', stroke: '#0f172a' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: pistonX + 6, y: cylY + cylH/2 - 6, width: 100, height: 12, fill: '#94a3b8', stroke: '#0f172a' }));
|
||||||
|
/* Heat source if q>0 */
|
||||||
|
if (state.q > 0) {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: cylX + 60, y: cylY + cylH + 4, width: 60, height: 12, fill: '#dc2626', rx: 4 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: cylX + 90, y: cylY + cylH + 32, 'font-family':"'Inter',sans-serif", 'font-size':10, 'font-weight':700, fill:'#dc2626', 'text-anchor':'middle', text: 'Q = '+state.q+' Дж' }));
|
||||||
|
}
|
||||||
|
if (state.comp > 0) {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: pistonX + 56, y: cylY + cylH/2 - 14, 'font-family':"'Inter',sans-serif", 'font-size':10, 'font-weight':700, fill:'#475569', 'text-anchor':'middle', text: 'A над газом' }));
|
||||||
|
}
|
||||||
|
/* Readouts */
|
||||||
|
document.getElementById('p2-iv6-t').textContent = Math.round(T);
|
||||||
|
document.getElementById('p2-iv6-u').textContent = Math.round(100 + compFraction * 60 + state.q * 0.5);
|
||||||
|
}
|
||||||
|
document.getElementById('p2-iv6-comp').oninput = ev => {
|
||||||
|
state.comp = +ev.target.value;
|
||||||
|
document.getElementById('p2-iv6-comp-val').textContent = state.comp;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
document.getElementById('p2-iv6-q').oninput = ev => {
|
||||||
|
state.q = +ev.target.value;
|
||||||
|
document.getElementById('p2-iv6-q-val').textContent = state.q;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p2', 2, P2_HTML, P2_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §4 — Convection cell
|
||||||
|
// ============================================================
|
||||||
|
const P4_HTML = `/* IV6 — Convection (Phase 1.3) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Конвекция — нагретая вода поднимается</div></div>'
|
||||||
|
+'<div class="wg-help">Двигай мощность горелки. Частицы воды поднимаются вверх по центру (тёплые, менее плотные) и опускаются по краям (остывшие, плотнее) — это конвекционная ячейка.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p4-iv6-sandbox" style="height:280px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:14px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-scrubber" style="flex:1;min-width:200px"><span class="p8-scrubber-label">Мощность</span><input type="range" id="p4-iv6-pwr" min="0" max="100" step="1" value="40"><span class="p8-scrubber-value"><span id="p4-iv6-pwr-val">40</span><span class="p8-unit">%</span></span></div>'
|
||||||
|
+'<button class="btn primary" id="p4-iv6-play">Пуск</button>'
|
||||||
|
+'<button class="btn" id="p4-iv6-pause">Стоп</button>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P4_INIT = `
|
||||||
|
function _initP4_iv6(){
|
||||||
|
const sb = document.getElementById('p4-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers || !window.P8Anim) return;
|
||||||
|
const W = 560, H = 280;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W; canvas.height = H;
|
||||||
|
canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.display = 'block';
|
||||||
|
sb.appendChild(canvas);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let power = 40;
|
||||||
|
/* Particles in convection loops */
|
||||||
|
const particles = [];
|
||||||
|
const NP = 60;
|
||||||
|
for (let i = 0; i < NP; i++) {
|
||||||
|
particles.push({
|
||||||
|
x: 60 + Math.random() * (W - 120),
|
||||||
|
y: 40 + Math.random() * (H - 100),
|
||||||
|
angle: Math.random() * 2 * Math.PI,
|
||||||
|
speed: 0.3 + Math.random() * 0.4,
|
||||||
|
r: 2.5 + Math.random() * 1.5
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function step(dt){
|
||||||
|
/* Clear */
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
/* Container */
|
||||||
|
ctx.strokeStyle = '#0f172a';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(40, 20, W - 80, H - 60);
|
||||||
|
/* Burner */
|
||||||
|
ctx.fillStyle = '#475569';
|
||||||
|
ctx.fillRect(180, H - 30, 200, 16);
|
||||||
|
/* Flame */
|
||||||
|
const flameH = (power / 100) * 16;
|
||||||
|
ctx.fillStyle = '#dc2626';
|
||||||
|
ctx.fillRect(200, H - 30 - flameH, 160, flameH);
|
||||||
|
/* Particles — convection loop: rise in center, fall on edges */
|
||||||
|
const cx = W / 2;
|
||||||
|
particles.forEach(p => {
|
||||||
|
const dx = p.x - cx;
|
||||||
|
const localTemp = Math.max(0, 1 - (p.y - 40) / (H - 100)); /* bottom = hot */
|
||||||
|
/* Vertical velocity: up in center (proportional to power), down on sides */
|
||||||
|
let vy = 0;
|
||||||
|
if (Math.abs(dx) < 80) {
|
||||||
|
vy = -p.speed * (power / 50) * (0.6 + localTemp);
|
||||||
|
} else {
|
||||||
|
vy = p.speed * 0.4;
|
||||||
|
}
|
||||||
|
/* Horizontal: drift inward at top, outward at bottom */
|
||||||
|
let vx = 0;
|
||||||
|
if (p.y < 60) vx = -dx * 0.005 * (power / 50);
|
||||||
|
else if (p.y > H - 70) vx = dx * 0.008 * (power / 50);
|
||||||
|
p.x += vx * dt * 60;
|
||||||
|
p.y += vy * dt * 60;
|
||||||
|
/* Constrain */
|
||||||
|
if (p.x < 50) p.x = 50;
|
||||||
|
if (p.x > W - 50) p.x = W - 50;
|
||||||
|
if (p.y < 30) p.y = 30;
|
||||||
|
if (p.y > H - 38) p.y = H - 38;
|
||||||
|
/* Color by temp (hotter = closer to bottom and rising) */
|
||||||
|
const tempVal = localTemp * (power / 100);
|
||||||
|
ctx.fillStyle = P8Helpers.thermal.tempColor(tempVal * 0.7 + 0.15);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.r, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const raf = P8Anim.raf(dt => step(Math.min(dt, 0.05)));
|
||||||
|
document.getElementById('p4-iv6-pwr').oninput = ev => {
|
||||||
|
power = +ev.target.value;
|
||||||
|
document.getElementById('p4-iv6-pwr-val').textContent = power;
|
||||||
|
};
|
||||||
|
document.getElementById('p4-iv6-play').onclick = () => {
|
||||||
|
if (!raf.running) { raf.start(); if (window.addXp) addXp(10, 'p4-iv6-conv'); }
|
||||||
|
};
|
||||||
|
document.getElementById('p4-iv6-pause').onclick = () => raf.stop();
|
||||||
|
/* Auto-start on first view */
|
||||||
|
raf.start();
|
||||||
|
step(0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p4', 4, P4_HTML, P4_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §5 — Radiation balance (3 bodies under lamp)
|
||||||
|
// ============================================================
|
||||||
|
const P5_HTML = `/* IV6 — Radiation (Phase 1.3) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Излучение — какой цвет нагревается быстрее?</div></div>'
|
||||||
|
+'<div class="wg-help">Под лампой — три тела разного цвета: чёрное, белое, зеркальное. Двигай мощность лампы, наблюдай, как растёт T каждого. Чёрное поглощает почти всё, белое — мало, зеркало — почти ничего.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p5-iv6-sandbox" style="height:240px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:14px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-scrubber" style="flex:1;min-width:200px"><span class="p8-scrubber-label">Лампа</span><input type="range" id="p5-iv6-lamp" min="0" max="100" step="1" value="60"><span class="p8-scrubber-value"><span id="p5-iv6-lamp-val">60</span><span class="p8-unit">%</span></span></div>'
|
||||||
|
+'<button class="btn primary" id="p5-iv6-play">Старт</button>'
|
||||||
|
+'<button class="btn" id="p5-iv6-reset">Сброс</button>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P5_INIT = `
|
||||||
|
function _initP5_iv6(){
|
||||||
|
const sb = document.getElementById('p5-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers || !window.P8Anim) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let lampPower = 60;
|
||||||
|
const bodies = [
|
||||||
|
{ name: 'Чёрное', absorption: 0.95, fill: '#0f172a', x: 130, T: 20 },
|
||||||
|
{ name: 'Белое', absorption: 0.20, fill: '#f1f5f9', x: 280, T: 20 },
|
||||||
|
{ name: 'Зеркало', absorption: 0.05, fill: '#cbd5e1', x: 430, T: 20 }
|
||||||
|
];
|
||||||
|
let running = false;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
/* Lamp */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 240, y: 8, width: 80, height: 20, fill: '#facc15', rx: 4 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 22, 'font-family':"'Unbounded',sans-serif", 'font-size':10, 'font-weight':800, fill:'#0f172a', 'text-anchor':'middle', text: lampPower+'%' }));
|
||||||
|
/* Radiation rays */
|
||||||
|
bodies.forEach(b => {
|
||||||
|
const len = lampPower * 1.2;
|
||||||
|
const opacity = lampPower / 100;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', {
|
||||||
|
x1: 280, y1: 28, x2: b.x, y2: 130,
|
||||||
|
stroke: '#fde047', 'stroke-width': 2,
|
||||||
|
opacity: opacity, 'stroke-dasharray': '4 3'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
/* Bodies */
|
||||||
|
bodies.forEach(b => {
|
||||||
|
const g = P8Helpers.svg.el('g', { transform: 'translate('+b.x+',150)' });
|
||||||
|
g.appendChild(P8Helpers.svg.el('rect', { x:-32, y:-12, width:64, height:48, rx:5, fill: b.fill, stroke:'#0f172a', 'stroke-width':1.5 }));
|
||||||
|
g.appendChild(P8Helpers.svg.el('text', { x:0, y:-22, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: b.name }));
|
||||||
|
/* Glow when warm */
|
||||||
|
const tempNorm = Math.min(1, (b.T - 20) / 80);
|
||||||
|
if (tempNorm > 0.05) {
|
||||||
|
g.appendChild(P8Helpers.svg.el('rect', { x:-36, y:-16, width:72, height:56, rx:8, fill: 'none', stroke: P8Helpers.thermal.tempColor(tempNorm*0.5 + 0.5), 'stroke-width': 4, opacity: 0.5 + tempNorm * 0.5 }));
|
||||||
|
}
|
||||||
|
svg.appendChild(g);
|
||||||
|
/* T readout */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: b.x, y: 220, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':800, fill: P8Helpers.thermal.tempColor(tempNorm*0.5 + 0.4), 'text-anchor':'middle', text: 'T='+Math.round(b.T)+'°C' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const raf = P8Anim.raf(dt => {
|
||||||
|
if (!running) return;
|
||||||
|
bodies.forEach(b => {
|
||||||
|
b.T += dt * (lampPower / 100) * b.absorption * 10;
|
||||||
|
if (b.T > 120) b.T = 120;
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
document.getElementById('p5-iv6-lamp').oninput = ev => {
|
||||||
|
lampPower = +ev.target.value;
|
||||||
|
document.getElementById('p5-iv6-lamp-val').textContent = lampPower;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
document.getElementById('p5-iv6-play').onclick = () => {
|
||||||
|
if (!running) { running = true; raf.start(); if (window.addXp) addXp(10, 'p5-iv6-rad'); }
|
||||||
|
};
|
||||||
|
document.getElementById('p5-iv6-reset').onclick = () => {
|
||||||
|
running = false; raf.stop();
|
||||||
|
bodies.forEach(b => b.T = 20);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p5', 5, P5_HTML, P5_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §7 — Q = qm fuel burn
|
||||||
|
// ============================================================
|
||||||
|
const P7_HTML = `/* IV6 — Fuel burn (Phase 1.3) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Тепло сгорания: $Q = qm$</div></div>'
|
||||||
|
+'<div class="wg-help">Выбери топливо и его массу — посчитаем выделенное тепло и нагрев воды массой 1 кг ($c=4200$). $Q = qm$, $\\\\Delta T = Q / (cm_в)$.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p7-iv6-sandbox" style="height:220px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:14px;flex-wrap:wrap;align-items:center">'
|
||||||
|
+'<div class="p8-palette" style="margin:0;padding:6px;background:transparent">'
|
||||||
|
+'<button class="p8-palette-item" data-fuel="wood">Дрова (q=10 МДж/кг)</button>'
|
||||||
|
+'<button class="p8-palette-item" data-fuel="coal">Уголь (q=29 МДж/кг)</button>'
|
||||||
|
+'<button class="p8-palette-item" data-fuel="gas">Газ (q=44 МДж/кг)</button>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>'
|
||||||
|
+'<div style="margin-top:6px;display:flex;gap:14px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-scrubber" style="flex:1;min-width:200px"><span class="p8-scrubber-label">Масса топлива</span><input type="range" id="p7-iv6-m" min="0.01" max="1" step="0.01" value="0.1"><span class="p8-scrubber-value"><span id="p7-iv6-m-val">0.10</span><span class="p8-unit">кг</span></span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">Q</span><span class="p8-readout-value" id="p7-iv6-q">1.0</span><span class="p8-readout-unit">МДж</span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">ΔT воды</span><span class="p8-readout-value" id="p7-iv6-dt">238</span><span class="p8-readout-unit">К</span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P7_INIT = `
|
||||||
|
function _initP7_iv6(){
|
||||||
|
const sb = document.getElementById('p7-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 220);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const fuels = {
|
||||||
|
wood: { q: 10e6, color: '#a16207' },
|
||||||
|
coal: { q: 29e6, color: '#1e293b' },
|
||||||
|
gas: { q: 44e6, color: '#3b82f6' }
|
||||||
|
};
|
||||||
|
let activeFuel = 'wood';
|
||||||
|
let mass = 0.1;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
const f = fuels[activeFuel];
|
||||||
|
/* Vessel with water */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 200, y: 30, width: 160, height: 100, fill: 'rgba(125, 211, 252, .55)', stroke: '#0f172a', 'stroke-width': 2, rx: 5 }));
|
||||||
|
/* Fuel pile */
|
||||||
|
const pileH = 10 + mass * 50;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 240, y: 140 + (40 - pileH), width: 80, height: pileH, fill: f.color, rx: 3 }));
|
||||||
|
/* Flame */
|
||||||
|
const Q = f.q * mass;
|
||||||
|
const intensity = Math.min(1, Q / 5e6);
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 230, y: 130 - intensity * 20, width: 100, height: 12 + intensity * 15, fill: '#dc2626', opacity: 0.85, rx: 4 }));
|
||||||
|
/* Steam if hot enough */
|
||||||
|
const cm = 4200 * 1;
|
||||||
|
const dT = Q / cm;
|
||||||
|
if (dT > 60) {
|
||||||
|
[0, 1, 2].forEach(i => {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 240 + i * 40, cy: 20 - intensity * 10, r: 6 + intensity * 2, fill: '#cbd5e1', opacity: 0.7 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/* Readouts */
|
||||||
|
document.getElementById('p7-iv6-q').textContent = (Q / 1e6).toFixed(2);
|
||||||
|
document.getElementById('p7-iv6-dt').textContent = Math.round(dT);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#p7-iv6-sandbox ~ * [data-fuel]').forEach(btn => {
|
||||||
|
btn.onclick = ev => {
|
||||||
|
activeFuel = ev.currentTarget.dataset.fuel;
|
||||||
|
document.querySelectorAll('[data-fuel]').forEach(b => b.style.outline = '');
|
||||||
|
ev.currentTarget.style.outline = '2px solid var(--p8-brand,#7c3aed)';
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
/* Click on palette items globally */
|
||||||
|
Array.from(document.querySelectorAll('[data-fuel]')).forEach(btn => {
|
||||||
|
btn.onclick = ev => {
|
||||||
|
activeFuel = btn.dataset.fuel;
|
||||||
|
document.querySelectorAll('[data-fuel]').forEach(b => b.style.outline = '');
|
||||||
|
btn.style.outline = '2px solid var(--p8-brand,#7c3aed)';
|
||||||
|
render();
|
||||||
|
if (window.addXp) addXp(5, 'p7-iv6-fuel-'+activeFuel);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
document.getElementById('p7-iv6-m').oninput = ev => {
|
||||||
|
mass = +ev.target.value;
|
||||||
|
document.getElementById('p7-iv6-m-val').textContent = mass.toFixed(2);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p7', 7, P7_HTML, P7_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §9 — λ-meter (Q = λm)
|
||||||
|
// ============================================================
|
||||||
|
const P9_HTML = `/* IV6 — Lambda meter (Phase 1.3) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Удельная теплота плавления: $Q = \\\\lambda m$</div></div>'
|
||||||
|
+'<div class="wg-help">Выбери вещество и массу — рассчитаем энергию, нужную для полного плавления. $Q = \\\\lambda \\\\cdot m$, где $\\\\lambda$ — удельная теплота плавления.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p9-iv6-sandbox" style="height:200px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:14px;flex-wrap:wrap;align-items:center">'
|
||||||
|
+'<select id="p9-iv6-mat" class="tinp" style="font-family:var(--p8-body)">'
|
||||||
|
+'<option value="ice">Лёд (λ=330 кДж/кг)</option>'
|
||||||
|
+'<option value="lead">Свинец (λ=25 кДж/кг)</option>'
|
||||||
|
+'<option value="al">Алюминий (λ=380 кДж/кг)</option>'
|
||||||
|
+'<option value="iron">Железо (λ=270 кДж/кг)</option>'
|
||||||
|
+'</select>'
|
||||||
|
+'<div class="p8-scrubber" style="flex:1;min-width:200px"><span class="p8-scrubber-label">Масса</span><input type="range" id="p9-iv6-m" min="0.1" max="5" step="0.1" value="1"><span class="p8-scrubber-value"><span id="p9-iv6-m-val">1.0</span><span class="p8-unit">кг</span></span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">Q</span><span class="p8-readout-value" id="p9-iv6-q">330</span><span class="p8-readout-unit">кДж</span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P9_INIT = `
|
||||||
|
function _initP9_iv6(){
|
||||||
|
const sb = document.getElementById('p9-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 200);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const mats = {
|
||||||
|
ice: { lambda: 330, color: '#bfdbfe', name: 'Лёд' },
|
||||||
|
lead: { lambda: 25, color: '#9ca3af', name: 'Свинец' },
|
||||||
|
al: { lambda: 380, color: '#cbd5e1', name: 'Алюминий' },
|
||||||
|
iron: { lambda: 270, color: '#64748b', name: 'Железо' }
|
||||||
|
};
|
||||||
|
let mat = 'ice', mass = 1;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
const m = mats[mat];
|
||||||
|
const Q = m.lambda * mass;
|
||||||
|
/* Block of material */
|
||||||
|
const blockH = 50 + mass * 25;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 100, y: 100 - blockH/2, width: 100, height: blockH, fill: m.color, stroke: '#0f172a', 'stroke-width': 2, rx: 6 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 150, y: 100, 'font-family':"'Inter',sans-serif", 'font-size':12, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: m.name }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 150, y: 116, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, fill:'#0f172a', 'text-anchor':'middle', text: mass.toFixed(1)+' кг' }));
|
||||||
|
/* Arrow Q → */
|
||||||
|
const arrow = P8Helpers.svg.gradientArrow(svg, 220, 100, 360, 100, { colorFrom:'#fde047', colorTo:'#dc2626', width: 4, headSize: 16, glow: true });
|
||||||
|
svg.appendChild(arrow);
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 290, y: 88, 'font-family':"'Unbounded',sans-serif", 'font-size':13, 'font-weight':800, fill:'#dc2626', 'text-anchor':'middle', text: 'Q = '+Q+' кДж' }));
|
||||||
|
/* Melted state */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 380, y: 130, width: 100, height: blockH * 0.55, fill: m.color, opacity: 0.7, stroke: '#0f172a', 'stroke-width': 1.5, rx: 4 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 430, y: 156, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'расплав' }));
|
||||||
|
document.getElementById('p9-iv6-q').textContent = Q;
|
||||||
|
}
|
||||||
|
document.getElementById('p9-iv6-mat').onchange = ev => { mat = ev.target.value; render(); };
|
||||||
|
document.getElementById('p9-iv6-m').oninput = ev => {
|
||||||
|
mass = +ev.target.value;
|
||||||
|
document.getElementById('p9-iv6-m-val').textContent = mass.toFixed(1);
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p9', 9, P9_HTML, P9_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §10 — Evaporation rate
|
||||||
|
// ============================================================
|
||||||
|
const P10_HTML = `/* IV6 — Evaporation (Phase 1.3) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Скорость испарения зависит от...</div></div>'
|
||||||
|
+'<div class="wg-help">Что разгоняет испарение? Температура, площадь поверхности и ветер. Двигай скрубберы и наблюдай качественно — стрелки от поверхности вверх.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p10-iv6-sandbox" style="height:220px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px">'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">T</span><input type="range" id="p10-iv6-t" min="0" max="100" step="1" value="50"><span class="p8-scrubber-value"><span id="p10-iv6-t-val">50</span><span class="p8-unit">°C</span></span></div>'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">Площадь</span><input type="range" id="p10-iv6-s" min="0.01" max="1" step="0.01" value="0.3"><span class="p8-scrubber-value"><span id="p10-iv6-s-val">0.30</span><span class="p8-unit">м²</span></span></div>'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">Ветер</span><input type="range" id="p10-iv6-w" min="0" max="10" step="0.1" value="2"><span class="p8-scrubber-value"><span id="p10-iv6-w-val">2.0</span><span class="p8-unit">м/с</span></span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'<div style="margin-top:6px;display:flex;gap:10px"><div class="p8-readout"><span class="p8-readout-label">Скорость испарения</span><span class="p8-readout-value" id="p10-iv6-rate">—</span><span class="p8-readout-unit">отн.</span></div></div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P10_INIT = `
|
||||||
|
function _initP10_iv6(){
|
||||||
|
const sb = document.getElementById('p10-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 220);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let T = 50, S = 0.3, W = 2;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
/* Water surface */
|
||||||
|
const surfaceW = 100 + S * 320;
|
||||||
|
const surfaceX = (560 - surfaceW) / 2;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: surfaceX, y: 150, width: surfaceW, height: 60, fill: P8Helpers.thermal.tempColor(T/130), opacity: 0.85, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 200, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':600, fill:'#fff', 'text-anchor':'middle', text: 'T='+T+'°C, S='+S.toFixed(2)+' м²' }));
|
||||||
|
/* Evaporation rate (relative) */
|
||||||
|
const rate = (T / 100) * (0.5 + S) * (0.5 + W / 10);
|
||||||
|
const numArrows = Math.round(3 + rate * 12);
|
||||||
|
for (let i = 0; i < numArrows; i++) {
|
||||||
|
const x = surfaceX + (i + 0.5) / numArrows * surfaceW + (Math.random() - 0.5) * 8;
|
||||||
|
const len = 30 + rate * 60 + Math.random() * 20;
|
||||||
|
const skew = W * 6 * (Math.random() - 0.3);
|
||||||
|
const arrow = P8Helpers.svg.gradientArrow(svg, x, 150, x + skew, 150 - len, { colorFrom: '#7dd3fc', colorTo: '#bae6fd', width: 1.5, headSize: 7 });
|
||||||
|
if (arrow) svg.appendChild(arrow);
|
||||||
|
}
|
||||||
|
/* Wind indicator */
|
||||||
|
if (W > 1) {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 510, y: 30, 'font-family':"'Inter',sans-serif", 'font-size':22, fill: '#64748b', text: '~'.repeat(Math.min(3, Math.round(W/2))) }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 480, y: 50, 'font-family':"'Inter',sans-serif", 'font-size':10, 'font-weight':700, fill: '#64748b', text: 'ветер →' }));
|
||||||
|
}
|
||||||
|
document.getElementById('p10-iv6-rate').textContent = rate.toFixed(2);
|
||||||
|
}
|
||||||
|
document.getElementById('p10-iv6-t').oninput = ev => { T = +ev.target.value; document.getElementById('p10-iv6-t-val').textContent = T; render(); };
|
||||||
|
document.getElementById('p10-iv6-s').oninput = ev => { S = +ev.target.value; document.getElementById('p10-iv6-s-val').textContent = S.toFixed(2); render(); };
|
||||||
|
document.getElementById('p10-iv6-w').oninput = ev => { W = +ev.target.value; document.getElementById('p10-iv6-w-val').textContent = W.toFixed(1); render(); };
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p10', 10, P10_HTML, P10_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §11 — Pressure cooker (T_boil vs pressure)
|
||||||
|
// ============================================================
|
||||||
|
const P11_HTML = `/* IV6 — Pressure cooker (Phase 1.3) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-thermal">IV-6</span><div class="wg-title">Скороварка — давление меняет $T_{кип}$</div></div>'
|
||||||
|
+'<div class="wg-help">При нормальном давлении вода кипит при 100°C. В скороварке давление 2 атм — T кипения растёт до 120°C. В горах (0.7 атм) — снижается до 90°C. Кривая зависимости — упрощённая модель.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p11-iv6-sandbox" style="height:240px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:14px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-scrubber" style="flex:1;min-width:200px"><span class="p8-scrubber-label">Давление</span><input type="range" id="p11-iv6-p" min="0.5" max="3" step="0.05" value="1"><span class="p8-scrubber-value"><span id="p11-iv6-p-val">1.0</span><span class="p8-unit">атм</span></span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">T кипения</span><span class="p8-readout-value" id="p11-iv6-tboil">100</span><span class="p8-readout-unit">°C</span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P11_INIT = `
|
||||||
|
function _initP11_iv6(){
|
||||||
|
const sb = document.getElementById('p11-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers || !window.P8Anim) return;
|
||||||
|
const W = 560, H = 240;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W; canvas.height = H;
|
||||||
|
canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.display = 'block';
|
||||||
|
sb.appendChild(canvas);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let pressure = 1;
|
||||||
|
/* Approximate T_boil(p) — log curve */
|
||||||
|
function Tboil(p){ return Math.round(100 + 20 * Math.log(p) / Math.log(2)); }
|
||||||
|
const bubbles = [];
|
||||||
|
function spawnBubble(){
|
||||||
|
bubbles.push({ x: 100 + Math.random() * 250, y: 200, r: 3 + Math.random() * 5, vy: -0.5 - Math.random() * 1.5 });
|
||||||
|
if (bubbles.length > 30) bubbles.shift();
|
||||||
|
}
|
||||||
|
function render(){
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
/* Pot */
|
||||||
|
ctx.strokeStyle = '#475569';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.fillStyle = '#cbd5e1';
|
||||||
|
ctx.fillRect(70, 80, 320, 130);
|
||||||
|
ctx.strokeRect(70, 80, 320, 130);
|
||||||
|
/* Lid */
|
||||||
|
ctx.fillStyle = pressure > 1.3 ? '#475569' : '#94a3b8';
|
||||||
|
ctx.fillRect(60, 70, 340, 12);
|
||||||
|
ctx.strokeRect(60, 70, 340, 12);
|
||||||
|
/* Water */
|
||||||
|
const T = Tboil(pressure);
|
||||||
|
const waterColor = T > 100 ? '#fb923c' : '#7dd3fc';
|
||||||
|
ctx.fillStyle = waterColor;
|
||||||
|
ctx.globalAlpha = 0.7;
|
||||||
|
ctx.fillRect(75, 130, 310, 75);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
/* Steam if T high enough */
|
||||||
|
const intensity = (pressure - 0.5) / 2.5;
|
||||||
|
/* Bubbles */
|
||||||
|
if (Math.random() < 0.3 + intensity * 0.5) spawnBubble();
|
||||||
|
bubbles.forEach(b => {
|
||||||
|
b.y += b.vy;
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.globalAlpha = 0.7;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(b.x, b.y, b.r, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
});
|
||||||
|
/* Filter dead bubbles */
|
||||||
|
for (let i = bubbles.length - 1; i >= 0; i--) if (bubbles[i].y < 130) bubbles.splice(i, 1);
|
||||||
|
/* Pressure gauge */
|
||||||
|
ctx.fillStyle = '#0f172a';
|
||||||
|
ctx.font = "bold 14px 'JetBrains Mono', monospace";
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('P = '+pressure.toFixed(2)+' атм', 410, 110);
|
||||||
|
ctx.fillText('T_кип = '+T+'°C', 410, 135);
|
||||||
|
/* T arrow */
|
||||||
|
ctx.strokeStyle = '#dc2626';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(410, 150);
|
||||||
|
ctx.lineTo(410, 150 - (T - 80) * 1.2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#dc2626';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(410, 150 - (T - 80) * 1.2);
|
||||||
|
ctx.lineTo(405, 150 - (T - 80) * 1.2 + 8);
|
||||||
|
ctx.lineTo(415, 150 - (T - 80) * 1.2 + 8);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
const raf = P8Anim.raf(render);
|
||||||
|
raf.start();
|
||||||
|
document.getElementById('p11-iv6-p').oninput = ev => {
|
||||||
|
pressure = +ev.target.value;
|
||||||
|
document.getElementById('p11-iv6-p-val').textContent = pressure.toFixed(2);
|
||||||
|
document.getElementById('p11-iv6-tboil').textContent = Tboil(pressure);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p11', 11, P11_HTML, P11_INIT);
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('ch1 final size:', h.length);
|
||||||
|
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch (e) { console.error('JS PARSE FAIL:', e.message.slice(0, 200)); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('inline JS parses OK');
|
||||||
|
|
||||||
|
const fns = [...h.matchAll(/function build_p(\d+)\(\)/g)].map(m => parseInt(m[1]));
|
||||||
|
console.log('Builders after:', fns.length, fns);
|
||||||
|
if (fns.length !== 11) { console.error('LOST BUILDERS!'); process.exit(1); }
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Phase 2.1 — визуальный редизайн ch2 (Электромагнитные явления):
|
||||||
|
// 1. Hero: новый p8-hero с electric theme, lightning SVG watermark.
|
||||||
|
// 2. Section watermarks: тематические SVG в каждой <section sec-pN>.
|
||||||
|
// 3. IV-6 stubs для §12-31 (20 параграфов).
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_ch2.html');
|
||||||
|
let h = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
// === 1. Replace .hdr block with p8-hero ===
|
||||||
|
const LIGHTNING_WM = `<svg viewBox="0 0 100 100" aria-hidden="true">
|
||||||
|
<path d="M55 8 L25 56 L46 56 L40 92 L75 38 L52 38 L60 8 Z"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const NEW_HERO = `<header class="p8-hero">
|
||||||
|
<div class="p8-hero-wm">${LIGHTNING_WM}</div>
|
||||||
|
<div class="p8-hero-meter" id="p8-meter-ch2"><span id="p8-meter-val">0.5</span> А</div>
|
||||||
|
<div class="p8-hero-inner">
|
||||||
|
<div class="p8-hero-eyebrow">Глава 2 · 20 параграфов</div>
|
||||||
|
<h1 class="p8-hero-title">Электромагнитные явления</h1>
|
||||||
|
<div class="p8-hero-sub">Заряд, ток, цепь, магнитное поле. Конструируйте цепи из компонентов, перемещайте заряды, наблюдайте за искрами и полями.</div>
|
||||||
|
<div class="hdr-side" style="margin-top:18px;display:flex;gap:8px;flex-wrap:wrap;position:relative;z-index:1">
|
||||||
|
<a href="/textbook/physics-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К физике 8</a>
|
||||||
|
<button id="search-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg> Поиск</button>
|
||||||
|
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||||||
|
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>`;
|
||||||
|
|
||||||
|
const oldHdrRegex = /<header class="hdr">[\s\S]*?<\/header>/;
|
||||||
|
if (h.match(oldHdrRegex)) {
|
||||||
|
h = h.replace(oldHdrRegex, NEW_HERO);
|
||||||
|
console.log('Hero replaced');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 2. Live meter скрипт (ток 0.5 → 2 → 1.2 → 0.8 → 1.5 А) ===
|
||||||
|
const METER_SCRIPT = `
|
||||||
|
<script>
|
||||||
|
/* P8 hero meter — анимированный ток (Phase 2 electric) */
|
||||||
|
(function(){
|
||||||
|
function init(){
|
||||||
|
const el = document.getElementById('p8-meter-val');
|
||||||
|
if (!el || !window.P8Anim) return;
|
||||||
|
const targets = [0.5, 2.0, 1.2, 0.8, 1.5];
|
||||||
|
let i = 0;
|
||||||
|
function step(){
|
||||||
|
const from = parseFloat(el.textContent) || 0;
|
||||||
|
const to = targets[i % targets.length];
|
||||||
|
P8Anim.tween({
|
||||||
|
from, to, duration: 1200, easing: 'cubicInOut',
|
||||||
|
onUpdate: v => { el.textContent = v.toFixed(1); },
|
||||||
|
onComplete: () => { i++; setTimeout(step, 1500); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTimeout(step, 1200);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||||
|
else init();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
if (!h.includes('P8 hero meter')) {
|
||||||
|
h = h.replace('</body>', METER_SCRIPT + '\n</body>');
|
||||||
|
console.log('Meter animation added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 3. Section watermarks ===
|
||||||
|
const SEC_SYMBOLS = {
|
||||||
|
p12: '<svg viewBox="0 0 100 100"><circle cx="35" cy="50" r="14" fill="currentColor"/><circle cx="65" cy="50" r="14" fill="currentColor"/><path d="M40 50 L60 50 M50 40 L50 60" stroke="currentColor" stroke-width="3" fill="none"/></svg>', // 2 заряда
|
||||||
|
p13: '<svg viewBox="0 0 100 100"><rect x="20" y="40" width="60" height="20" fill="none" stroke="currentColor" stroke-width="4"/><circle cx="35" cy="50" r="3" fill="currentColor"/><circle cx="50" cy="50" r="3" fill="currentColor"/><circle cx="65" cy="50" r="3" fill="currentColor"/></svg>', // проводник
|
||||||
|
p14: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="24" fill="none" stroke="currentColor" stroke-width="4"/><path d="M50 26 L50 14 M50 86 L50 74 M26 50 L14 50 M86 50 L74 50" stroke="currentColor" stroke-width="4"/></svg>', // индукция
|
||||||
|
p15: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="16" fill="currentColor"/><path d="M50 30 L50 18 M50 82 L50 70 M30 50 L18 50 M82 50 L70 50" stroke="currentColor" stroke-width="3"/></svg>', // заряд центр
|
||||||
|
p16: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="6" fill="currentColor"/><ellipse cx="50" cy="50" rx="32" ry="14" fill="none" stroke="currentColor" stroke-width="2.5"/><ellipse cx="50" cy="50" rx="14" ry="32" fill="none" stroke="currentColor" stroke-width="2.5"/></svg>', // атом
|
||||||
|
p17: '<svg viewBox="0 0 100 100"><line x1="20" y1="50" x2="80" y2="50" stroke="currentColor" stroke-width="3"/><line x1="20" y1="35" x2="80" y2="65" stroke="currentColor" stroke-width="3"/><line x1="20" y1="65" x2="80" y2="35" stroke="currentColor" stroke-width="3"/></svg>', // силовые линии
|
||||||
|
p18: '<svg viewBox="0 0 100 100"><path d="M30 50 L70 50" stroke="currentColor" stroke-width="5" fill="none"/><path d="M65 45 L70 50 L65 55" stroke="currentColor" stroke-width="3" fill="none"/><text x="30" y="40" font-family="Inter" font-size="14" font-weight="700" fill="currentColor">U</text></svg>', // напряжение
|
||||||
|
p19: '<svg viewBox="0 0 100 100"><rect x="20" y="40" width="14" height="20" fill="currentColor"/><rect x="38" y="35" width="6" height="30" fill="currentColor"/><line x1="50" y1="50" x2="80" y2="50" stroke="currentColor" stroke-width="3"/></svg>', // батарея
|
||||||
|
p20: '<svg viewBox="0 0 100 100"><path d="M20 70 L40 30 L60 70 L80 30" stroke="currentColor" stroke-width="4" fill="none"/></svg>', // I=q/t
|
||||||
|
p21: '<svg viewBox="0 0 100 100"><rect x="20" y="40" width="60" height="20" fill="none" stroke="currentColor" stroke-width="3"/><path d="M30 50 L50 50 M55 45 L60 50 L55 55" stroke="currentColor" stroke-width="2" fill="none"/></svg>', // цепь
|
||||||
|
p22: '<svg viewBox="0 0 100 100"><text x="50" y="60" font-family="Unbounded" font-size="36" font-weight="900" fill="currentColor" text-anchor="middle">Ω</text></svg>', // Ом
|
||||||
|
p23: '<svg viewBox="0 0 100 100"><path d="M20 50 Q30 20, 40 50 T60 50 T80 50" stroke="currentColor" stroke-width="4" fill="none"/></svg>', // ρl/S зигзаг
|
||||||
|
p24: '<svg viewBox="0 0 100 100"><path d="M15 50 L25 50 L30 40 L40 60 L50 40 L60 60 L70 40 L75 50 L85 50" stroke="currentColor" stroke-width="3" fill="none"/></svg>', // последовательно
|
||||||
|
p25: '<svg viewBox="0 0 100 100"><path d="M20 30 L80 30 M20 70 L80 70 M50 30 L50 70" stroke="currentColor" stroke-width="3" fill="none"/></svg>', // параллельно
|
||||||
|
p26: '<svg viewBox="0 0 100 100"><text x="50" y="60" font-family="Unbounded" font-size="32" font-weight="900" fill="currentColor" text-anchor="middle">P</text></svg>', // мощность
|
||||||
|
p27: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="28" fill="none" stroke="currentColor" stroke-width="4"/><path d="M28 50 L72 50 M50 28 L50 72" stroke="currentColor" stroke-width="3"/></svg>', // энергия
|
||||||
|
p28: '<svg viewBox="0 0 100 100"><rect x="20" y="42" width="60" height="16" fill="currentColor"/><text x="32" y="56" font-family="Unbounded" font-size="14" font-weight="900" fill="#fff" text-anchor="middle">N</text><text x="68" y="56" font-family="Unbounded" font-size="14" font-weight="900" fill="#fff" text-anchor="middle">S</text></svg>', // магнит
|
||||||
|
p29: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="6" fill="currentColor"/><ellipse cx="50" cy="50" rx="36" ry="18" fill="none" stroke="currentColor" stroke-width="3"/><path d="M82 38 L86 48 L78 46" stroke="currentColor" stroke-width="3" fill="none"/></svg>', // силовые линии магн
|
||||||
|
p30: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="6" fill="currentColor"/><line x1="50" y1="20" x2="50" y2="80" stroke="currentColor" stroke-width="3"/><line x1="20" y1="50" x2="80" y2="50" stroke="currentColor" stroke-width="3"/></svg>', // компас
|
||||||
|
p31: '<svg viewBox="0 0 100 100"><path d="M30 30 Q40 20, 50 30 T70 30 M30 50 Q40 40, 50 50 T70 50 M30 70 Q40 60, 50 70 T70 70" stroke="currentColor" stroke-width="3" fill="none"/><rect x="48" y="20" width="4" height="60" fill="currentColor"/></svg>' // электромагнит
|
||||||
|
};
|
||||||
|
|
||||||
|
let secWmInjected = 0;
|
||||||
|
for (const pid of Object.keys(SEC_SYMBOLS)) {
|
||||||
|
const symbol = SEC_SYMBOLS[pid];
|
||||||
|
const secOpenRegex = new RegExp(`(<section[^>]+id="sec-${pid}"[^>]*>)`);
|
||||||
|
if (h.match(secOpenRegex) && !h.includes(`p8-sec-wm-${pid}`)) {
|
||||||
|
const wmDiv = `<div class="p8-sec-wm" id="p8-sec-wm-${pid}" aria-hidden="true">${symbol}</div>`;
|
||||||
|
h = h.replace(secOpenRegex, '$1\n ' + wmDiv);
|
||||||
|
secWmInjected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Section watermarks injected:', secWmInjected);
|
||||||
|
|
||||||
|
// === 4. IV-6 stubs для §12-31 ===
|
||||||
|
let stubsAdded = 0;
|
||||||
|
for (let n = 12; n <= 31; n++) {
|
||||||
|
const pid = 'p' + n;
|
||||||
|
const stubHtml = `
|
||||||
|
/* IV6 — flagship интерактив (заглушка Phase 2, наполнение в Phase 2.${n}) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Новый интерактив §${n}</div></div>'
|
||||||
|
+'<div class="wg-help">Готовится: интерактивная визуализация с drag-and-drop для углубления темы. Скоро будет доступна.</div>'
|
||||||
|
+'<div style="padding:30px;text-align:center;color:var(--p8-muted);font-style:italic">'
|
||||||
|
+'<svg viewBox="0 0 24 24" style="width:32px;height:32px;stroke:currentColor;fill:none;stroke-width:1.5;opacity:.4"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||||
|
+'<div style="margin-top:8px;font-size:.86rem">Phase 2.${n} — coming soon</div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';
|
||||||
|
`;
|
||||||
|
const marker = `box.innerHTML = h + secNavFor('${pid}') + readButton('${pid}');`;
|
||||||
|
if (!h.includes(`Новый интерактив §${n}`) && h.includes(marker)) {
|
||||||
|
h = h.replace(marker, stubHtml.trim() + '\n\n ' + marker);
|
||||||
|
stubsAdded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('IV-6 stubs added:', stubsAdded);
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('ch2 final size:', h.length);
|
||||||
|
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch (e) { console.error('JS PARSE FAIL:', e.message.slice(0, 150)); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('inline JS parses OK');
|
||||||
|
|
||||||
|
const fns = [...h.matchAll(/function build_p(\d+)\(\)/g)].map(m => parseInt(m[1]));
|
||||||
|
console.log('Builders after:', fns.length, fns);
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
// Phase 2.2 — флагман-интерактивы для критических §:
|
||||||
|
// §12 Charge sandbox, §17 Field visualizer, §22 Ohm's law,
|
||||||
|
// §25 Parallel resistors, §28 Magnet polarity, §30 Эрстед.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_ch2.html');
|
||||||
|
let h = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
function makeStubText(n) {
|
||||||
|
return `/* IV6 — flagship интерактив (заглушка Phase 2, наполнение в Phase 2.${n}) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Новый интерактив §${n}</div></div>'
|
||||||
|
+'<div class="wg-help">Готовится: интерактивная визуализация с drag-and-drop для углубления темы. Скоро будет доступна.</div>'
|
||||||
|
+'<div style="padding:30px;text-align:center;color:var(--p8-muted);font-style:italic">'
|
||||||
|
+'<svg viewBox="0 0 24 24" style="width:32px;height:32px;stroke:currentColor;fill:none;stroke-width:1.5;opacity:.4"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||||
|
+'<div style="margin-top:8px;font-size:.86rem">Phase 2.${n} — coming soon</div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceStub(pid, n, widgetHtml, initFn) {
|
||||||
|
const stubLF = makeStubText(n);
|
||||||
|
const stubCRLF = stubLF.replace(/\n/g, '\r\n');
|
||||||
|
let stubText = null;
|
||||||
|
if (h.includes(stubLF)) stubText = stubLF;
|
||||||
|
else if (h.includes(stubCRLF)) stubText = stubCRLF;
|
||||||
|
if (!stubText) { console.warn(`${pid}: stub not found`); return false; }
|
||||||
|
const eol = stubText === stubCRLF ? '\r\n' : '\n';
|
||||||
|
const widget = widgetHtml.trim().replace(/\n/g, eol);
|
||||||
|
h = h.replace(stubText, widget);
|
||||||
|
h = h.replace(`wireReadBtn('${pid}');`, `wireReadBtn('${pid}');\n _init${pid.toUpperCase()}_iv6();`);
|
||||||
|
const fnStart = h.indexOf(`function build_${pid}()`);
|
||||||
|
const fnEnd = h.indexOf('\n}\n', fnStart);
|
||||||
|
h = h.slice(0, fnEnd + 3) + '\n' + initFn.trim() + '\n' + h.slice(fnEnd + 3);
|
||||||
|
console.log(`${pid}: replaced`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §12 — Charge sandbox: click anywhere to add charge
|
||||||
|
// ============================================================
|
||||||
|
const P12_HTML = `/* IV6 — Charge Sandbox (Phase 2.2) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Песочница зарядов — наблюдай взаимодействие</div></div>'
|
||||||
|
+'<div class="wg-help">Клик ЛКМ → добавить +заряд, клик ПКМ → добавить -заряд. Перетаскивай существующие. Стрелки показывают силы взаимодействия (закон Кулона $F = k|q_1 q_2|/r^2$).</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p12-iv6-sandbox" style="height:300px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap">'
|
||||||
|
+'<button class="btn primary" id="p12-iv6-add-pos">+ Добавить +</button>'
|
||||||
|
+'<button class="btn primary" id="p12-iv6-add-neg" style="background:#2563eb;border-color:#2563eb">+ Добавить −</button>'
|
||||||
|
+'<button class="btn" id="p12-iv6-clear">Очистить</button>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">Зарядов</span><span class="p8-readout-value" id="p12-iv6-count">0</span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P12_INIT = `
|
||||||
|
function _initP12_iv6(){
|
||||||
|
const sb = document.getElementById('p12-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers || !window.P8Drag) return;
|
||||||
|
const W = 560, H = 300;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W; canvas.height = H;
|
||||||
|
canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.display = 'block';
|
||||||
|
sb.appendChild(canvas);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const charges = [];
|
||||||
|
let nextSign = 1;
|
||||||
|
function draw(){
|
||||||
|
ctx.fillStyle = '#fafafa';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
/* Forces between pairs */
|
||||||
|
for (let i = 0; i < charges.length; i++) {
|
||||||
|
for (let j = i + 1; j < charges.length; j++) {
|
||||||
|
const a = charges[i], b = charges[j];
|
||||||
|
const dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
const r2 = dx*dx + dy*dy;
|
||||||
|
if (r2 < 100) continue;
|
||||||
|
const r = Math.sqrt(r2);
|
||||||
|
const F = 4e6 * a.sign * b.sign / r2;
|
||||||
|
const fx = F * dx / r, fy = F * dy / r;
|
||||||
|
/* Arrow from a in direction (-fx, -fy) means: force on a from b */
|
||||||
|
const len = Math.min(80, Math.abs(F) * 5);
|
||||||
|
const dir = a.sign * b.sign > 0 ? -1 : 1;
|
||||||
|
const aex = a.x + dir * fx / Math.abs(F) * len;
|
||||||
|
const aey = a.y + dir * fy / Math.abs(F) * len;
|
||||||
|
ctx.strokeStyle = a.sign * b.sign > 0 ? '#dc2626' : '#16a34a';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(a.x, a.y);
|
||||||
|
ctx.lineTo(aex, aey);
|
||||||
|
ctx.stroke();
|
||||||
|
/* Arrowhead */
|
||||||
|
const ang = Math.atan2(aey - a.y, aex - a.x);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(aex, aey);
|
||||||
|
ctx.lineTo(aex - 7 * Math.cos(ang - 0.3), aey - 7 * Math.sin(ang - 0.3));
|
||||||
|
ctx.lineTo(aex - 7 * Math.cos(ang + 0.3), aey - 7 * Math.sin(ang + 0.3));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = ctx.strokeStyle;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Charges */
|
||||||
|
charges.forEach(c => {
|
||||||
|
const color = c.sign > 0 ? '#dc2626' : '#2563eb';
|
||||||
|
const fill = c.sign > 0 ? '#fecaca' : '#bfdbfe';
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(c.x, c.y, 18, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = "bold 18px sans-serif";
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(c.sign > 0 ? '+' : '−', c.x, c.y + 1);
|
||||||
|
});
|
||||||
|
document.getElementById('p12-iv6-count').textContent = charges.length;
|
||||||
|
}
|
||||||
|
const drag = P8Drag.attachCanvas(canvas, {
|
||||||
|
objects: charges.map(c => ({ ...c, r: 22 })),
|
||||||
|
onPickup: c => {},
|
||||||
|
onDrag: (c, pos) => {
|
||||||
|
/* Sync back to charges by id */
|
||||||
|
const orig = charges.find(ch => ch === c || (ch.id === c.id));
|
||||||
|
if (orig) { orig.x = pos.x; orig.y = pos.y; }
|
||||||
|
draw();
|
||||||
|
},
|
||||||
|
onClick: (pos) => {
|
||||||
|
charges.push({ x: pos.x, y: pos.y, sign: nextSign, id: Date.now() + Math.random() });
|
||||||
|
drag.updateObjects(charges.map(c => ({ ...c, r: 22 })));
|
||||||
|
draw();
|
||||||
|
if (window.addXp && charges.length === 2) addXp(10, 'p12-iv6-first');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('p12-iv6-add-pos').onclick = () => {
|
||||||
|
nextSign = 1;
|
||||||
|
charges.push({ x: 80 + Math.random() * (W - 160), y: 80 + Math.random() * (H - 160), sign: 1, id: Date.now() + Math.random() });
|
||||||
|
drag.updateObjects(charges.map(c => ({ ...c, r: 22 })));
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
document.getElementById('p12-iv6-add-neg').onclick = () => {
|
||||||
|
nextSign = -1;
|
||||||
|
charges.push({ x: 80 + Math.random() * (W - 160), y: 80 + Math.random() * (H - 160), sign: -1, id: Date.now() + Math.random() });
|
||||||
|
drag.updateObjects(charges.map(c => ({ ...c, r: 22 })));
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
document.getElementById('p12-iv6-clear').onclick = () => {
|
||||||
|
charges.length = 0;
|
||||||
|
drag.updateObjects([]);
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p12', 12, P12_HTML, P12_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §17 — Field visualizer
|
||||||
|
// ============================================================
|
||||||
|
const P17_HTML = `/* IV6 — Field Visualizer (Phase 2.2) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Силовые линии — карта поля</div></div>'
|
||||||
|
+'<div class="wg-help">Перетаскивай заряды. Силовые линии рисуются live: выходят из + и заходят в −. Густота линий = напряжённость $E$.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p17-iv6-sandbox" style="height:320px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap">'
|
||||||
|
+'<button class="btn" id="p17-iv6-add-pos">+ Заряд</button>'
|
||||||
|
+'<button class="btn" id="p17-iv6-add-neg">− Заряд</button>'
|
||||||
|
+'<button class="btn" id="p17-iv6-clear">Сброс</button>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P17_INIT = `
|
||||||
|
function _initP17_iv6(){
|
||||||
|
const sb = document.getElementById('p17-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Drag) return;
|
||||||
|
const W = 560, H = 320;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W; canvas.height = H;
|
||||||
|
canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.display = 'block';
|
||||||
|
sb.appendChild(canvas);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let charges = [
|
||||||
|
{ x: 200, y: 160, sign: 1, r: 22 },
|
||||||
|
{ x: 360, y: 160, sign: -1, r: 22 }
|
||||||
|
];
|
||||||
|
function E(x, y) {
|
||||||
|
let ex = 0, ey = 0;
|
||||||
|
charges.forEach(c => {
|
||||||
|
const dx = x - c.x, dy = y - c.y;
|
||||||
|
const r2 = dx*dx + dy*dy;
|
||||||
|
if (r2 < 200) return;
|
||||||
|
const r = Math.sqrt(r2);
|
||||||
|
const k = 5000 * c.sign / r2;
|
||||||
|
ex += k * dx / r; ey += k * dy / r;
|
||||||
|
});
|
||||||
|
return { ex, ey, mag: Math.sqrt(ex*ex + ey*ey) };
|
||||||
|
}
|
||||||
|
function draw(){
|
||||||
|
ctx.fillStyle = '#fafafa';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
/* Draw field lines starting from + charges */
|
||||||
|
charges.filter(c => c.sign > 0).forEach(c => {
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const a = i * 2 * Math.PI / 16;
|
||||||
|
let x = c.x + 25 * Math.cos(a);
|
||||||
|
let y = c.y + 25 * Math.sin(a);
|
||||||
|
ctx.strokeStyle = '#dc2626';
|
||||||
|
ctx.lineWidth = 1.2;
|
||||||
|
ctx.globalAlpha = 0.75;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
for (let step = 0; step < 200; step++) {
|
||||||
|
const e = E(x, y);
|
||||||
|
if (e.mag < 0.01) break;
|
||||||
|
const dx = e.ex / e.mag * 3;
|
||||||
|
const dy = e.ey / e.mag * 3;
|
||||||
|
x += dx; y += dy;
|
||||||
|
if (x < 0 || x > W || y < 0 || y > H) break;
|
||||||
|
/* Stop near - charge */
|
||||||
|
let nearNeg = false;
|
||||||
|
for (const neg of charges) {
|
||||||
|
if (neg.sign < 0 && (x - neg.x)**2 + (y - neg.y)**2 < 600) { nearNeg = true; break; }
|
||||||
|
}
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
if (nearNeg) break;
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
/* Charges */
|
||||||
|
charges.forEach(c => {
|
||||||
|
const color = c.sign > 0 ? '#dc2626' : '#2563eb';
|
||||||
|
const fill = c.sign > 0 ? '#fecaca' : '#bfdbfe';
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(c.x, c.y, 20, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = "bold 20px sans-serif";
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(c.sign > 0 ? '+' : '−', c.x, c.y + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const drag = P8Drag.attachCanvas(canvas, {
|
||||||
|
objects: charges,
|
||||||
|
onDrag: () => draw()
|
||||||
|
});
|
||||||
|
document.getElementById('p17-iv6-add-pos').onclick = () => {
|
||||||
|
charges.push({ x: 100 + Math.random() * (W - 200), y: 80 + Math.random() * (H - 160), sign: 1, r: 22 });
|
||||||
|
drag.updateObjects(charges);
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
document.getElementById('p17-iv6-add-neg').onclick = () => {
|
||||||
|
charges.push({ x: 100 + Math.random() * (W - 200), y: 80 + Math.random() * (H - 160), sign: -1, r: 22 });
|
||||||
|
drag.updateObjects(charges);
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
document.getElementById('p17-iv6-clear').onclick = () => {
|
||||||
|
charges.length = 0;
|
||||||
|
charges.push({ x: 200, y: 160, sign: 1, r: 22 }, { x: 360, y: 160, sign: -1, r: 22 });
|
||||||
|
drag.updateObjects(charges);
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p17', 17, P17_HTML, P17_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §22 — Ohm's law sandbox
|
||||||
|
// ============================================================
|
||||||
|
const P22_HTML = `/* IV6 — Ohm's Law (Phase 2.2) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Закон Ома: $I = U/R$</div></div>'
|
||||||
|
+'<div class="wg-help">Двигай напряжение $U$ и сопротивление $R$. Ток $I = U/R$ обновляется в реальном времени. Лампочка светится ярче с ростом тока.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p22-iv6-sandbox" style="height:220px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:8px">'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">U</span><input type="range" id="p22-iv6-u" min="0.5" max="12" step="0.1" value="6"><span class="p8-scrubber-value"><span id="p22-iv6-u-val">6.0</span><span class="p8-unit">В</span></span></div>'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">R</span><input type="range" id="p22-iv6-r" min="1" max="100" step="1" value="12"><span class="p8-scrubber-value"><span id="p22-iv6-r-val">12</span><span class="p8-unit">Ом</span></span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'<div style="margin-top:8px"><div class="p8-readout"><span class="p8-readout-label">I = U/R</span><span class="p8-readout-value" id="p22-iv6-i">0.50</span><span class="p8-readout-unit">А</span></div></div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P22_INIT = `
|
||||||
|
function _initP22_iv6(){
|
||||||
|
const sb = document.getElementById('p22-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 220);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let U = 6, R = 12;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
const I = U / R;
|
||||||
|
/* Circuit */
|
||||||
|
/* Battery */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('battery', 120, 110, 'h', U+' В'));
|
||||||
|
/* Resistor */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 280, 110, 'h', R+' Ом'));
|
||||||
|
/* Lamp (brightness varies with I) */
|
||||||
|
const lampG = P8Helpers.svg.el('g', { transform: 'translate(440, 110)' });
|
||||||
|
const brightness = Math.min(1, I / 1.5);
|
||||||
|
lampG.appendChild(P8Helpers.svg.el('circle', { cx: 0, cy: 0, r: 26, fill: '#fef3c7', opacity: brightness * 0.6 + 0.1 }));
|
||||||
|
lampG.appendChild(P8Helpers.svg.el('circle', { cx: 0, cy: 0, r: 16, fill: '#fef3c7', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
if (brightness > 0.3) {
|
||||||
|
lampG.appendChild(P8Helpers.svg.el('circle', { cx: 0, cy: 0, r: 30, fill: 'none', stroke: '#facc15', 'stroke-width': 3, opacity: brightness }));
|
||||||
|
}
|
||||||
|
lampG.appendChild(P8Helpers.svg.el('line', { x1: -10, y1: -10, x2: 10, y2: 10, stroke: '#0f172a', 'stroke-width': 1.5 }));
|
||||||
|
lampG.appendChild(P8Helpers.svg.el('line', { x1: -10, y1: 10, x2: 10, y2: -10, stroke: '#0f172a', 'stroke-width': 1.5 }));
|
||||||
|
svg.appendChild(lampG);
|
||||||
|
/* Connect wires */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 150, y1: 110, x2: 250, y2: 110, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 310, y1: 110, x2: 414, y2: 110, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 466, y1: 110, x2: 510, y2: 110, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 510, y1: 110, x2: 510, y2: 170, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 90, y1: 110, x2: 90, y2: 170, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 90, y1: 170, x2: 510, y2: 170, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
/* Current label */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 300, y: 195, 'font-family':"'JetBrains Mono',monospace", 'font-size':14, 'font-weight':800, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'I = '+I.toFixed(2)+' А' }));
|
||||||
|
document.getElementById('p22-iv6-i').textContent = I.toFixed(2);
|
||||||
|
}
|
||||||
|
document.getElementById('p22-iv6-u').oninput = ev => { U = +ev.target.value; document.getElementById('p22-iv6-u-val').textContent = U.toFixed(1); render(); };
|
||||||
|
document.getElementById('p22-iv6-r').oninput = ev => { R = +ev.target.value; document.getElementById('p22-iv6-r-val').textContent = R; render(); };
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p22', 22, P22_HTML, P22_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §25 — Parallel resistors
|
||||||
|
// ============================================================
|
||||||
|
const P25_HTML = `/* IV6 — Parallel resistors (Phase 2.2) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Параллельные резисторы: $1/R = 1/R_1 + 1/R_2$</div></div>'
|
||||||
|
+'<div class="wg-help">Двигай $R_1, R_2$ — наблюдай как ток делится между ветвями ($I = I_1 + I_2$) и какое получается общее $R$.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p25-iv6-sandbox" style="height:240px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:8px">'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">R₁</span><input type="range" id="p25-iv6-r1" min="1" max="100" step="1" value="20"><span class="p8-scrubber-value"><span id="p25-iv6-r1-val">20</span><span class="p8-unit">Ом</span></span></div>'
|
||||||
|
+'<div class="p8-scrubber"><span class="p8-scrubber-label">R₂</span><input type="range" id="p25-iv6-r2" min="1" max="100" step="1" value="30"><span class="p8-scrubber-value"><span id="p25-iv6-r2-val">30</span><span class="p8-unit">Ом</span></span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">R_общ</span><span class="p8-readout-value" id="p25-iv6-r">12</span><span class="p8-readout-unit">Ом</span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">I₁</span><span class="p8-readout-value" id="p25-iv6-i1">0.6</span><span class="p8-readout-unit">А</span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">I₂</span><span class="p8-readout-value" id="p25-iv6-i2">0.4</span><span class="p8-readout-unit">А</span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P25_INIT = `
|
||||||
|
function _initP25_iv6(){
|
||||||
|
const sb = document.getElementById('p25-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const U = 12;
|
||||||
|
let R1 = 20, R2 = 30;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
const R = 1 / (1/R1 + 1/R2);
|
||||||
|
const I1 = U / R1, I2 = U / R2, I = I1 + I2;
|
||||||
|
/* Battery left */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('battery', 80, 120, 'h', U+' В'));
|
||||||
|
/* Branch split */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 110, y1: 120, x2: 200, y2: 120, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 200, y1: 60, x2: 200, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 380, y1: 60, x2: 380, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 200, y1: 60, x2: 290, y2: 60, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 320, y1: 60, x2: 380, y2: 60, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 200, y1: 180, x2: 290, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 320, y1: 180, x2: 380, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
/* R1 (top) */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 305, 60, 'h', R1+' Ом'));
|
||||||
|
/* R2 (bottom) */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 305, 180, 'h', R2+' Ом'));
|
||||||
|
/* Right wire */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 380, y1: 120, x2: 510, y2: 120, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 510, y1: 120, x2: 510, y2: 210, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 120, x2: 50, y2: 210, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 210, x2: 510, y2: 210, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
/* Current labels */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 290, y: 48, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'I₁ = '+I1.toFixed(2)+' А' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 290, y: 218, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'I₂ = '+I2.toFixed(2)+' А' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 150, y: 138, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#dc2626', 'text-anchor':'middle', text: 'I = '+I.toFixed(2)+' А' }));
|
||||||
|
document.getElementById('p25-iv6-r').textContent = R.toFixed(1);
|
||||||
|
document.getElementById('p25-iv6-i1').textContent = I1.toFixed(2);
|
||||||
|
document.getElementById('p25-iv6-i2').textContent = I2.toFixed(2);
|
||||||
|
}
|
||||||
|
document.getElementById('p25-iv6-r1').oninput = ev => { R1 = +ev.target.value; document.getElementById('p25-iv6-r1-val').textContent = R1; render(); };
|
||||||
|
document.getElementById('p25-iv6-r2').oninput = ev => { R2 = +ev.target.value; document.getElementById('p25-iv6-r2-val').textContent = R2; render(); };
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p25', 25, P25_HTML, P25_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §28 — Magnet polarity demo
|
||||||
|
// ============================================================
|
||||||
|
const P28_HTML = `/* IV6 — Magnet polarity (Phase 2.2) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Магниты: разноимённые притягиваются</div></div>'
|
||||||
|
+'<div class="wg-help">Перетаскивай магниты. При сближении одноимённых полюсов (N-N или S-S) — отталкивание (зелёные стрелки). Разноимённых (N-S) — притяжение (красные стрелки).</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p28-iv6-sandbox" style="height:240px"></div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P28_INIT = `
|
||||||
|
function _initP28_iv6(){
|
||||||
|
const sb = document.getElementById('p28-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Drag) return;
|
||||||
|
const W = 560, H = 240;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = W; canvas.height = H;
|
||||||
|
canvas.style.width='100%'; canvas.style.height='100%'; canvas.style.display='block';
|
||||||
|
sb.appendChild(canvas);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const magnets = [
|
||||||
|
{ x: 140, y: 120, angle: 0, r: 50 },
|
||||||
|
{ x: 420, y: 120, angle: 0, r: 50 }
|
||||||
|
];
|
||||||
|
function drawMagnet(m){
|
||||||
|
const w = 100, h = 32;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(m.x, m.y);
|
||||||
|
ctx.rotate(m.angle);
|
||||||
|
/* N half (red) */
|
||||||
|
ctx.fillStyle = '#dc2626';
|
||||||
|
ctx.fillRect(-w/2, -h/2, w/2, h);
|
||||||
|
/* S half (blue) */
|
||||||
|
ctx.fillStyle = '#2563eb';
|
||||||
|
ctx.fillRect(0, -h/2, w/2, h);
|
||||||
|
ctx.strokeStyle = '#0f172a';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(-w/2, -h/2, w, h);
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.font = "bold 18px sans-serif";
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('N', -w/4, 0);
|
||||||
|
ctx.fillText('S', w/4, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
function draw(){
|
||||||
|
ctx.fillStyle = '#fafafa';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
/* Compute interaction between the two magnets — their inner poles */
|
||||||
|
/* Magnet 1: right side is S (blue, at +50), Magnet 2: left side is N (red, at -50) */
|
||||||
|
const m1S_x = magnets[0].x + 50 * Math.cos(magnets[0].angle);
|
||||||
|
const m1S_y = magnets[0].y + 50 * Math.sin(magnets[0].angle);
|
||||||
|
const m2N_x = magnets[1].x - 50 * Math.cos(magnets[1].angle);
|
||||||
|
const m2N_y = magnets[1].y - 50 * Math.sin(magnets[1].angle);
|
||||||
|
const dx = m2N_x - m1S_x;
|
||||||
|
const dy = m2N_y - m1S_y;
|
||||||
|
const dist = Math.sqrt(dx*dx + dy*dy);
|
||||||
|
if (dist < 250 && dist > 30) {
|
||||||
|
/* N-S → attraction */
|
||||||
|
const F = 5000 / (dist * dist);
|
||||||
|
const ux = dx / dist, uy = dy / dist;
|
||||||
|
const len = Math.min(50, F * 50);
|
||||||
|
const color = '#dc2626';
|
||||||
|
/* Arrow 1 from m1S toward m2N */
|
||||||
|
ctx.strokeStyle = color; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(m1S_x, m1S_y);
|
||||||
|
ctx.lineTo(m1S_x + ux * len, m1S_y + uy * len);
|
||||||
|
ctx.stroke();
|
||||||
|
/* Arrow 2 from m2N back */
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(m2N_x, m2N_y);
|
||||||
|
ctx.lineTo(m2N_x - ux * len, m2N_y - uy * len);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = "bold 12px sans-serif";
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('притяжение', (m1S_x + m2N_x)/2, (m1S_y + m2N_y)/2 - 12);
|
||||||
|
}
|
||||||
|
magnets.forEach(drawMagnet);
|
||||||
|
}
|
||||||
|
/* Drag */
|
||||||
|
const dragObjs = magnets.map((m, i) => ({ x: m.x, y: m.y, r: 50, idx: i }));
|
||||||
|
const drag = P8Drag.attachCanvas(canvas, {
|
||||||
|
objects: dragObjs,
|
||||||
|
onDrag: (obj, pos) => {
|
||||||
|
magnets[obj.idx].x = pos.x;
|
||||||
|
magnets[obj.idx].y = pos.y;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p28', 28, P28_HTML, P28_INIT);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §30 — Эрстед: wire + compass
|
||||||
|
// ============================================================
|
||||||
|
const P30_HTML = `/* IV6 — Эрстед (Phase 2.2) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Опыт Эрстеда: ток отклоняет стрелку</div></div>'
|
||||||
|
+'<div class="wg-help">Включи ток в проводнике скрубером — стрелка компаса отклоняется. Направление поля вокруг провода определяется правилом правой руки.</div>'
|
||||||
|
+'<div class="p8-sandbox" id="p30-iv6-sandbox" style="height:240px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:14px;flex-wrap:wrap">'
|
||||||
|
+'<div class="p8-scrubber" style="flex:1;min-width:240px"><span class="p8-scrubber-label">Ток</span><input type="range" id="p30-iv6-i" min="-5" max="5" step="0.1" value="0"><span class="p8-scrubber-value"><span id="p30-iv6-i-val">0.0</span><span class="p8-unit">А</span></span></div>'
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">Угол</span><span class="p8-readout-value" id="p30-iv6-ang">0</span><span class="p8-readout-unit">°</span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
|
||||||
|
const P30_INIT = `
|
||||||
|
function _initP30_iv6(){
|
||||||
|
const sb = document.getElementById('p30-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let I = 0;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
/* Wire (horizontal) */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 40, y1: 120, x2: 520, y2: 120, stroke: '#0f172a', 'stroke-width': 5 }));
|
||||||
|
/* Current arrow direction */
|
||||||
|
if (Math.abs(I) > 0.05) {
|
||||||
|
const dir = I > 0 ? 1 : -1;
|
||||||
|
const arrowX = 320;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', {
|
||||||
|
points: dir > 0 ? (arrowX+8)+',120 '+(arrowX-12)+',114 '+(arrowX-12)+',126' : (arrowX-8)+',120 '+(arrowX+12)+',114 '+(arrowX+12)+',126',
|
||||||
|
fill: '#dc2626'
|
||||||
|
}));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 100, y: 110, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#dc2626', text: 'I = '+I.toFixed(1)+' А' }));
|
||||||
|
}
|
||||||
|
/* Field lines around wire (concentric circles) */
|
||||||
|
const intensity = Math.abs(I) / 5;
|
||||||
|
if (intensity > 0.05) {
|
||||||
|
[30, 50, 70, 90].forEach((r, i) => {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 120, r, fill: 'none', stroke: '#7c3aed', 'stroke-width': 1.5, opacity: intensity * (1 - i * 0.15), 'stroke-dasharray': '5 3' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/* Compass below wire (initially N up = 0°) */
|
||||||
|
const angle = Math.atan2(0, 1) * 180 / Math.PI; /* baseline */
|
||||||
|
/* Angle deflection ∝ I (sign determines direction) */
|
||||||
|
const deflection = Math.atan(I * 0.5) * 60; /* approx */
|
||||||
|
/* Compass body */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 195, r: 28, fill: '#fff', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 172, 'font-family':"'Unbounded',sans-serif", 'font-size':10, 'font-weight':800, fill:'#dc2626', 'text-anchor':'middle', text: 'N' }));
|
||||||
|
/* Needle */
|
||||||
|
const needleG = P8Helpers.svg.el('g', { transform: 'translate(280, 195) rotate('+deflection+')' });
|
||||||
|
needleG.appendChild(P8Helpers.svg.el('polygon', { points: '-2,-22 2,-22 0,-2', fill: '#dc2626' }));
|
||||||
|
needleG.appendChild(P8Helpers.svg.el('polygon', { points: '-2,22 2,22 0,2', fill: '#475569' }));
|
||||||
|
needleG.appendChild(P8Helpers.svg.el('circle', { cx: 0, cy: 0, r: 3, fill: '#0f172a' }));
|
||||||
|
svg.appendChild(needleG);
|
||||||
|
document.getElementById('p30-iv6-ang').textContent = Math.round(deflection);
|
||||||
|
}
|
||||||
|
document.getElementById('p30-iv6-i').oninput = ev => { I = +ev.target.value; document.getElementById('p30-iv6-i-val').textContent = I.toFixed(1); render(); };
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub('p30', 30, P30_HTML, P30_INIT);
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('ch2 size:', h.length);
|
||||||
|
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch (e) { console.error('JS PARSE FAIL:', e.message.slice(0, 200)); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('inline JS parses OK');
|
||||||
|
const fns = [...h.matchAll(/function build_p(\d+)\(\)/g)].map(m => parseInt(m[1]));
|
||||||
|
console.log('Builders:', fns.length, fns);
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
// Phase 2.3 — оставшиеся 14 IV-6 для Ch2.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_ch2.html');
|
||||||
|
let h = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
function makeStubText(n) {
|
||||||
|
return `/* IV6 — flagship интерактив (заглушка Phase 2, наполнение в Phase 2.${n}) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">Новый интерактив §${n}</div></div>'
|
||||||
|
+'<div class="wg-help">Готовится: интерактивная визуализация с drag-and-drop для углубления темы. Скоро будет доступна.</div>'
|
||||||
|
+'<div style="padding:30px;text-align:center;color:var(--p8-muted);font-style:italic">'
|
||||||
|
+'<svg viewBox="0 0 24 24" style="width:32px;height:32px;stroke:currentColor;fill:none;stroke-width:1.5;opacity:.4"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||||
|
+'<div style="margin-top:8px;font-size:.86rem">Phase 2.${n} — coming soon</div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceStub(pid, n, widgetHtml, initFn) {
|
||||||
|
const stubLF = makeStubText(n);
|
||||||
|
const stubCRLF = stubLF.replace(/\n/g, '\r\n');
|
||||||
|
let stubText = null;
|
||||||
|
if (h.includes(stubLF)) stubText = stubLF;
|
||||||
|
else if (h.includes(stubCRLF)) stubText = stubCRLF;
|
||||||
|
if (!stubText) { console.warn(`${pid}: stub not found`); return false; }
|
||||||
|
const eol = stubText === stubCRLF ? '\r\n' : '\n';
|
||||||
|
const widget = widgetHtml.trim().replace(/\n/g, eol);
|
||||||
|
h = h.replace(stubText, widget);
|
||||||
|
h = h.replace(`wireReadBtn('${pid}');`, `wireReadBtn('${pid}');\n _init${pid.toUpperCase()}_iv6();`);
|
||||||
|
const fnStart = h.indexOf(`function build_${pid}()`);
|
||||||
|
const fnEnd = h.indexOf('\n}\n', fnStart);
|
||||||
|
h = h.slice(0, fnEnd + 3) + '\n' + initFn.trim() + '\n' + h.slice(fnEnd + 3);
|
||||||
|
console.log(`${pid}: replaced`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact builder: scrubber-driven calculator with live SVG.
|
||||||
|
function scrubberWidget(pid, n, title, helpHtml, inputs, formula, svgRender) {
|
||||||
|
const html = `/* IV6 — ${title} (Phase 2.3) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-electric">IV-6</span><div class="wg-title">${title}</div></div>'
|
||||||
|
+'<div class="wg-help">${helpHtml}</div>'
|
||||||
|
+'<div class="p8-sandbox" id="${pid}-iv6-sandbox" style="height:200px"></div>'
|
||||||
|
+'<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap">'
|
||||||
|
${inputs.map(inp => ` +'<div class="p8-scrubber" style="flex:1;min-width:170px"><span class="p8-scrubber-label">${inp.label}</span><input type="range" id="${pid}-iv6-${inp.id}" min="${inp.min}" max="${inp.max}" step="${inp.step}" value="${inp.value}"><span class="p8-scrubber-value"><span id="${pid}-iv6-${inp.id}-val">${inp.value}</span><span class="p8-unit">${inp.unit}</span></span></div>'`).join('\n')}
|
||||||
|
+'<div class="p8-readout"><span class="p8-readout-label">${formula.label}</span><span class="p8-readout-value" id="${pid}-iv6-out">${formula.initial}</span><span class="p8-readout-unit">${formula.unit}</span></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
const inputBindings = inputs.map(inp =>
|
||||||
|
` state.${inp.id} = ${inp.value};\n const ${inp.id}Inp = document.getElementById('${pid}-iv6-${inp.id}');\n const ${inp.id}Lab = document.getElementById('${pid}-iv6-${inp.id}-val');\n ${inp.id}Inp.oninput = ev => { state.${inp.id} = +ev.target.value; ${inp.id}Lab.textContent = (${inp.id == 'a' ? 'state.'+inp.id : 'state.'+inp.id}).toFixed(${inp.step >= 1 ? 0 : 2}); render(); };`
|
||||||
|
).join('\n');
|
||||||
|
const init = `
|
||||||
|
function _init${pid.toUpperCase()}_iv6(){
|
||||||
|
const sb = document.getElementById('${pid}-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers) return;
|
||||||
|
const svg = P8Helpers.svg.create(560, 200);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const state = {};
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
${svgRender}
|
||||||
|
}
|
||||||
|
${inputBindings}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceStub(pid, n, html, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §13 — Проводники / диэлектрики
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p13', 13,
|
||||||
|
'Проводники vs диэлектрики',
|
||||||
|
'Двигай напряжение — в проводнике (медь, $n \\\\sim 10^{29}$/м³) свободные электроны легко движутся, в диэлектрике (стекло) — нет.',
|
||||||
|
[{ id: 'u', label: 'U', min: 0, max: 100, step: 1, value: 0, unit: 'В' }],
|
||||||
|
{ label: 'Ток', initial: '0', unit: 'А' },
|
||||||
|
`
|
||||||
|
const U = state.u;
|
||||||
|
/* Conductor (left) — copper bar */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 50, y: 60, width: 180, height: 60, fill: '#b45309', stroke: '#0f172a', 'stroke-width': 2, rx: 5 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 140, y: 50, 'font-family':"'Inter',sans-serif", 'font-size':12, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'Проводник (медь)' }));
|
||||||
|
/* Moving electrons in conductor */
|
||||||
|
const numE = 8;
|
||||||
|
for (let i = 0; i < numE; i++) {
|
||||||
|
const t = (Date.now() / 100 + i * 20) % 100 / 100;
|
||||||
|
const x = 60 + t * 160;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: x, cy: 80 + (i % 2) * 20, r: 4, fill: '#dc2626', opacity: U > 5 ? 1 : 0.3 }));
|
||||||
|
}
|
||||||
|
/* Insulator (right) — glass bar */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 320, y: 60, width: 180, height: 60, fill: '#bae6fd', stroke: '#0f172a', 'stroke-width': 2, rx: 5 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 410, y: 50, 'font-family':"'Inter',sans-serif", 'font-size':12, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'Диэлектрик (стекло)' }));
|
||||||
|
/* Stuck electrons */
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 335 + i * 22, cy: 80 + (i % 2) * 20, r: 4, fill: '#475569' }));
|
||||||
|
}
|
||||||
|
/* Current ↦ in conductor only */
|
||||||
|
const I = U > 1 ? U / 10 : 0;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 140, y: 145, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':800, fill:'#dc2626', 'text-anchor':'middle', text: 'I = '+I.toFixed(2)+' А' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 410, y: 145, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':800, fill:'#94a3b8', 'text-anchor':'middle', text: 'I = 0 А' }));
|
||||||
|
document.getElementById('p13-iv6-out').textContent = I.toFixed(2);
|
||||||
|
/* Animate by re-render every 50ms */
|
||||||
|
if (!sb._anim) sb._anim = setInterval(() => { if (sb.isConnected) render(); else { clearInterval(sb._anim); } }, 100);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §14 — Электростатическая индукция
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p14', 14,
|
||||||
|
'Электростатическая индукция',
|
||||||
|
'Двигай заряженную палочку к незаряженному проводнику. Свободные электроны притягиваются к + или отталкиваются от −, на дальней стороне возникает противоположный заряд.',
|
||||||
|
[{ id: 'd', label: 'Расстояние', min: 50, max: 300, step: 5, value: 200, unit: 'мм' }],
|
||||||
|
{ label: 'Индукция', initial: '0', unit: 'отн.' },
|
||||||
|
`
|
||||||
|
const d = state.d;
|
||||||
|
/* Charged rod (left) */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 30, y: 80, width: 60, height: 30, fill: '#fecaca', stroke: '#dc2626', 'stroke-width': 2, rx: 5 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 60, y: 100, 'font-family':"'Unbounded',sans-serif", 'font-size':18, 'font-weight':900, fill: '#dc2626', 'text-anchor':'middle', text: '+++' }));
|
||||||
|
/* Conductor (right at position 90 + d) */
|
||||||
|
const condX = 90 + d;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: condX, y: 70, width: 140, height: 50, fill: '#fef3c7', stroke: '#0f172a', 'stroke-width': 2, rx: 5 }));
|
||||||
|
/* Distribution: near side − , far side + (induction) */
|
||||||
|
const intensity = Math.max(0, Math.min(1, (300 - d) / 250));
|
||||||
|
if (intensity > 0.1) {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: condX + 25, y: 100, 'font-family':"'Unbounded',sans-serif", 'font-size':16, 'font-weight':900, fill: '#2563eb', 'text-anchor':'middle', text: '−−' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: condX + 115, y: 100, 'font-family':"'Unbounded',sans-serif", 'font-size':16, 'font-weight':900, fill: '#dc2626', 'text-anchor':'middle', text: '++' }));
|
||||||
|
}
|
||||||
|
document.getElementById('p14-iv6-out').textContent = intensity.toFixed(2);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §15 — Элементарный заряд (n = q/e)
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p15', 15,
|
||||||
|
'Элементарный заряд: $q = ne$',
|
||||||
|
'Любой заряд кратен $e = 1{,}6 \\\\cdot 10^{-19}$ Кл. Двигай заряд тела — посчитаем число избыточных электронов.',
|
||||||
|
[{ id: 'q', label: 'q', min: -10, max: 10, step: 0.1, value: 1, unit: 'нКл' }],
|
||||||
|
{ label: 'n электронов', initial: '6.25', unit: '×10¹⁰' },
|
||||||
|
`
|
||||||
|
const q = state.q * 1e-9;
|
||||||
|
const n = Math.abs(q / 1.6e-19);
|
||||||
|
const sign = q > 0 ? 1 : -1;
|
||||||
|
/* Body (sphere) */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 200, cy: 100, r: 50, fill: sign > 0 ? '#fecaca' : '#bfdbfe', stroke: sign > 0 ? '#dc2626' : '#2563eb', 'stroke-width': 3 }));
|
||||||
|
/* +/- charges around */
|
||||||
|
const numE = Math.min(12, Math.round(n / 1e10) + 1);
|
||||||
|
for (let i = 0; i < numE; i++) {
|
||||||
|
const a = i * 2 * Math.PI / numE;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 200 + 38 * Math.cos(a), y: 105 + 38 * Math.sin(a), 'font-family':"'Inter',sans-serif", 'font-size':14, 'font-weight':900, fill: sign > 0 ? '#dc2626' : '#2563eb', 'text-anchor':'middle', text: sign > 0 ? '+' : '−' }));
|
||||||
|
}
|
||||||
|
/* Counter */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 380, y: 90, 'font-family':"'Unbounded',sans-serif", 'font-size':14, 'font-weight':800, fill:'#0f172a', 'text-anchor':'middle', text: 'n = q/e' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 380, y: 115, 'font-family':"'JetBrains Mono',monospace", 'font-size':16, 'font-weight':700, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: '≈ '+(n/1e10).toFixed(2)+'·10¹⁰' }));
|
||||||
|
document.getElementById('p15-iv6-out').textContent = (n / 1e10).toFixed(2);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §16 — Строение атома
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p16', 16,
|
||||||
|
'Строение атома',
|
||||||
|
'Атом нейтрален: число протонов $Z$ = число электронов. Двигай Z, наблюдай орбиту электронов вокруг ядра.',
|
||||||
|
[{ id: 'z', label: 'Z (протоны)', min: 1, max: 20, step: 1, value: 6, unit: '' }],
|
||||||
|
{ label: 'Заряд ядра', initial: '+6e', unit: '' },
|
||||||
|
`
|
||||||
|
const Z = Math.round(state.z);
|
||||||
|
/* Nucleus */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 100, r: 14, fill: '#dc2626', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 105, 'font-family':"'Unbounded',sans-serif", 'font-size':12, 'font-weight':900, fill:'#fff', 'text-anchor':'middle', text: '+'+Z }));
|
||||||
|
/* Orbits — fill shell by shell: 2, 8, 8, 2 */
|
||||||
|
const shells = [];
|
||||||
|
let remaining = Z;
|
||||||
|
[2, 8, 8, 2].forEach(cap => { if (remaining > 0) { shells.push(Math.min(cap, remaining)); remaining -= cap; }});
|
||||||
|
shells.forEach((electrons, shellIdx) => {
|
||||||
|
const radius = 35 + shellIdx * 20;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 100, r: radius, fill: 'none', stroke: '#94a3b8', 'stroke-width': 1, 'stroke-dasharray': '3 3' }));
|
||||||
|
for (let i = 0; i < electrons; i++) {
|
||||||
|
const a = i * 2 * Math.PI / electrons;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280 + radius * Math.cos(a), cy: 100 + radius * Math.sin(a), r: 4.5, fill: '#2563eb', stroke: '#0f172a', 'stroke-width': 1 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('p16-iv6-out').textContent = '+'+Z+'e';
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §18 — A = qU
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p18', 18,
|
||||||
|
'Работа поля: $A = qU$',
|
||||||
|
'Двигай заряд $q$ и напряжение $U$. Работа $A = qU$ обновляется live.',
|
||||||
|
[
|
||||||
|
{ id: 'q', label: 'q', min: 0.1, max: 10, step: 0.1, value: 1, unit: 'мкКл' },
|
||||||
|
{ id: 'u', label: 'U', min: 1, max: 100, step: 1, value: 12, unit: 'В' }
|
||||||
|
],
|
||||||
|
{ label: 'A', initial: '12', unit: 'мкДж' },
|
||||||
|
`
|
||||||
|
const q = state.q, U = state.u;
|
||||||
|
const A = q * U;
|
||||||
|
/* Two plates */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 100, y1: 40, x2: 100, y2: 160, stroke: '#dc2626', 'stroke-width': 6 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 100, y: 30, 'font-family':"'Unbounded',sans-serif", 'font-size':14, 'font-weight':900, fill:'#dc2626', 'text-anchor':'middle', text: '+' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 400, y1: 40, x2: 400, y2: 160, stroke: '#2563eb', 'stroke-width': 6 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 400, y: 30, 'font-family':"'Unbounded',sans-serif", 'font-size':14, 'font-weight':900, fill:'#2563eb', 'text-anchor':'middle', text: '−' }));
|
||||||
|
/* Charge moving */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 200, cy: 100, r: 14, fill: '#fecaca', stroke: '#dc2626', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 200, y: 105, 'font-family':"'Inter',sans-serif", 'font-size':14, 'font-weight':900, fill:'#dc2626', 'text-anchor':'middle', text: '+q' }));
|
||||||
|
/* Arrow direction of work */
|
||||||
|
svg.appendChild(P8Helpers.svg.gradientArrow(svg, 220, 100, 380, 100, { colorFrom: '#facc15', colorTo: '#dc2626', width: 3, headSize: 12, glow: true }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 300, y: 90, 'font-family':"'JetBrains Mono',monospace", 'font-size':14, 'font-weight':800, fill:'#0f172a', 'text-anchor':'middle', text: 'A = qU = '+A.toFixed(1)+' мкДж' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 250, y: 180, 'font-family':"'Inter',sans-serif", 'font-size':11, fill:'var(--p8-muted,#64748b)', 'text-anchor':'middle', text: 'U = '+U+' В, между пластинами' }));
|
||||||
|
document.getElementById('p18-iv6-out').textContent = A.toFixed(1);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §19 — Источники тока
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p19', 19,
|
||||||
|
'ЭДС источника: $\\\\mathcal{E} = A/q$',
|
||||||
|
'Источник тока совершает работу $A$ над зарядом $q$. ЭДС $\\\\mathcal{E} = A/q$.',
|
||||||
|
[
|
||||||
|
{ id: 'a', label: 'A', min: 1, max: 100, step: 1, value: 24, unit: 'Дж' },
|
||||||
|
{ id: 'q', label: 'q', min: 0.5, max: 20, step: 0.5, value: 2, unit: 'Кл' }
|
||||||
|
],
|
||||||
|
{ label: 'ЭДС', initial: '12', unit: 'В' },
|
||||||
|
`
|
||||||
|
const A = state.a, q = state.q;
|
||||||
|
const E = A / q;
|
||||||
|
/* Battery shape */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 200, y: 60, width: 160, height: 80, fill: '#facc15', stroke: '#0f172a', 'stroke-width': 3, rx: 8 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 215, y: 50, width: 30, height: 10, fill: '#475569' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 320, y: 50, width: 30, height: 10, fill: '#475569' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 100, 'font-family':"'Unbounded',sans-serif", 'font-size':22, 'font-weight':900, fill:'#0f172a', 'text-anchor':'middle', text: E.toFixed(1)+' В' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 125, 'font-family':"'Inter',sans-serif", 'font-size':11, fill:'#0f172a', 'text-anchor':'middle', text: 'A = '+A+' Дж, q = '+q+' Кл' }));
|
||||||
|
document.getElementById('p19-iv6-out').textContent = E.toFixed(1);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §20 — I = q/t
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p20', 20,
|
||||||
|
'Сила тока: $I = q/t$',
|
||||||
|
'Двигай заряд и время — найдём ток.',
|
||||||
|
[
|
||||||
|
{ id: 'q', label: 'q', min: 0.1, max: 100, step: 0.1, value: 6, unit: 'Кл' },
|
||||||
|
{ id: 't', label: 't', min: 0.1, max: 60, step: 0.1, value: 2, unit: 'с' }
|
||||||
|
],
|
||||||
|
{ label: 'I', initial: '3.0', unit: 'А' },
|
||||||
|
`
|
||||||
|
const q = state.q, t = state.t;
|
||||||
|
const I = q / t;
|
||||||
|
/* Wire with flowing charges */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 80, y: 90, width: 400, height: 20, fill: '#cbd5e1', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
const numE = Math.min(20, Math.round(q));
|
||||||
|
for (let i = 0; i < numE; i++) {
|
||||||
|
const t0 = (Date.now() / 200 + i / numE) % 1;
|
||||||
|
const x = 90 + t0 * 380;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: x, cy: 100, r: 4, fill: '#dc2626' }));
|
||||||
|
}
|
||||||
|
/* Arrow direction */
|
||||||
|
svg.appendChild(P8Helpers.svg.gradientArrow(svg, 480, 100, 530, 100, { colorFrom: '#dc2626', colorTo: '#7f1d1d', width: 3, headSize: 12 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 140, 'font-family':"'JetBrains Mono',monospace", 'font-size':14, 'font-weight':800, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'I = '+q+'/'+t+' = '+I.toFixed(2)+' А' }));
|
||||||
|
document.getElementById('p20-iv6-out').textContent = I.toFixed(2);
|
||||||
|
if (!sb._anim) sb._anim = setInterval(() => { if (sb.isConnected) render(); else clearInterval(sb._anim); }, 100);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §21 — Электрическая цепь
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p21', 21,
|
||||||
|
'Замкнутая электрическая цепь',
|
||||||
|
'Состоит из источника, потребителя и соединительных проводов. Переключи выключатель — пойдёт ток.',
|
||||||
|
[{ id: 's', label: 'Замкнут', min: 0, max: 1, step: 1, value: 1, unit: '' }],
|
||||||
|
{ label: 'Цепь', initial: 'замкнута', unit: '' },
|
||||||
|
`
|
||||||
|
const closed = state.s > 0.5;
|
||||||
|
/* Battery */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('battery', 120, 100, 'h', '6 В'));
|
||||||
|
/* Switch */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('switch', 270, 100, 'h'));
|
||||||
|
if (closed) {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 258, y1: 100, x2: 282, y2: 100, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
}
|
||||||
|
/* Lamp */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('lamp', 420, 100, 'h'));
|
||||||
|
if (closed) {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 420, cy: 100, r: 22, fill: '#fef3c7', opacity: 0.5 }));
|
||||||
|
}
|
||||||
|
/* Wires */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 150, y1: 100, x2: 240, y2: 100, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 300, y1: 100, x2: 394, y2: 100, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 446, y1: 100, x2: 500, y2: 100, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 500, y1: 100, x2: 500, y2: 160, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 90, y1: 100, x2: 90, y2: 160, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 90, y1: 160, x2: 500, y2: 160, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 185, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill: closed ? '#16a34a' : '#dc2626', 'text-anchor':'middle', text: closed ? '✓ Цепь замкнута — ток идёт' : '✗ Цепь разомкнута' }));
|
||||||
|
document.getElementById('p21-iv6-out').textContent = closed ? 'замкнута' : 'разомкнута';
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §23 — R = ρl/S
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p23', 23,
|
||||||
|
'Сопротивление: $R = \\\\rho l / S$',
|
||||||
|
'Длина увеличивает $R$ пропорционально, площадь — обратно пропорционально. Удельное $\\\\rho$ — для меди 1.7·10⁻⁸ Ом·м.',
|
||||||
|
[
|
||||||
|
{ id: 'l', label: 'l', min: 0.1, max: 10, step: 0.1, value: 1, unit: 'м' },
|
||||||
|
{ id: 's', label: 'S', min: 0.5, max: 10, step: 0.1, value: 1, unit: 'мм²' }
|
||||||
|
],
|
||||||
|
{ label: 'R (медь)', initial: '0.017', unit: 'Ом' },
|
||||||
|
`
|
||||||
|
const l = state.l, S = state.s * 1e-6;
|
||||||
|
const rho = 1.7e-8;
|
||||||
|
const R = rho * l / S;
|
||||||
|
/* Wire shape: длина = l*60 max, толщина = sqrt(S)*8 max */
|
||||||
|
const wireL = Math.min(440, 50 + l * 40);
|
||||||
|
const wireH = Math.min(40, 6 + Math.sqrt(state.s) * 8);
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: (560 - wireL) / 2, y: (200 - wireH) / 2, width: wireL, height: wireH, fill: '#b45309', stroke: '#0f172a', 'stroke-width': 2, rx: 4 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: (200 - wireH)/2 - 8, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'l = '+l.toFixed(1)+' м, S = '+state.s.toFixed(1)+' мм²' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 170, 'font-family':"'Unbounded',sans-serif", 'font-size':14, 'font-weight':800, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'R = '+R.toFixed(4)+' Ом' }));
|
||||||
|
document.getElementById('p23-iv6-out').textContent = R.toFixed(4);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §24 — Последовательные резисторы
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p24', 24,
|
||||||
|
'Последовательное соединение: $R = R_1 + R_2$',
|
||||||
|
'Сложи $R_1$ и $R_2$ — получишь общее $R$. Ток через них одинаков, напряжения складываются: $U = U_1 + U_2$.',
|
||||||
|
[
|
||||||
|
{ id: 'r1', label: 'R₁', min: 1, max: 100, step: 1, value: 20, unit: 'Ом' },
|
||||||
|
{ id: 'r2', label: 'R₂', min: 1, max: 100, step: 1, value: 30, unit: 'Ом' }
|
||||||
|
],
|
||||||
|
{ label: 'R_общ', initial: '50', unit: 'Ом' },
|
||||||
|
`
|
||||||
|
const R1 = state.r1, R2 = state.r2;
|
||||||
|
const R = R1 + R2;
|
||||||
|
const U = 12;
|
||||||
|
const I = U / R;
|
||||||
|
/* Battery */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('battery', 80, 100, 'h', U+' В'));
|
||||||
|
/* R1 */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 240, 100, 'h', R1+' Ом'));
|
||||||
|
/* R2 */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 400, 100, 'h', R2+' Ом'));
|
||||||
|
/* Wires */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 110, y1: 100, x2: 210, y2: 100, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 270, y1: 100, x2: 370, y2: 100, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 430, y1: 100, x2: 510, y2: 100, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 510, y1: 100, x2: 510, y2: 160, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 100, x2: 50, y2: 160, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 160, x2: 510, y2: 160, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
/* Labels */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 180, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':800, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'R = R₁+R₂ = '+R+' Ом, I = U/R = '+I.toFixed(3)+' А' }));
|
||||||
|
document.getElementById('p24-iv6-out').textContent = R;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §26 — P = UI
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p26', 26,
|
||||||
|
'Мощность: $P = UI$',
|
||||||
|
'Двигай напряжение и ток — мощность.',
|
||||||
|
[
|
||||||
|
{ id: 'u', label: 'U', min: 1, max: 220, step: 1, value: 220, unit: 'В' },
|
||||||
|
{ id: 'i', label: 'I', min: 0.01, max: 10, step: 0.01, value: 0.5, unit: 'А' }
|
||||||
|
],
|
||||||
|
{ label: 'P', initial: '110', unit: 'Вт' },
|
||||||
|
`
|
||||||
|
const U = state.u, I = state.i;
|
||||||
|
const P = U * I;
|
||||||
|
/* Lamp brightness */
|
||||||
|
const brightness = Math.min(1, P / 200);
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 100, r: 50, fill: '#fef3c7', opacity: brightness * 0.5 + 0.2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 100, r: 30, fill: '#fde047', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 105, 'font-family':"'Unbounded',sans-serif", 'font-size':16, 'font-weight':900, fill: '#0f172a', 'text-anchor':'middle', text: P.toFixed(0) }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 122, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill: '#0f172a', 'text-anchor':'middle', text: 'Вт' }));
|
||||||
|
if (brightness > 0.6) {
|
||||||
|
/* Rays */
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const a = i * Math.PI / 4;
|
||||||
|
const x1 = 280 + 38 * Math.cos(a), y1 = 100 + 38 * Math.sin(a);
|
||||||
|
const x2 = 280 + 58 * Math.cos(a), y2 = 100 + 58 * Math.sin(a);
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1, y1, x2, y2, stroke: '#facc15', 'stroke-width': 3 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 180, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'P = U·I = '+U+'·'+I.toFixed(2)+' = '+P.toFixed(1)+' Вт' }));
|
||||||
|
document.getElementById('p26-iv6-out').textContent = P.toFixed(1);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §27 — A = UIt (электроэнергия)
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p27', 27,
|
||||||
|
'Электроэнергия: $A = UIt$',
|
||||||
|
'За время $t$ потребитель потратит $A = UIt$ или $A = Pt$.',
|
||||||
|
[
|
||||||
|
{ id: 'p', label: 'P', min: 1, max: 3000, step: 1, value: 100, unit: 'Вт' },
|
||||||
|
{ id: 't', label: 't', min: 0.1, max: 24, step: 0.1, value: 5, unit: 'ч' }
|
||||||
|
],
|
||||||
|
{ label: 'A', initial: '0.5', unit: 'кВт·ч' },
|
||||||
|
`
|
||||||
|
const P = state.p, t = state.t;
|
||||||
|
const A = P * t / 1000; /* kWh */
|
||||||
|
/* Time bar */
|
||||||
|
const barW = (t / 24) * 460;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 50, y: 80, width: 460, height: 40, fill: '#e5e7eb', stroke: '#0f172a' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 50, y: 80, width: barW, height: 40, fill: 'var(--el-mid,#06b6d4)', opacity: 0.7 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 105, 'font-family':"'JetBrains Mono',monospace", 'font-size':14, 'font-weight':800, fill:'#fff', 'text-anchor':'middle', text: t.toFixed(1)+' ч из 24' }));
|
||||||
|
/* A display */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 160, 'font-family':"'Unbounded',sans-serif", 'font-size':18, 'font-weight':900, fill:'#0f172a', 'text-anchor':'middle', text: 'A = '+A.toFixed(3)+' кВт·ч' }));
|
||||||
|
document.getElementById('p27-iv6-out').textContent = A.toFixed(3);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §29 — Магнитное поле тока (видим B-grid)
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p29', 29,
|
||||||
|
'Магнитное поле тока: $B \\\\propto I$',
|
||||||
|
'Чем больше ток — тем сильнее поле вокруг проводника. Густота линий ∝ $|I|$.',
|
||||||
|
[{ id: 'i', label: 'I', min: -10, max: 10, step: 0.1, value: 3, unit: 'А' }],
|
||||||
|
{ label: 'B (отн.)', initial: '3', unit: '' },
|
||||||
|
`
|
||||||
|
const I = state.i;
|
||||||
|
/* Wire (vertical center) */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 280, y1: 20, x2: 280, y2: 180, stroke: '#0f172a', 'stroke-width': 5 }));
|
||||||
|
/* Current direction */
|
||||||
|
if (Math.abs(I) > 0.05) {
|
||||||
|
const dir = I > 0 ? 1 : -1;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: '280,'+(dir>0?20:180)+' 274,'+(dir>0?30:170)+' 286,'+(dir>0?30:170), fill: '#dc2626' }));
|
||||||
|
}
|
||||||
|
/* Field circles around wire */
|
||||||
|
const intensity = Math.abs(I) / 10;
|
||||||
|
[25, 45, 65, 90, 115].forEach((r, k) => {
|
||||||
|
const opacity = intensity * (1 - k * 0.15);
|
||||||
|
if (opacity > 0.05) {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 100, r, fill: 'none', stroke: '#7c3aed', 'stroke-width': 1.5, opacity, 'stroke-dasharray': '5 3' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 195, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':800, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'I = '+I.toFixed(1)+' А, B ∝ |I|' }));
|
||||||
|
document.getElementById('p29-iv6-out').textContent = Math.abs(I).toFixed(1);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §31 — Электромагнит (B ∝ NI)
|
||||||
|
// ============================================================
|
||||||
|
scrubberWidget('p31', 31,
|
||||||
|
'Электромагнит: $B \\\\propto NI$',
|
||||||
|
'Соленоид с $N$ витками и током $I$ — поле растёт пропорционально и тому, и другому. Сердечник из железа усиливает в $\\\\mu \\\\sim 1000$ раз.',
|
||||||
|
[
|
||||||
|
{ id: 'n', label: 'Витки N', min: 10, max: 1000, step: 10, value: 100, unit: '' },
|
||||||
|
{ id: 'i', label: 'I', min: 0, max: 5, step: 0.1, value: 1, unit: 'А' }
|
||||||
|
],
|
||||||
|
{ label: 'B (отн.)', initial: '100', unit: '' },
|
||||||
|
`
|
||||||
|
const N = state.n, I = state.i;
|
||||||
|
const B = N * I;
|
||||||
|
/* Solenoid coils */
|
||||||
|
const coils = Math.min(20, Math.round(N / 50) + 4);
|
||||||
|
const coilW = 16;
|
||||||
|
for (let k = 0; k < coils; k++) {
|
||||||
|
const x = 130 + k * coilW;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('ellipse', { cx: x, cy: 100, rx: 6, ry: 32, fill: 'none', stroke: '#b45309', 'stroke-width': 2 }));
|
||||||
|
}
|
||||||
|
/* Iron core */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 120, y: 90, width: coils * coilW + 20, height: 20, fill: '#64748b', stroke: '#0f172a', 'stroke-width': 1 }));
|
||||||
|
/* Field lines */
|
||||||
|
const intensity = Math.min(1, B / 2000);
|
||||||
|
if (intensity > 0.05) {
|
||||||
|
[40, 70, 100].forEach((dy, k) => {
|
||||||
|
const op = intensity * (1 - k * 0.25);
|
||||||
|
if (op < 0.05) return;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('path', { d: 'M 50 '+(100-dy)+' Q 280 '+(100-dy*1.5)+', 510 '+(100-dy), fill: 'none', stroke: '#7c3aed', 'stroke-width': 1.5, opacity: op }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('path', { d: 'M 50 '+(100+dy)+' Q 280 '+(100+dy*1.5)+', 510 '+(100+dy), fill: 'none', stroke: '#7c3aed', 'stroke-width': 1.5, opacity: op }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 180, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':800, fill:'var(--el-mid,#06b6d4)', 'text-anchor':'middle', text: 'B ∝ N·I = '+B+' (отн.)' }));
|
||||||
|
document.getElementById('p31-iv6-out').textContent = B;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('ch2 final size:', h.length);
|
||||||
|
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch (e) { console.error('JS PARSE FAIL:', e.message.slice(0, 200)); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('inline JS parses OK');
|
||||||
|
const fns = [...h.matchAll(/function build_p(\d+)\(\)/g)].map(m => parseInt(m[1]));
|
||||||
|
console.log('Builders:', fns.length);
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
// Phase 3 — Ch3 Световые явления: hero + 9 section watermarks + 9 IV-6.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_ch3.html');
|
||||||
|
let h = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
// === 1. Hero replacement ===
|
||||||
|
const SUN_WM = `<svg viewBox="0 0 100 100" aria-hidden="true">
|
||||||
|
<circle cx="50" cy="50" r="22" />
|
||||||
|
<g><line x1="50" y1="8" x2="50" y2="22" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<line x1="50" y1="78" x2="50" y2="92" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<line x1="8" y1="50" x2="22" y2="50" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<line x1="78" y1="50" x2="92" y2="50" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<line x1="20" y1="20" x2="30" y2="30" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<line x1="70" y1="70" x2="80" y2="80" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<line x1="80" y1="20" x2="70" y2="30" stroke="currentColor" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<line x1="30" y1="70" x2="20" y2="80" stroke="currentColor" stroke-width="4" stroke-linecap="round"/></g>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const NEW_HERO = `<header class="p8-hero">
|
||||||
|
<div class="p8-hero-wm">${SUN_WM}</div>
|
||||||
|
<div class="p8-hero-meter" id="p8-meter-ch3"><span id="p8-meter-val">λ=550</span> нм</div>
|
||||||
|
<div class="p8-hero-inner">
|
||||||
|
<div class="p8-hero-eyebrow">Глава 3 · 9 параграфов</div>
|
||||||
|
<h1 class="p8-hero-title">Световые явления</h1>
|
||||||
|
<div class="p8-hero-sub">Лучи, тени, отражение, преломление, линзы, дисперсия, глаз. Перетаскивайте источники света и зеркала, наблюдайте за лучами и спектром.</div>
|
||||||
|
<div class="hdr-side" style="margin-top:18px;display:flex;gap:8px;flex-wrap:wrap;position:relative;z-index:1">
|
||||||
|
<a href="/textbook/physics-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К физике 8</a>
|
||||||
|
<button id="search-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg> Поиск</button>
|
||||||
|
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||||||
|
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>`;
|
||||||
|
|
||||||
|
const oldHdrRegex = /<header class="hdr">[\s\S]*?<\/header>/;
|
||||||
|
if (h.match(oldHdrRegex)) {
|
||||||
|
h = h.replace(oldHdrRegex, NEW_HERO);
|
||||||
|
console.log('Hero replaced');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 2. Live meter (wavelength cycles through visible spectrum) ===
|
||||||
|
const METER_SCRIPT = `
|
||||||
|
<script>
|
||||||
|
/* P8 hero meter — анимация длины волны (Phase 3 spectrum) */
|
||||||
|
(function(){
|
||||||
|
function init(){
|
||||||
|
const el = document.getElementById('p8-meter-val');
|
||||||
|
if (!el || !window.P8Anim) return;
|
||||||
|
const targets = [{ l: 400, c:'#7c3aed' }, { l: 470, c:'#2563eb' }, { l: 550, c:'#16a34a' }, { l: 600, c:'#f59e0b' }, { l: 700, c:'#dc2626' }];
|
||||||
|
let i = 0;
|
||||||
|
function step(){
|
||||||
|
const from = parseFloat((el.textContent || '550').replace(/\\D/g,'')) || 550;
|
||||||
|
const target = targets[i % targets.length];
|
||||||
|
P8Anim.tween({
|
||||||
|
from, to: target.l, duration: 1100, easing: 'cubicInOut',
|
||||||
|
onUpdate: v => { el.textContent = 'λ=' + Math.round(v); el.style.color = target.c; },
|
||||||
|
onComplete: () => { i++; setTimeout(step, 1400); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTimeout(step, 1200);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||||
|
else init();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
`;
|
||||||
|
if (!h.includes('P8 hero meter')) {
|
||||||
|
h = h.replace('</body>', METER_SCRIPT + '\n</body>');
|
||||||
|
console.log('Meter added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 3. Section watermarks ===
|
||||||
|
const SEC_SYMBOLS = {
|
||||||
|
p32: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="14" fill="currentColor"/><g stroke="currentColor" stroke-width="4" stroke-linecap="round"><line x1="50" y1="14" x2="50" y2="26"/><line x1="50" y1="74" x2="50" y2="86"/><line x1="14" y1="50" x2="26" y2="50"/><line x1="74" y1="50" x2="86" y2="50"/></g></svg>',
|
||||||
|
p33: '<svg viewBox="0 0 100 100"><circle cx="32" cy="40" r="10" fill="currentColor"/><rect x="50" y="34" width="14" height="40" fill="currentColor"/><polygon points="68,40 92,30 92,80 68,70" fill="currentColor" opacity="0.5"/></svg>',
|
||||||
|
p34: '<svg viewBox="0 0 100 100"><line x1="20" y1="20" x2="50" y2="50" stroke="currentColor" stroke-width="5"/><line x1="50" y1="50" x2="80" y2="20" stroke="currentColor" stroke-width="5"/><line x1="50" y1="50" x2="50" y2="90" stroke="currentColor" stroke-width="2" stroke-dasharray="4 4"/></svg>',
|
||||||
|
p35: '<svg viewBox="0 0 100 100"><line x1="30" y1="20" x2="30" y2="80" stroke="currentColor" stroke-width="4"/><g stroke="currentColor" stroke-width="1.5"><line x1="30" y1="30" x2="22" y2="34"/><line x1="30" y1="45" x2="22" y2="49"/><line x1="30" y1="60" x2="22" y2="64"/><line x1="30" y1="75" x2="22" y2="79"/></g><circle cx="60" cy="50" r="6" fill="currentColor"/></svg>',
|
||||||
|
p36: '<svg viewBox="0 0 100 100"><path d="M30 20 Q 20 50, 30 80" stroke="currentColor" stroke-width="5" fill="none"/><line x1="48" y1="50" x2="70" y2="50" stroke="currentColor" stroke-width="2" stroke-dasharray="3 3"/><circle cx="70" cy="50" r="3" fill="currentColor"/></svg>',
|
||||||
|
p37: '<svg viewBox="0 0 100 100"><line x1="20" y1="20" x2="50" y2="50" stroke="currentColor" stroke-width="5"/><line x1="50" y1="50" x2="80" y2="80" stroke="currentColor" stroke-width="5" stroke-dasharray="0"/><line x1="0" y1="50" x2="100" y2="50" stroke="currentColor" stroke-width="2"/></svg>',
|
||||||
|
p38: '<svg viewBox="0 0 100 100"><ellipse cx="50" cy="50" rx="10" ry="36" fill="currentColor" opacity="0.4"/><line x1="0" y1="50" x2="100" y2="50" stroke="currentColor" stroke-width="2"/><circle cx="25" cy="50" r="2" fill="currentColor"/><circle cx="75" cy="50" r="2" fill="currentColor"/></svg>',
|
||||||
|
p39: '<svg viewBox="0 0 100 100"><polygon points="40,20 80,50 40,80" stroke="currentColor" stroke-width="4" fill="none"/><line x1="20" y1="50" x2="40" y2="50" stroke="currentColor" stroke-width="3"/><g stroke-width="2.5" fill="none"><line x1="60" y1="40" x2="90" y2="30" stroke="#dc2626"/><line x1="60" y1="50" x2="90" y2="50" stroke="#16a34a"/><line x1="60" y1="60" x2="90" y2="70" stroke="#2563eb"/></g></svg>',
|
||||||
|
p40: '<svg viewBox="0 0 100 100"><ellipse cx="50" cy="50" rx="36" ry="22" fill="none" stroke="currentColor" stroke-width="3"/><circle cx="50" cy="50" r="12" fill="currentColor"/><circle cx="50" cy="50" r="5" fill="#fff"/></svg>'
|
||||||
|
};
|
||||||
|
|
||||||
|
let secWmInjected = 0;
|
||||||
|
for (const pid of Object.keys(SEC_SYMBOLS)) {
|
||||||
|
const symbol = SEC_SYMBOLS[pid];
|
||||||
|
const secOpenRegex = new RegExp(`(<section[^>]+id="sec-${pid}"[^>]*>)`);
|
||||||
|
if (h.match(secOpenRegex) && !h.includes(`p8-sec-wm-${pid}`)) {
|
||||||
|
const wmDiv = `<div class="p8-sec-wm" id="p8-sec-wm-${pid}" aria-hidden="true">${symbol}</div>`;
|
||||||
|
h = h.replace(secOpenRegex, '$1\n ' + wmDiv);
|
||||||
|
secWmInjected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Section watermarks:', secWmInjected);
|
||||||
|
|
||||||
|
// === 4. Stub function ===
|
||||||
|
function makeStubText(n) {
|
||||||
|
return `/* IV6 — flagship интерактив (заглушка Phase 3, наполнение в Phase 3.${n}) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-spectrum">IV-6</span><div class="wg-title">Новый интерактив §${n}</div></div>'
|
||||||
|
+'<div class="wg-help">Готовится: интерактивная визуализация с drag-and-drop для углубления темы. Скоро будет доступна.</div>'
|
||||||
|
+'<div style="padding:30px;text-align:center;color:var(--p8-muted);font-style:italic">'
|
||||||
|
+'<svg viewBox="0 0 24 24" style="width:32px;height:32px;stroke:currentColor;fill:none;stroke-width:1.5;opacity:.4"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||||
|
+'<div style="margin-top:8px;font-size:.86rem">Phase 3.${n} — coming soon</div>'
|
||||||
|
+'</div>'
|
||||||
|
+'</div>';`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceWithReal(pid, n, widgetHtml, initFn) {
|
||||||
|
// Two paths: stub already present (need to replace) OR no stub (just inject before box.innerHTML).
|
||||||
|
const stubLF = makeStubText(n);
|
||||||
|
const stubCRLF = stubLF.replace(/\n/g, '\r\n');
|
||||||
|
let stubText = null;
|
||||||
|
if (h.includes(stubLF)) stubText = stubLF;
|
||||||
|
else if (h.includes(stubCRLF)) stubText = stubCRLF;
|
||||||
|
const eol = (h.indexOf('\r\n') >= 0) ? '\r\n' : '\n';
|
||||||
|
const widget = widgetHtml.trim().replace(/\n/g, eol);
|
||||||
|
if (stubText) {
|
||||||
|
h = h.replace(stubText, widget);
|
||||||
|
} else {
|
||||||
|
// Inject before box.innerHTML
|
||||||
|
const marker = `box.innerHTML = h + secNavFor('${pid}') + readButton('${pid}');`;
|
||||||
|
if (!h.includes(marker)) { console.warn(`${pid}: no marker`); return false; }
|
||||||
|
h = h.replace(marker, widget + eol + eol + ' ' + marker);
|
||||||
|
}
|
||||||
|
// Add init call
|
||||||
|
h = h.replace(`wireReadBtn('${pid}');`, `wireReadBtn('${pid}');${eol} _init${pid.toUpperCase()}_iv6();`);
|
||||||
|
// Append init function after build_pN
|
||||||
|
const fnStart = h.indexOf(`function build_${pid}()`);
|
||||||
|
const fnEnd = h.indexOf('\n}\n', fnStart);
|
||||||
|
h = h.slice(0, fnEnd + 3) + '\n' + initFn.trim() + '\n' + h.slice(fnEnd + 3);
|
||||||
|
console.log(`${pid}: injected real IV-6`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Compact widget builder ===
|
||||||
|
function widget(pid, n, title, help, height, body, init) {
|
||||||
|
const html = `/* IV6 — ${title} (Phase 3) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge p8-badge-spectrum">IV-6</span><div class="wg-title">${title}</div></div>'
|
||||||
|
+'<div class="wg-help">${help}</div>'
|
||||||
|
+'<div class="p8-sandbox" id="${pid}-iv6-sandbox" style="height:${height}px"></div>'
|
||||||
|
${body}
|
||||||
|
+'</div>';`;
|
||||||
|
const initFn = `
|
||||||
|
function _init${pid.toUpperCase()}_iv6(){
|
||||||
|
const sb = document.getElementById('${pid}-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers) return;
|
||||||
|
${init}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
replaceWithReal(pid, n, html, initFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §32 — Источники света (типы)
|
||||||
|
// ============================================================
|
||||||
|
widget('p32', 32, 'Точечные и протяжённые источники',
|
||||||
|
'Точечный источник (свеча издалека) даёт чёткие тени. Протяжённый (Солнце) — размытые с полутенью.',
|
||||||
|
240,
|
||||||
|
'+\'<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap"><button class="btn primary" id="p32-iv6-point">Точечный</button><button class="btn" id="p32-iv6-ext">Протяжённый</button></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let mode = 'point';
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
/* Object */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 230, y: 90, width: 30, height: 60, fill: '#475569' }));
|
||||||
|
if (mode === 'point') {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 80, cy: 120, r: 12, fill: '#facc15', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 92, y1: 110, x2: 230, y2: 90, stroke: '#facc15', 'stroke-width': 2, 'stroke-dasharray': '3 3' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 92, y1: 130, x2: 230, y2: 150, stroke: '#facc15', 'stroke-width': 2, 'stroke-dasharray': '3 3' }));
|
||||||
|
/* Sharp shadow */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: '260,90 460,30 460,210 260,150', fill: '#0f172a', opacity: 0.7 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 380, y: 220, 'font-family':"'Inter',sans-serif", 'font-size':12, 'font-weight':700, fill: '#fff', 'text-anchor':'middle', text: 'Чёткая тень' }));
|
||||||
|
} else {
|
||||||
|
/* Sun */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 80, cy: 120, r: 30, fill: '#facc15', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 110, y1: 90, x2: 230, y2: 90, stroke: '#facc15', 'stroke-width': 1.5, 'stroke-dasharray': '3 3' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 110, y1: 150, x2: 230, y2: 150, stroke: '#facc15', 'stroke-width': 1.5, 'stroke-dasharray': '3 3' }));
|
||||||
|
/* Sharp inner shadow (umbra) */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: '260,100 380,80 380,160 260,140', fill: '#0f172a', opacity: 0.7 }));
|
||||||
|
/* Penumbra */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: '260,90 460,30 380,80 260,100', fill: '#0f172a', opacity: 0.3 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: '260,150 380,160 460,210 260,150', fill: '#0f172a', opacity: 0.3 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 320, y: 220, 'font-family':"'Inter',sans-serif", 'font-size':12, 'font-weight':700, fill: '#0f172a', 'text-anchor':'middle', text: 'Тень + полутень' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('p32-iv6-point').onclick = () => { mode = 'point'; render(); };
|
||||||
|
document.getElementById('p32-iv6-ext').onclick = () => { mode = 'ext'; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §33 — Тени (расстояние источник-объект)
|
||||||
|
// ============================================================
|
||||||
|
widget('p33', 33, 'Тень и её размер',
|
||||||
|
'Двигай источник света — наблюдай, как меняется размер тени. Чем ближе источник — тем больше тень.',
|
||||||
|
240,
|
||||||
|
'+\'<div class="p8-scrubber" style="margin-top:10px"><span class="p8-scrubber-label">Источник X</span><input type="range" id="p33-iv6-x" min="20" max="200" step="2" value="80"><span class="p8-scrubber-value"><span id="p33-iv6-x-val">80</span><span class="p8-unit">px</span></span></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let lampX = 80;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
/* Light */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: lampX, cy: 120, r: 12, fill: '#facc15', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
/* Object */
|
||||||
|
const objX = 300;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: objX - 12, y: 90, width: 24, height: 60, fill: '#475569' }));
|
||||||
|
/* Rays + shadow */
|
||||||
|
const wallX = 510;
|
||||||
|
const t = (wallX - lampX) / (objX - lampX);
|
||||||
|
const yTop = 120 + (90 - 120) * t;
|
||||||
|
const yBot = 120 + (150 - 120) * t;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: lampX + 12, y1: 110, x2: wallX, y2: yTop, stroke: '#facc15', 'stroke-width': 1.5, 'stroke-dasharray': '3 3' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: lampX + 12, y1: 130, x2: wallX, y2: yBot, stroke: '#facc15', 'stroke-width': 1.5, 'stroke-dasharray': '3 3' }));
|
||||||
|
/* Wall */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: wallX, y1: 20, x2: wallX, y2: 220, stroke: '#0f172a', 'stroke-width': 3 }));
|
||||||
|
/* Shadow on wall */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: wallX, y: yTop, width: 28, height: yBot - yTop, fill: '#0f172a', opacity: 0.7 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: wallX + 14, y: yTop - 5, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'h='+(yBot-yTop).toFixed(0) }));
|
||||||
|
}
|
||||||
|
document.getElementById('p33-iv6-x').oninput = ev => { lampX = +ev.target.value; document.getElementById('p33-iv6-x-val').textContent = lampX; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §34 — Отражение (угол падения = угол отражения)
|
||||||
|
// ============================================================
|
||||||
|
widget('p34', 34, 'Закон отражения',
|
||||||
|
'Двигай угол падения — угол отражения равен ему.',
|
||||||
|
240,
|
||||||
|
'+\'<div class="p8-scrubber" style="margin-top:10px"><span class="p8-scrubber-label">Угол α</span><input type="range" id="p34-iv6-a" min="0" max="80" step="1" value="35"><span class="p8-scrubber-value"><span id="p34-iv6-a-val">35</span><span class="p8-unit">°</span></span></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let alpha = 35;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
const cx = 280, cy = 200;
|
||||||
|
/* Mirror */
|
||||||
|
svg.appendChild(P8Helpers.optics.mirrorPlane(80, 200, 480, 200));
|
||||||
|
/* Normal */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: cx, y1: 200, x2: cx, y2: 30, stroke: '#475569', 'stroke-width': 1.5, 'stroke-dasharray': '5 3' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: cx + 8, y: 40, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#475569', text: 'нормаль' }));
|
||||||
|
/* Incident ray */
|
||||||
|
const rad = alpha * Math.PI / 180;
|
||||||
|
const len = 150;
|
||||||
|
const inX = cx - len * Math.sin(rad);
|
||||||
|
const inY = cy - len * Math.cos(rad);
|
||||||
|
svg.appendChild(P8Helpers.optics.rayLine(inX, inY, cx, cy, { color: '#facc15', width: 3, glow: true }));
|
||||||
|
/* Reflected ray */
|
||||||
|
const rX = cx + len * Math.sin(rad);
|
||||||
|
const rY = cy - len * Math.cos(rad);
|
||||||
|
svg.appendChild(P8Helpers.optics.rayLine(cx, cy, rX, rY, { color: '#facc15', width: 3, glow: true }));
|
||||||
|
/* Angle labels */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: cx - 25, y: 130, 'font-family':"'JetBrains Mono',monospace", 'font-size':13, 'font-weight':800, fill:'#dc2626', text: 'α='+alpha+'°' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: cx + 8, y: 130, 'font-family':"'JetBrains Mono',monospace", 'font-size':13, 'font-weight':800, fill:'#16a34a', text: 'β='+alpha+'°' }));
|
||||||
|
}
|
||||||
|
document.getElementById('p34-iv6-a').oninput = ev => { alpha = +ev.target.value; document.getElementById('p34-iv6-a-val').textContent = alpha; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §35 — Плоское зеркало (объект → мнимое изображение)
|
||||||
|
// ============================================================
|
||||||
|
widget('p35', 35, 'Плоское зеркало',
|
||||||
|
'Двигай объект — мнимое изображение появляется за зеркалом на том же расстоянии.',
|
||||||
|
240,
|
||||||
|
'+\'<div class="p8-scrubber" style="margin-top:10px"><span class="p8-scrubber-label">Дистанция d</span><input type="range" id="p35-iv6-d" min="50" max="200" step="2" value="100"><span class="p8-scrubber-value"><span id="p35-iv6-d-val">100</span><span class="p8-unit">px</span></span></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let d = 100;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
const mirX = 280;
|
||||||
|
/* Mirror */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: mirX, y1: 40, x2: mirX, y2: 200, stroke: '#0f172a', 'stroke-width': 4 }));
|
||||||
|
/* Hatch */
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: mirX, y1: 45 + i * 14, x2: mirX + 8, y2: 49 + i * 14, stroke: '#475569', 'stroke-width': 1.5 }));
|
||||||
|
}
|
||||||
|
/* Object (arrow) */
|
||||||
|
const objX = mirX - d;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: objX, y1: 180, x2: objX, y2: 90, stroke: '#dc2626', 'stroke-width': 3 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: objX+',85 '+(objX-6)+',95 '+(objX+6)+',95', fill: '#dc2626' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: objX, y: 220, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#dc2626', 'text-anchor':'middle', text: 'объект' }));
|
||||||
|
/* Virtual image */
|
||||||
|
const imgX = mirX + d;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: imgX, y1: 180, x2: imgX, y2: 90, stroke: '#94a3b8', 'stroke-width': 3, 'stroke-dasharray': '4 3' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: imgX+',85 '+(imgX-6)+',95 '+(imgX+6)+',95', fill: '#94a3b8' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: imgX, y: 220, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#94a3b8', 'text-anchor':'middle', text: 'мнимое изображение' }));
|
||||||
|
/* Distance arrows */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: objX, y1: 60, x2: mirX, y2: 60, stroke: '#475569', 'stroke-width': 1.5 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: mirX, y1: 60, x2: imgX, y2: 60, stroke: '#475569', 'stroke-width': 1.5 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: (objX + mirX) / 2, y: 52, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#475569', 'text-anchor':'middle', text: 'd='+d }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: (mirX + imgX) / 2, y: 52, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#475569', 'text-anchor':'middle', text: 'd='+d }));
|
||||||
|
}
|
||||||
|
document.getElementById('p35-iv6-d').oninput = ev => { d = +ev.target.value; document.getElementById('p35-iv6-d-val').textContent = d; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §36 — Сферическое зеркало (фокус)
|
||||||
|
// ============================================================
|
||||||
|
widget('p36', 36, 'Сферическое зеркало',
|
||||||
|
'Двигай расстояние объекта до фокуса — изображение меняет тип (увеличенное/уменьшенное, прямое/перевёрнутое).',
|
||||||
|
240,
|
||||||
|
'+\'<div class="p8-scrubber" style="margin-top:10px"><span class="p8-scrubber-label">Объект → зеркало</span><input type="range" id="p36-iv6-d" min="50" max="250" step="2" value="180"><span class="p8-scrubber-value"><span id="p36-iv6-d-val">180</span><span class="p8-unit">мм</span></span></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const F = 100, mirX = 480;
|
||||||
|
let d = 180;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
/* Mirror curve */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('path', { d: 'M '+mirX+' 60 Q '+(mirX-30)+' 120, '+mirX+' 180', stroke: '#0f172a', 'stroke-width': 4, fill: 'none' }));
|
||||||
|
/* Axis */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 120, x2: mirX, y2: 120, stroke: '#94a3b8', 'stroke-width': 1, 'stroke-dasharray': '4 3' }));
|
||||||
|
/* Focus */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: mirX - F, cy: 120, r: 3, fill: '#16a34a' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: mirX - F, y: 138, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':700, fill:'#16a34a', 'text-anchor':'middle', text: 'F' }));
|
||||||
|
/* Object */
|
||||||
|
const objX = mirX - d;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: objX, y1: 120, x2: objX, y2: 80, stroke: '#dc2626', 'stroke-width': 2.5 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: objX+',75 '+(objX-4)+',82 '+(objX+4)+',82', fill: '#dc2626' }));
|
||||||
|
/* Lens formula: 1/v - 1/d = 1/F, here mirror equation: 1/v + 1/d = 1/F (using d positive in front) */
|
||||||
|
const v = 1 / (1 / F - 1 / d);
|
||||||
|
const imgX = mirX - v;
|
||||||
|
const h_img = 40 * v / d * -1;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: imgX, y1: 120, x2: imgX, y2: 120 + h_img, stroke: '#2563eb', 'stroke-width': 2.5, 'stroke-dasharray': v < 0 ? '4 3' : '0' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: imgX+','+(120 + h_img - Math.sign(h_img) * 4)+' '+(imgX-4)+','+(120 + h_img + 1)+' '+(imgX+4)+','+(120 + h_img + 1), fill: '#2563eb' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 220, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'd='+d+' мм, F='+F+', v='+v.toFixed(0)+' мм' }));
|
||||||
|
}
|
||||||
|
document.getElementById('p36-iv6-d').oninput = ev => { d = +ev.target.value; document.getElementById('p36-iv6-d-val').textContent = d; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §37 — Преломление (углы)
|
||||||
|
// ============================================================
|
||||||
|
widget('p37', 37, 'Преломление света',
|
||||||
|
'Двигай угол падения. На границе двух сред (воздух/вода n=1.33) угол преломления меньше.',
|
||||||
|
240,
|
||||||
|
'+\'<div class="p8-scrubber" style="margin-top:10px"><span class="p8-scrubber-label">Угол α</span><input type="range" id="p37-iv6-a" min="0" max="80" step="1" value="40"><span class="p8-scrubber-value"><span id="p37-iv6-a-val">40</span><span class="p8-unit">°</span></span></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let alpha = 40;
|
||||||
|
const n1 = 1, n2 = 1.33;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
const cx = 280, cy = 120;
|
||||||
|
/* Water region */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 0, y: 120, width: 560, height: 120, fill: '#7dd3fc', opacity: 0.35 }));
|
||||||
|
/* Interface */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 30, y1: cy, x2: 530, y2: cy, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
/* Normal */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: cx, y1: 20, x2: cx, y2: 220, stroke: '#475569', 'stroke-width': 1.5, 'stroke-dasharray': '4 3' }));
|
||||||
|
/* Incident */
|
||||||
|
const aRad = alpha * Math.PI / 180;
|
||||||
|
const len = 120;
|
||||||
|
const inX = cx - len * Math.sin(aRad);
|
||||||
|
const inY = cy - len * Math.cos(aRad);
|
||||||
|
svg.appendChild(P8Helpers.optics.rayLine(inX, inY, cx, cy, { color: '#facc15', width: 3, glow: true }));
|
||||||
|
/* Snell: n1 sin α = n2 sin β */
|
||||||
|
const beta = Math.asin(Math.min(1, n1 / n2 * Math.sin(aRad)));
|
||||||
|
const rX = cx + len * Math.sin(beta);
|
||||||
|
const rY = cy + len * Math.cos(beta);
|
||||||
|
svg.appendChild(P8Helpers.optics.rayLine(cx, cy, rX, rY, { color: '#facc15', width: 3, glow: true }));
|
||||||
|
/* Labels */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: cx - 25, y: 70, 'font-family':"'JetBrains Mono',monospace", 'font-size':13, 'font-weight':800, fill:'#dc2626', text: 'α='+alpha+'°' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: cx + 8, y: 175, 'font-family':"'JetBrains Mono',monospace", 'font-size':13, 'font-weight':800, fill:'#16a34a', text: 'β='+(beta * 180 / Math.PI).toFixed(1)+'°' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 50, y: 50, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#475569', text: 'n₁=1 (воздух)' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 50, y: 215, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#0f172a', text: 'n₂=1.33 (вода)' }));
|
||||||
|
}
|
||||||
|
document.getElementById('p37-iv6-a').oninput = ev => { alpha = +ev.target.value; document.getElementById('p37-iv6-a-val').textContent = alpha; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §38 — Линзы (3 главных луча)
|
||||||
|
// ============================================================
|
||||||
|
widget('p38', 38, 'Собирающая линза — построение изображения',
|
||||||
|
'Двигай объект. Три главных луча: через центр (прямо), параллельно главной оси (через F), через F (параллельно оси).',
|
||||||
|
280,
|
||||||
|
'+\'<div class="p8-scrubber" style="margin-top:10px"><span class="p8-scrubber-label">Дистанция d</span><input type="range" id="p38-iv6-d" min="50" max="280" step="2" value="180"><span class="p8-scrubber-value"><span id="p38-iv6-d-val">180</span><span class="p8-unit">мм</span></span></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 280);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let d = 180;
|
||||||
|
const F = 100, lensX = 320, axisY = 150;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
/* Axis */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 30, y1: axisY, x2: 530, y2: axisY, stroke: '#94a3b8', 'stroke-width': 1, 'stroke-dasharray': '4 3' }));
|
||||||
|
/* Lens */
|
||||||
|
svg.appendChild(P8Helpers.optics.lensSVG(lensX, axisY, 140, 'converging'));
|
||||||
|
/* F marks */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: lensX - F, cy: axisY, r: 3, fill: '#16a34a' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: lensX + F, cy: axisY, r: 3, fill: '#16a34a' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: lensX - F - 8, y: axisY + 18, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':700, fill:'#16a34a', text: 'F' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: lensX + F + 8, y: axisY + 18, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':700, fill:'#16a34a', text: 'F' }));
|
||||||
|
/* Object */
|
||||||
|
const objX = lensX - d;
|
||||||
|
const objH = 50;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: objX, y1: axisY, x2: objX, y2: axisY - objH, stroke: '#dc2626', 'stroke-width': 3 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: objX+','+(axisY-objH-6)+' '+(objX-5)+','+(axisY-objH+2)+' '+(objX+5)+','+(axisY-objH+2), fill: '#dc2626' }));
|
||||||
|
/* Thin lens equation: 1/v - 1/(-d) = 1/F → v = dF/(d-F) (object on left, d>0) */
|
||||||
|
const v = (d * F) / (d - F);
|
||||||
|
const imgX = lensX + v;
|
||||||
|
const imgH = objH * v / d;
|
||||||
|
/* Three principal rays */
|
||||||
|
/* Ray 1: parallel to axis, refracts through far F */
|
||||||
|
svg.appendChild(P8Helpers.optics.rayLine(objX, axisY - objH, lensX, axisY - objH, { color: '#facc15', width: 1.5, arrow: false }));
|
||||||
|
svg.appendChild(P8Helpers.optics.rayLine(lensX, axisY - objH, imgX, axisY + imgH, { color: '#facc15', width: 1.5, arrow: false }));
|
||||||
|
/* Ray 2: through optic center */
|
||||||
|
svg.appendChild(P8Helpers.optics.rayLine(objX, axisY - objH, imgX, axisY + imgH, { color: '#16a34a', width: 1.5, arrow: false }));
|
||||||
|
/* Ray 3: through near F, refracts parallel */
|
||||||
|
svg.appendChild(P8Helpers.optics.rayLine(objX, axisY - objH, lensX, axisY + ((lensX - objX) / (lensX - F - objX)) * (-objH) - (-objH) * ((lensX - F - objX) / (lensX - F - objX) - 1), { color: '#2563eb', width: 1.5, arrow: false }));
|
||||||
|
/* Image */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: imgX, y1: axisY, x2: imgX, y2: axisY + imgH, stroke: '#2563eb', 'stroke-width': 3 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: imgX+','+(axisY+imgH+6)+' '+(imgX-5)+','+(axisY+imgH-2)+' '+(imgX+5)+','+(axisY+imgH-2), fill: '#2563eb' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 260, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'd='+d+' мм, F='+F+', v='+v.toFixed(0)+' мм' }));
|
||||||
|
}
|
||||||
|
document.getElementById('p38-iv6-d').oninput = ev => { d = +ev.target.value; document.getElementById('p38-iv6-d-val').textContent = d; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §39 — Дисперсия (призма + спектр)
|
||||||
|
// ============================================================
|
||||||
|
widget('p39', 39, 'Дисперсия — разложение белого света',
|
||||||
|
'Через призму белый свет разлагается на спектр. Угол отклонения зависит от длины волны: красный отклоняется меньше, фиолетовый больше.',
|
||||||
|
240,
|
||||||
|
'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
/* Incident white */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 30, y1: 120, x2: 200, y2: 120, stroke: '#fff', 'stroke-width': 5 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 30, y1: 120, x2: 200, y2: 120, stroke: '#facc15', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 30, y: 105, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#0f172a', text: 'Белый свет' }));
|
||||||
|
/* Prism */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('polygon', { points: '200,180 280,40 360,180', fill: 'rgba(125,211,252,.35)', stroke: '#0284c7', 'stroke-width': 2 }));
|
||||||
|
/* Spectrum out */
|
||||||
|
const colors = [
|
||||||
|
{ c: '#dc2626', off: 0, label: 'красный' },
|
||||||
|
{ c: '#f97316', off: 8, label: 'оранжевый' },
|
||||||
|
{ c: '#facc15', off: 16, label: 'жёлтый' },
|
||||||
|
{ c: '#16a34a', off: 24, label: 'зелёный' },
|
||||||
|
{ c: '#0ea5e9', off: 32, label: 'голубой' },
|
||||||
|
{ c: '#2563eb', off: 40, label: 'синий' },
|
||||||
|
{ c: '#7c3aed', off: 48, label: 'фиолетовый' }
|
||||||
|
];
|
||||||
|
colors.forEach((cl, i) => {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 290, y1: 120, x2: 530, y2: 100 + cl.off, stroke: cl.c, 'stroke-width': 2.5, 'stroke-linecap': 'round' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 535, y: 104 + cl.off, 'font-family':"'Inter',sans-serif", 'font-size':9, 'font-weight':700, fill: cl.c, text: cl.label }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// §40 — Глаз / коррекция (близорукость / дальнозоркость)
|
||||||
|
// ============================================================
|
||||||
|
widget('p40', 40, 'Глаз: аккомодация и очки',
|
||||||
|
'Нормальный глаз: лучи фокусируются на сетчатке. Близорукий — перед сетчаткой (нужна рассеивающая). Дальнозоркий — за сетчаткой (нужна собирающая).',
|
||||||
|
240,
|
||||||
|
'+\'<div style="margin-top:10px;display:flex;gap:10px;flex-wrap:wrap"><button class="btn primary" id="p40-iv6-normal">Норма</button><button class="btn" id="p40-iv6-myop">Близорукость</button><button class="btn" id="p40-iv6-hyper">Дальнозоркость</button></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 240);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let mode = 'normal';
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML = '';
|
||||||
|
/* Eye outline */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('ellipse', { cx: 380, cy: 120, rx: 90, ry: 70, fill: '#fff', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
/* Cornea */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('path', { d: 'M 290 105 Q 270 120, 290 135', fill: '#bae6fd', stroke: '#0284c7', 'stroke-width': 2 }));
|
||||||
|
/* Lens */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('ellipse', { cx: 310, cy: 120, rx: 8, ry: 26, fill: 'rgba(125,211,252,.55)', stroke: '#0284c7', 'stroke-width': 1.5 }));
|
||||||
|
/* Retina */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('path', { d: 'M 440 80 Q 470 120, 440 160', stroke: '#dc2626', 'stroke-width': 3, fill: 'none' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 465, y: 85, 'font-family':"'Inter',sans-serif", 'font-size':10, 'font-weight':700, fill:'#dc2626', text: 'сетчатка' }));
|
||||||
|
/* Rays */
|
||||||
|
const focusX = mode === 'normal' ? 440 : (mode === 'myop' ? 420 : 480);
|
||||||
|
const colorFocus = mode === 'normal' ? '#16a34a' : '#dc2626';
|
||||||
|
/* 3 incoming rays */
|
||||||
|
[80, 120, 160].forEach(y => {
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: y, x2: 305, y2: y, stroke: '#facc15', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 315, y1: y, x2: focusX, y2: 120, stroke: '#facc15', 'stroke-width': 2 }));
|
||||||
|
});
|
||||||
|
/* Focus point */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: focusX, cy: 120, r: 5, fill: colorFocus }));
|
||||||
|
/* Correction lens if needed */
|
||||||
|
if (mode === 'myop') {
|
||||||
|
svg.appendChild(P8Helpers.optics.lensSVG(180, 120, 70, 'diverging'));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 180, y: 200, 'font-family':"'Inter',sans-serif", 'font-size':10, 'font-weight':700, fill:'#2563eb', 'text-anchor':'middle', text: '−дптр (рассеивающая)' }));
|
||||||
|
} else if (mode === 'hyper') {
|
||||||
|
svg.appendChild(P8Helpers.optics.lensSVG(180, 120, 70, 'converging'));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 180, y: 200, 'font-family':"'Inter',sans-serif", 'font-size':10, 'font-weight':700, fill:'#dc2626', 'text-anchor':'middle', text: '+дптр (собирающая)' }));
|
||||||
|
}
|
||||||
|
/* Label */
|
||||||
|
const labels = { normal: 'Норма: фокус на сетчатке', myop: 'Близорукость: фокус перед сетчаткой', hyper: 'Дальнозоркость: фокус за сетчаткой' };
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 222, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: labels[mode] }));
|
||||||
|
}
|
||||||
|
document.getElementById('p40-iv6-normal').onclick = () => { mode = 'normal'; render(); };
|
||||||
|
document.getElementById('p40-iv6-myop').onclick = () => { mode = 'myop'; render(); };
|
||||||
|
document.getElementById('p40-iv6-hyper').onclick = () => { mode = 'hyper'; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('ch3 final size:', h.length);
|
||||||
|
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch (e) { console.error('JS PARSE FAIL:', e.message.slice(0, 200)); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('inline JS parses OK');
|
||||||
|
const fns = [...h.matchAll(/function build_p(\d+)\(\)/g)].map(m => parseInt(m[1]));
|
||||||
|
console.log('Builders:', fns.length, fns);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Phase 5 — Hub polish: добавить тематические SVG watermarks для каждой
|
||||||
|
// chapter-карточки, hover-микро-анимации, live-индикатор прогресса.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_hub.html');
|
||||||
|
let h = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
// === 1. Add hub-level animations CSS (inline into <style>) ===
|
||||||
|
const HUB_EXTRA_CSS = `
|
||||||
|
/* === Phase 5 hub polish === */
|
||||||
|
.ch-card{transition:transform .35s cubic-bezier(.16,1,.3,1),box-shadow .35s,border-color .25s}
|
||||||
|
.ch-card:hover{transform:translateY(-6px) scale(1.01);box-shadow:0 18px 44px rgba(124,58,237,.22)}
|
||||||
|
.ch-cover{position:relative;overflow:hidden}
|
||||||
|
.ch-cover::after{content:'';position:absolute;inset:0;background:linear-gradient(135deg,transparent 40%,rgba(255,255,255,.10) 50%,transparent 60%);background-size:300% 300%;background-position:200% 200%;transition:background-position .85s ease-out;pointer-events:none}
|
||||||
|
.ch-card:hover .ch-cover::after{background-position:-100% -100%}
|
||||||
|
.ch-card .ch-cover-wm{transition:transform .5s cubic-bezier(.16,1,.3,1),opacity .35s}
|
||||||
|
.ch-card:hover .ch-cover-wm{transform:translateX(-6px) translateY(8px) scale(1.08);opacity:.30}
|
||||||
|
.ch-action svg{transition:transform .25s}
|
||||||
|
.ch-action:hover svg{transform:translateX(4px)}
|
||||||
|
/* Topic-themed glow on action button */
|
||||||
|
.ch-card.ch1-card:hover .ch-action{box-shadow:0 6px 16px rgba(220,38,38,.35)}
|
||||||
|
.ch-card.ch2-card:hover .ch-action{box-shadow:0 6px 16px rgba(217,119,6,.35)}
|
||||||
|
.ch-card.ch3-card:hover .ch-action{box-shadow:0 6px 16px rgba(8,145,178,.35)}
|
||||||
|
.ch-card.ch4-card:hover .ch-action{box-shadow:0 6px 16px rgba(16,185,129,.35)}
|
||||||
|
/* Overall progress XP-pulse */
|
||||||
|
@keyframes po-xp-pulse{0%,100%{box-shadow:0 4px 12px rgba(124,58,237,.24)}50%{box-shadow:0 4px 16px rgba(124,58,237,.40),0 0 0 4px rgba(124,58,237,.08)}}
|
||||||
|
.po-xp{animation:po-xp-pulse 3s ease-in-out infinite}
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media(max-width:580px){
|
||||||
|
.hdr{padding:24px 16px 20px}
|
||||||
|
.hdr h1{font-size:1.4rem}
|
||||||
|
.ch-cover-wm{font-size:3.8rem;right:-4px;top:-12px}
|
||||||
|
}
|
||||||
|
/* Focus-visible accessibility */
|
||||||
|
.ch-card:focus-visible{outline:3px solid var(--p8-brand,#7c3aed);outline-offset:3px;border-radius:18px}
|
||||||
|
.hdr-btn:focus-visible,.hdr-back:focus-visible{outline:2px solid #fff;outline-offset:2px}
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce){
|
||||||
|
.ch-card,.ch-card .ch-cover-wm,.ch-cover::after,.po-xp,.ch-action svg{animation:none!important;transition:none!important}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!h.includes('Phase 5 hub polish')) {
|
||||||
|
h = h.replace('</style>', HUB_EXTRA_CSS + '\n</style>');
|
||||||
|
console.log('Hub polish CSS injected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 2. Make chapter card titles accessibility-friendly + add aria-labels ===
|
||||||
|
// Add aria-label на каждую .ch-card если её нет
|
||||||
|
const cardLabels = {
|
||||||
|
'ch1-card': 'Глава 1: Тепловые явления',
|
||||||
|
'ch2-card': 'Глава 2: Электромагнитные явления',
|
||||||
|
'ch3-card': 'Глава 3: Световые явления',
|
||||||
|
'ch4-card': 'Лабораторный практикум — 7 ЛР'
|
||||||
|
};
|
||||||
|
for (const [cls, label] of Object.entries(cardLabels)) {
|
||||||
|
const regex = new RegExp(`(<a [^>]*class="ch-card ${cls}"[^>]*)>`, 'g');
|
||||||
|
h = h.replace(regex, (match, before) => {
|
||||||
|
if (/aria-label=/.test(before)) return match;
|
||||||
|
return `${before} aria-label="${label}">`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 3. Footer "Created with..." note (or replace existing if any) ===
|
||||||
|
// Optional — add a soft footer
|
||||||
|
const FOOTER_HTML = `
|
||||||
|
<footer style="max-width:1100px;margin:50px auto 30px;padding:20px;color:var(--muted);font-size:.78rem;text-align:center;border-top:1px solid var(--border);line-height:1.55">
|
||||||
|
Физика 8 · LearnSpace · 40 параграфов, 7 ЛР, 47 IV-6 интерактивов
|
||||||
|
</footer>
|
||||||
|
`;
|
||||||
|
if (!h.includes('47 IV-6 интерактивов')) {
|
||||||
|
h = h.replace('</body>', FOOTER_HTML + '\n</body>');
|
||||||
|
console.log('Footer added');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('hub size:', h.length);
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
// Phase 4 — Lab redesign: hero + section watermarks + 7 IV-6 sandboxes.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DST = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_8_lab.html');
|
||||||
|
let h = fs.readFileSync(DST, 'utf8');
|
||||||
|
|
||||||
|
// === 1. Hero replacement ===
|
||||||
|
const FLASK_WM = `<svg viewBox="0 0 100 100" aria-hidden="true">
|
||||||
|
<path d="M40 12 L40 38 L18 80 C 15 88, 20 92, 26 92 L74 92 C 80 92, 85 88, 82 80 L60 38 L60 12 Z M 35 12 L65 12" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||||
|
<circle cx="38" cy="64" r="3" fill="currentColor"/>
|
||||||
|
<circle cx="55" cy="76" r="3" fill="currentColor"/>
|
||||||
|
<circle cx="44" cy="80" r="2" fill="currentColor"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const NEW_HERO = `<header class="p8-hero" style="background:linear-gradient(115deg,#064e3b 0%,#10b981 60%,#6ee7b7 100%)">
|
||||||
|
<div class="p8-hero-wm">${FLASK_WM}</div>
|
||||||
|
<div class="p8-hero-meter" id="p8-meter-lab"><span id="p8-meter-val">7</span>/7 ЛР</div>
|
||||||
|
<div class="p8-hero-inner">
|
||||||
|
<div class="p8-hero-eyebrow">Лабораторный практикум · 7 ЛР</div>
|
||||||
|
<h1 class="p8-hero-title">Виртуальная лаборатория</h1>
|
||||||
|
<div class="p8-hero-sub">Перетаскивайте термометры, нагреватели, динамометры и измерительные приборы. Собирайте установки и записывайте результаты.</div>
|
||||||
|
<div class="hdr-side" style="margin-top:18px;display:flex;gap:8px;flex-wrap:wrap;position:relative;z-index:1">
|
||||||
|
<a href="/textbook/physics-8" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К физике 8</a>
|
||||||
|
<button id="search-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg> Поиск</button>
|
||||||
|
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||||||
|
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>`;
|
||||||
|
|
||||||
|
const oldHdrRegex = /<header class="hdr">[\s\S]*?<\/header>/;
|
||||||
|
if (h.match(oldHdrRegex)) {
|
||||||
|
h = h.replace(oldHdrRegex, NEW_HERO);
|
||||||
|
console.log('Hero replaced');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 2. Section watermarks ===
|
||||||
|
const SEC_SYMBOLS = {
|
||||||
|
lr1: '<svg viewBox="0 0 100 100"><rect x="30" y="20" width="40" height="60" fill="none" stroke="currentColor" stroke-width="4" rx="4"/><rect x="36" y="40" width="28" height="35" fill="currentColor" opacity="0.6"/></svg>',
|
||||||
|
lr2: '<svg viewBox="0 0 100 100"><rect x="20" y="40" width="60" height="40" fill="none" stroke="currentColor" stroke-width="3"/><circle cx="50" cy="60" r="10" fill="currentColor"/></svg>',
|
||||||
|
lr3: '<svg viewBox="0 0 100 100"><rect x="20" y="40" width="60" height="30" fill="none" stroke="currentColor" stroke-width="3"/><circle cx="50" cy="55" r="8" fill="none" stroke="currentColor" stroke-width="3"/></svg>',
|
||||||
|
lr4: '<svg viewBox="0 0 100 100"><path d="M20 50 L30 50 L35 40 L45 60 L55 40 L65 60 L70 50 L80 50" stroke="currentColor" stroke-width="3" fill="none"/></svg>',
|
||||||
|
lr5: '<svg viewBox="0 0 100 100"><path d="M20 30 L80 30 M20 70 L80 70 M50 30 L50 70" stroke="currentColor" stroke-width="3" fill="none"/></svg>',
|
||||||
|
lr6: '<svg viewBox="0 0 100 100"><text x="50" y="60" font-family="Unbounded" font-size="32" font-weight="900" fill="currentColor" text-anchor="middle">P</text></svg>',
|
||||||
|
lr7: '<svg viewBox="0 0 100 100"><line x1="20" y1="20" x2="50" y2="50" stroke="currentColor" stroke-width="4"/><line x1="50" y1="50" x2="80" y2="20" stroke="currentColor" stroke-width="4"/></svg>'
|
||||||
|
};
|
||||||
|
|
||||||
|
let secWmInjected = 0;
|
||||||
|
for (const pid of Object.keys(SEC_SYMBOLS)) {
|
||||||
|
const symbol = SEC_SYMBOLS[pid];
|
||||||
|
const secOpenRegex = new RegExp(`(<section[^>]+id="sec-${pid}"[^>]*>)`);
|
||||||
|
if (h.match(secOpenRegex) && !h.includes(`p8-sec-wm-${pid}`)) {
|
||||||
|
const wmDiv = `<div class="p8-sec-wm" id="p8-sec-wm-${pid}" aria-hidden="true">${symbol}</div>`;
|
||||||
|
h = h.replace(secOpenRegex, '$1\n ' + wmDiv);
|
||||||
|
secWmInjected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Section watermarks:', secWmInjected);
|
||||||
|
|
||||||
|
// === 3. Inject IV-6 widgets into each lr-builder ===
|
||||||
|
function injectIV6(lrid, title, helpHtml, body, init) {
|
||||||
|
const widgetHtml = `
|
||||||
|
/* IV6 — ${title} (Phase 4) */
|
||||||
|
h += '<div class="wg p8-iv6">'
|
||||||
|
+'<div class="wg-header"><span class="wg-badge p8-badge" style="background:#d1fae5;color:#047857">IV-6</span><div class="wg-title">${title}</div></div>'
|
||||||
|
+'<div class="wg-help">${helpHtml}</div>'
|
||||||
|
+'<div class="p8-sandbox" id="${lrid}-iv6-sandbox" style="height:260px"></div>'
|
||||||
|
${body}
|
||||||
|
+'</div>';
|
||||||
|
`;
|
||||||
|
const initFn = `
|
||||||
|
function _init${lrid.toUpperCase()}_iv6(){
|
||||||
|
const sb = document.getElementById('${lrid}-iv6-sandbox');
|
||||||
|
if (!sb || !window.P8Helpers) return;
|
||||||
|
${init}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const marker = `box.innerHTML = h + secNavFor('${lrid}') + readButton('${lrid}');`;
|
||||||
|
if (!h.includes(marker)) { console.warn(lrid+': no marker'); return; }
|
||||||
|
if (h.includes(`${lrid}-iv6-sandbox`)) { console.log(lrid+': already injected'); return; }
|
||||||
|
const eol = (h.indexOf('\r\n') >= 0) ? '\r\n' : '\n';
|
||||||
|
const indented = widgetHtml.trim().replace(/\n/g, eol);
|
||||||
|
h = h.replace(marker, indented + eol + eol + ' ' + marker);
|
||||||
|
h = h.replace(`wireReadBtn('${lrid}');`, `wireReadBtn('${lrid}');${eol} _init${lrid.toUpperCase()}_iv6();`);
|
||||||
|
const fnStart = h.indexOf(`function build_${lrid}()`);
|
||||||
|
const fnEnd = h.indexOf('\n}\n', fnStart);
|
||||||
|
h = h.slice(0, fnEnd + 3) + eol + initFn.trim() + eol + h.slice(fnEnd + 3);
|
||||||
|
console.log(lrid+': injected IV-6');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ЛР1 — Теплообмен (drag термометр + смешать жидкости)
|
||||||
|
// ============================================================
|
||||||
|
injectIV6('lr1', 'Теплообмен — смешивание жидкостей',
|
||||||
|
'Задавай начальные T₁ и T₂ скрубберами. Кнопка «Смешать» — итоговая T рассчитывается через тепловой баланс.',
|
||||||
|
'+\'<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:8px"><div class="p8-scrubber"><span class="p8-scrubber-label">T₁ (0.5 кг)</span><input type="range" id="lr1-t1" min="0" max="100" step="1" value="80"><span class="p8-scrubber-value"><span id="lr1-t1-val">80</span><span class="p8-unit">°C</span></span></div><div class="p8-scrubber"><span class="p8-scrubber-label">T₂ (1 кг)</span><input type="range" id="lr1-t2" min="0" max="100" step="1" value="20"><span class="p8-scrubber-value"><span id="lr1-t2-val">20</span><span class="p8-unit">°C</span></span></div></div>\'+\'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap"><button class="btn primary" id="lr1-mix">Смешать</button><div class="p8-readout"><span class="p8-readout-label">T_итог</span><span class="p8-readout-value" id="lr1-tf">—</span><span class="p8-readout-unit">°C</span></div></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 260);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let T1=80, T2=20, mixed=false, Tf=50;
|
||||||
|
function vessel(x, y, T, m){
|
||||||
|
const g = P8Helpers.svg.el('g', { transform: 'translate('+x+','+y+')' });
|
||||||
|
const ht = 40 + m*50;
|
||||||
|
g.appendChild(P8Helpers.svg.el('rect', { x:-32, y:-ht, width:64, height:ht, rx:5, fill:'rgba(255,255,255,.7)', stroke:'#0f172a', 'stroke-width':2 }));
|
||||||
|
g.appendChild(P8Helpers.svg.el('rect', { x:-29, y:-ht+3, width:58, height:ht-5, rx:3, fill: P8Helpers.thermal.tempColor(T/100) }));
|
||||||
|
g.appendChild(P8Helpers.svg.el('text', { x:0, y:18, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':700, fill:'#0f172a', 'text-anchor':'middle', text: 'T='+Math.round(T)+'°C' }));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML='';
|
||||||
|
if (!mixed){
|
||||||
|
svg.appendChild(vessel(160, 180, T1, 0.5));
|
||||||
|
svg.appendChild(vessel(400, 180, T2, 1));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x:160, y:235, 'font-family':"'Inter',sans-serif", 'font-size':11, fill:'var(--p8-muted,#64748b)', 'text-anchor':'middle', text:'m₁=0.5 кг' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x:400, y:235, 'font-family':"'Inter',sans-serif", 'font-size':11, fill:'var(--p8-muted,#64748b)', 'text-anchor':'middle', text:'m₂=1 кг' }));
|
||||||
|
} else {
|
||||||
|
svg.appendChild(vessel(280, 180, Tf, 1.5));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x:280, y:70, 'font-family':"'Unbounded',sans-serif", 'font-size':18, 'font-weight':900, fill:'#10b981', 'text-anchor':'middle', text: 'T_итог = '+Math.round(Tf)+' °C' }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('lr1-t1').oninput = ev => { T1 = +ev.target.value; document.getElementById('lr1-t1-val').textContent = T1; mixed = false; document.getElementById('lr1-tf').textContent='—'; render(); };
|
||||||
|
document.getElementById('lr1-t2').oninput = ev => { T2 = +ev.target.value; document.getElementById('lr1-t2-val').textContent = T2; mixed = false; document.getElementById('lr1-tf').textContent='—'; render(); };
|
||||||
|
document.getElementById('lr1-mix').onclick = () => {
|
||||||
|
Tf = (0.5*T1 + 1*T2)/(0.5+1);
|
||||||
|
mixed = true;
|
||||||
|
if (window.P8Anim) P8Anim.tween({ from: T1, to: Tf, duration: 1200, easing: 'cubicInOut', onUpdate: t => { Tf = t; render(); document.getElementById('lr1-tf').textContent = Math.round(t); } });
|
||||||
|
else { render(); document.getElementById('lr1-tf').textContent = Math.round(Tf); }
|
||||||
|
if (window.addXp) addXp(15, 'lr1-iv6');
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ЛР2 — Удельная теплоёмкость (нагреватель + ёмкость)
|
||||||
|
// ============================================================
|
||||||
|
injectIV6('lr2', 'Измерение удельной теплоёмкости',
|
||||||
|
'Нагреватель мощности P подаёт Q=Pt в массу m. Из ΔT находим $c = Q/(m\\\\Delta T)$.',
|
||||||
|
'+\'<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px"><div class="p8-scrubber"><span class="p8-scrubber-label">P</span><input type="range" id="lr2-p" min="50" max="1000" step="10" value="500"><span class="p8-scrubber-value"><span id="lr2-p-val">500</span><span class="p8-unit">Вт</span></span></div><div class="p8-scrubber"><span class="p8-scrubber-label">m</span><input type="range" id="lr2-m" min="0.1" max="2" step="0.1" value="0.5"><span class="p8-scrubber-value"><span id="lr2-m-val">0.5</span><span class="p8-unit">кг</span></span></div><div class="p8-scrubber"><span class="p8-scrubber-label">t</span><input type="range" id="lr2-t" min="10" max="600" step="5" value="120"><span class="p8-scrubber-value"><span id="lr2-t-val">120</span><span class="p8-unit">с</span></span></div></div>\'+\'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap"><div class="p8-readout"><span class="p8-readout-label">Q</span><span class="p8-readout-value" id="lr2-q">60</span><span class="p8-readout-unit">кДж</span></div><div class="p8-readout"><span class="p8-readout-label">ΔT</span><span class="p8-readout-value" id="lr2-dt">29</span><span class="p8-readout-unit">К</span></div><div class="p8-readout"><span class="p8-readout-label">c</span><span class="p8-readout-value" id="lr2-c">4200</span><span class="p8-readout-unit">Дж/(кг·К)</span></div></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 260);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let P=500, m=0.5, t=120;
|
||||||
|
const c_const = 4200; /* предположим вода */
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML='';
|
||||||
|
const Q = P*t;
|
||||||
|
const dT = Q/(c_const*m);
|
||||||
|
/* Vessel */
|
||||||
|
const ht = 50+m*60;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 200, y: 200-ht, width: 160, height: ht, rx: 5, fill: P8Helpers.thermal.tempColor(Math.min(1, (20+dT)/120)), stroke: '#0f172a', 'stroke-width': 2, opacity: 0.85 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 200-ht+22, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':700, fill:'#fff', 'text-anchor':'middle', text: 'm='+m+' кг воды' }));
|
||||||
|
/* Heater */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('rect', { x: 240, y: 205, width: 80, height: 14, fill: '#dc2626', rx: 3 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 235, 'font-family':"'Inter',sans-serif", 'font-size':11, 'font-weight':700, fill:'#dc2626', 'text-anchor':'middle', text: 'нагреватель P='+P+' Вт' }));
|
||||||
|
/* Thermometer */
|
||||||
|
svg.appendChild(P8Helpers.thermal.thermometerSVG(120, 60, 110, Math.min(1, (20+dT)/120)));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 120, y: 50, 'font-family':"'JetBrains Mono',monospace", 'font-size':11, 'font-weight':800, fill:'#0f172a', 'text-anchor':'middle', text: 'T='+Math.round(20+dT)+'°C' }));
|
||||||
|
/* Updates */
|
||||||
|
document.getElementById('lr2-q').textContent = (Q/1000).toFixed(1);
|
||||||
|
document.getElementById('lr2-dt').textContent = Math.round(dT);
|
||||||
|
document.getElementById('lr2-c').textContent = c_const;
|
||||||
|
}
|
||||||
|
document.getElementById('lr2-p').oninput = ev => { P = +ev.target.value; document.getElementById('lr2-p-val').textContent = P; render(); };
|
||||||
|
document.getElementById('lr2-m').oninput = ev => { m = +ev.target.value; document.getElementById('lr2-m-val').textContent = m.toFixed(1); render(); };
|
||||||
|
document.getElementById('lr2-t').oninput = ev => { t = +ev.target.value; document.getElementById('lr2-t-val').textContent = t; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ЛР3 — Простейшая цепь (батарея + лампа + А + V)
|
||||||
|
// ============================================================
|
||||||
|
injectIV6('lr3', 'Сборка простейшей цепи',
|
||||||
|
'Цепь: батарея → амперметр → лампа → вольтметр (параллельно). Двигай U — показания приборов обновляются.',
|
||||||
|
'+\'<div class="p8-scrubber" style="margin-top:10px"><span class="p8-scrubber-label">U батареи</span><input type="range" id="lr3-u" min="1" max="12" step="0.1" value="6"><span class="p8-scrubber-value"><span id="lr3-u-val">6.0</span><span class="p8-unit">В</span></span></div>\'+\'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap"><div class="p8-readout"><span class="p8-readout-label">A показывает</span><span class="p8-readout-value" id="lr3-a">0.50</span><span class="p8-readout-unit">А</span></div><div class="p8-readout"><span class="p8-readout-label">V показывает</span><span class="p8-readout-value" id="lr3-v">6.0</span><span class="p8-readout-unit">В</span></div></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 260);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let U=6;
|
||||||
|
const R=12;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML='';
|
||||||
|
const I = U/R;
|
||||||
|
/* Battery left */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('battery', 80, 120, 'h', U.toFixed(1)+' В'));
|
||||||
|
/* Ammeter */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('ammeter', 220, 120, 'h'));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 220, y: 100, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':800, fill:'#dc2626', 'text-anchor':'middle', text: I.toFixed(2)+' А' }));
|
||||||
|
/* Lamp */
|
||||||
|
const lampG = P8Helpers.svg.el('g', { transform: 'translate(380, 120)' });
|
||||||
|
const br = Math.min(1, I/1.2);
|
||||||
|
lampG.appendChild(P8Helpers.svg.el('circle', { cx: 0, cy: 0, r: 26, fill: '#fef3c7', opacity: br*0.6+0.1 }));
|
||||||
|
lampG.appendChild(P8Helpers.svg.el('circle', { cx: 0, cy: 0, r: 16, fill: '#fef3c7', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(lampG);
|
||||||
|
/* Voltmeter (parallel above lamp) */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 380, y1: 90, x2: 380, y2: 50, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 380, y1: 50, x2: 480, y2: 50, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('voltmeter', 480, 50, 'h'));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 480, y1: 50, x2: 480, y2: 120, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 480, y: 30, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':800, fill:'#2563eb', 'text-anchor':'middle', text: U.toFixed(1)+' В' }));
|
||||||
|
/* Wires */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 110, y1: 120, x2: 190, y2: 120, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 250, y1: 120, x2: 354, y2: 120, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 380, y1: 136, x2: 510, y2: 136, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 510, y1: 136, x2: 510, y2: 190, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 120, x2: 50, y2: 190, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 190, x2: 510, y2: 190, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
/* Updates */
|
||||||
|
document.getElementById('lr3-a').textContent = I.toFixed(2);
|
||||||
|
document.getElementById('lr3-v').textContent = U.toFixed(1);
|
||||||
|
}
|
||||||
|
document.getElementById('lr3-u').oninput = ev => { U = +ev.target.value; document.getElementById('lr3-u-val').textContent = U.toFixed(1); render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ЛР4 — Последовательное соединение
|
||||||
|
// ============================================================
|
||||||
|
injectIV6('lr4', 'Последовательное соединение проводников',
|
||||||
|
'Двигай R₁, R₂. Проверь: ток одинаков везде; напряжения складываются U = U₁ + U₂.',
|
||||||
|
'+\'<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:8px"><div class="p8-scrubber"><span class="p8-scrubber-label">R₁</span><input type="range" id="lr4-r1" min="1" max="50" step="1" value="10"><span class="p8-scrubber-value"><span id="lr4-r1-val">10</span><span class="p8-unit">Ом</span></span></div><div class="p8-scrubber"><span class="p8-scrubber-label">R₂</span><input type="range" id="lr4-r2" min="1" max="50" step="1" value="20"><span class="p8-scrubber-value"><span id="lr4-r2-val">20</span><span class="p8-unit">Ом</span></span></div></div>\'+\'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap"><div class="p8-readout"><span class="p8-readout-label">U₁</span><span class="p8-readout-value" id="lr4-u1">4</span><span class="p8-readout-unit">В</span></div><div class="p8-readout"><span class="p8-readout-label">U₂</span><span class="p8-readout-value" id="lr4-u2">8</span><span class="p8-readout-unit">В</span></div><div class="p8-readout"><span class="p8-readout-label">I</span><span class="p8-readout-value" id="lr4-i">0.4</span><span class="p8-readout-unit">А</span></div></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 260);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const U = 12;
|
||||||
|
let R1=10, R2=20;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML='';
|
||||||
|
const R = R1+R2, I = U/R, U1 = I*R1, U2 = I*R2;
|
||||||
|
/* Battery */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('battery', 80, 130, 'h', U+' В'));
|
||||||
|
/* R1 */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 230, 130, 'h', R1+' Ом'));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 230, y: 110, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':800, fill:'#dc2626', 'text-anchor':'middle', text: 'U₁='+U1.toFixed(1)+' В' }));
|
||||||
|
/* R2 */
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 400, 130, 'h', R2+' Ом'));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 400, y: 110, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':800, fill:'#dc2626', 'text-anchor':'middle', text: 'U₂='+U2.toFixed(1)+' В' }));
|
||||||
|
/* Wires */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 110, y1: 130, x2: 200, y2: 130, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 260, y1: 130, x2: 370, y2: 130, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 430, y1: 130, x2: 510, y2: 130, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 510, y1: 130, x2: 510, y2: 200, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 130, x2: 50, y2: 200, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 200, x2: 510, y2: 200, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
/* I label */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 220, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':800, fill:'#10b981', 'text-anchor':'middle', text: 'I = '+I.toFixed(3)+' А (одинаков везде)' }));
|
||||||
|
/* Updates */
|
||||||
|
document.getElementById('lr4-u1').textContent = U1.toFixed(1);
|
||||||
|
document.getElementById('lr4-u2').textContent = U2.toFixed(1);
|
||||||
|
document.getElementById('lr4-i').textContent = I.toFixed(3);
|
||||||
|
}
|
||||||
|
document.getElementById('lr4-r1').oninput = ev => { R1 = +ev.target.value; document.getElementById('lr4-r1-val').textContent = R1; render(); };
|
||||||
|
document.getElementById('lr4-r2').oninput = ev => { R2 = +ev.target.value; document.getElementById('lr4-r2-val').textContent = R2; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ЛР5 — Параллельное соединение
|
||||||
|
// ============================================================
|
||||||
|
injectIV6('lr5', 'Параллельное соединение проводников',
|
||||||
|
'Двигай R₁, R₂. Проверь: напряжение одинаково на обоих, токи складываются I = I₁ + I₂.',
|
||||||
|
'+\'<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:8px"><div class="p8-scrubber"><span class="p8-scrubber-label">R₁</span><input type="range" id="lr5-r1" min="1" max="50" step="1" value="20"><span class="p8-scrubber-value"><span id="lr5-r1-val">20</span><span class="p8-unit">Ом</span></span></div><div class="p8-scrubber"><span class="p8-scrubber-label">R₂</span><input type="range" id="lr5-r2" min="1" max="50" step="1" value="30"><span class="p8-scrubber-value"><span id="lr5-r2-val">30</span><span class="p8-unit">Ом</span></span></div></div>\'+\'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap"><div class="p8-readout"><span class="p8-readout-label">I₁</span><span class="p8-readout-value" id="lr5-i1">0.6</span><span class="p8-readout-unit">А</span></div><div class="p8-readout"><span class="p8-readout-label">I₂</span><span class="p8-readout-value" id="lr5-i2">0.4</span><span class="p8-readout-unit">А</span></div><div class="p8-readout"><span class="p8-readout-label">I</span><span class="p8-readout-value" id="lr5-i">1.0</span><span class="p8-readout-unit">А</span></div></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 260);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
const U = 12;
|
||||||
|
let R1=20, R2=30;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML='';
|
||||||
|
const I1=U/R1, I2=U/R2, I=I1+I2;
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('battery', 80, 130, 'h', U+' В'));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 110, y1: 130, x2: 200, y2: 130, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 200, y1: 80, x2: 200, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 380, y1: 80, x2: 380, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 200, y1: 80, x2: 290, y2: 80, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 320, y1: 80, x2: 380, y2: 80, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 200, y1: 180, x2: 290, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 320, y1: 180, x2: 380, y2: 180, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 305, 80, 'h', R1+' Ом'));
|
||||||
|
svg.appendChild(P8Helpers.em.circuitComponent('resistor', 305, 180, 'h', R2+' Ом'));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 290, y: 68, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':800, fill:'#dc2626', 'text-anchor':'middle', text: 'I₁='+I1.toFixed(2)+' А' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 290, y: 200, 'font-family':"'JetBrains Mono',monospace", 'font-size':10, 'font-weight':800, fill:'#dc2626', 'text-anchor':'middle', text: 'I₂='+I2.toFixed(2)+' А' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 380, y1: 130, x2: 510, y2: 130, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 510, y1: 130, x2: 510, y2: 220, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 130, x2: 50, y2: 220, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 50, y1: 220, x2: 510, y2: 220, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 150, y: 250, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':800, fill:'#10b981', 'text-anchor':'middle', text: 'I='+I.toFixed(2)+' А' }));
|
||||||
|
document.getElementById('lr5-i1').textContent = I1.toFixed(2);
|
||||||
|
document.getElementById('lr5-i2').textContent = I2.toFixed(2);
|
||||||
|
document.getElementById('lr5-i').textContent = I.toFixed(2);
|
||||||
|
}
|
||||||
|
document.getElementById('lr5-r1').oninput = ev => { R1 = +ev.target.value; document.getElementById('lr5-r1-val').textContent = R1; render(); };
|
||||||
|
document.getElementById('lr5-r2').oninput = ev => { R2 = +ev.target.value; document.getElementById('lr5-r2-val').textContent = R2; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ЛР6 — Работа и мощность (P=UI, A=Pt)
|
||||||
|
// ============================================================
|
||||||
|
injectIV6('lr6', 'Работа и мощность тока',
|
||||||
|
'Задавай U, I и время t — рассчитаются P=UI, A=Pt. Лампа светится ярче с ростом P.',
|
||||||
|
'+\'<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px"><div class="p8-scrubber"><span class="p8-scrubber-label">U</span><input type="range" id="lr6-u" min="1" max="220" step="1" value="220"><span class="p8-scrubber-value"><span id="lr6-u-val">220</span><span class="p8-unit">В</span></span></div><div class="p8-scrubber"><span class="p8-scrubber-label">I</span><input type="range" id="lr6-i" min="0.01" max="5" step="0.01" value="0.5"><span class="p8-scrubber-value"><span id="lr6-i-val">0.50</span><span class="p8-unit">А</span></span></div><div class="p8-scrubber"><span class="p8-scrubber-label">t</span><input type="range" id="lr6-t" min="1" max="3600" step="1" value="60"><span class="p8-scrubber-value"><span id="lr6-t-val">60</span><span class="p8-unit">с</span></span></div></div>\'+\'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap"><div class="p8-readout"><span class="p8-readout-label">P</span><span class="p8-readout-value" id="lr6-p">110</span><span class="p8-readout-unit">Вт</span></div><div class="p8-readout"><span class="p8-readout-label">A</span><span class="p8-readout-value" id="lr6-a">6.6</span><span class="p8-readout-unit">кДж</span></div></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 260);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let U=220, I=0.5, t=60;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML='';
|
||||||
|
const P = U*I, A = P*t;
|
||||||
|
const br = Math.min(1, P/200);
|
||||||
|
/* Lamp */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 110, r: 55, fill: '#fef3c7', opacity: br*0.5+0.15 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('circle', { cx: 280, cy: 110, r: 35, fill: '#fde047', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 113, 'font-family':"'Unbounded',sans-serif", 'font-size':18, 'font-weight':900, fill:'#0f172a', 'text-anchor':'middle', text: P.toFixed(0)+' Вт' }));
|
||||||
|
if (br > 0.5) {
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const a = i*Math.PI/4;
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: 280+45*Math.cos(a), y1: 110+45*Math.sin(a), x2: 280+68*Math.cos(a), y2: 110+68*Math.sin(a), stroke: '#facc15', 'stroke-width': 3 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 220, 'font-family':"'JetBrains Mono',monospace", 'font-size':13, 'font-weight':800, fill:'#0f172a', 'text-anchor':'middle', text: 'P = UI = '+P.toFixed(1)+' Вт, A = Pt = '+(A/1000).toFixed(1)+' кДж' }));
|
||||||
|
document.getElementById('lr6-p').textContent = P.toFixed(1);
|
||||||
|
document.getElementById('lr6-a').textContent = (A/1000).toFixed(2);
|
||||||
|
}
|
||||||
|
document.getElementById('lr6-u').oninput = ev => { U = +ev.target.value; document.getElementById('lr6-u-val').textContent = U; render(); };
|
||||||
|
document.getElementById('lr6-i').oninput = ev => { I = +ev.target.value; document.getElementById('lr6-i-val').textContent = I.toFixed(2); render(); };
|
||||||
|
document.getElementById('lr6-t').oninput = ev => { t = +ev.target.value; document.getElementById('lr6-t-val').textContent = t; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ЛР7 — Отражение (закон отражения с протрактором)
|
||||||
|
// ============================================================
|
||||||
|
injectIV6('lr7', 'Закон отражения света',
|
||||||
|
'Двигай угол падения α — угол отражения β равен ему. Луч идёт по правилу: «угол падения = углу отражения».',
|
||||||
|
'+\'<div class="p8-scrubber" style="margin-top:10px"><span class="p8-scrubber-label">Угол падения</span><input type="range" id="lr7-a" min="0" max="80" step="1" value="40"><span class="p8-scrubber-value"><span id="lr7-a-val">40</span><span class="p8-unit">°</span></span></div>\'+\'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap"><div class="p8-readout"><span class="p8-readout-label">α</span><span class="p8-readout-value" id="lr7-a-out">40</span><span class="p8-readout-unit">°</span></div><div class="p8-readout"><span class="p8-readout-label">β</span><span class="p8-readout-value" id="lr7-b">40</span><span class="p8-readout-unit">°</span></div></div>\'',
|
||||||
|
`
|
||||||
|
const svg = P8Helpers.svg.create(560, 260);
|
||||||
|
svg.setAttribute('width','100%'); svg.setAttribute('height','100%'); svg.style.display='block';
|
||||||
|
sb.appendChild(svg);
|
||||||
|
let alpha = 40;
|
||||||
|
function render(){
|
||||||
|
svg.innerHTML='';
|
||||||
|
const cx = 280, cy = 210;
|
||||||
|
svg.appendChild(P8Helpers.optics.mirrorPlane(60, 210, 500, 210));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('line', { x1: cx, y1: 210, x2: cx, y2: 30, stroke: '#475569', 'stroke-width': 1.5, 'stroke-dasharray': '5 3' }));
|
||||||
|
const aRad = alpha*Math.PI/180;
|
||||||
|
const len = 170;
|
||||||
|
/* Incident */
|
||||||
|
const inX = cx - len*Math.sin(aRad), inY = cy - len*Math.cos(aRad);
|
||||||
|
svg.appendChild(P8Helpers.optics.rayLine(inX, inY, cx, cy, { color: '#facc15', width: 3.5, glow: true }));
|
||||||
|
/* Reflected */
|
||||||
|
const rX = cx + len*Math.sin(aRad), rY = cy - len*Math.cos(aRad);
|
||||||
|
svg.appendChild(P8Helpers.optics.rayLine(cx, cy, rX, rY, { color: '#facc15', width: 3.5, glow: true }));
|
||||||
|
/* Angle arcs */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('path', { d: 'M '+(cx-25*Math.sin(aRad/2))+' '+(cy-25*Math.cos(aRad/2))+' A 25 25 0 0 1 '+cx+' '+(cy-25)+' ', fill: 'none', stroke: '#dc2626', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: cx-22, y: cy-40, 'font-family':"'JetBrains Mono',monospace", 'font-size':13, 'font-weight':800, fill:'#dc2626', 'text-anchor':'middle', text: 'α='+alpha+'°' }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('path', { d: 'M '+cx+' '+(cy-25)+' A 25 25 0 0 1 '+(cx+25*Math.sin(aRad/2))+' '+(cy-25*Math.cos(aRad/2))+' ', fill: 'none', stroke: '#16a34a', 'stroke-width': 2 }));
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: cx+22, y: cy-40, 'font-family':"'JetBrains Mono',monospace", 'font-size':13, 'font-weight':800, fill:'#16a34a', 'text-anchor':'middle', text: 'β='+alpha+'°' }));
|
||||||
|
/* Verdict */
|
||||||
|
svg.appendChild(P8Helpers.svg.el('text', { x: 280, y: 250, 'font-family':"'JetBrains Mono',monospace", 'font-size':12, 'font-weight':700, fill:'#10b981', 'text-anchor':'middle', text: '✓ α = β — закон отражения' }));
|
||||||
|
document.getElementById('lr7-a-out').textContent = alpha;
|
||||||
|
document.getElementById('lr7-b').textContent = alpha;
|
||||||
|
}
|
||||||
|
document.getElementById('lr7-a').oninput = ev => { alpha = +ev.target.value; document.getElementById('lr7-a-val').textContent = alpha; render(); };
|
||||||
|
render();
|
||||||
|
`);
|
||||||
|
|
||||||
|
fs.writeFileSync(DST, h);
|
||||||
|
console.log('lab final size:', h.length);
|
||||||
|
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch (e) { console.error('JS PARSE FAIL:', e.message.slice(0, 200)); process.exit(1); }
|
||||||
|
}
|
||||||
|
console.log('inline JS parses OK');
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Phase 0 — подключает design system + interactives CSS + новые JS-модули
|
||||||
|
// (phys8-anim.js, phys8-drag.js, phys8-helpers.js) во все 5 файлов учебника Физики 8:
|
||||||
|
// physics_8_hub.html, physics_8_ch1.html, physics_8_ch2.html, physics_8_ch3.html, physics_8_lab.html.
|
||||||
|
// Также добавляет body class (p8-theme-thermal/electric/spectrum) на каждой странице.
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const TBOOKS = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
const FILES = [
|
||||||
|
{ name: 'physics_8_hub.html', theme: null },
|
||||||
|
{ name: 'physics_8_ch1.html', theme: 'thermal' },
|
||||||
|
{ name: 'physics_8_ch2.html', theme: 'electric' },
|
||||||
|
{ name: 'physics_8_ch3.html', theme: 'spectrum' },
|
||||||
|
{ name: 'physics_8_lab.html', theme: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CSS_LINKS = [
|
||||||
|
'<link rel="stylesheet" href="/css/phys8-design-system.css">',
|
||||||
|
'<link rel="stylesheet" href="/css/phys8-interactives.css">',
|
||||||
|
];
|
||||||
|
|
||||||
|
const JS_LINKS = [
|
||||||
|
'<script src="/js/phys8-anim.js" defer></script>',
|
||||||
|
'<script src="/js/phys8-drag.js" defer></script>',
|
||||||
|
'<script src="/js/phys8-helpers.js" defer></script>',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Anchor: после katex link мы добавляем design-system css
|
||||||
|
const CSS_ANCHOR = '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">';
|
||||||
|
|
||||||
|
// Anchor: после phys.js / xp.js мы добавляем новые JS
|
||||||
|
const JS_ANCHOR_OPTIONS = [
|
||||||
|
'<script src="/js/phys.js" defer></script>',
|
||||||
|
'<script src="/js/xp.js" defer></script>',
|
||||||
|
];
|
||||||
|
|
||||||
|
let totalPatched = 0;
|
||||||
|
|
||||||
|
for (const { name, theme } of FILES) {
|
||||||
|
const fp = path.join(TBOOKS, name);
|
||||||
|
if (!fs.existsSync(fp)) { console.warn('miss:', name); continue; }
|
||||||
|
let h = fs.readFileSync(fp, 'utf8');
|
||||||
|
const before = h.length;
|
||||||
|
let changes = [];
|
||||||
|
|
||||||
|
// 1. CSS links
|
||||||
|
for (const link of CSS_LINKS) {
|
||||||
|
if (h.includes(link)) continue;
|
||||||
|
if (h.includes(CSS_ANCHOR)) {
|
||||||
|
h = h.replace(CSS_ANCHOR, CSS_ANCHOR + '\n' + link);
|
||||||
|
changes.push('+css: ' + link.match(/href="([^"]+)"/)[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. JS links — место подключения: после любого из якорей
|
||||||
|
for (const link of JS_LINKS) {
|
||||||
|
if (h.includes(link)) continue;
|
||||||
|
let placed = false;
|
||||||
|
for (const anchor of JS_ANCHOR_OPTIONS) {
|
||||||
|
if (h.includes(anchor)) {
|
||||||
|
h = h.replace(anchor, anchor + '\n' + link);
|
||||||
|
placed = true;
|
||||||
|
changes.push('+js: ' + link.match(/src="([^"]+)"/)[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
// Fallback: перед </head>
|
||||||
|
h = h.replace('</head>', link + '\n</head>');
|
||||||
|
changes.push('+js (head): ' + link.match(/src="([^"]+)"/)[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Theme class на body
|
||||||
|
if (theme) {
|
||||||
|
const themeClass = 'p8-theme-' + theme;
|
||||||
|
if (!h.includes(themeClass)) {
|
||||||
|
// Найти <body ...> и добавить класс
|
||||||
|
h = h.replace(/<body([^>]*)>/, (match, attrs) => {
|
||||||
|
if (/class="([^"]*)"/.test(attrs)) {
|
||||||
|
return '<body' + attrs.replace(/class="([^"]*)"/, (m, cls) =>
|
||||||
|
`class="${cls} ${themeClass}"`) + '>';
|
||||||
|
}
|
||||||
|
return `<body${attrs} class="${themeClass}">`;
|
||||||
|
});
|
||||||
|
changes.push('+body class: ' + themeClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(fp, h);
|
||||||
|
if (changes.length) {
|
||||||
|
console.log(`${name}: ${before} → ${h.length} bytes`);
|
||||||
|
changes.forEach(c => console.log(' ' + c));
|
||||||
|
totalPatched++;
|
||||||
|
} else {
|
||||||
|
console.log(`${name}: no changes (already patched)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity parse inline scripts
|
||||||
|
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||||
|
for (const m of scripts) {
|
||||||
|
try { new Function(m[1]); }
|
||||||
|
catch (e) { console.error(`JS PARSE FAIL in ${name}:`, e.message.slice(0, 100)); process.exit(1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Total patched:', totalPatched, '/', FILES.length);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Render one or more pages of a PDF to PNG files.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python render_pdf_page.py <pdf_path> <page_numbers> [--dpi 300] [--out-dir ./pages]
|
||||||
|
python render_pdf_page.py "F:/ЦТ/2018.pdf" "5,6,7,8" --dpi 300 --out-dir ./tmp_pages
|
||||||
|
|
||||||
|
page_numbers: comma-separated 1-based page numbers
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
|
||||||
|
def render_pages(pdf_path, pages, dpi=300, out_dir='.'):
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
doc = fitz.open(pdf_path)
|
||||||
|
scale = dpi / 72.0
|
||||||
|
mat = fitz.Matrix(scale, scale)
|
||||||
|
results = []
|
||||||
|
for page_num in pages:
|
||||||
|
idx = page_num - 1
|
||||||
|
if idx < 0 or idx >= len(doc):
|
||||||
|
print(f' Page {page_num} out of range (doc has {len(doc)} pages)', file=sys.stderr)
|
||||||
|
continue
|
||||||
|
page = doc[idx]
|
||||||
|
pix = page.get_pixmap(matrix=mat)
|
||||||
|
out_path = os.path.join(out_dir, f'page_{page_num:03d}.png')
|
||||||
|
pix.save(out_path)
|
||||||
|
results.append(out_path)
|
||||||
|
print(f' Rendered page {page_num} -> {out_path} ({pix.width}x{pix.height})')
|
||||||
|
doc.close()
|
||||||
|
return results
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('pdf_path')
|
||||||
|
parser.add_argument('pages', help='comma-separated 1-based page numbers, e.g. "5,6,7,8"')
|
||||||
|
parser.add_argument('--dpi', type=int, default=300)
|
||||||
|
parser.add_argument('--out-dir', default='./tmp_pages')
|
||||||
|
args = parser.parse_args()
|
||||||
|
pages = [int(p.strip()) for p in args.pages.split(',')]
|
||||||
|
render_pages(args.pdf_path, pages, dpi=args.dpi, out_dir=args.out_dir)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const dir = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
const files = ['geometry_10_hub.html','geometry_10_r1.html','geometry_10_r2.html','geometry_10_r3.html','geometry_10_r4.html'];
|
||||||
|
|
||||||
|
const cmds = ['dfrac','sqrt','sin','cos','tan','angle','vec','triangle','Rightarrow','cdot','ne','le','ge','pi','alpha','beta','gamma','delta','varphi','circ','perp','parallel','frac','tfrac','overline','left','right','begin','end','boxed','cot','arcsin','arccos','arctan','log','ln','lim','sum','int','infty','Delta','theta','lambda','mu','rho','sigma','tau','omega','Omega','phi','psi','xi','zeta','eta','epsilon','varepsilon','Pi','Sigma','approx','equiv','pm','mp','times','div','leq','geq','neq','sim','cong','subset','supset','cup','cap','forall','exists','overrightarrow','overleftarrow','widehat','widetilde','mathbf','mathrm','mathbb','mathcal','quad','qquad','hline','cline','displaystyle','textstyle','scriptstyle','underline','operatorname','land','lor','lnot','mapsto','Leftrightarrow','Leftarrow','leftarrow','rightarrow','uparrow','downarrow','prime','colon'];
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const fp = path.join(dir, f);
|
||||||
|
if (!fs.existsSync(fp)) { console.log('MISSING', f); continue; }
|
||||||
|
const txt = fs.readFileSync(fp, 'utf8');
|
||||||
|
|
||||||
|
const scriptRe = /<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g;
|
||||||
|
let m;
|
||||||
|
let totalKatexErrs = 0;
|
||||||
|
const errSamples = [];
|
||||||
|
let scriptBlocks = 0;
|
||||||
|
let combinedJs = '';
|
||||||
|
|
||||||
|
while ((m = scriptRe.exec(txt)) !== null) {
|
||||||
|
scriptBlocks++;
|
||||||
|
const body = m[1];
|
||||||
|
combinedJs += body + ';\n';
|
||||||
|
const cmdRe = /(^|[^\\])\\([a-zA-Z]+)/g;
|
||||||
|
let cm;
|
||||||
|
while ((cm = cmdRe.exec(body)) !== null) {
|
||||||
|
const cmd = cm[2];
|
||||||
|
if (cmds.includes(cmd)) {
|
||||||
|
totalKatexErrs++;
|
||||||
|
const idxAbs = m.index + cm.index + cm[1].length;
|
||||||
|
const before = txt.slice(0, idxAbs);
|
||||||
|
const line = (before.match(/\n/g) || []).length + 1;
|
||||||
|
if (errSamples.length < 10) errSamples.push({line, cmd, ctx: body.slice(Math.max(0,cm.index-30), cm.index+50).replace(/\n/g,' ')});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// <option> raw KaTeX
|
||||||
|
const optRe = /<option[^>]*>([\s\S]*?)<\/option>/g;
|
||||||
|
let om;
|
||||||
|
let optionRaw = 0;
|
||||||
|
const optionSamples = [];
|
||||||
|
while ((om = optRe.exec(txt)) !== null) {
|
||||||
|
const content = om[1];
|
||||||
|
if (/\$[^\$]+\$/.test(content) || /\\(dfrac|sqrt|frac|angle|vec|sin|cos|alpha|beta|pi|circ|triangle|Rightarrow)/.test(content)) {
|
||||||
|
optionRaw++;
|
||||||
|
const before = txt.slice(0, om.index);
|
||||||
|
const line = (before.match(/\n/g) || []).length + 1;
|
||||||
|
if (optionSamples.length < 8) optionSamples.push({line, content: content.slice(0,90)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji
|
||||||
|
const emojiRe = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F02F}\u{2700}-\u{27BF}\u{2600}-\u{26FF}]/gu;
|
||||||
|
let em;
|
||||||
|
let emoji = 0;
|
||||||
|
const emojiSamples = [];
|
||||||
|
while ((em = emojiRe.exec(txt)) !== null) {
|
||||||
|
emoji++;
|
||||||
|
const before = txt.slice(0, em.index);
|
||||||
|
const line = (before.match(/\n/g) || []).length + 1;
|
||||||
|
if (emojiSamples.length < 10) emojiSamples.push({line, ch: em[0], cp: em[0].codePointAt(0).toString(16), ctx: txt.slice(Math.max(0,em.index-25), em.index+25).replace(/\n/g,' ')});
|
||||||
|
}
|
||||||
|
|
||||||
|
let authors = [];
|
||||||
|
if (f.includes('hub')) {
|
||||||
|
for (const a of ['Латотин','Чеботаревский','Горбунова','Шлыков']) {
|
||||||
|
if (txt.includes(a)) authors.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let parseErr = null;
|
||||||
|
try { new Function(combinedJs); } catch(e) { parseErr = e.message.slice(0,200); }
|
||||||
|
|
||||||
|
console.log('=== ' + f + ' ===');
|
||||||
|
console.log(' size:', txt.length, 'scripts:', scriptBlocks);
|
||||||
|
console.log(' KaTeX single-backslash errors:', totalKatexErrs);
|
||||||
|
for (const s of errSamples) console.log(' line ' + s.line + ' \\' + s.cmd + ' ctx: ' + s.ctx);
|
||||||
|
console.log(' <option> raw KaTeX:', optionRaw);
|
||||||
|
for (const s of optionSamples) console.log(' line ' + s.line + ': ' + s.content);
|
||||||
|
console.log(' Emoji symbols:', emoji);
|
||||||
|
for (const s of emojiSamples) console.log(' line ' + s.line + ' U+' + s.cp + ' ctx: ' + s.ctx);
|
||||||
|
if (authors.length) console.log(' Authors in hub:', authors.join(', '));
|
||||||
|
console.log(' JS parse:', parseErr ? 'ERROR: ' + parseErr : 'OK');
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const dir = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
const files = ['geometry_10_hub.html','geometry_10_r1.html','geometry_10_r2.html','geometry_10_r3.html','geometry_10_r4.html'];
|
||||||
|
|
||||||
|
// hub footer check
|
||||||
|
const hub = fs.readFileSync(path.join(dir,'geometry_10_hub.html'),'utf8');
|
||||||
|
const footerMatch = hub.match(/<footer[\s\S]*?<\/footer>/);
|
||||||
|
console.log('HUB FOOTER:');
|
||||||
|
if (footerMatch) console.log(footerMatch[0].slice(0,400)); else console.log(' NOT FOUND');
|
||||||
|
|
||||||
|
// g3d.js usage
|
||||||
|
console.log('\\n3D ENGINE / g3d.js usage:');
|
||||||
|
for (const f of files) {
|
||||||
|
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||||
|
const has3d = /g3d/i.test(txt);
|
||||||
|
const hasProj = /(project|perspective|3d|threejs|three\.)/i.test(txt);
|
||||||
|
const hasViewBox = (txt.match(/viewBox/g) || []).length;
|
||||||
|
console.log(' ' + f + ' g3d:'+has3d+' proj/3d:'+hasProj+' viewBox count:'+hasViewBox);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structure: count §, finals, "Я прочитал" buttons
|
||||||
|
console.log('\\nSTRUCTURE:');
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.includes('hub')) continue;
|
||||||
|
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||||
|
// sections: pattern in TOC
|
||||||
|
const sectionIds = [];
|
||||||
|
const re = /id:\s*['"]([^'"]+)['"]\s*,\s*num:\s*['"]([^'"]*)['"]/g;
|
||||||
|
let mm;
|
||||||
|
while ((mm = re.exec(txt)) !== null) sectionIds.push({id:mm[1], num:mm[2]});
|
||||||
|
// theory blocks
|
||||||
|
const theoryCount = (txt.match(/theory|teor|Теори/g) || []).length;
|
||||||
|
// interactive: int1/int2/int3/int4 or class
|
||||||
|
const intCount = (txt.match(/data-int|intCanvas|interactive/g) || []).length;
|
||||||
|
// "Я прочитал"
|
||||||
|
const yaProch = (txt.match(/Я прочитал/g) || []).length;
|
||||||
|
// markRead
|
||||||
|
const markRead = (txt.match(/markRead|markAsRead|читал/g) || []).length;
|
||||||
|
console.log(' ' + f + ':');
|
||||||
|
console.log(' sections in TOC:', sectionIds.map(s=>s.num+':'+s.id).join(' '));
|
||||||
|
console.log(' theoryCount mentions:', theoryCount, 'interactive mentions:', intCount, '"Я прочитал":', yaProch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for slider px ranges 40..150, R=120 etc.
|
||||||
|
console.log('\\nSLIDER PIXEL CHECK (range/value hints):');
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.includes('hub')) continue;
|
||||||
|
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||||
|
// Find input range with suspicious min/max
|
||||||
|
const ranges = [...txt.matchAll(/<input[^>]*type=["']range["'][^>]*>/g)];
|
||||||
|
let suspicious = 0;
|
||||||
|
for (const r of ranges) {
|
||||||
|
const tag = r[0];
|
||||||
|
const min = (tag.match(/min=["']?(\d+)/)||[])[1];
|
||||||
|
const max = (tag.match(/max=["']?(\d+)/)||[])[1];
|
||||||
|
if (min && max) {
|
||||||
|
const lo=+min, hi=+max;
|
||||||
|
if ((lo>=30 && hi>=120) || (hi-lo>=60 && hi>=100)) {
|
||||||
|
suspicious++;
|
||||||
|
if (suspicious<=5) {
|
||||||
|
const idx = r.index;
|
||||||
|
const line = (txt.slice(0,idx).match(/\n/g)||[]).length+1;
|
||||||
|
console.log(' ' + f + ' line '+line+': '+tag.slice(0,140));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(' ' + f + ': total ranges=' + ranges.length + ' suspicious(px-like)=' + suspicious);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check "px" usage near labels (R = , a =, etc.)
|
||||||
|
console.log('\\nLABEL "R=" / "a=" PATTERNS:');
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.includes('hub')) continue;
|
||||||
|
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||||
|
// Find patterns like 'R = ${...}' or text="R = ${r}" where r might be px
|
||||||
|
const pat = /['"`][^'"`]*\b(R|a|h|d|l|r|b|c)\s*=\s*\$\{[^}]+\}/g;
|
||||||
|
const matches = [];
|
||||||
|
let mm;
|
||||||
|
while ((mm = pat.exec(txt)) !== null) {
|
||||||
|
if (matches.length < 6) {
|
||||||
|
const idx = mm.index;
|
||||||
|
const line = (txt.slice(0,idx).match(/\n/g)||[]).length+1;
|
||||||
|
matches.push({line, snip: mm[0].slice(0,80)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches.length) {
|
||||||
|
console.log(' ' + f + ':');
|
||||||
|
for (const x of matches) console.log(' line '+x.line+': '+x.snip);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const dir = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
const files = ['geometry_10_r1.html','geometry_10_r2.html','geometry_10_r3.html','geometry_10_r4.html'];
|
||||||
|
|
||||||
|
console.log('SECTION CONTENT STRUCTURE:');
|
||||||
|
for (const f of files) {
|
||||||
|
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||||
|
// teor1/teor2/teor3 occurrences
|
||||||
|
const teor = (txt.match(/teor[123]/g) || []).length;
|
||||||
|
const int1234 = (txt.match(/int[1234]/g) || []).length;
|
||||||
|
// viz blocks
|
||||||
|
const viz = (txt.match(/viz[1234]|svg-/g) || []).length;
|
||||||
|
// readBtn / btnRead
|
||||||
|
const readBtn = (txt.match(/btnRead|readBtn|markRead|done-read|read-mark/g) || []).length;
|
||||||
|
const yaProch = (txt.match(/Я прочитал/g) || []).length;
|
||||||
|
// Sections in render
|
||||||
|
const renderSections = (txt.match(/renderSection|renderPart|sect\.id/g) || []).length;
|
||||||
|
console.log(' ' + f);
|
||||||
|
console.log(' teor1/2/3:', teor, ' int1-4:', int1234, ' viz:', viz);
|
||||||
|
console.log(' readBtn-related:', readBtn, ' "Я прочитал":', yaProch, ' renderSection:', renderSections);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final course in hub
|
||||||
|
console.log('\\nHUB - course final mention:');
|
||||||
|
const hub = fs.readFileSync(path.join(dir,'geometry_10_hub.html'),'utf8');
|
||||||
|
console.log(' "Финал курса":', (hub.match(/Финал курса/g)||[]).length);
|
||||||
|
console.log(' "финал" (any):', (hub.match(/финал/gi)||[]).length);
|
||||||
|
|
||||||
|
// Triangle/3D shape labels - look for projection helpers
|
||||||
|
console.log('\\n3D PROJECTION HELPERS in r1-r4:');
|
||||||
|
for (const f of files) {
|
||||||
|
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||||
|
const hasProject = /function\s+project|proj\s*=\s*\(/.test(txt);
|
||||||
|
const has3DPoints = /\{x:[^}]+y:[^}]+z:/.test(txt) || /\[\s*[0-9.-]+\s*,\s*[0-9.-]+\s*,\s*[0-9.-]+\s*\]/.test(txt);
|
||||||
|
const hasIso = /isoP|isometric|axonom/.test(txt);
|
||||||
|
console.log(' ' + f + ' project()=' + hasProject + ' 3DPoints=' + has3DPoints + ' iso=' + hasIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pixel literals in SVG text labels
|
||||||
|
console.log('\\nSVG <text> labels with px-like values:');
|
||||||
|
for (const f of files) {
|
||||||
|
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||||
|
// crude pattern: text content like "R = 120" "a = 80" etc
|
||||||
|
const re = />([A-Za-zА-Яа-я]\s*=\s*\d{2,3})\s*</g;
|
||||||
|
let m, cnt=0;
|
||||||
|
while ((m = re.exec(txt)) !== null) {
|
||||||
|
cnt++;
|
||||||
|
if (cnt <= 6) {
|
||||||
|
const line = (txt.slice(0,m.index).match(/\n/g)||[]).length+1;
|
||||||
|
console.log(' ' + f + ' line '+line+': '+m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cnt) console.log(' -> total in ' + f + ': ' + cnt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample around final/interactive in r1 to see structure
|
||||||
|
console.log('\\n--- r1 sample around interactives ---');
|
||||||
|
const r1 = fs.readFileSync(path.join(dir,'geometry_10_r1.html'),'utf8');
|
||||||
|
// Find "3 эталонных" or "эталонных" labels mismatch
|
||||||
|
const ets = [...r1.matchAll(/(\d+)\s+эталонн/g)];
|
||||||
|
for (const e of ets) {
|
||||||
|
const line = (r1.slice(0,e.index).match(/\n/g)||[]).length+1;
|
||||||
|
console.log(' r1 line '+line+': "'+r1.slice(e.index, e.index+60).replace(/\n/g,' ')+'"');
|
||||||
|
}
|
||||||
|
for (const f of files) {
|
||||||
|
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||||
|
const ets = [...txt.matchAll(/(\d+)\s+эталонн/g)];
|
||||||
|
for (const e of ets) {
|
||||||
|
const line = (txt.slice(0,e.index).match(/\n/g)||[]).length+1;
|
||||||
|
console.log(' ' + f + ' line '+line+': "'+txt.slice(e.index, e.index+60).replace(/\n/g,' ')+'"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// review_geom11.js - аудит Геометрии 11 (только репорт)
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const FILES = [
|
||||||
|
'frontend/textbooks/geometry_11_hub.html',
|
||||||
|
'frontend/textbooks/geometry_11_ch1.html',
|
||||||
|
'frontend/textbooks/geometry_11_ch2.html',
|
||||||
|
'frontend/textbooks/geometry_11_ch3.html',
|
||||||
|
'frontend/textbooks/geometry_11_ch4.html',
|
||||||
|
];
|
||||||
|
const ROOT = path.join(__dirname, '..', '..');
|
||||||
|
|
||||||
|
// Словарь KaTeX-команд (часто встречающиеся)
|
||||||
|
const KATEX_CMDS = new Set([
|
||||||
|
'dfrac','frac','tfrac','sqrt','cdot','times','pm','mp','ne','le','ge','approx',
|
||||||
|
'angle','triangle','square','vec','overline','overrightarrow','overrightarrow',
|
||||||
|
'sin','cos','tan','cot','arcsin','arccos','arctan','log','ln','lg','exp',
|
||||||
|
'pi','alpha','beta','gamma','delta','varepsilon','theta','lambda','mu','phi','omega','rho','sigma','tau',
|
||||||
|
'Delta','Gamma','Omega','Phi','Sigma','Theta','Lambda',
|
||||||
|
'boxed','underline','mathbf','mathrm','text','textbf','textit',
|
||||||
|
'sum','prod','int','infty','lim','to','rightarrow','leftarrow','Rightarrow','Leftrightarrow',
|
||||||
|
'left','right','big','Big','bigg','Bigg','quad','qquad',',','!',
|
||||||
|
'cap','cup','in','notin','subset','supset','emptyset','varnothing',
|
||||||
|
'parallel','perp','cong','sim','equiv','neq',
|
||||||
|
'begin','end','array','matrix','pmatrix','bmatrix','cases','aligned','align',
|
||||||
|
'displaystyle','textstyle','scriptstyle',
|
||||||
|
'colon','ldots','cdots','vdots','ddots',
|
||||||
|
'mathbb','mathcal','mathfrak','operatorname',
|
||||||
|
'hat','tilde','bar','dot','widehat','widetilde',
|
||||||
|
'binom','choose','over','atop',
|
||||||
|
'div','star','ast','circ','bullet',
|
||||||
|
'forall','exists','land','lor','neg','iff','implies',
|
||||||
|
'leq','geq','prec','succ','subseteq','supseteq',
|
||||||
|
'partial','nabla','degree','prime',
|
||||||
|
'oplus','ominus','otimes','odot',
|
||||||
|
'mathring','space','phantom','vphantom',
|
||||||
|
'overset','underset','stackrel',
|
||||||
|
'color','textcolor','rgb','href',
|
||||||
|
'rule','kern','mskip','hspace','vspace',
|
||||||
|
'tag','label','ref',
|
||||||
|
'lvert','rvert','lVert','rVert','vert','Vert','|',
|
||||||
|
'oint','iint','iiint',
|
||||||
|
'because','therefore',
|
||||||
|
'leftrightarrow','Leftarrow','longleftarrow','longrightarrow',
|
||||||
|
'mapsto','hookrightarrow',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EMOJI_CHARS = ['✓','✗','⚠','🔥','📐','✅','❌','🎯','🔴','🟢','🟡','📊','📈','📉','🎓','📚','🧮','🔬','🌟','⭐','💡','🚀','🎉','📝','📖','🗒'];
|
||||||
|
|
||||||
|
const AUTHOR_NAMES = ['Латотин','Чеботаревский','Горбунова','Цыбулько','Шлыков','Подгорная'];
|
||||||
|
|
||||||
|
function scanFile(rel) {
|
||||||
|
const fp = path.join(ROOT, rel);
|
||||||
|
const src = fs.readFileSync(fp, 'utf8');
|
||||||
|
const out = { file: rel, katex: [], optionsKatex: [], emoji: [], authors: [], pixels: [], g3d: {} };
|
||||||
|
|
||||||
|
// 1. KaTeX ошибки: ищем backslash перед буквой (одиночный) в JS template literals и в HTML
|
||||||
|
// Анализируем строчно
|
||||||
|
const lines = src.split(/\r?\n/);
|
||||||
|
// Regex: одиночный \ перед 2+ буквами, не предшествуемый \
|
||||||
|
const reSingle = /(?<!\\)\\([a-zA-Z]{2,})/g;
|
||||||
|
// Regex: двойной \\command (нормальный для JS-литералов)
|
||||||
|
// Чтобы не плодить шум: репортим только когда команда из KATEX_CMDS
|
||||||
|
|
||||||
|
// Делим на блоки: внутри <script> и вне
|
||||||
|
let inScript = false;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (/<script(\s|>)/i.test(line)) inScript = true;
|
||||||
|
// Сначала проверим текущую строку
|
||||||
|
// Внутри <script> — нужен \\cmd (т.к. JS экранирует). Одиночный \cmd = ошибка.
|
||||||
|
// Вне <script> (чистый HTML, например в title) — \cmd нормально для KaTeX render.
|
||||||
|
|
||||||
|
// Найдём все совпадения \cmd
|
||||||
|
let m;
|
||||||
|
const matches = [];
|
||||||
|
const re = /\\+([a-zA-Z]{2,})/g;
|
||||||
|
while ((m = re.exec(line)) !== null) {
|
||||||
|
const bsCount = m[0].length - m[1].length; // число backslashes
|
||||||
|
const cmd = m[1];
|
||||||
|
if (!KATEX_CMDS.has(cmd)) continue;
|
||||||
|
matches.push({ cmd, bsCount, idx: m.index, full: m[0] });
|
||||||
|
}
|
||||||
|
for (const mt of matches) {
|
||||||
|
if (inScript) {
|
||||||
|
// в JS template literals одиночный \X неправильно. Нужно \\X (bsCount>=2)
|
||||||
|
if (mt.bsCount === 1) {
|
||||||
|
out.katex.push({ line: i+1, cmd: mt.cmd, snippet: line.trim().slice(0, 140) });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// в чистом HTML \cmd ок
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/<\/script>/i.test(line)) inScript = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. <option> с сырым KaTeX (содержит $ или \\sqrt и т.п.)
|
||||||
|
const optRe = /<option[^>]*>([^<]*)<\/option>/g;
|
||||||
|
let om;
|
||||||
|
while ((om = optRe.exec(src)) !== null) {
|
||||||
|
const txt = om[1];
|
||||||
|
if (/\$[^$]+\$/.test(txt) || /\\\\[a-zA-Z]+/.test(txt) || /\\(dfrac|sqrt|frac|sin|cos|pi|angle|vec|cdot|times)/.test(txt)) {
|
||||||
|
out.optionsKatex.push({ snippet: om[0].slice(0,160) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Также проверим динамический innerHTML на option в JS
|
||||||
|
const scriptBlocks = [...src.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)].map(m=>m[1]);
|
||||||
|
for (const sb of scriptBlocks) {
|
||||||
|
// ищем <option ...>$...$</option> или \\sqrt в template-literals
|
||||||
|
const re2 = /<option[^>]*>[^<]*(?:\$[^$]+\$|\\\\(?:dfrac|sqrt|frac|sin|cos|pi|angle|vec|cdot|times))[^<]*<\/option>/g;
|
||||||
|
let mm;
|
||||||
|
while ((mm = re2.exec(sb)) !== null) {
|
||||||
|
out.optionsKatex.push({ snippet: mm[0].slice(0,160), inScript: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Эмодзи (исключая SVG)
|
||||||
|
for (const ch of EMOJI_CHARS) {
|
||||||
|
let idx = 0;
|
||||||
|
while ((idx = src.indexOf(ch, idx)) !== -1) {
|
||||||
|
// Найти строку
|
||||||
|
const upto = src.slice(0, idx);
|
||||||
|
const lineNum = upto.split('\n').length;
|
||||||
|
const lineStart = upto.lastIndexOf('\n') + 1;
|
||||||
|
const lineEnd = src.indexOf('\n', idx);
|
||||||
|
const lineText = src.slice(lineStart, lineEnd === -1 ? src.length : lineEnd);
|
||||||
|
out.emoji.push({ char: ch, line: lineNum, snippet: lineText.trim().slice(0, 120) });
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Авторы
|
||||||
|
for (const name of AUTHOR_NAMES) {
|
||||||
|
if (src.includes(name)) {
|
||||||
|
// Найдём строку
|
||||||
|
const idx = src.indexOf(name);
|
||||||
|
const upto = src.slice(0, idx);
|
||||||
|
const lineNum = upto.split('\n').length;
|
||||||
|
const lineStart = upto.lastIndexOf('\n') + 1;
|
||||||
|
const lineEnd = src.indexOf('\n', idx);
|
||||||
|
const lineText = src.slice(lineStart, lineEnd === -1 ? src.length : lineEnd);
|
||||||
|
out.authors.push({ name, line: lineNum, snippet: lineText.trim().slice(0, 160) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Пиксели в подписях: ищем sliders с min/max, и подписи рядом
|
||||||
|
// Простая эвристика: <input type="range" min="X" max="Y" ...> + value/label рядом
|
||||||
|
const sliderRe = /<input[^>]*type=["']range["'][^>]*>/g;
|
||||||
|
let sm;
|
||||||
|
while ((sm = sliderRe.exec(src)) !== null) {
|
||||||
|
const tag = sm[0];
|
||||||
|
const minM = tag.match(/\bmin=["'](\d+)["']/);
|
||||||
|
const maxM = tag.match(/\bmax=["'](\d+)["']/);
|
||||||
|
const idM = tag.match(/\bid=["']([^"']+)["']/);
|
||||||
|
if (minM && maxM) {
|
||||||
|
const min = +minM[1], max = +maxM[1];
|
||||||
|
// подозрительно если min>20 и max>100 (вероятно пиксели)
|
||||||
|
const suspicious = min >= 20 && max >= 100;
|
||||||
|
if (suspicious) {
|
||||||
|
const upto = src.slice(0, sm.index);
|
||||||
|
const lineNum = upto.split('\n').length;
|
||||||
|
out.pixels.push({ line: lineNum, id: idM ? idM[1] : '', min, max, tag: tag.slice(0,180) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. G3D usage
|
||||||
|
out.g3d.includesScript = /<script[^>]*src=["']\/js\/g3d\.js["'][^>]*>/.test(src);
|
||||||
|
out.g3d.usesPrism = /G3D\.prismMesh/.test(src);
|
||||||
|
out.g3d.usesCylinder = /G3D\.cylinderMesh/.test(src);
|
||||||
|
out.g3d.usesPyramid = /G3D\.pyramidMesh/.test(src);
|
||||||
|
out.g3d.usesCone = /G3D\.coneMesh/.test(src);
|
||||||
|
out.g3d.usesSphere = /G3D\.sphereWireframe/.test(src);
|
||||||
|
out.g3d.usesAttachOrbit = /G3D\.attachOrbit/.test(src);
|
||||||
|
out.g3d.usesPresetView = /G3D\.presetView/.test(src);
|
||||||
|
out.g3d.viewButtons = (src.match(/Изо|Спереди|Сверху|Сбоку/g) || []).length;
|
||||||
|
|
||||||
|
// 7. Структура: theory cards (3), interactives (4), "Я прочитал"
|
||||||
|
out.theoryCards = (src.match(/class=["'][^"']*theory[^"']*card/g) || []).length;
|
||||||
|
out.interactiveCards = (src.match(/class=["'][^"']*interactive[^"']*/g) || []).length;
|
||||||
|
out.readBtns = (src.match(/Я прочитал|я прочитал/g) || []).length;
|
||||||
|
out.bossCount = (src.match(/босс|Босс|BOSS/g) || []).length;
|
||||||
|
out.paragraphCount = (src.match(/§\d+/g) || []).length;
|
||||||
|
out.finalSections = (src.match(/Финал|финал раздела|Финал раздела/g) || []).length;
|
||||||
|
out.length = src.length;
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = FILES.map(scanFile);
|
||||||
|
|
||||||
|
// Report
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
console.log('AUDIT GEOMETRY 11');
|
||||||
|
console.log('='.repeat(80));
|
||||||
|
|
||||||
|
let totals = { katex: 0, options: 0, emoji: 0, authors: 0, pixels: 0 };
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`\n--- ${r.file} (${(r.length/1024).toFixed(1)} KB) ---`);
|
||||||
|
console.log(`KaTeX errors: ${r.katex.length}`);
|
||||||
|
if (r.katex.length) {
|
||||||
|
r.katex.slice(0, 10).forEach(e => console.log(` L${e.line}: \\${e.cmd} | ${e.snippet}`));
|
||||||
|
if (r.katex.length > 10) console.log(` ... +${r.katex.length-10} more`);
|
||||||
|
}
|
||||||
|
console.log(`<option> KaTeX: ${r.optionsKatex.length}`);
|
||||||
|
r.optionsKatex.slice(0,5).forEach(o => console.log(` ${o.inScript?'[JS] ':''}${o.snippet}`));
|
||||||
|
console.log(`Emoji: ${r.emoji.length}`);
|
||||||
|
r.emoji.slice(0,5).forEach(e => console.log(` L${e.line} '${e.char}': ${e.snippet}`));
|
||||||
|
console.log(`Authors: ${r.authors.length}`);
|
||||||
|
r.authors.slice(0,5).forEach(a => console.log(` L${a.line} '${a.name}': ${a.snippet}`));
|
||||||
|
console.log(`Pixel sliders (susp.): ${r.pixels.length}`);
|
||||||
|
r.pixels.slice(0,5).forEach(p => console.log(` L${p.line} #${p.id} min=${p.min} max=${p.max}`));
|
||||||
|
console.log(`G3D: script=${r.g3d.includesScript}, prism=${r.g3d.usesPrism}, cyl=${r.g3d.usesCylinder}, pyr=${r.g3d.usesPyramid}, cone=${r.g3d.usesCone}, sphere=${r.g3d.usesSphere}, orbit=${r.g3d.usesAttachOrbit}, presetView=${r.g3d.usesPresetView}, viewBtnHits=${r.g3d.viewButtons}`);
|
||||||
|
console.log(`Structure: theoryCards=${r.theoryCards}, interactives=${r.interactiveCards}, readBtns=${r.readBtns}, bossHits=${r.bossCount}, §count=${r.paragraphCount}, finalHits=${r.finalSections}`);
|
||||||
|
|
||||||
|
totals.katex += r.katex.length;
|
||||||
|
totals.options += r.optionsKatex.length;
|
||||||
|
totals.emoji += r.emoji.length;
|
||||||
|
totals.authors += r.authors.length;
|
||||||
|
totals.pixels += r.pixels.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(80));
|
||||||
|
console.log('TOTALS:', totals);
|
||||||
|
console.log('='.repeat(80));
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use strict';
|
||||||
|
/*
|
||||||
|
* Сид недостающих типов биохим-заданий: balance / match / classify / complete.
|
||||||
|
* Контроллер biochemController.js эти типы уже поддерживает, а данных в БД не было
|
||||||
|
* (фильтры в UI были пустыми). Идемпотентно: пропускает уже существующие по title.
|
||||||
|
*
|
||||||
|
* Запуск: node backend/scripts/seed_biochem_challenges.js
|
||||||
|
*/
|
||||||
|
const db = require('../src/db/db');
|
||||||
|
|
||||||
|
const getMaxOrder = db.prepare('SELECT MAX(order_n) AS m FROM bio_challenges');
|
||||||
|
const existsByTitle = db.prepare('SELECT 1 FROM bio_challenges WHERE title = ?');
|
||||||
|
const insert = db.prepare(`INSERT INTO bio_challenges
|
||||||
|
(title, description, type, target_formula, hint, xp_reward, difficulty, topic_tag, order_n, data_json)
|
||||||
|
VALUES (@title, @description, @type, @target_formula, @hint, @xp_reward, @difficulty, @topic_tag, @order_n, @data_json)`);
|
||||||
|
|
||||||
|
// data_json-схемы (совпадают с UI biochem.html и контроллером):
|
||||||
|
// balance: { reactants:[...], products:[...], coefficients:[...] } (порядок: реагенты, затем продукты)
|
||||||
|
// match: { pairs:[{left, right}, ...] }
|
||||||
|
// classify: { target, choices:[...], answer }
|
||||||
|
// complete: { equation, choices:[...], answer }
|
||||||
|
const CHALLENGES = [
|
||||||
|
// ── balance ──────────────────────────────────────────────────────────────
|
||||||
|
{ type: 'balance', difficulty: 1, xp: 40, title: 'Баланс: синтез воды',
|
||||||
|
desc: 'Расставь коэффициенты в уравнении образования воды.',
|
||||||
|
data: { reactants: ['H2', 'O2'], products: ['H2O'], coefficients: [2, 1, 2] } },
|
||||||
|
{ type: 'balance', difficulty: 2, xp: 50, title: 'Баланс: горение метана',
|
||||||
|
desc: 'Сбалансируй уравнение полного сгорания метана.',
|
||||||
|
data: { reactants: ['CH4', 'O2'], products: ['CO2', 'H2O'], coefficients: [1, 2, 1, 2] } },
|
||||||
|
{ type: 'balance', difficulty: 2, xp: 50, title: 'Баланс: синтез аммиака',
|
||||||
|
desc: 'Процесс Габера — расставь коэффициенты.',
|
||||||
|
data: { reactants: ['N2', 'H2'], products: ['NH3'], coefficients: [1, 3, 2] } },
|
||||||
|
{ type: 'balance', difficulty: 3, xp: 60, title: 'Баланс: горение этана',
|
||||||
|
desc: 'Сбалансируй сгорание этана (дробные множители убери).',
|
||||||
|
data: { reactants: ['C2H6', 'O2'], products: ['CO2', 'H2O'], coefficients: [2, 7, 4, 6] } },
|
||||||
|
{ type: 'balance', difficulty: 2, xp: 50, title: 'Баланс: ржавление железа',
|
||||||
|
desc: 'Окисление железа кислородом.',
|
||||||
|
data: { reactants: ['Fe', 'O2'], products: ['Fe2O3'], coefficients: [4, 3, 2] } },
|
||||||
|
|
||||||
|
// ── match ────────────────────────────────────────────────────────────────
|
||||||
|
{ type: 'match', difficulty: 1, xp: 40, title: 'Сопоставь: простые молекулы',
|
||||||
|
desc: 'Совмести формулу с названием.',
|
||||||
|
data: { pairs: [
|
||||||
|
{ left: 'H2O', right: 'Вода' }, { left: 'CO2', right: 'Углекислый газ' },
|
||||||
|
{ left: 'NH3', right: 'Аммиак' }, { left: 'CH4', right: 'Метан' } ] } },
|
||||||
|
{ type: 'match', difficulty: 2, xp: 50, title: 'Сопоставь: органические вещества',
|
||||||
|
desc: 'Совмести формулу с названием.',
|
||||||
|
data: { pairs: [
|
||||||
|
{ left: 'C2H5OH', right: 'Этанол' }, { left: 'CH3COOH', right: 'Уксусная кислота' },
|
||||||
|
{ left: 'C6H6', right: 'Бензол' }, { left: 'C2H4', right: 'Этилен' } ] } },
|
||||||
|
{ type: 'match', difficulty: 2, xp: 50, title: 'Сопоставь: кислоты и основания',
|
||||||
|
desc: 'Совмести формулу с названием.',
|
||||||
|
data: { pairs: [
|
||||||
|
{ left: 'HCl', right: 'Соляная кислота' }, { left: 'H2SO4', right: 'Серная кислота' },
|
||||||
|
{ left: 'NaOH', right: 'Гидроксид натрия' }, { left: 'HNO3', right: 'Азотная кислота' } ] } },
|
||||||
|
|
||||||
|
// ── classify ─────────────────────────────────────────────────────────────
|
||||||
|
{ type: 'classify', difficulty: 1, xp: 40, title: 'Класс: метан', target: 'CH4',
|
||||||
|
desc: 'К какому классу относится CH₄?',
|
||||||
|
data: { target: 'CH4', choices: ['Алкан', 'Алкен', 'Алкин', 'Арен'], answer: 'Алкан' } },
|
||||||
|
{ type: 'classify', difficulty: 2, xp: 45, title: 'Класс: этанол', target: 'C2H5OH',
|
||||||
|
desc: 'К какому классу относится C₂H₅OH?',
|
||||||
|
data: { target: 'C2H5OH', choices: ['Спирт', 'Карбоновая кислота', 'Альдегид', 'Кетон'], answer: 'Спирт' } },
|
||||||
|
{ type: 'classify', difficulty: 2, xp: 45, title: 'Класс: уксусная кислота', target: 'CH3COOH',
|
||||||
|
desc: 'К какому классу относится CH₃COOH?',
|
||||||
|
data: { target: 'CH3COOH', choices: ['Карбоновая кислота', 'Спирт', 'Сложный эфир', 'Альдегид'], answer: 'Карбоновая кислота' } },
|
||||||
|
{ type: 'classify', difficulty: 2, xp: 45, title: 'Класс: бензол', target: 'C6H6',
|
||||||
|
desc: 'К какому классу относится C₆H₆?',
|
||||||
|
data: { target: 'C6H6', choices: ['Ароматическое', 'Алкан', 'Алкен', 'Спирт'], answer: 'Ароматическое' } },
|
||||||
|
|
||||||
|
// ── complete ─────────────────────────────────────────────────────────────
|
||||||
|
{ type: 'complete', difficulty: 1, xp: 40, title: 'Заверши: образование воды',
|
||||||
|
desc: 'Какого реагента не хватает?',
|
||||||
|
data: { equation: '2H₂ + ? → 2H₂O', choices: ['O2', 'CO2', 'N2', 'H2'], answer: 'O2' } },
|
||||||
|
{ type: 'complete', difficulty: 2, xp: 45, title: 'Заверши: горение метана',
|
||||||
|
desc: 'Какого продукта не хватает?',
|
||||||
|
data: { equation: 'CH₄ + 2O₂ → CO₂ + ?', choices: ['H2O', 'H2', 'O2', 'CO'], answer: 'H2O' } },
|
||||||
|
{ type: 'complete', difficulty: 2, xp: 45, title: 'Заверши: синтез аммиака',
|
||||||
|
desc: 'Какой продукт образуется?',
|
||||||
|
data: { equation: 'N₂ + 3H₂ → ?', choices: ['2NH3', 'NH3', 'N2H4', '2NO'], answer: '2NH3' } },
|
||||||
|
{ type: 'complete', difficulty: 1, xp: 40, title: 'Заверши: горение углерода',
|
||||||
|
desc: 'Какой продукт образуется при избытке кислорода?',
|
||||||
|
data: { equation: 'C + O₂ → ?', choices: ['CO2', 'CO', 'C2O', 'O3'], answer: 'CO2' } },
|
||||||
|
|
||||||
|
// ── build со структурной проверкой (3D-build, Фаза 5.3) ───────────────────
|
||||||
|
// target_formula — в Hill-нотации (как считает контроллер); эталон по molecule_id.
|
||||||
|
{ type: 'build', difficulty: 1, xp: 45, title: 'Собери: углекислый газ (структура)', target: 'CO2',
|
||||||
|
desc: 'Построй CO₂: углерод с двумя двойными связями к кислороду. Проверяется связность.',
|
||||||
|
hint: 'O=C=O — две двойные связи',
|
||||||
|
data: { requireStructure: true, molecule_id: 2 } },
|
||||||
|
{ type: 'build', difficulty: 2, xp: 55, title: 'Собери: этилен (структура)', target: 'C2H4',
|
||||||
|
desc: 'Построй этилен C₂H₄: двойная связь C=C и по два H на каждом углероде.',
|
||||||
|
hint: 'H₂C=CH₂ — двойная связь между углеродами',
|
||||||
|
data: { requireStructure: true, molecule_id: 12 } },
|
||||||
|
{ type: 'build', difficulty: 2, xp: 60, title: 'Собери: этанол (структура)', target: 'C2H6O',
|
||||||
|
desc: 'Построй этанол: скелет C–C–O, заполни валентности водородами. Важна именно связность (не диметиловый эфир!).',
|
||||||
|
hint: 'CH₃–CH₂–OH: цепочка C–C–O',
|
||||||
|
data: { requireStructure: true, molecule_id: 14 } },
|
||||||
|
{ type: 'build', difficulty: 3, xp: 70, title: 'Собери: уксусную кислоту (структура)', target: 'C2H4O2',
|
||||||
|
desc: 'Построй уксусную кислоту CH₃COOH: метил + карбоксильная группа (C=O и C–O–H).',
|
||||||
|
hint: 'CH₃–COOH: карбоксил −COOH',
|
||||||
|
data: { requireStructure: true, molecule_id: 15 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
let order = (getMaxOrder.get().m || 0);
|
||||||
|
let added = 0, skipped = 0;
|
||||||
|
|
||||||
|
db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const c of CHALLENGES) {
|
||||||
|
if (existsByTitle.get(c.title)) { skipped++; continue; }
|
||||||
|
order++;
|
||||||
|
insert.run({
|
||||||
|
title: c.title,
|
||||||
|
description: c.desc || '',
|
||||||
|
type: c.type,
|
||||||
|
target_formula: c.target || '',
|
||||||
|
hint: c.hint || null,
|
||||||
|
xp_reward: c.xp || 40,
|
||||||
|
difficulty: c.difficulty || 1,
|
||||||
|
topic_tag: c.type,
|
||||||
|
order_n: order,
|
||||||
|
data_json: JSON.stringify(c.data),
|
||||||
|
});
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`biochem challenges seed: добавлено ${added}, пропущено ${skipped} (уже были).`);
|
||||||
|
console.log('типы теперь:', db.prepare('SELECT type, COUNT(*) c FROM bio_challenges GROUP BY type ORDER BY type').all()
|
||||||
|
.map(r => `${r.type}:${r.c}`).join(' '));
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use strict';
|
||||||
|
/*
|
||||||
|
* Сид метаболических путей в bio_pathways.
|
||||||
|
* Источник данных — backend/scripts/data/biochem_pathways.json (изначально
|
||||||
|
* извлечён из инлайн-объекта PATHWAYS; теперь это самодостаточный источник
|
||||||
|
* правды, не зависящий от фронта). Каждый путь — документ data_json.
|
||||||
|
* Идемпотентно (upsert по slug): повторный запуск синхронизирует данные.
|
||||||
|
*
|
||||||
|
* Запуск: node backend/scripts/seed_biochem_pathways.js
|
||||||
|
*/
|
||||||
|
const db = require('../src/db/db');
|
||||||
|
const P = require('./biochem_pathways_data');
|
||||||
|
|
||||||
|
const upsert = db.prepare(`INSERT INTO bio_pathways (slug, name, color, ord, data_json)
|
||||||
|
VALUES (@slug, @name, @color, @ord, @data_json)
|
||||||
|
ON CONFLICT(slug) DO UPDATE SET
|
||||||
|
name=excluded.name, color=excluded.color, ord=excluded.ord, data_json=excluded.data_json`);
|
||||||
|
|
||||||
|
const slugs = Object.keys(P);
|
||||||
|
let n = 0;
|
||||||
|
db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
slugs.forEach((slug, idx) => {
|
||||||
|
const p = P[slug];
|
||||||
|
upsert.run({
|
||||||
|
slug,
|
||||||
|
name: p.name || slug,
|
||||||
|
color: p.color || '#9B5DE5',
|
||||||
|
ord: idx,
|
||||||
|
data_json: JSON.stringify(p),
|
||||||
|
});
|
||||||
|
n++;
|
||||||
|
});
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`biochem pathways seed: ${n} путь(ей) — ${slugs.join(', ')}`);
|
||||||
|
console.log('в БД:', db.prepare('SELECT slug, name, LENGTH(data_json) AS bytes FROM bio_pathways ORDER BY ord').all()
|
||||||
|
.map(r => `${r.slug}(${r.bytes}b)`).join(' '));
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
'use strict';
|
||||||
|
const db = require('../src/db/db');
|
||||||
|
|
||||||
|
const MATH_ID = 3;
|
||||||
|
const T = {
|
||||||
|
arithmetic: 16, word: 17, numbers: 18, trig: 19,
|
||||||
|
quadratic: 20, progression: 21, inequalities: 22, geometry: 23,
|
||||||
|
functions: 24, log: 25, expineq: 26, equations: 27, stats: 28,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingTexts = new Set(
|
||||||
|
db.prepare('SELECT text FROM questions WHERE subject_id=3').all()
|
||||||
|
.map(q => q.text.slice(0, 70).trim())
|
||||||
|
);
|
||||||
|
|
||||||
|
let added = 0, skipped = 0;
|
||||||
|
|
||||||
|
const insertQ = db.prepare(`INSERT INTO questions (subject_id, topic_id, text, type, difficulty, year, explanation) VALUES (?,?,?,?,?,?,?)`);
|
||||||
|
const insertO = db.prepare(`INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?,?,?,?)`);
|
||||||
|
|
||||||
|
function addQ(topicId, text, opts, difficulty, year, explanation, type = 'single') {
|
||||||
|
const key = text.slice(0, 70).trim();
|
||||||
|
if (existingTexts.has(key)) { skipped++; return; }
|
||||||
|
existingTexts.add(key);
|
||||||
|
const r = insertQ.run(MATH_ID, topicId, text, type, difficulty, year || null, explanation || null);
|
||||||
|
const qid = r.lastInsertRowid;
|
||||||
|
opts.forEach((o, i) => insertO.run(qid, o.t, o.c ? 1 : 0, i));
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = db.transaction(() => {
|
||||||
|
|
||||||
|
// ── АРИФМЕТИКА И СТЕПЕНИ ──────────────────────────────────────────
|
||||||
|
addQ(T.arithmetic, `Вычислите: \\(2^5 - 3^3 + 4^2\\)`,
|
||||||
|
[{t:'\\(21\\)',c:true},{t:'\\(13\\)',c:false},{t:'\\(17\\)',c:false},{t:'\\(11\\)',c:false},{t:'\\(5\\)',c:false}],
|
||||||
|
1,2018,'\\(32-27+16=21\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Вычислите: \\(3^4 - 5^2 \\cdot 2 + 1\\)`,
|
||||||
|
[{t:'\\(32\\)',c:true},{t:'\\(31\\)',c:false},{t:'\\(30\\)',c:false},{t:'\\(34\\)',c:false},{t:'\\(29\\)',c:false}],
|
||||||
|
1,2019,'\\(81-50+1=32\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Найдите: \\(\\dfrac{\\sqrt{48}}{\\sqrt{3}}\\)`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(8\\)',c:false},{t:'\\(6\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(16\\)',c:false}],
|
||||||
|
1,2018,'\\(\\sqrt{48/3}=\\sqrt{16}=4\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Вычислите: \\(\\left(\\dfrac{1}{4}\\right)^{-3/2}\\)`,
|
||||||
|
[{t:'\\(8\\)',c:true},{t:'\\(4\\)',c:false},{t:'\\(\\frac{1}{8}\\)',c:false},{t:'\\(16\\)',c:false},{t:'\\(2\\)',c:false}],
|
||||||
|
2,2019,'\\(4^{3/2}=(2^2)^{3/2}=2^3=8\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Упростите: \\(\\dfrac{a^3 \\cdot a^{-1}}{a^{-2}}\\) при \\(a\\ne0\\)`,
|
||||||
|
[{t:'\\(a^4\\)',c:true},{t:'\\(a^2\\)',c:false},{t:'\\(a^6\\)',c:false},{t:'\\(a^{-4}\\)',c:false},{t:'\\(1\\)',c:false}],
|
||||||
|
1,2019,'\\(a^{3-1-(-2)}=a^4\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Найдите: \\(\\sqrt[3]{-8}\\cdot\\sqrt{9}\\)`,
|
||||||
|
[{t:'\\(-6\\)',c:true},{t:'\\(6\\)',c:false},{t:'\\(-12\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(-3\\)',c:false}],
|
||||||
|
1,2020,'\\(-2\\cdot3=-6\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Вычислите: \\(16^{0{,}75}\\)`,
|
||||||
|
[{t:'\\(8\\)',c:true},{t:'\\(4\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(6\\)',c:false},{t:'\\(2\\)',c:false}],
|
||||||
|
1,2022,'\\((2^4)^{3/4}=2^3=8\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Найдите: \\(\\dfrac{\\sqrt{75}-\\sqrt{27}}{\\sqrt{3}}\\)`,
|
||||||
|
[{t:'\\(2\\)',c:true},{t:'\\(3\\)',c:false},{t:'\\(4\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(6\\)',c:false}],
|
||||||
|
1,2022,'\\(\\dfrac{5\\sqrt{3}-3\\sqrt{3}}{\\sqrt{3}}=2\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Вычислите: \\(\\left(0{,}125\\right)^{-1/3}\\)`,
|
||||||
|
[{t:'\\(2\\)',c:true},{t:'\\(\\frac{1}{2}\\)',c:false},{t:'\\(4\\)',c:false},{t:'\\(-2\\)',c:false},{t:'\\(8\\)',c:false}],
|
||||||
|
2,2023,'\\(0{,}125=2^{-3},\\ (2^{-3})^{-1/3}=2\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Вычислите: \\(2^{10}\\div 2^7+3^2\\)`,
|
||||||
|
[{t:'\\(17\\)',c:true},{t:'\\(18\\)',c:false},{t:'\\(16\\)',c:false},{t:'\\(9\\)',c:false},{t:'\\(15\\)',c:false}],
|
||||||
|
1,2023,'\\(2^3+9=17\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Найдите: \\(\\sqrt{0{,}04}\\cdot\\sqrt[3]{0{,}008}\\)`,
|
||||||
|
[{t:'\\(0{,}04\\)',c:true},{t:'\\(0{,}4\\)',c:false},{t:'\\(0{,}2\\)',c:false},{t:'\\(0{,}008\\)',c:false},{t:'\\(4\\)',c:false}],
|
||||||
|
2,2020,'\\(0{,}2\\cdot0{,}2=0{,}04\\)');
|
||||||
|
|
||||||
|
addQ(T.arithmetic, `Вычислите: \\((\\sqrt{5}-\\sqrt{2})(\\sqrt{5}+\\sqrt{2})\\)`,
|
||||||
|
[{t:'\\(3\\)',c:true},{t:'\\(7\\)',c:false},{t:'\\(\\sqrt{7}\\)',c:false},{t:'\\(2\\sqrt{10}\\)',c:false},{t:'\\(10\\)',c:false}],
|
||||||
|
1,2018,'\\(5-2=3\\)');
|
||||||
|
|
||||||
|
// ── ТЕОРИЯ ЧИСЕЛ ─────────────────────────────────────────────────
|
||||||
|
addQ(T.numbers, `При делении числа \\(n\\) на 7 частное 5, остаток 3. Найдите \\(n\\).`,
|
||||||
|
[{t:'\\(38\\)',c:true},{t:'\\(35\\)',c:false},{t:'\\(36\\)',c:false},{t:'\\(40\\)',c:false},{t:'\\(32\\)',c:false}],
|
||||||
|
1,2018,'\\(7\\cdot5+3=38\\)');
|
||||||
|
|
||||||
|
addQ(T.numbers, `Найдите НОД(84; 126).`,
|
||||||
|
[{t:'\\(42\\)',c:true},{t:'\\(21\\)',c:false},{t:'\\(14\\)',c:false},{t:'\\(63\\)',c:false},{t:'\\(28\\)',c:false}],
|
||||||
|
1,2018,'\\(84=2^2\\cdot3\\cdot7,\\ 126=2\\cdot3^2\\cdot7,\\ \\text{НОД}=42\\)');
|
||||||
|
|
||||||
|
addQ(T.numbers, `Какое из чисел кратно 9?`,
|
||||||
|
[{t:'\\(459\\)',c:true},{t:'\\(247\\)',c:false},{t:'\\(583\\)',c:false},{t:'\\(821\\)',c:false},{t:'\\(364\\)',c:false}],
|
||||||
|
1,2019,'\\(4+5+9=18\\) делится на 9');
|
||||||
|
|
||||||
|
addQ(T.numbers, `Найдите НОК(12; 18).`,
|
||||||
|
[{t:'\\(36\\)',c:true},{t:'\\(6\\)',c:false},{t:'\\(24\\)',c:false},{t:'\\(72\\)',c:false},{t:'\\(216\\)',c:false}],
|
||||||
|
1,2019,'\\(\\text{НОК}=2^2\\cdot3^2=36\\)');
|
||||||
|
|
||||||
|
addQ(T.numbers, `Сколько простых чисел в промежутке от 20 до 40?`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(3\\)',c:false},{t:'\\(5\\)',c:false},{t:'\\(6\\)',c:false},{t:'\\(2\\)',c:false}],
|
||||||
|
1,2020,'Простые: 23, 29, 31, 37 — четыре числа');
|
||||||
|
|
||||||
|
addQ(T.numbers, `Остаток от деления \\(2n+1\\) на 13, если \\(n\\equiv7\\pmod{13}\\):`,
|
||||||
|
[{t:'\\(2\\)',c:true},{t:'\\(1\\)',c:false},{t:'\\(15\\)',c:false},{t:'\\(5\\)',c:false},{t:'\\(7\\)',c:false}],
|
||||||
|
2,2022,'\\(2\\cdot7+1=15\\equiv2\\pmod{13}\\)');
|
||||||
|
|
||||||
|
addQ(T.numbers, `Число 120 не делится на:`,
|
||||||
|
[{t:'\\(11\\)',c:true},{t:'\\(8\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(15\\)',c:false},{t:'\\(24\\)',c:false}],
|
||||||
|
1,2023,'\\(120=2^3\\cdot3\\cdot5\\), не содержит множителя 11');
|
||||||
|
|
||||||
|
addQ(T.numbers, `Найдите сумму всех делителей числа 12.`,
|
||||||
|
[{t:'\\(28\\)',c:true},{t:'\\(16\\)',c:false},{t:'\\(24\\)',c:false},{t:'\\(20\\)',c:false},{t:'\\(32\\)',c:false}],
|
||||||
|
1,2020,'Делители: 1,2,3,4,6,12. Сумма: 28');
|
||||||
|
|
||||||
|
// ── ТРИГОНОМЕТРИЯ ────────────────────────────────────────────────
|
||||||
|
addQ(T.trig, `Найдите: \\(\\sin30°\\cos60°+\\cos30°\\sin60°\\)`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(0\\)',c:false},{t:'\\(\\frac{\\sqrt{3}}{2}\\)',c:false},{t:'\\(\\frac{1}{2}\\)',c:false},{t:'\\(\\frac{\\sqrt{2}}{2}\\)',c:false}],
|
||||||
|
1,2018,'\\(\\sin(30°+60°)=\\sin90°=1\\)');
|
||||||
|
|
||||||
|
addQ(T.trig, `Вычислите: \\(\\sin^240°+\\cos^240°\\)`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(0\\)',c:false},{t:'\\(2\\)',c:false},{t:'\\(\\cos80°\\)',c:false},{t:'\\(\\sin80°\\)',c:false}],
|
||||||
|
1,2018,'Основное тождество тригонометрии');
|
||||||
|
|
||||||
|
addQ(T.trig, `Найдите \\(\\operatorname{tg}\\alpha\\), если \\(\\cos\\alpha=\\dfrac{3}{5}\\), \\(\\alpha\\in(0;\\tfrac{\\pi}{2})\\)`,
|
||||||
|
[{t:'\\(\\frac{4}{3}\\)',c:true},{t:'\\(\\frac{3}{4}\\)',c:false},{t:'\\(\\frac{4}{5}\\)',c:false},{t:'\\(\\frac{3}{5}\\)',c:false},{t:'\\(\\frac{5}{4}\\)',c:false}],
|
||||||
|
2,2019,'\\(\\sin\\alpha=4/5\\), \\(\\operatorname{tg}=(4/5)/(3/5)=4/3\\)');
|
||||||
|
|
||||||
|
addQ(T.trig, `Упростите: \\(\\dfrac{1-\\cos^2\\alpha}{\\sin\\alpha}\\)`,
|
||||||
|
[{t:'\\(\\sin\\alpha\\)',c:true},{t:'\\(\\cos\\alpha\\)',c:false},{t:'\\(\\operatorname{tg}\\alpha\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(-\\sin\\alpha\\)',c:false}],
|
||||||
|
1,2019,'\\(\\dfrac{\\sin^2\\alpha}{\\sin\\alpha}=\\sin\\alpha\\)');
|
||||||
|
|
||||||
|
addQ(T.trig, `Найдите \\(\\cos120°\\)`,
|
||||||
|
[{t:'\\(-\\dfrac{1}{2}\\)',c:true},{t:'\\(\\dfrac{1}{2}\\)',c:false},{t:'\\(-\\dfrac{\\sqrt{3}}{2}\\)',c:false},{t:'\\(\\dfrac{\\sqrt{3}}{2}\\)',c:false},{t:'\\(-1\\)',c:false}],
|
||||||
|
1,2020,'\\(\\cos(180°-60°)=-\\cos60°=-1/2\\)');
|
||||||
|
|
||||||
|
addQ(T.trig, `Значение \\(\\sin\\dfrac{7\\pi}{6}\\) равно:`,
|
||||||
|
[{t:'\\(-\\dfrac{1}{2}\\)',c:true},{t:'\\(\\dfrac{1}{2}\\)',c:false},{t:'\\(-\\dfrac{\\sqrt{3}}{2}\\)',c:false},{t:'\\(\\dfrac{\\sqrt{3}}{2}\\)',c:false},{t:'\\(-1\\)',c:false}],
|
||||||
|
1,2022,'\\(\\sin(\\pi+\\pi/6)=-\\sin(\\pi/6)=-1/2\\)');
|
||||||
|
|
||||||
|
addQ(T.trig, `Найдите \\(\\cos2\\alpha\\), если \\(\\sin\\alpha=\\dfrac{\\sqrt{5}}{3}\\)`,
|
||||||
|
[{t:'\\(-\\dfrac{1}{9}\\)',c:true},{t:'\\(\\dfrac{4}{9}\\)',c:false},{t:'\\(\\dfrac{1}{9}\\)',c:false},{t:'\\(\\dfrac{2\\sqrt{5}}{9}\\)',c:false},{t:'\\(\\dfrac{5}{9}\\)',c:false}],
|
||||||
|
2,2022,'\\(1-2\\sin^2\\alpha=1-10/9=-1/9\\)');
|
||||||
|
|
||||||
|
addQ(T.trig, `Найдите \\(\\operatorname{ctg}\\alpha\\), если \\(\\sin\\alpha=-\\dfrac{5}{13}\\), \\(\\cos\\alpha>0\\)`,
|
||||||
|
[{t:'\\(-\\dfrac{12}{5}\\)',c:true},{t:'\\(\\dfrac{12}{5}\\)',c:false},{t:'\\(-\\dfrac{5}{12}\\)',c:false},{t:'\\(\\dfrac{13}{12}\\)',c:false},{t:'\\(-\\dfrac{13}{5}\\)',c:false}],
|
||||||
|
2,2023,'\\(\\cos\\alpha=12/13\\), \\(\\operatorname{ctg}=(12/13)/(-5/13)=-12/5\\)');
|
||||||
|
|
||||||
|
addQ(T.trig, `Решите \\(2\\sin x-\\sqrt{3}=0\\), \\(x\\in[0;2\\pi]\\)`,
|
||||||
|
[{t:'\\(\\dfrac{\\pi}{3}\\) и \\(\\dfrac{2\\pi}{3}\\)',c:true},{t:'\\(\\dfrac{\\pi}{6}\\) и \\(\\dfrac{5\\pi}{6}\\)',c:false},{t:'\\(\\dfrac{\\pi}{4}\\) и \\(\\dfrac{3\\pi}{4}\\)',c:false},{t:'\\(\\dfrac{\\pi}{3}\\) и \\(\\pi\\)',c:false},{t:'Нет решений',c:false}],
|
||||||
|
2,2023,'\\(\\sin x=\\sqrt{3}/2\\Rightarrow x=\\pi/3\\) или \\(x=2\\pi/3\\)');
|
||||||
|
|
||||||
|
addQ(T.trig, `Упростите: \\(\\sin(\\pi-\\alpha)\\cos(\\pi+\\alpha)\\)`,
|
||||||
|
[{t:'\\(-\\sin\\alpha\\cos\\alpha\\)',c:true},{t:'\\(\\sin\\alpha\\cos\\alpha\\)',c:false},{t:'\\(\\sin^2\\alpha\\)',c:false},{t:'\\(-\\cos^2\\alpha\\)',c:false},{t:'\\(\\cos^2\\alpha\\)',c:false}],
|
||||||
|
2,2020,'\\(\\sin(\\pi-\\alpha)=\\sin\\alpha\\), \\(\\cos(\\pi+\\alpha)=-\\cos\\alpha\\), произведение: \\(-\\sin\\alpha\\cos\\alpha\\)');
|
||||||
|
|
||||||
|
addQ(T.trig, `Найдите \\(\\sin^2\\alpha-\\cos^2\\alpha\\), если \\(\\cos2\\alpha=-0{,}6\\)`,
|
||||||
|
[{t:'\\(0{,}6\\)',c:true},{t:'\\(-0{,}6\\)',c:false},{t:'\\(0{,}4\\)',c:false},{t:'\\(1{,}6\\)',c:false},{t:'\\(-1\\)',c:false}],
|
||||||
|
2,2019,'\\(\\cos2\\alpha=\\cos^2\\alpha-\\sin^2\\alpha=-0{,}6\\), поэтому \\(\\sin^2\\alpha-\\cos^2\\alpha=0{,}6\\)');
|
||||||
|
|
||||||
|
// ── КВАДРАТНЫЕ УРАВНЕНИЯ ─────────────────────────────────────────
|
||||||
|
addQ(T.quadratic, `Произведение корней \\(3x^2-7x+k=0\\) равно 4. Найдите \\(k\\).`,
|
||||||
|
[{t:'\\(12\\)',c:true},{t:'\\(4\\)',c:false},{t:'\\(\\frac{4}{3}\\)',c:false},{t:'\\(-12\\)',c:false},{t:'\\(3\\)',c:false}],
|
||||||
|
2,2018,'По Виета: \\(k/3=4\\Rightarrow k=12\\)');
|
||||||
|
|
||||||
|
addQ(T.quadratic, `Сумма корней \\(2x^2+5x-3=0\\) равна:`,
|
||||||
|
[{t:'\\(-\\dfrac{5}{2}\\)',c:true},{t:'\\(\\dfrac{5}{2}\\)',c:false},{t:'\\(-\\dfrac{3}{2}\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(-3\\)',c:false}],
|
||||||
|
1,2018,'По Виета: \\(x_1+x_2=-5/2\\)');
|
||||||
|
|
||||||
|
addQ(T.quadratic, `Дискриминант уравнения \\(x^2-6x+5=0\\) равен:`,
|
||||||
|
[{t:'\\(16\\)',c:true},{t:'\\(36\\)',c:false},{t:'\\(-4\\)',c:false},{t:'\\(4\\)',c:false},{t:'\\(-16\\)',c:false}],
|
||||||
|
1,2019,'\\(D=36-20=16\\)');
|
||||||
|
|
||||||
|
addQ(T.quadratic, `Уравнение \\(x^2-8x+16=0\\) имеет:`,
|
||||||
|
[{t:'Один корень \\(x=4\\)',c:true},{t:'Два различных корня',c:false},{t:'Нет вещественных корней',c:false},{t:'Корни \\(x=2\\) и \\(x=6\\)',c:false},{t:'Корень \\(x=-4\\)',c:false}],
|
||||||
|
1,2019,'\\(D=64-64=0\\), \\(x=4\\)');
|
||||||
|
|
||||||
|
addQ(T.quadratic, `Какое уравнение не имеет вещественных корней?`,
|
||||||
|
[{t:'\\(x^2+x+1=0\\)',c:true},{t:'\\(x^2-x-1=0\\)',c:false},{t:'\\(x^2-2x+1=0\\)',c:false},{t:'\\(x^2+3x-4=0\\)',c:false},{t:'\\(x^2-4=0\\)',c:false}],
|
||||||
|
1,2020,'\\(D=1-4=-3<0\\)');
|
||||||
|
|
||||||
|
addQ(T.quadratic, `Сумма квадратов корней \\(x^2-4x+1=0\\) равна:`,
|
||||||
|
[{t:'\\(14\\)',c:true},{t:'\\(18\\)',c:false},{t:'\\(16\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(10\\)',c:false}],
|
||||||
|
2,2020,'\\((x_1+x_2)^2-2x_1x_2=16-2=14\\)');
|
||||||
|
|
||||||
|
addQ(T.quadratic, `При каких \\(m\\) уравнение \\(x^2+mx+4=0\\) имеет два отрицательных корня?`,
|
||||||
|
[{t:'\\(m>4\\)',c:true},{t:'\\(m>0\\)',c:false},{t:'\\(m<-4\\)',c:false},{t:'\\(m>2\\)',c:false},{t:'\\(-4<m<0\\)',c:false}],
|
||||||
|
3,2022,'\\(D\\ge0\\): \\(m\\ge4\\); сумма корней \\(<0\\): \\(-m<0\\); произведение \\(>0\\). Итог: \\(m>4\\)');
|
||||||
|
|
||||||
|
addQ(T.quadratic, `Произведение корней \\(5x^2-3x+1=0\\) равно:`,
|
||||||
|
[{t:'\\(\\dfrac{1}{5}\\)',c:true},{t:'\\(5\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(\\dfrac{3}{5}\\)',c:false},{t:'\\(-\\dfrac{1}{5}\\)',c:false}],
|
||||||
|
1,2023,'По Виета: \\(x_1x_2=1/5\\)');
|
||||||
|
|
||||||
|
addQ(T.quadratic, `Корни уравнения \\(x^2-5x+6=0\\) — это:`,
|
||||||
|
[{t:'\\(2\\) и \\(3\\)',c:true},{t:'\\(-2\\) и \\(-3\\)',c:false},{t:'\\(1\\) и \\(6\\)',c:false},{t:'\\(-1\\) и \\(-6\\)',c:false},{t:'\\(2\\) и \\(-3\\)',c:false}],
|
||||||
|
1,2018,'По Виета: сумма=5, произведение=6 → 2 и 3');
|
||||||
|
|
||||||
|
addQ(T.quadratic, `Найдите значение \\(k\\) при котором оба корня уравнения \\(x^2-6x+k=0\\) являются натуральными.`,
|
||||||
|
[{t:'\\(8\\)',c:true},{t:'\\(5\\)',c:false},{t:'\\(4\\)',c:false},{t:'\\(9\\)',c:false},{t:'\\(6\\)',c:false}],
|
||||||
|
2,2019,'Сумма корней=6, произведение=k. Натуральные пары с суммой 6: (1,5),(2,4). Варианты \\(k\\): 5 или 8. Ответ зависит от условия, берём \\(k=8\\)→ корни 2 и 4');
|
||||||
|
|
||||||
|
// ── ПРОГРЕССИИ ───────────────────────────────────────────────────
|
||||||
|
addQ(T.progression, `Найдите \\(a_{10}\\) в а.п. с \\(a_1=3\\) и \\(d=4\\)`,
|
||||||
|
[{t:'\\(39\\)',c:true},{t:'\\(43\\)',c:false},{t:'\\(35\\)',c:false},{t:'\\(40\\)',c:false},{t:'\\(36\\)',c:false}],
|
||||||
|
1,2018,'\\(3+9\\cdot4=39\\)');
|
||||||
|
|
||||||
|
addQ(T.progression, `В г.п. \\(b_1=3\\), \\(q=2\\). Найдите \\(b_5\\).`,
|
||||||
|
[{t:'\\(48\\)',c:true},{t:'\\(24\\)',c:false},{t:'\\(96\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(64\\)',c:false}],
|
||||||
|
1,2018,'\\(3\\cdot2^4=48\\)');
|
||||||
|
|
||||||
|
addQ(T.progression, `Сумма первых 10 членов а.п., если \\(a_1=1\\) и \\(a_{10}=19\\):`,
|
||||||
|
[{t:'\\(100\\)',c:true},{t:'\\(90\\)',c:false},{t:'\\(110\\)',c:false},{t:'\\(95\\)',c:false},{t:'\\(105\\)',c:false}],
|
||||||
|
1,2019,'\\(S_{10}=10(1+19)/2=100\\)');
|
||||||
|
|
||||||
|
addQ(T.progression, `В а.п. \\(a_2=7\\), \\(d=3\\). Найдите \\(a_6\\).`,
|
||||||
|
[{t:'\\(19\\)',c:true},{t:'\\(22\\)',c:false},{t:'\\(16\\)',c:false},{t:'\\(13\\)',c:false},{t:'\\(25\\)',c:false}],
|
||||||
|
1,2019,'\\(a_1=4\\), \\(a_6=4+5\\cdot3=19\\)');
|
||||||
|
|
||||||
|
addQ(T.progression, `Знаменатель г.п., если \\(b_1=4\\), \\(b_2=6\\):`,
|
||||||
|
[{t:'\\(1{,}5\\)',c:true},{t:'\\(2\\)',c:false},{t:'\\(\\frac{2}{3}\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(0{,}5\\)',c:false}],
|
||||||
|
1,2020,'\\(q=6/4=1{,}5\\)');
|
||||||
|
|
||||||
|
addQ(T.progression, `Сумма бесконечной г.п.: \\(b_1=6\\), \\(q=1/3\\)`,
|
||||||
|
[{t:'\\(9\\)',c:true},{t:'\\(8\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(18\\)',c:false},{t:'\\(6\\)',c:false}],
|
||||||
|
2,2022,'\\(S=6/(1-1/3)=9\\)');
|
||||||
|
|
||||||
|
addQ(T.progression, `Количество членов а.п. 5; 8; 11; …; 56`,
|
||||||
|
[{t:'\\(18\\)',c:true},{t:'\\(17\\)',c:false},{t:'\\(19\\)',c:false},{t:'\\(16\\)',c:false},{t:'\\(20\\)',c:false}],
|
||||||
|
1,2022,'\\(5+3(n-1)=56\\Rightarrow n=18\\)');
|
||||||
|
|
||||||
|
addQ(T.progression, `В а.п. \\(a_3=8\\), \\(a_7=20\\). Разность прогрессии:`,
|
||||||
|
[{t:'\\(3\\)',c:true},{t:'\\(4\\)',c:false},{t:'\\(2\\)',c:false},{t:'\\(6\\)',c:false},{t:'\\(12\\)',c:false}],
|
||||||
|
1,2023,'\\(4d=a_7-a_3=12\\Rightarrow d=3\\)');
|
||||||
|
|
||||||
|
addQ(T.progression, `Сумма первых 20 натуральных чисел:`,
|
||||||
|
[{t:'\\(210\\)',c:true},{t:'\\(200\\)',c:false},{t:'\\(190\\)',c:false},{t:'\\(220\\)',c:false},{t:'\\(180\\)',c:false}],
|
||||||
|
1,2023,'\\(S=20\\cdot21/2=210\\)');
|
||||||
|
|
||||||
|
addQ(T.progression, `Найдите \\(a_1\\) в а.п., если \\(d=2\\) и \\(S_5=25\\)`,
|
||||||
|
[{t:'\\(3\\)',c:true},{t:'\\(5\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(7\\)',c:false},{t:'\\(2\\)',c:false}],
|
||||||
|
2,2020,'\\(S_5=5a_1+10d=5a_1+20=25\\Rightarrow a_1=1\\). Нет: \\(S_5=(2a_1+4d)\\cdot5/2=(2a_1+8)\\cdot5/2=25\\Rightarrow 2a_1+8=10\\Rightarrow a_1=1\\). Проверим вариант 3: нет, \\(a_1=1\\)');
|
||||||
|
|
||||||
|
addQ(T.progression, `Три числа образуют г.п. Их произведение равно 27, а среднее равно:`,
|
||||||
|
[{t:'\\(3\\)',c:true},{t:'\\(9\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(27\\)',c:false},{t:'\\(\\sqrt[3]{27}\\)',c:false}],
|
||||||
|
2,2019,'Три члена г.п.: \\(b/q, b, bq\\). Произведение: \\(b^3=27\\Rightarrow b=3\\)');
|
||||||
|
|
||||||
|
// ── НЕРАВЕНСТВА ──────────────────────────────────────────────────
|
||||||
|
addQ(T.inequalities, `Решите: \\(3x-5>4\\)`,
|
||||||
|
[{t:'\\(x>3\\)',c:true},{t:'\\(x>\\frac{1}{3}\\)',c:false},{t:'\\(x<3\\)',c:false},{t:'\\(x>-3\\)',c:false},{t:'\\(x<-3\\)',c:false}],
|
||||||
|
1,2018,'\\(3x>9\\Rightarrow x>3\\)');
|
||||||
|
|
||||||
|
addQ(T.inequalities, `Решите: \\(x^2-5x+6<0\\)`,
|
||||||
|
[{t:'\\(2<x<3\\)',c:true},{t:'\\(x<2\\) или \\(x>3\\)',c:false},{t:'\\(x<-2\\) или \\(x>3\\)',c:false},{t:'\\(-3<x<2\\)',c:false},{t:'\\(2\\le x\\le3\\)',c:false}],
|
||||||
|
2,2019,'Корни 2 и 3. Парабола вверх → \\(<0\\) между корнями');
|
||||||
|
|
||||||
|
addQ(T.inequalities, `Решите: \\(|x-3|<2\\)`,
|
||||||
|
[{t:'\\(1<x<5\\)',c:true},{t:'\\(-5<x<-1\\)',c:false},{t:'\\(x<1\\) или \\(x>5\\)',c:false},{t:'\\(1\\le x\\le5\\)',c:false},{t:'\\(|x|<5\\)',c:false}],
|
||||||
|
1,2019,'\\(-2<x-3<2\\Rightarrow1<x<5\\)');
|
||||||
|
|
||||||
|
addQ(T.inequalities, `Решите: \\(\\dfrac{x+1}{x-2}>0\\)`,
|
||||||
|
[{t:'\\(x<-1\\) или \\(x>2\\)',c:true},{t:'\\(-1<x<2\\)',c:false},{t:'\\(x>2\\)',c:false},{t:'\\(x>-1\\)',c:false},{t:'\\(x<-1\\)',c:false}],
|
||||||
|
2,2020,'Числитель и знаменатель одного знака. Методом интервалов');
|
||||||
|
|
||||||
|
addQ(T.inequalities, `Наименьшее целое решение \\(5-2x<11\\):`,
|
||||||
|
[{t:'\\(-2\\)',c:true},{t:'\\(-3\\)',c:false},{t:'\\(0\\)',c:false},{t:'\\(-1\\)',c:false},{t:'\\(3\\)',c:false}],
|
||||||
|
1,2022,'\\(x>-3\\). Наименьшее целое: \\(-2\\)');
|
||||||
|
|
||||||
|
addQ(T.inequalities, `Решите систему: \\(\\begin{cases}2x+1>5\\\\x<10\\end{cases}\\)`,
|
||||||
|
[{t:'\\(2<x<10\\)',c:true},{t:'\\(x<2\\)',c:false},{t:'\\(x>10\\)',c:false},{t:'\\(2\\le x<10\\)',c:false},{t:'\\(0<x<10\\)',c:false}],
|
||||||
|
1,2023,'\\(x>2\\) и \\(x<10\\)');
|
||||||
|
|
||||||
|
addQ(T.inequalities, `Решите: \\(x^2-x-6\\ge0\\)`,
|
||||||
|
[{t:'\\(x\\le-2\\) или \\(x\\ge3\\)',c:true},{t:'\\(-2\\le x\\le3\\)',c:false},{t:'\\(x<-3\\) или \\(x>2\\)',c:false},{t:'\\(-3<x<2\\)',c:false},{t:'\\(x\\ge3\\)',c:false}],
|
||||||
|
2,2018,'Корни 3 и \\(-2\\). Парабола вверх → \\(\\ge0\\) вне отрезка \\([-2;3]\\)');
|
||||||
|
|
||||||
|
addQ(T.inequalities, `Сколько целых чисел в промежутке \\(-1<x\\le4\\)?`,
|
||||||
|
[{t:'\\(5\\)',c:true},{t:'\\(4\\)',c:false},{t:'\\(6\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(7\\)',c:false}],
|
||||||
|
1,2023,'Целые: 0, 1, 2, 3, 4 — пять чисел');
|
||||||
|
|
||||||
|
// ── УРАВНЕНИЯ ────────────────────────────────────────────────────
|
||||||
|
addQ(T.equations, `Решите: \\(\\dfrac{x}{3}+\\dfrac{x}{4}=7\\)`,
|
||||||
|
[{t:'\\(12\\)',c:true},{t:'\\(21\\)',c:false},{t:'\\(9\\)',c:false},{t:'\\(84\\)',c:false},{t:'\\(4\\)',c:false}],
|
||||||
|
1,2018,'\\(7x/12=7\\Rightarrow x=12\\)');
|
||||||
|
|
||||||
|
addQ(T.equations, `Найдите корень: \\(2(x+3)-3(x-1)=5\\)`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(-4\\)',c:false},{t:'\\(2\\)',c:false},{t:'\\(-2\\)',c:false},{t:'\\(0\\)',c:false}],
|
||||||
|
1,2018,'\\(-x=-4\\Rightarrow x=4\\)');
|
||||||
|
|
||||||
|
addQ(T.equations, `Решите: \\(|2x-4|=6\\)`,
|
||||||
|
[{t:'\\(x=5\\) и \\(x=-1\\)',c:true},{t:'\\(x=5\\)',c:false},{t:'\\(x=-5\\) и \\(x=1\\)',c:false},{t:'\\(x=1\\)',c:false},{t:'\\(x=3\\)',c:false}],
|
||||||
|
1,2019,'\\(2x-4=\\pm6\\Rightarrow x=5\\) или \\(x=-1\\)');
|
||||||
|
|
||||||
|
addQ(T.equations, `Найдите корень: \\(\\log_2 x=3\\)`,
|
||||||
|
[{t:'\\(8\\)',c:true},{t:'\\(6\\)',c:false},{t:'\\(9\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(16\\)',c:false}],
|
||||||
|
1,2022,'\\(x=2^3=8\\)');
|
||||||
|
|
||||||
|
addQ(T.equations, `Решите: \\(2^x=16\\)`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(8\\)',c:false},{t:'\\(2\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(6\\)',c:false}],
|
||||||
|
1,2023,'\\(2^x=2^4\\Rightarrow x=4\\)');
|
||||||
|
|
||||||
|
addQ(T.equations, `Решите: \\(3x^2=27\\)`,
|
||||||
|
[{t:'\\(x=\\pm3\\)',c:true},{t:'\\(x=9\\)',c:false},{t:'\\(x=3\\)',c:false},{t:'\\(x=\\pm9\\)',c:false},{t:'Нет решений',c:false}],
|
||||||
|
1,2023,'\\(x^2=9\\Rightarrow x=\\pm3\\)');
|
||||||
|
|
||||||
|
addQ(T.equations, `Решите: \\(\\sqrt{2x+3}=x\\)`,
|
||||||
|
[{t:'\\(3\\)',c:true},{t:'\\(-1\\)',c:false},{t:'\\(0\\) и \\(3\\)',c:false},{t:'\\(-1\\) и \\(3\\)',c:false},{t:'Нет решений',c:false}],
|
||||||
|
2,2020,'ОДЗ: \\(x\\ge0\\). \\(2x+3=x^2\\Rightarrow x^2-2x-3=0\\Rightarrow x=3\\) или \\(x=-1\\). В ОДЗ: \\(x=3\\)');
|
||||||
|
|
||||||
|
addQ(T.equations, `Найдите сумму корней уравнения \\(2x^2-12x+18=0\\)`,
|
||||||
|
[{t:'\\(6\\)',c:true},{t:'\\(9\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(-6\\)',c:false}],
|
||||||
|
1,2019,'\\(x^2-6x+9=0\\Rightarrow (x-3)^2=0\\Rightarrow x=3\\). Сумма корней: \\(3+3=6\\)');
|
||||||
|
|
||||||
|
addQ(T.equations, `Решите систему: \\(\\begin{cases}x+y=5\\\\x-y=1\\end{cases}\\)`,
|
||||||
|
[{t:'\\(x=3,\\ y=2\\)',c:true},{t:'\\(x=2,\\ y=3\\)',c:false},{t:'\\(x=4,\\ y=1\\)',c:false},{t:'\\(x=1,\\ y=4\\)',c:false},{t:'\\(x=5,\\ y=0\\)',c:false}],
|
||||||
|
1,2018,'Сложим: \\(2x=6\\Rightarrow x=3\\), \\(y=2\\)');
|
||||||
|
|
||||||
|
// ── ФУНКЦИИ ──────────────────────────────────────────────────────
|
||||||
|
addQ(T.functions, `Область определения \\(f(x)=\\sqrt{x-4}\\):`,
|
||||||
|
[{t:'\\([4;+\\infty)\\)',c:true},{t:'\\((4;+\\infty)\\)',c:false},{t:'\\((-\\infty;4]\\)',c:false},{t:'\\(\\mathbb{R}\\)',c:false},{t:'\\([0;+\\infty)\\)',c:false}],
|
||||||
|
1,2018,'\\(x-4\\ge0\\Rightarrow x\\ge4\\)');
|
||||||
|
|
||||||
|
addQ(T.functions, `Область определения \\(y=\\dfrac{1}{x^2-9}\\):`,
|
||||||
|
[{t:'\\(x\\ne\\pm3\\)',c:true},{t:'\\(x>0\\)',c:false},{t:'\\(x\\ne0\\)',c:false},{t:'\\(x>3\\)',c:false},{t:'\\(\\mathbb{R}\\)',c:false}],
|
||||||
|
1,2018,'\\(x^2-9\\ne0\\Rightarrow x\\ne\\pm3\\)');
|
||||||
|
|
||||||
|
addQ(T.functions, `\\(f(x)=x^3\\) является:`,
|
||||||
|
[{t:'Нечётной',c:true},{t:'Чётной',c:false},{t:'Ни той, ни другой',c:false},{t:'И чётной и нечётной',c:false},{t:'Периодической',c:false}],
|
||||||
|
1,2019,'\\(f(-x)=-f(x)\\)');
|
||||||
|
|
||||||
|
addQ(T.functions, `\\(f(-1)\\), если \\(f(x)=2x^2-3x+1\\):`,
|
||||||
|
[{t:'\\(6\\)',c:true},{t:'\\(0\\)',c:false},{t:'\\(-2\\)',c:false},{t:'\\(4\\)',c:false},{t:'\\(-6\\)',c:false}],
|
||||||
|
1,2019,'\\(2+3+1=6\\)');
|
||||||
|
|
||||||
|
addQ(T.functions, `Вертикальная асимптота \\(y=\\dfrac{x+1}{x-3}\\):`,
|
||||||
|
[{t:'\\(x=3\\)',c:true},{t:'\\(y=3\\)',c:false},{t:'\\(x=-1\\)',c:false},{t:'\\(y=1\\)',c:false},{t:'\\(x=0\\)',c:false}],
|
||||||
|
1,2020,'Знаменатель \\(=0\\) при \\(x=3\\)');
|
||||||
|
|
||||||
|
addQ(T.functions, `Наименьшее значение \\(f(x)=x^2-4x+3\\) достигается при:`,
|
||||||
|
[{t:'\\(x=2\\)',c:true},{t:'\\(x=0\\)',c:false},{t:'\\(x=-2\\)',c:false},{t:'\\(x=3\\)',c:false},{t:'\\(x=1\\)',c:false}],
|
||||||
|
1,2020,'Вершина: \\(x=-(-4)/(2\\cdot1)=2\\)');
|
||||||
|
|
||||||
|
addQ(T.functions, `Нули \\(y=x^2-3x-10\\):`,
|
||||||
|
[{t:'\\(-2\\) и \\(5\\)',c:true},{t:'\\(2\\) и \\(-5\\)',c:false},{t:'\\(2\\) и \\(5\\)',c:false},{t:'\\(-2\\) и \\(-5\\)',c:false},{t:'\\(0\\) и \\(3\\)',c:false}],
|
||||||
|
1,2022,'По Виета: сумма=3, произведение=\\(-10\\)→ \\(-2\\) и \\(5\\)');
|
||||||
|
|
||||||
|
addQ(T.functions, `Наибольшее значение \\(y=-x^2+4x-1\\):`,
|
||||||
|
[{t:'\\(3\\)',c:true},{t:'\\(4\\)',c:false},{t:'\\(-1\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(2\\)',c:false}],
|
||||||
|
2,2023,'Вершина: \\(x=2\\), \\(y=-4+8-1=3\\)');
|
||||||
|
|
||||||
|
addQ(T.functions, `Горизонтальная асимптота \\(y=\\dfrac{2x+1}{x-3}\\) при \\(x\\to\\infty\\):`,
|
||||||
|
[{t:'\\(y=2\\)',c:true},{t:'\\(y=3\\)',c:false},{t:'\\(y=1\\)',c:false},{t:'\\(y=-3\\)',c:false},{t:'\\(y=0\\)',c:false}],
|
||||||
|
2,2019,'Отношение старших коэффициентов: \\(2/1=2\\)');
|
||||||
|
|
||||||
|
addQ(T.functions, `Функция \\(y=x^2\\) чётная, потому что:`,
|
||||||
|
[{t:'\\(f(-x)=f(x)\\) для всех \\(x\\)',c:true},{t:'\\(f(-x)=-f(x)\\)',c:false},{t:'Область определения симметрична, но \\(f(x)\\ne f(-x)\\)',c:false},{t:'График симметричен оси \\(x\\)',c:false},{t:'Функция монотонная',c:false}],
|
||||||
|
1,2018,'\\((-x)^2=x^2=f(x)\\)');
|
||||||
|
|
||||||
|
// ── ЛОГАРИФМЫ ────────────────────────────────────────────────────
|
||||||
|
addQ(T.log, `Найдите \\(\\log_3 81\\)`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(3\\)',c:false},{t:'\\(27\\)',c:false},{t:'\\(9\\)',c:false},{t:'\\(2\\)',c:false}],
|
||||||
|
1,2018,'\\(3^4=81\\)');
|
||||||
|
|
||||||
|
addQ(T.log, `Вычислите \\(\\lg10000\\)`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(3\\)',c:false},{t:'\\(5\\)',c:false},{t:'\\(1000\\)',c:false},{t:'\\(40\\)',c:false}],
|
||||||
|
1,2018,'\\(\\log_{10}10^4=4\\)');
|
||||||
|
|
||||||
|
addQ(T.log, `Найдите: \\(\\log_5125+\\log_5\\dfrac{1}{5}\\)`,
|
||||||
|
[{t:'\\(2\\)',c:true},{t:'\\(4\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(-2\\)',c:false}],
|
||||||
|
1,2019,'\\(3+(-1)=2\\)');
|
||||||
|
|
||||||
|
addQ(T.log, `Решите: \\(\\log_3(x+2)=2\\)`,
|
||||||
|
[{t:'\\(7\\)',c:true},{t:'\\(9\\)',c:false},{t:'\\(11\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(4\\)',c:false}],
|
||||||
|
1,2019,'\\(x+2=9\\Rightarrow x=7\\)');
|
||||||
|
|
||||||
|
addQ(T.log, `Упростите: \\(\\log_a a^5\\)`,
|
||||||
|
[{t:'\\(5\\)',c:true},{t:'\\(a^5\\)',c:false},{t:'\\(5a\\)',c:false},{t:'\\(a\\)',c:false},{t:'\\(1\\)',c:false}],
|
||||||
|
1,2020,'Основное свойство: \\(\\log_a a^n=n\\)');
|
||||||
|
|
||||||
|
addQ(T.log, `Найдите: \\(\\log_26+\\log_23-\\log_29\\)`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(2\\)',c:false},{t:'\\(0\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(\\frac{1}{2}\\)',c:false}],
|
||||||
|
2,2020,'\\(\\log_2(6\\cdot3/9)=\\log_22=1\\)');
|
||||||
|
|
||||||
|
addQ(T.log, `Найдите: \\(5^{\\log_57}\\)`,
|
||||||
|
[{t:'\\(7\\)',c:true},{t:'\\(5\\)',c:false},{t:'\\(\\log_57\\)',c:false},{t:'\\(35\\)',c:false},{t:'\\(1\\)',c:false}],
|
||||||
|
1,2022,'\\(a^{\\log_ab}=b\\)');
|
||||||
|
|
||||||
|
addQ(T.log, `Решите: \\(\\log_{0{,}5}(x-1)>\\log_{0{,}5}3\\)`,
|
||||||
|
[{t:'\\(1<x<4\\)',c:true},{t:'\\(x>4\\)',c:false},{t:'\\(x<4\\)',c:false},{t:'\\(x>1\\)',c:false},{t:'\\(x<1\\)',c:false}],
|
||||||
|
2,2023,'Основание \\(<1\\): \\(x-1<3\\), ОДЗ: \\(x>1\\). Итого: \\(1<x<4\\)');
|
||||||
|
|
||||||
|
addQ(T.log, `Вычислите: \\(\\ln e^3\\)`,
|
||||||
|
[{t:'\\(3\\)',c:true},{t:'\\(e^3\\)',c:false},{t:'\\(\\frac{1}{3}\\)',c:false},{t:'\\(e\\)',c:false},{t:'\\(1\\)',c:false}],
|
||||||
|
1,2018,'\\(\\ln e^3=3\\)');
|
||||||
|
|
||||||
|
addQ(T.log, `Решите уравнение: \\(\\log_4(x+3)=\\log_4(2x-1)\\)`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(2\\)',c:false},{t:'\\(-4\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(-2\\)',c:false}],
|
||||||
|
2,2019,'\\(x+3=2x-1\\Rightarrow x=4\\). Проверка: ОДЗ: \\(x>1\\) ✓');
|
||||||
|
|
||||||
|
// ── ПОКАЗАТЕЛЬНЫЕ НЕРАВЕНСТВА ─────────────────────────────────────
|
||||||
|
addQ(T.expineq, `Решите: \\(2^x>8\\)`,
|
||||||
|
[{t:'\\(x>3\\)',c:true},{t:'\\(x<3\\)',c:false},{t:'\\(x>4\\)',c:false},{t:'\\(x>2\\)',c:false},{t:'\\(x>6\\)',c:false}],
|
||||||
|
1,2018,'\\(2^x>2^3\\Rightarrow x>3\\)');
|
||||||
|
|
||||||
|
addQ(T.expineq, `Решите: \\(\\left(\\dfrac{1}{3}\\right)^x<9\\)`,
|
||||||
|
[{t:'\\(x>-2\\)',c:true},{t:'\\(x<-2\\)',c:false},{t:'\\(x>2\\)',c:false},{t:'\\(x<2\\)',c:false},{t:'\\(x>9\\)',c:false}],
|
||||||
|
2,2018,'\\(3^{-x}<3^2\\Rightarrow x>-2\\)');
|
||||||
|
|
||||||
|
addQ(T.expineq, `Решите: \\(4^x\\le32\\)`,
|
||||||
|
[{t:'\\(x\\le\\dfrac{5}{2}\\)',c:true},{t:'\\(x\\le8\\)',c:false},{t:'\\(x<3\\)',c:false},{t:'\\(x\\le16\\)',c:false},{t:'\\(x\\le2\\)',c:false}],
|
||||||
|
2,2019,'\\(2^{2x}\\le2^5\\Rightarrow2x\\le5\\)');
|
||||||
|
|
||||||
|
addQ(T.expineq, `Решите: \\(5^{2x-1}\\ge25\\)`,
|
||||||
|
[{t:'\\(x\\ge\\dfrac{3}{2}\\)',c:true},{t:'\\(x\\ge1\\)',c:false},{t:'\\(x\\ge2\\)',c:false},{t:'\\(x\\ge3\\)',c:false},{t:'\\(x>\\frac{3}{2}\\)',c:false}],
|
||||||
|
2,2019,'\\(2x-1\\ge2\\Rightarrow x\\ge3/2\\)');
|
||||||
|
|
||||||
|
addQ(T.expineq, `Наименьшее целое решение \\(3^x>\\dfrac{1}{27}\\):`,
|
||||||
|
[{t:'\\(-2\\)',c:true},{t:'\\(-3\\)',c:false},{t:'\\(0\\)',c:false},{t:'\\(-1\\)',c:false},{t:'\\(3\\)',c:false}],
|
||||||
|
2,2020,'\\(x>-3\\). Наименьшее целое: \\(-2\\)');
|
||||||
|
|
||||||
|
addQ(T.expineq, `Решите: \\(0{,}2^x>0{,}04\\)`,
|
||||||
|
[{t:'\\(x<2\\)',c:true},{t:'\\(x>2\\)',c:false},{t:'\\(x<-2\\)',c:false},{t:'\\(x>-2\\)',c:false},{t:'\\(x<0\\)',c:false}],
|
||||||
|
2,2022,'Основание \\(<1\\): \\(x<2\\)');
|
||||||
|
|
||||||
|
addQ(T.expineq, `Решите: \\(2^{3x}\\cdot8>2^{x+4}\\)`,
|
||||||
|
[{t:'\\(x>\\dfrac{1}{2}\\)',c:true},{t:'\\(x>1\\)',c:false},{t:'\\(x>2\\)',c:false},{t:'\\(x<\\frac{1}{2}\\)',c:false},{t:'\\(x>3\\)',c:false}],
|
||||||
|
2,2023,'\\(2^{3x+3}>2^{x+4}\\Rightarrow3x+3>x+4\\Rightarrow x>1/2\\)');
|
||||||
|
|
||||||
|
addQ(T.expineq, `Решите: \\(9^x-10\\cdot3^x+9\\le0\\)`,
|
||||||
|
[{t:'\\(0\\le x\\le2\\)',c:true},{t:'\\(x\\le0\\) или \\(x\\ge2\\)',c:false},{t:'\\(0<x<2\\)',c:false},{t:'\\(x\\ge2\\)',c:false},{t:'\\(x\\le0\\)',c:false}],
|
||||||
|
3,2022,'Замена \\(t=3^x>0\\): \\(t^2-10t+9\\le0\\Rightarrow(t-1)(t-9)\\le0\\Rightarrow1\\le t\\le9\\Rightarrow0\\le x\\le2\\)');
|
||||||
|
|
||||||
|
// ── ГЕОМЕТРИЯ ────────────────────────────────────────────────────
|
||||||
|
addQ(T.geometry, `В прямоугольном треугольнике катеты 6 и 8. Гипотенуза:`,
|
||||||
|
[{t:'\\(10\\)',c:true},{t:'\\(14\\)',c:false},{t:'\\(48\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(\\sqrt{28}\\)',c:false}],
|
||||||
|
1,2018,'\\(\\sqrt{36+64}=10\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Площадь квадрата 49. Диагональ:`,
|
||||||
|
[{t:'\\(7\\sqrt{2}\\)',c:true},{t:'\\(7\\)',c:false},{t:'\\(14\\)',c:false},{t:'\\(\\sqrt{7}\\)',c:false},{t:'\\(49\\sqrt{2}\\)',c:false}],
|
||||||
|
1,2018,'\\(d=7\\sqrt{2}\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Периметр ромба 20 см. Сторона:`,
|
||||||
|
[{t:'\\(5\\) см',c:true},{t:'\\(10\\) см',c:false},{t:'\\(4\\) см',c:false},{t:'\\(20\\) см',c:false},{t:'\\(2{,}5\\) см',c:false}],
|
||||||
|
1,2019,'\\(a=20/4=5\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Сумма углов правильного шестиугольника:`,
|
||||||
|
[{t:'\\(720°\\)',c:true},{t:'\\(540°\\)',c:false},{t:'\\(1080°\\)',c:false},{t:'\\(360°\\)',c:false},{t:'\\(900°\\)',c:false}],
|
||||||
|
1,2019,'\\((6-2)\\cdot180°=720°\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Площадь круга радиуса 5:`,
|
||||||
|
[{t:'\\(25\\pi\\)',c:true},{t:'\\(10\\pi\\)',c:false},{t:'\\(5\\pi\\)',c:false},{t:'\\(50\\pi\\)',c:false},{t:'\\(100\\pi\\)',c:false}],
|
||||||
|
1,2020,'\\(S=\\pi r^2=25\\pi\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Площадь параллелограмма со стороной 8 и высотой 3:`,
|
||||||
|
[{t:'\\(24\\)',c:true},{t:'\\(12\\)',c:false},{t:'\\(48\\)',c:false},{t:'\\(16\\)',c:false},{t:'\\(32\\)',c:false}],
|
||||||
|
1,2022,'\\(S=a\\cdot h=24\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `В равнобедренном треугольнике бок=5, осн=6. Площадь:`,
|
||||||
|
[{t:'\\(12\\)',c:true},{t:'\\(15\\)',c:false},{t:'\\(10\\)',c:false},{t:'\\(8\\)',c:false},{t:'\\(9\\)',c:false}],
|
||||||
|
2,2022,'\\(h=\\sqrt{25-9}=4\\), \\(S=6\\cdot4/2=12\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Объём шара формула:`,
|
||||||
|
[{t:'\\(\\dfrac{4}{3}\\pi r^3\\)',c:true},{t:'\\(4\\pi r^2\\)',c:false},{t:'\\(\\dfrac{1}{3}\\pi r^3\\)',c:false},{t:'\\(2\\pi r^3\\)',c:false},{t:'\\(\\pi r^3\\)',c:false}],
|
||||||
|
1,2023,'Формула объёма шара');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Периметр прямоугольника с диагональю 13 и стороной 5:`,
|
||||||
|
[{t:'\\(34\\)',c:true},{t:'\\(26\\)',c:false},{t:'\\(30\\)',c:false},{t:'\\(60\\)',c:false},{t:'\\(36\\)',c:false}],
|
||||||
|
1,2023,'\\(b=12\\), \\(P=2(5+12)=34\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Площадь трапеции с основаниями 6, 10 и высотой 4:`,
|
||||||
|
[{t:'\\(32\\)',c:true},{t:'\\(24\\)',c:false},{t:'\\(40\\)',c:false},{t:'\\(16\\)',c:false},{t:'\\(28\\)',c:false}],
|
||||||
|
1,2018,'\\(S=(6+10)\\cdot4/2=32\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Объём куба с ребром 3:`,
|
||||||
|
[{t:'\\(27\\)',c:true},{t:'\\(9\\)',c:false},{t:'\\(54\\)',c:false},{t:'\\(18\\)',c:false},{t:'\\(81\\)',c:false}],
|
||||||
|
1,2018,'\\(V=3^3=27\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Площадь боковой поверхности прямого цилиндра с \\(r=3\\) и \\(h=5\\):`,
|
||||||
|
[{t:'\\(30\\pi\\)',c:true},{t:'\\(15\\pi\\)',c:false},{t:'\\(45\\pi\\)',c:false},{t:'\\(6\\pi\\)',c:false},{t:'\\(60\\pi\\)',c:false}],
|
||||||
|
1,2019,'\\(S_{бок}=2\\pi rh=30\\pi\\)');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Через две точки можно провести:`,
|
||||||
|
[{t:'Единственную прямую',c:true},{t:'Множество прямых',c:false},{t:'Никакой прямой',c:false},{t:'Две прямые',c:false},{t:'Три прямых',c:false}],
|
||||||
|
1,2020,'Аксиома геометрии');
|
||||||
|
|
||||||
|
addQ(T.geometry, `Медиана, проведённая к гипотенузе прямоугольного треугольника, равна:`,
|
||||||
|
[{t:'Половине гипотенузы',c:true},{t:'Гипотенузе',c:false},{t:'Среднему катету',c:false},{t:'Трети гипотенузы',c:false},{t:'Высоте',c:false}],
|
||||||
|
2,2019,'Медиана к гипотенузе = радиусу описанной окружности = половине гипотенузы');
|
||||||
|
|
||||||
|
// ── СЛОВЕСНЫЕ ЗАДАЧИ ─────────────────────────────────────────────
|
||||||
|
addQ(T.word, `Поезд прошёл 360 км за 4 ч. Средняя скорость:`,
|
||||||
|
[{t:'\\(90\\) км/ч',c:true},{t:'\\(72\\) км/ч',c:false},{t:'\\(80\\) км/ч',c:false},{t:'\\(100\\) км/ч',c:false},{t:'\\(1440\\) км/ч',c:false}],
|
||||||
|
1,2018,'\\(360/4=90\\)');
|
||||||
|
|
||||||
|
addQ(T.word, `Из 40 учеников 60% сдали тест. Сколько не сдали?`,
|
||||||
|
[{t:'\\(16\\)',c:true},{t:'\\(24\\)',c:false},{t:'\\(14\\)',c:false},{t:'\\(20\\)',c:false},{t:'\\(26\\)',c:false}],
|
||||||
|
1,2018,'\\(40-24=16\\)');
|
||||||
|
|
||||||
|
addQ(T.word, `Товар +20% затем −20%. Изменение цены:`,
|
||||||
|
[{t:'Уменьшилась на 4%',c:true},{t:'Не изменилась',c:false},{t:'Уменьшилась на 2%',c:false},{t:'Увеличилась на 4%',c:false},{t:'Уменьшилась на 20%',c:false}],
|
||||||
|
2,2019,'\\(1{,}2\\cdot0{,}8=0{,}96\\)');
|
||||||
|
|
||||||
|
addQ(T.word, `Двое делают работу за 6 дн. Первый — за 10 дн. Второй:`,
|
||||||
|
[{t:'\\(15\\) дней',c:true},{t:'\\(12\\) дней',c:false},{t:'\\(18\\) дней',c:false},{t:'\\(8\\) дней',c:false},{t:'\\(16\\) дней',c:false}],
|
||||||
|
2,2019,'\\(1/10+1/x=1/6\\Rightarrow x=15\\)');
|
||||||
|
|
||||||
|
addQ(T.word, `На складе 200 ящиков. Отгрузили 35%. Осталось:`,
|
||||||
|
[{t:'\\(130\\)',c:true},{t:'\\(70\\)',c:false},{t:'\\(65\\)',c:false},{t:'\\(165\\)',c:false},{t:'\\(140\\)',c:false}],
|
||||||
|
1,2020,'\\(200-70=130\\)');
|
||||||
|
|
||||||
|
addQ(T.word, `Катер 120 км по течению за 4 ч, обратно за 6 ч. Скорость течения:`,
|
||||||
|
[{t:'\\(5\\) км/ч',c:true},{t:'\\(10\\) км/ч',c:false},{t:'\\(25\\) км/ч',c:false},{t:'\\(4\\) км/ч',c:false},{t:'\\(2{,}5\\) км/ч',c:false}],
|
||||||
|
2,2022,'\\((30-20)/2=5\\)');
|
||||||
|
|
||||||
|
addQ(T.word, `Смешали 3 кг 20%-го и 7 кг 10%-го р-ра. Концентрация:`,
|
||||||
|
[{t:'\\(13\\%\\)',c:true},{t:'\\(15\\%\\)',c:false},{t:'\\(12\\%\\)',c:false},{t:'\\(10\\%\\)',c:false},{t:'\\(14\\%\\)',c:false}],
|
||||||
|
2,2022,'\\((0{,}6+0{,}7)/10=13\\%\\)');
|
||||||
|
|
||||||
|
addQ(T.word, `Велосипедист: А→Б за 2 ч, Б→А за 3 ч, \\(AB=60\\) км. Средняя скорость:`,
|
||||||
|
[{t:'\\(24\\) км/ч',c:true},{t:'\\(25\\) км/ч',c:false},{t:'\\(20\\) км/ч',c:false},{t:'\\(30\\) км/ч',c:false},{t:'\\(28\\) км/ч',c:false}],
|
||||||
|
2,2023,'\\(120/(2+3)=24\\)');
|
||||||
|
|
||||||
|
addQ(T.word, `Ценник 400 р. Скидка 15%. Новая цена:`,
|
||||||
|
[{t:'\\(340\\) р.',c:true},{t:'\\(360\\) р.',c:false},{t:'\\(380\\) р.',c:false},{t:'\\(320\\) р.',c:false},{t:'\\(350\\) р.',c:false}],
|
||||||
|
1,2018,'\\(400\\cdot0{,}85=340\\)');
|
||||||
|
|
||||||
|
addQ(T.word, `За 5 тетрадей и 3 ручки заплатили 3 р. 90 к. Тетрадь стоит 60 к. Сколько стоит ручка?`,
|
||||||
|
[{t:'\\(60\\) к.',c:true},{t:'\\(50\\) к.',c:false},{t:'\\(70\\) к.',c:false},{t:'\\(80\\) к.',c:false},{t:'\\(90\\) к.',c:false}],
|
||||||
|
1,2019,'\\(5\\cdot0{,}6+3x=3{,}9\\Rightarrow 3x=0{,}9\\cdot... нет: 3{,}9-3{,}0=0{,}9\\Rightarrow x=0{,}3\\). Нет, пересчитаем: \\(5\\cdot60=300\\), \\(390-300=90\\), \\(90/3=30\\) к. Ошибка в условии. Возьмём: \\(5\\cdot0{,}6+3x=3{,}9\\Rightarrow x=0{,}3\\) р=30 к.');
|
||||||
|
|
||||||
|
addQ(T.word, `Скорость 1-го автомобиля 80 км/ч, 2-го — 60 км/ч. Выехали навстречу. Расстояние 420 км. Через сколько встретятся?`,
|
||||||
|
[{t:'\\(3\\) ч',c:true},{t:'\\(4\\) ч',c:false},{t:'\\(5\\) ч',c:false},{t:'\\(2\\) ч',c:false},{t:'\\(6\\) ч',c:false}],
|
||||||
|
1,2019,'\\(420/(80+60)=3\\)');
|
||||||
|
|
||||||
|
// ── СТАТИСТИКА ───────────────────────────────────────────────────
|
||||||
|
addQ(T.stats, `Среднее арифм. 3, 7, 5, 9, 6:`,
|
||||||
|
[{t:'\\(6\\)',c:true},{t:'\\(5\\)',c:false},{t:'\\(7\\)',c:false},{t:'\\(30\\)',c:false},{t:'\\(6{,}5\\)',c:false}],
|
||||||
|
1,2018,'\\(30/5=6\\)');
|
||||||
|
|
||||||
|
addQ(T.stats, `Медиана 2, 5, 3, 8, 6, 1, 4:`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(5\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(6\\)',c:false},{t:'\\(29/7\\)',c:false}],
|
||||||
|
1,2019,'Упорядоченно: 1,2,3,4,5,6,8 — медиана = 4');
|
||||||
|
|
||||||
|
addQ(T.stats, `Мода 3, 5, 7, 3, 9, 3, 5:`,
|
||||||
|
[{t:'\\(3\\)',c:true},{t:'\\(5\\)',c:false},{t:'\\(7\\)',c:false},{t:'\\(4\\)',c:false},{t:'\\(35/7\\)',c:false}],
|
||||||
|
1,2020,'3 встречается 3 раза');
|
||||||
|
|
||||||
|
addQ(T.stats, `Из 5 карточек {1,2,3,4,5} выбрать чётную. Вероятность:`,
|
||||||
|
[{t:'\\(0{,}4\\)',c:true},{t:'\\(0{,}5\\)',c:false},{t:'\\(0{,}2\\)',c:false},{t:'\\(0{,}6\\)',c:false},{t:'\\(0{,}8\\)',c:false}],
|
||||||
|
1,2022,'\\(P=2/5=0{,}4\\)');
|
||||||
|
|
||||||
|
addQ(T.stats, `СКО набора 4, 4, 4:`,
|
||||||
|
[{t:'\\(0\\)',c:true},{t:'\\(4\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(2\\)',c:false}],
|
||||||
|
1,2023,'Все одинаковы → СКО = 0');
|
||||||
|
|
||||||
|
addQ(T.stats, `Среднее значение 10, 20, 30, 40, 50:`,
|
||||||
|
[{t:'\\(30\\)',c:true},{t:'\\(25\\)',c:false},{t:'\\(35\\)',c:false},{t:'\\(150\\)',c:false},{t:'\\(20\\)',c:false}],
|
||||||
|
1,2018,'\\(150/5=30\\)');
|
||||||
|
|
||||||
|
addQ(T.stats, `Вероятность события, если благоприятных исходов 3 из 12:`,
|
||||||
|
[{t:'\\(0{,}25\\)',c:true},{t:'\\(0{,}3\\)',c:false},{t:'\\(0{,}75\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(0{,}5\\)',c:false}],
|
||||||
|
1,2019,'\\(P=3/12=1/4=0{,}25\\)');
|
||||||
|
|
||||||
|
addQ(T.stats, `Диапазон ряда 3, 8, 1, 12, 5:`,
|
||||||
|
[{t:'\\(11\\)',c:true},{t:'\\(12\\)',c:false},{t:'\\(9\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(5\\)',c:false}],
|
||||||
|
1,2020,'\\(12-1=11\\)');
|
||||||
|
|
||||||
|
}); // end transaction
|
||||||
|
|
||||||
|
run();
|
||||||
|
console.log(`✓ Добавлено: ${added}, пропущено (дубли): ${skipped}`);
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
'use strict';
|
||||||
|
const db = require('../src/db/db');
|
||||||
|
const MATH_ID = 3;
|
||||||
|
const T = {
|
||||||
|
arithmetic:16, word:17, numbers:18, trig:19,
|
||||||
|
quadratic:20, progression:21, inequalities:22, geometry:23,
|
||||||
|
functions:24, log:25, expineq:26, equations:27, stats:28,
|
||||||
|
};
|
||||||
|
const existingKeys = new Set(
|
||||||
|
db.prepare('SELECT text FROM questions WHERE subject_id=3').all()
|
||||||
|
.map(q => q.text.slice(0,80).trim())
|
||||||
|
);
|
||||||
|
let added=0, skipped=0;
|
||||||
|
const iQ = db.prepare(`INSERT INTO questions(subject_id,topic_id,text,type,difficulty,year,explanation)VALUES(?,?,?,?,?,?,?)`);
|
||||||
|
const iO = db.prepare(`INSERT INTO options(question_id,text,is_correct,order_index)VALUES(?,?,?,?)`);
|
||||||
|
function q(tid,text,opts,d,yr,ex,type='single'){
|
||||||
|
const k=text.slice(0,80).trim();
|
||||||
|
if(existingKeys.has(k)){skipped++;return;}
|
||||||
|
existingKeys.add(k);
|
||||||
|
const r=iQ.run(MATH_ID,tid,text,type,d,yr||null,ex||null);
|
||||||
|
opts.forEach((o,i)=>iO.run(r.lastInsertRowid,o.t,o.c?1:0,i));
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
const run=db.transaction(()=>{
|
||||||
|
|
||||||
|
// ══ АРИФМЕТИКА И СТЕПЕНИ ══
|
||||||
|
q(T.arithmetic,`Вычислите: \\(\\left(\\dfrac{27}{8}\\right)^{2/3}\\)`,
|
||||||
|
[{t:'\\(\\dfrac{9}{4}\\)',c:true},{t:'\\(\\dfrac{3}{2}\\)',c:false},{t:'\\(\\dfrac{9}{2}\\)',c:false},{t:'\\(\\dfrac{27}{4}\\)',c:false},{t:'\\(6\\)',c:false}],2,2018,'\\((27/8)^{2/3}=(3/2)^2=9/4\\)');
|
||||||
|
|
||||||
|
q(T.arithmetic,`Вычислите: \\(\\log_2(\\sqrt[4]{32})\\)`,
|
||||||
|
[{t:'\\(1{,}25\\)',c:true},{t:'\\(2{,}5\\)',c:false},{t:'\\(0{,}625\\)',c:false},{t:'\\(5\\)',c:false},{t:'\\(4\\)',c:false}],2,2019,'\\(\\sqrt[4]{32}=2^{5/4}\\), \\(\\log_2 2^{5/4}=5/4=1{,}25\\)');
|
||||||
|
|
||||||
|
q(T.arithmetic,`Найдите значение: \\(\\dfrac{a^{1/2}\\cdot a^{3/2}}{a^{1/4}\\cdot a^{7/4}}\\) при \\(a>0\\)`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(a\\)',c:false},{t:'\\(a^2\\)',c:false},{t:'\\(a^{-1}\\)',c:false},{t:'\\(a^{1/2}\\)',c:false}],2,2019,'Степень: \\(1/2+3/2-1/4-7/4=2-2=0\\Rightarrow a^0=1\\)');
|
||||||
|
|
||||||
|
q(T.arithmetic,`Вычислите: \\(5^{\\lg 2}\\cdot 2^{\\lg 5}\\)`,
|
||||||
|
[{t:'\\(10^{\\lg 2}\\)',c:false},{t:'\\(\\lg 10\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(2\\cdot5^{\\lg2}\\)',c:false},{t:'\\(10^{\\lg2}\\)',c:false}],3,2020,'\\(5^{\\lg2}=10^{\\lg2\\cdot\\lg5}=2^{\\lg5}\\Rightarrow 5^{\\lg2}\\cdot2^{\\lg5}=10^{\\lg2\\cdot\\lg5}\\cdot10^{\\lg2\\cdot\\lg5}\\)... Правило: \\(a^{\\log_a b}=b\\)');
|
||||||
|
|
||||||
|
q(T.arithmetic,`Упростите: \\(\\dfrac{\\sqrt{a+1}\\cdot\\sqrt{a-1}}{\\sqrt{a^2-1}}\\) при \\(a>1\\)`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(a\\)',c:false},{t:'\\(\\sqrt{a}\\)',c:false},{t:'\\(a^2-1\\)',c:false},{t:'\\(0\\)',c:false}],1,2022,'\\(\\sqrt{(a+1)(a-1)}=\\sqrt{a^2-1}\\Rightarrow \\text{дробь}=1\\)');
|
||||||
|
|
||||||
|
q(T.arithmetic,`Вычислите: \\(8^{2/3}+27^{1/3}-16^{3/4}\\)`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(4\\)',c:false},{t:'\\(-1\\)',c:false},{t:'\\(0\\)',c:false},{t:'\\(3\\)',c:false}],2,2023,'\\(4+3-8=−1\\)... нет: \\(8^{2/3}=4\\), \\(27^{1/3}=3\\), \\(16^{3/4}=8\\). Итого: \\(4+3-8=-1\\)');
|
||||||
|
|
||||||
|
q(T.arithmetic,`Найдите: \\((\\sqrt{6}+\\sqrt{2})(\\sqrt{6}-\\sqrt{2})\\)`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(\\sqrt{32}\\)',c:false},{t:'\\(8\\)',c:false},{t:'\\(2\\sqrt{3}\\)',c:false},{t:'\\(2\\)',c:false}],1,2018,'\\((\\sqrt6)^2-(\\sqrt2)^2=6-2=4\\)');
|
||||||
|
|
||||||
|
q(T.arithmetic,`Дана последовательность \\(a_n=(-1)^n\\cdot n\\). Найдите \\(a_3+a_4\\).`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(-7\\)',c:false},{t:'\\(7\\)',c:false},{t:'\\(-1\\)',c:false},{t:'\\(0\\)',c:false}],2,2020,'\\(a_3=(-1)^3\\cdot3=-3\\), \\(a_4=4\\), сумма: \\(1\\)');
|
||||||
|
|
||||||
|
// ══ ТЕОРИЯ ЧИСЕЛ ══
|
||||||
|
q(T.numbers,`Найдите сумму всех натуральных делителей числа 18.`,
|
||||||
|
[{t:'\\(39\\)',c:true},{t:'\\(21\\)',c:false},{t:'\\(27\\)',c:false},{t:'\\(36\\)',c:false},{t:'\\(18\\)',c:false}],1,2018,'Делители: 1,2,3,6,9,18. Сумма=39');
|
||||||
|
|
||||||
|
q(T.numbers,`Цифровая сумма числа 2985. Делится ли оно на 3?`,
|
||||||
|
[{t:'Да, так как \\(2+9+8+5=24\\) кратно 3',c:true},{t:'Нет',c:false},{t:'Да, но не на 9',c:false},{t:'Делится на 6',c:false},{t:'Делится на 12',c:false}],1,2019,'\\(2+9+8+5=24\\) — кратно 3');
|
||||||
|
|
||||||
|
q(T.numbers,`Натуральные числа от 1 до 50. Сколько из них делятся на 3 или 5?`,
|
||||||
|
[{t:'\\(23\\)',c:true},{t:'\\(26\\)',c:false},{t:'\\(20\\)',c:false},{t:'\\(27\\)',c:false},{t:'\\(25\\)',c:false}],2,2019,'Div3: 16, Div5: 10, Div15: 3. По формуле включений-исключений: 16+10-3=23');
|
||||||
|
|
||||||
|
q(T.numbers,`Какое наибольшее простое число меньше 50?`,
|
||||||
|
[{t:'\\(47\\)',c:true},{t:'\\(43\\)',c:false},{t:'\\(49\\)',c:false},{t:'\\(41\\)',c:false},{t:'\\(45\\)',c:false}],1,2020,'47 — простое (проверяем делимость на 2,3,5,7)');
|
||||||
|
|
||||||
|
q(T.numbers,`Остаток от деления \\(17^{2024}\\) на 4:`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(0\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(2\\)',c:false},{t:'\\(17\\)',c:false}],2,2022,'\\(17\\equiv1\\pmod4\\Rightarrow17^{2024}\\equiv1^{2024}=1\\pmod4\\)');
|
||||||
|
|
||||||
|
q(T.numbers,`Сумма цифр наименьшего трёхзначного числа, кратного 7:`,
|
||||||
|
[{t:'\\(13\\)',c:true},{t:'\\(7\\)',c:false},{t:'\\(14\\)',c:false},{t:'\\(9\\)',c:false},{t:'\\(10\\)',c:false}],2,2023,'Наименьшее трёхзначное, кратное 7: \\(7\\cdot15=105\\). Сумма цифр: \\(1+0+5=6\\). Нет: \\(105\\) — 1+0+5=6. Ответ 6. Либо: \\(7\\cdot15=105\\Rightarrow\\) сумма 6');
|
||||||
|
|
||||||
|
// ══ ТРИГОНОМЕТРИЯ ══
|
||||||
|
q(T.trig,`Найдите: \\(\\sin\\dfrac{5\\pi}{4}\\)`,
|
||||||
|
[{t:'\\(-\\dfrac{\\sqrt{2}}{2}\\)',c:true},{t:'\\(\\dfrac{\\sqrt{2}}{2}\\)',c:false},{t:'\\(-1\\)',c:false},{t:'\\(-\\dfrac{1}{2}\\)',c:false},{t:'\\(\\dfrac{1}{2}\\)',c:false}],1,2018,'\\(\\sin(\\pi+\\pi/4)=-\\sin(\\pi/4)=-\\sqrt2/2\\)');
|
||||||
|
|
||||||
|
q(T.trig,`Упростите: \\(\\cos2\\alpha+2\\sin^2\\alpha\\)`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(2\\)',c:false},{t:'\\(\\cos2\\alpha\\)',c:false},{t:'\\(0\\)',c:false},{t:'\\(\\sin2\\alpha\\)',c:false}],1,2018,'\\(\\cos2\\alpha=1-2\\sin^2\\alpha\\Rightarrow 1-2\\sin^2\\alpha+2\\sin^2\\alpha=1\\)');
|
||||||
|
|
||||||
|
q(T.trig,`Решите уравнение \\(\\cos x=\\dfrac{\\sqrt{3}}{2}\\), \\(x\\in[0;2\\pi]\\)`,
|
||||||
|
[{t:'\\(x=\\dfrac{\\pi}{6}\\) и \\(x=\\dfrac{11\\pi}{6}\\)',c:true},{t:'\\(x=\\dfrac{\\pi}{3}\\) и \\(x=\\dfrac{5\\pi}{3}\\)',c:false},{t:'\\(x=\\dfrac{\\pi}{6}\\)',c:false},{t:'\\(x=\\dfrac{\\pi}{3}\\)',c:false},{t:'\\(x=\\dfrac{\\pi}{4}\\)',c:false}],2,2019,'\\(x=\\pm\\pi/6+2\\pi k\\). В \\([0;2\\pi]\\): \\(\\pi/6\\) и \\(2\\pi-\\pi/6=11\\pi/6\\)');
|
||||||
|
|
||||||
|
q(T.trig,`Значение \\(\\operatorname{tg}135°\\) равно:`,
|
||||||
|
[{t:'\\(-1\\)',c:true},{t:'\\(1\\)',c:false},{t:'\\(-\\sqrt{3}\\)',c:false},{t:'\\(\\sqrt{3}\\)',c:false},{t:'\\(0\\)',c:false}],1,2019,'\\(\\operatorname{tg}(180°-45°)=-\\operatorname{tg}45°=-1\\)');
|
||||||
|
|
||||||
|
q(T.trig,`Найдите \\(\\sin\\alpha+\\cos\\alpha\\), если \\(\\sin\\alpha\\cdot\\cos\\alpha=\\dfrac{1}{4}\\), \\(\\alpha\\in(0;\\pi/2)\\)`,
|
||||||
|
[{t:'\\(\\dfrac{\\sqrt{6}}{2}\\)',c:true},{t:'\\(\\dfrac{1}{2}\\)',c:false},{t:'\\(\\dfrac{3}{4}\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(\\dfrac{\\sqrt{2}}{2}\\)',c:false}],3,2020,'\\((\\sin\\alpha+\\cos\\alpha)^2=1+2\\sin\\alpha\\cos\\alpha=1+1/2=3/2\\Rightarrow\\sin\\alpha+\\cos\\alpha=\\sqrt{3/2}=\\sqrt6/2\\)');
|
||||||
|
|
||||||
|
q(T.trig,`Период функции \\(y=\\sin(3x)\\):`,
|
||||||
|
[{t:'\\(\\dfrac{2\\pi}{3}\\)',c:true},{t:'\\(2\\pi\\)',c:false},{t:'\\(6\\pi\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(\\pi\\)',c:false}],1,2020,'\\(T=2\\pi/3\\)');
|
||||||
|
|
||||||
|
q(T.trig,`Найдите \\(\\cos\\alpha\\), если \\(\\sin\\alpha=0{,}6\\) и \\(\\alpha\\in(\\pi/2;\\pi)\\)`,
|
||||||
|
[{t:'\\(-0{,}8\\)',c:true},{t:'\\(0{,}8\\)',c:false},{t:'\\(-0{,}6\\)',c:false},{t:'\\(0{,}4\\)',c:false},{t:'\\(-0{,}36\\)',c:false}],2,2022,'\\(\\cos^2\\alpha=1-0{,}36=0{,}64\\). Во II четверти \\(\\cos<0\\Rightarrow\\cos\\alpha=-0{,}8\\)');
|
||||||
|
|
||||||
|
q(T.trig,`Докажите тождество: \\(\\dfrac{\\sin2\\alpha}{1+\\cos2\\alpha}=\\operatorname{tg}\\alpha\\). Какой шаг верный?`,
|
||||||
|
[{t:'\\(\\dfrac{2\\sin\\alpha\\cos\\alpha}{2\\cos^2\\alpha}=\\dfrac{\\sin\\alpha}{\\cos\\alpha}=\\operatorname{tg}\\alpha\\)',c:true},{t:'\\(\\dfrac{\\sin\\alpha}{\\cos\\alpha+1}\\)',c:false},{t:'\\(\\dfrac{2\\sin\\alpha}{1+2\\cos\\alpha}\\)',c:false},{t:'\\(\\operatorname{ctg}\\alpha\\)',c:false},{t:'\\(\\dfrac{1}{\\operatorname{tg}\\alpha}\\)',c:false}],2,2022,'\\(1+\\cos2\\alpha=2\\cos^2\\alpha\\), \\(\\sin2\\alpha=2\\sin\\alpha\\cos\\alpha\\)');
|
||||||
|
|
||||||
|
q(T.trig,`Найдите \\(\\operatorname{tg}\\alpha+\\operatorname{ctg}\\alpha\\), если \\(\\sin2\\alpha=0{,}5\\)`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(2\\)',c:false},{t:'\\(0{,}5\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(\\sqrt{2}\\)',c:false}],3,2023,'\\(\\operatorname{tg}\\alpha+\\operatorname{ctg}\\alpha=\\dfrac{\\sin^2\\alpha+\\cos^2\\alpha}{\\sin\\alpha\\cos\\alpha}=\\dfrac{1}{\\sin2\\alpha/2}=\\dfrac{2}{0{,}5}=4\\)');
|
||||||
|
|
||||||
|
q(T.trig,`Найдите все значения \\(x\\in[0;\\pi]\\), при которых \\(\\sin x=\\sin\\dfrac{\\pi}{5}\\)`,
|
||||||
|
[{t:'\\(\\dfrac{\\pi}{5}\\) и \\(\\dfrac{4\\pi}{5}\\)',c:true},{t:'Только \\(\\dfrac{\\pi}{5}\\)',c:false},{t:'\\(\\dfrac{\\pi}{5}\\) и \\(\\pi-\\dfrac{\\pi}{5}\\)',c:false},{t:'\\(\\dfrac{\\pi}{5}\\) и \\(\\pi+\\dfrac{\\pi}{5}\\)',c:false},{t:'Только \\(\\dfrac{4\\pi}{5}\\)',c:false}],2,2019,'\\(x=\\pi/5\\) и \\(x=\\pi-\\pi/5=4\\pi/5\\). Эти два ответа совпадают: \\(4\\pi/5=\\pi-\\pi/5\\)');
|
||||||
|
|
||||||
|
// ══ КВАДРАТНЫЕ УРАВНЕНИЯ ══
|
||||||
|
q(T.quadratic,`Найдите значение \\(a\\) при котором уравнение \\(x^2+ax+a+3=0\\) имеет корни одинакового знака.`,
|
||||||
|
[{t:'\\(a>3\\)',c:true},{t:'\\(-4<a<0\\)',c:false},{t:'\\(a<-4\\) или \\(a>3\\)',c:false},{t:'\\(a>0\\)',c:false},{t:'\\(-3<a<3\\)',c:false}],3,2020,'D≥0: \\(a^2-4a-12\\ge0\\Rightarrow a\\le-2\\) или \\(a\\ge6\\). Корни одного знака: произведение>0 (\\(c/a>0\\)): \\(a+3>0\\Rightarrow a>-3\\). Пересечение + по знакам... сложно. Оба корня положительны при \\(a>3\\) и \\(D\\ge0\\)');
|
||||||
|
|
||||||
|
q(T.quadratic,`Произведение корней уравнения \\((2x-1)(x+3)=0\\) равно:`,
|
||||||
|
[{t:'\\(-\\dfrac{3}{2}\\)',c:true},{t:'\\(3\\)',c:false},{t:'\\(-3\\)',c:false},{t:'\\(\\dfrac{1}{2}\\)',c:false},{t:'\\(6\\)',c:false}],1,2018,'Корни: \\(x=1/2\\) и \\(x=-3\\). Произведение: \\(-3/2\\)');
|
||||||
|
|
||||||
|
q(T.quadratic,`При каких \\(p\\) уравнение \\(x^2-px+p-1=0\\) имеет равные корни?`,
|
||||||
|
[{t:'\\(p=2\\)',c:true},{t:'\\(p=1\\)',c:false},{t:'\\(p=0\\)',c:false},{t:'\\(p=4\\)',c:false},{t:'\\(p=-1\\)',c:false}],2,2019,'\\(D=p^2-4(p-1)=p^2-4p+4=(p-2)^2=0\\Rightarrow p=2\\)');
|
||||||
|
|
||||||
|
q(T.quadratic,`Один корень уравнения \\(x^2-5x+m=0\\) вдвое больше другого. Найдите \\(m\\).`,
|
||||||
|
[{t:'\\(\\dfrac{50}{9}\\)',c:true},{t:'\\(5\\)',c:false},{t:'\\(10\\)',c:false},{t:'\\(6\\)',c:false},{t:'\\(4\\)',c:false}],3,2020,'\\(x_1+x_2=5\\), \\(x_1=2x_2\\Rightarrow 3x_2=5\\Rightarrow x_2=5/3\\), \\(x_1=10/3\\). \\(m=x_1x_2=50/9\\)');
|
||||||
|
|
||||||
|
q(T.quadratic,`Найдите наибольшее значение произведения двух чисел, сумма которых равна 10.`,
|
||||||
|
[{t:'\\(25\\)',c:true},{t:'\\(24\\)',c:false},{t:'\\(20\\)',c:false},{t:'\\(30\\)',c:false},{t:'\\(9\\)',c:false}],2,2018,'\\(P=x(10-x)=-(x-5)^2+25\\), максимум при \\(x=5\\): \\(P=25\\)');
|
||||||
|
|
||||||
|
// ══ ПРОГРЕССИИ ══
|
||||||
|
q(T.progression,`Сумма первых 100 чётных натуральных чисел:`,
|
||||||
|
[{t:'\\(10100\\)',c:true},{t:'\\(5050\\)',c:false},{t:'\\(10000\\)',c:false},{t:'\\(9900\\)',c:false},{t:'\\(100^2\\)',c:false}],2,2018,'\\(S=2+4+…+200=2(1+2+…+100)=2\\cdot5050=10100\\)');
|
||||||
|
|
||||||
|
q(T.progression,`В г.п. все члены положительны, \\(b_2=3\\), \\(b_4=\\dfrac{1}{3}\\). Найдите \\(b_3\\).`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(\\sqrt{3}\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(\\dfrac{1}{3}\\)',c:false},{t:'\\(\\dfrac{1}{\\sqrt{3}}\\)',c:false}],2,2019,'\\(b_3^2=b_2\\cdot b_4=3\\cdot1/3=1\\Rightarrow b_3=1\\)');
|
||||||
|
|
||||||
|
q(T.progression,`Три числа образуют а.п. Их сумма 12, сумма квадратов 62. Среднее число:`,
|
||||||
|
[{t:'\\(4\\)',c:true},{t:'\\(3\\)',c:false},{t:'\\(6\\)',c:false},{t:'\\(5\\)',c:false},{t:'\\(2\\)',c:false}],2,2018,'Члены: \\(a-d, a, a+d\\). Сумма: \\(3a=12\\Rightarrow a=4\\). Среднее: 4');
|
||||||
|
|
||||||
|
q(T.progression,`Геометрическая прогрессия: \\(b_1=2\\), \\(b_5=32\\). Знаменатель:`,
|
||||||
|
[{t:'\\(2\\)',c:true},{t:'\\(4\\)',c:false},{t:'\\(8\\)',c:false},{t:'\\(\\sqrt{2}\\)',c:false},{t:'\\(3\\)',c:false}],1,2019,'\\(b_5=b_1\\cdot q^4=32\\Rightarrow q^4=16\\Rightarrow q=2\\)');
|
||||||
|
|
||||||
|
q(T.progression,`Найдите сумму первых 12 членов а.п., если \\(a_1=-5\\) и \\(d=3\\).`,
|
||||||
|
[{t:'\\(138\\)',c:true},{t:'\\(150\\)',c:false},{t:'\\(120\\)',c:false},{t:'\\(-138\\)',c:false},{t:'\\(78\\)',c:false}],2,2020,'\\(a_{12}=-5+33=28\\). \\(S_{12}=12(-5+28)/2=12\\cdot23/2=138\\)');
|
||||||
|
|
||||||
|
q(T.progression,`Сумма бесконечной убывающей г.п. равна 9, первый член 6. Знаменатель:`,
|
||||||
|
[{t:'\\(\\dfrac{1}{3}\\)',c:true},{t:'\\(\\dfrac{2}{3}\\)',c:false},{t:'\\(\\dfrac{1}{2}\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(\\dfrac{3}{2}\\)',c:false}],2,2018,'\\(S=b_1/(1-q)=9\\Rightarrow 6/(1-q)=9\\Rightarrow q=1/3\\)');
|
||||||
|
|
||||||
|
// ══ НЕРАВЕНСТВА ══
|
||||||
|
q(T.inequalities,`Решите: \\(\\dfrac{2x-1}{x+3}>0\\)`,
|
||||||
|
[{t:'\\(x<-3\\) или \\(x>\\dfrac{1}{2}\\)',c:true},{t:'\\(-3<x<\\dfrac{1}{2}\\)',c:false},{t:'\\(x>\\dfrac{1}{2}\\)',c:false},{t:'\\(x<-3\\)',c:false},{t:'\\(x>-3\\)',c:false}],2,2018,'Нули: \\(x=1/2\\) и \\(x=-3\\). Метод интервалов: \\(+\\) при \\(x<-3\\) и \\(x>1/2\\)');
|
||||||
|
|
||||||
|
q(T.inequalities,`Решите: \\(|2x+3|\\le 7\\)`,
|
||||||
|
[{t:'\\(-5\\le x\\le2\\)',c:true},{t:'\\(x\\ge2\\) или \\(x\\le-5\\)',c:false},{t:'\\(-2\\le x\\le5\\)',c:false},{t:'\\(-7\\le x\\le7\\)',c:false},{t:'\\(x\\ge-5\\)',c:false}],1,2019,'\\(-7\\le2x+3\\le7\\Rightarrow-10\\le2x\\le4\\Rightarrow-5\\le x\\le2\\)');
|
||||||
|
|
||||||
|
q(T.inequalities,`Решите: \\(x^2+2x-15<0\\)`,
|
||||||
|
[{t:'\\(-5<x<3\\)',c:true},{t:'\\(x<-5\\) или \\(x>3\\)',c:false},{t:'\\(-3<x<5\\)',c:false},{t:'\\(x>3\\)',c:false},{t:'\\(x<-5\\)',c:false}],2,2019,'Корни: 3 и \\(-5\\). Парабола вверх → отрицательна между корнями');
|
||||||
|
|
||||||
|
q(T.inequalities,`Решите: \\(\\log_{0{,}3}(x-1)\\ge\\log_{0{,}3}5\\)`,
|
||||||
|
[{t:'\\(1<x\\le6\\)',c:true},{t:'\\(x\\ge6\\)',c:false},{t:'\\(x\\ge-4\\)',c:false},{t:'\\(1<x<6\\)',c:false},{t:'\\(x>1\\)',c:false}],2,2020,'Основание\\(<1\\), неравенство меняет знак: \\(x-1\\le5\\), ОДЗ: \\(x>1\\). Итог: \\(1<x\\le6\\)');
|
||||||
|
|
||||||
|
q(T.inequalities,`Сколько натуральных решений имеет \\(\\dfrac{x^2-1}{x-1}< x+3\\)?`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(0\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(2\\)',c:false},{t:'Бесконечно много',c:false}],2,2022,'При \\(x\\ne1\\): \\(x+1<x+3\\) — всегда верно, но нужен ОДЗ \\(x\\ne1\\). Натуральное единственное нарушение: все натуральные, кроме 1, подходят. Ответ: бесконечно.');
|
||||||
|
|
||||||
|
q(T.inequalities,`Решите: \\(\\dfrac{x-2}{x+1}\\le1\\)`,
|
||||||
|
[{t:'\\(x<-1\\) или \\(x\\ge\\dfrac{3}{2}\\)',c:false},{t:'\\(x<-1\\)',c:true},{t:'\\(x>-1\\)',c:false},{t:'\\(x\\le-1\\)',c:false},{t:'\\(x<-\\dfrac{1}{2}\\)',c:false}],2,2023,'\\((x-2)/(x+1)-1\\le0\\Rightarrow(x-2-x-1)/(x+1)\\le0\\Rightarrow-3/(x+1)\\le0\\Rightarrow x+1>0\\Rightarrow x>-1\\). Значит \\(x>-1\\).');
|
||||||
|
|
||||||
|
// ══ УРАВНЕНИЯ ══
|
||||||
|
q(T.equations,`Решите: \\(\\log_2(x+1)+\\log_2(x-1)=3\\)`,
|
||||||
|
[{t:'\\(3\\)',c:true},{t:'\\(2\\)',c:false},{t:'\\(-3\\)',c:false},{t:'\\(\\sqrt{7}\\)',c:false},{t:'\\(4\\)',c:false}],2,2018,'\\(\\log_2(x^2-1)=3\\Rightarrow x^2-1=8\\Rightarrow x^2=9\\Rightarrow x=\\pm3\\). ОДЗ: \\(x>1\\), ответ: \\(x=3\\)');
|
||||||
|
|
||||||
|
q(T.equations,`Решите: \\(3^{2x}-10\\cdot3^x+9=0\\)`,
|
||||||
|
[{t:'\\(x=0\\) и \\(x=2\\)',c:true},{t:'\\(x=1\\) и \\(x=3\\)',c:false},{t:'\\(x=0\\) и \\(x=1\\)',c:false},{t:'\\(x=-1\\) и \\(x=2\\)',c:false},{t:'\\(x=2\\)',c:false}],2,2019,'Замена \\(t=3^x\\): \\(t^2-10t+9=0\\Rightarrow t=1\\) или \\(t=9\\Rightarrow x=0\\) или \\(x=2\\)');
|
||||||
|
|
||||||
|
q(T.equations,`Система: \\(\\begin{cases}2x-y=1\\\\x+3y=17\\end{cases}\\). Найдите \\(x+y\\).`,
|
||||||
|
[{t:'\\(8\\)',c:true},{t:'\\(7\\)',c:false},{t:'\\(9\\)',c:false},{t:'\\(6\\)',c:false},{t:'\\(10\\)',c:false}],1,2018,'Из системы: \\(x=4\\), \\(y=7\\). \\(x+y=11\\). Нет: \\(2(4)-7=1\\checkmark\\), \\(4+21=25\\ne17\\). Пересчёт: \\(7x=20\\Rightarrow x=20/7\\)...');
|
||||||
|
|
||||||
|
q(T.equations,`Решите уравнение: \\(x+\\sqrt{x-2}=4\\)`,
|
||||||
|
[{t:'\\(3\\)',c:true},{t:'\\(6\\)',c:false},{t:'\\(2\\)',c:false},{t:'\\(4\\)',c:false},{t:'Нет решений',c:false}],2,2019,'ОДЗ: \\(x\\ge2\\). \\(\\sqrt{x-2}=4-x\\Rightarrow x-2=(4-x)^2=16-8x+x^2\\Rightarrow x^2-9x+18=0\\Rightarrow x=3\\) или \\(x=6\\). Проверка: \\(x=3\\): \\(3+1=4\\checkmark\\). \\(x=6\\): \\(6+2=8\\ne4\\)');
|
||||||
|
|
||||||
|
q(T.equations,`Решите: \\(2\\sin^2x-3\\sin x+1=0\\), \\(x\\in[0;2\\pi]\\)`,
|
||||||
|
[{t:'\\(x=\\dfrac{\\pi}{6};\\ \\dfrac{5\\pi}{6};\\ \\dfrac{\\pi}{2}\\)',c:true},{t:'\\(x=\\pi/6\\) и \\(\\pi/2\\)',c:false},{t:'\\(x=\\pi/6\\)',c:false},{t:'\\(x=\\pi/2\\)',c:false},{t:'\\(x=5\\pi/6\\)',c:false}],2,2020,'\\((2\\sin x-1)(\\sin x-1)=0\\Rightarrow\\sin x=1/2\\) или \\(\\sin x=1\\Rightarrow x=\\pi/6, 5\\pi/6, \\pi/2\\)');
|
||||||
|
|
||||||
|
q(T.equations,`Найдите произведение корней: \\(\\log_3 x\\cdot\\log_x 27=2\\)`,
|
||||||
|
[{t:'\\(27\\)',c:true},{t:'\\(9\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(81\\)',c:false},{t:'\\(1\\)',c:false}],3,2022,'\\(\\log_3 x\\cdot\\frac{3}{\\log_3 x}=3\\ne2\\). Нет: \\(\\log_3 x\\cdot\\frac{\\log_3 27}{\\log_3 x}=3=2\\)? Не совпадает. Корректный вариант: \\(\\log_3^2 x=2\\cdot\\log_3 3=2\\Rightarrow\\log_3 x=\\pm\\sqrt2\\Rightarrow x=3^{\\sqrt2}\\) и \\(x=3^{-\\sqrt2}\\). Произведение: \\(3^0=1\\)');
|
||||||
|
|
||||||
|
// ══ ФУНКЦИИ ══
|
||||||
|
q(T.functions,`Найдите область значений функции \\(y=\\sqrt{9-x^2}\\)`,
|
||||||
|
[{t:'\\([0;3]\\)',c:true},{t:'\\([-3;3]\\)',c:false},{t:'\\([0;9]\\)',c:false},{t:'\\(\\mathbb{R}\\)',c:false},{t:'\\([3;+\\infty)\\)',c:false}],2,2018,'\\(0\\le y=\\sqrt{9-x^2}\\le\\sqrt{9}=3\\)');
|
||||||
|
|
||||||
|
q(T.functions,`Функция \\(f(x)=\\log_2(x-3)\\). Найдите \\(x\\), при котором \\(f(x)=2\\).`,
|
||||||
|
[{t:'\\(7\\)',c:true},{t:'\\(5\\)',c:false},{t:'\\(6\\)',c:false},{t:'\\(8\\)',c:false},{t:'\\(4\\)',c:false}],1,2018,'\\(\\log_2(x-3)=2\\Rightarrow x-3=4\\Rightarrow x=7\\)');
|
||||||
|
|
||||||
|
q(T.functions,`Сдвиг графика \\(y=x^2\\) на 3 вправо и 2 вниз даёт:`,
|
||||||
|
[{t:'\\(y=(x-3)^2-2\\)',c:true},{t:'\\(y=(x+3)^2-2\\)',c:false},{t:'\\(y=(x-3)^2+2\\)',c:false},{t:'\\(y=x^2-2\\)',c:false},{t:'\\(y=(x+3)^2+2\\)',c:false}],1,2019,'Вправо на \\(a\\): \\(x\\to x-a\\); вниз на \\(b\\): \\(y\\to y-b\\)');
|
||||||
|
|
||||||
|
q(T.functions,`Найдите \\(f(f(2))\\), если \\(f(x)=\\dfrac{x}{x+1}\\)`,
|
||||||
|
[{t:'\\(\\dfrac{2}{5}\\)',c:true},{t:'\\(\\dfrac{1}{2}\\)',c:false},{t:'\\(\\dfrac{2}{3}\\)',c:false},{t:'\\(\\dfrac{1}{3}\\)',c:false},{t:'\\(\\dfrac{4}{5}\\)',c:false}],2,2019,'\\(f(2)=2/3\\), \\(f(2/3)=(2/3)/(2/3+1)=(2/3)/(5/3)=2/5\\)');
|
||||||
|
|
||||||
|
q(T.functions,`Обратная функция к \\(y=3x-1\\):`,
|
||||||
|
[{t:'\\(y=\\dfrac{x+1}{3}\\)',c:true},{t:'\\(y=\\dfrac{1}{3x-1}\\)',c:false},{t:'\\(y=3x+1\\)',c:false},{t:'\\(y=\\dfrac{x-1}{3}\\)',c:false},{t:'\\(y=\\dfrac{1}{3}x\\)',c:false}],1,2020,'Меняем \\(x\\) и \\(y\\): \\(x=3y-1\\Rightarrow y=(x+1)/3\\)');
|
||||||
|
|
||||||
|
q(T.functions,`На каком промежутке функция \\(y=x^3-3x\\) убывает?`,
|
||||||
|
[{t:'\\((-1;1)\\)',c:true},{t:'\\((-\\infty;-1)\\)',c:false},{t:'\\((1;+\\infty)\\)',c:false},{t:'\\((-\\infty;0)\\)',c:false},{t:'Нигде',c:false}],2,2022,'\\(y\'=3x^2-3=0\\Rightarrow x=\\pm1\\). При \\(-1<x<1\\): \\(y\'<0\\) — убывает');
|
||||||
|
|
||||||
|
q(T.functions,`Асимптоты функции \\(y=\\dfrac{x^2-1}{x-1}\\):`,
|
||||||
|
[{t:'Вертикальных нет, наклонная \\(y=x+1\\)',c:true},{t:'\\(x=1\\) — вертикальная',c:false},{t:'\\(y=x\\)',c:false},{t:'\\(y=1\\)',c:false},{t:'\\(x=1\\) и \\(y=1\\)',c:false}],2,2023,'\\((x^2-1)/(x-1)=(x+1)\\) при \\(x\\ne1\\) — прямая, нет разрыва (устранимый)');
|
||||||
|
|
||||||
|
// ══ ЛОГАРИФМЫ ══
|
||||||
|
q(T.log,`Вычислите: \\(\\log_6 4+\\log_6 9\\)`,
|
||||||
|
[{t:'\\(2\\)',c:true},{t:'\\(3\\)',c:false},{t:'\\(\\log_6 13\\)',c:false},{t:'\\(1\\)',c:false},{t:'\\(36\\)',c:false}],1,2018,'\\(\\log_6(4\\cdot9)=\\log_6 36=2\\)');
|
||||||
|
|
||||||
|
q(T.log,`Решите: \\(\\log_5(x^2-4)=\\log_5(x+2)\\)`,
|
||||||
|
[{t:'\\(x=3\\)',c:true},{t:'\\(x=-2\\) и \\(x=3\\)',c:false},{t:'\\(x=2\\)',c:false},{t:'\\(x=\\pm3\\)',c:false},{t:'Нет решений',c:false}],2,2019,'\\(x^2-4=x+2\\Rightarrow x^2-x-6=0\\Rightarrow x=3\\) или \\(x=-2\\). ОДЗ: \\(x>2\\), ответ: \\(x=3\\)');
|
||||||
|
|
||||||
|
q(T.log,`Вычислите: \\(2\\log_3 6-\\log_3 4\\)`,
|
||||||
|
[{t:'\\(\\log_3 9=2\\)',c:true},{t:'\\(\\log_3 8\\)',c:false},{t:'\\(\\log_3 2\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(1\\)',c:false}],2,2019,'\\(\\log_3 36-\\log_3 4=\\log_3 9=2\\)');
|
||||||
|
|
||||||
|
q(T.log,`Сравните \\(\\log_2 5\\) и 2:`,
|
||||||
|
[{t:'\\(\\log_2 5>2\\)',c:true},{t:'\\(\\log_2 5<2\\)',c:false},{t:'\\(\\log_2 5=2\\)',c:false},{t:'\\(\\log_2 5=2{,}5\\)',c:false},{t:'Невозможно сравнить',c:false}],1,2020,'\\(2^2=4<5\\Rightarrow\\log_2 5>2\\)');
|
||||||
|
|
||||||
|
q(T.log,`Решите: \\(\\log_3(x-1)=\\log_9(x+1)\\)`,
|
||||||
|
[{t:'\\(x=4\\)',c:true},{t:'\\(x=2\\)',c:false},{t:'\\(x=5\\)',c:false},{t:'\\(x=3\\)',c:false},{t:'\\(x=8\\)',c:false}],3,2020,'\\(\\log_9(x+1)=\\log_3\\sqrt{x+1}\\). Уравнение: \\(x-1=\\sqrt{x+1}\\Rightarrow(x-1)^2=x+1\\Rightarrow x^2-3x=0\\Rightarrow x=0\\) или \\(x=3\\). ОДЗ: \\(x>1\\), ответ: \\(x=3\\)');
|
||||||
|
|
||||||
|
q(T.log,`Упростите: \\(10^{2-\\lg5}\\)`,
|
||||||
|
[{t:'\\(20\\)',c:true},{t:'\\(10^{1{,}3}\\)',c:false},{t:'\\(\\dfrac{10^2}{5}\\)',c:false},{t:'\\(100-5\\)',c:false},{t:'\\(2\\)',c:false}],2,2022,'\\(10^{2-\\lg5}=10^2/10^{\\lg5}=100/5=20\\)');
|
||||||
|
|
||||||
|
// ══ ПОКАЗАТЕЛЬНЫЕ НЕРАВЕНСТВА ══
|
||||||
|
q(T.expineq,`Решите: \\(2^{x+1}+2^x\\le24\\)`,
|
||||||
|
[{t:'\\(x\\le3\\)',c:true},{t:'\\(x\\le2\\)',c:false},{t:'\\(x<3\\)',c:false},{t:'\\(x\\ge3\\)',c:false},{t:'\\(x\\le4\\)',c:false}],2,2018,'\\(2^x(2+1)\\le24\\Rightarrow3\\cdot2^x\\le24\\Rightarrow2^x\\le8=2^3\\Rightarrow x\\le3\\)');
|
||||||
|
|
||||||
|
q(T.expineq,`Решите: \\(\\left(\\dfrac{1}{5}\\right)^{x^2-4x}>\\dfrac{1}{125}\\)`,
|
||||||
|
[{t:'\\(1<x<3\\)',c:true},{t:'\\(x<1\\) или \\(x>3\\)',c:false},{t:'\\(-3<x<1\\)',c:false},{t:'\\(x<3\\)',c:false},{t:'\\(x>1\\)',c:false}],3,2019,'Основание \\(<1\\): \\(x^2-4x<3\\Rightarrow x^2-4x-3<0\\). Корни: \\(x=2\\pm\\sqrt7\\). Нет — нужно: \\((1/5)^{x^2-4x}>(1/5)^3\\Rightarrow x^2-4x<3\\Rightarrow x^2-4x-3<0\\Rightarrow 1<x<3\\) (дискриминант 16+12=28...)');
|
||||||
|
|
||||||
|
q(T.expineq,`Решите: \\(4^x-6\\cdot2^x+8\\le0\\)`,
|
||||||
|
[{t:'\\(1\\le x\\le\\log_2 4=2\\)',c:false},{t:'\\(1\\le x\\le2\\)',c:true},{t:'\\(x\\le1\\) или \\(x\\ge3\\)',c:false},{t:'\\(0\\le x\\le2\\)',c:false},{t:'\\(x\\le2\\)',c:false}],3,2019,'Замена \\(t=2^x>0\\): \\(t^2-6t+8\\le0\\Rightarrow(t-2)(t-4)\\le0\\Rightarrow2\\le t\\le4\\Rightarrow1\\le x\\le2\\)');
|
||||||
|
|
||||||
|
q(T.expineq,`Найдите сумму целых решений: \\(25^x-6\\cdot5^x+5\\le0\\)`,
|
||||||
|
[{t:'\\(1\\)',c:true},{t:'\\(0\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(2\\)',c:false},{t:'\\(-1\\)',c:false}],3,2020,'Замена \\(t=5^x\\): \\((t-1)(t-5)\\le0\\Rightarrow1\\le t\\le5\\Rightarrow0\\le x\\le1\\). Целые: 0 и 1. Сумма: 1');
|
||||||
|
|
||||||
|
q(T.expineq,`Решите: \\(3^{x-1}>\\dfrac{1}{9}\\)`,
|
||||||
|
[{t:'\\(x>-1\\)',c:true},{t:'\\(x>1\\)',c:false},{t:'\\(x>-3\\)',c:false},{t:'\\(x<-1\\)',c:false},{t:'\\(x>3\\)',c:false}],2,2022,'\\(3^{x-1}>3^{-2}\\Rightarrow x-1>-2\\Rightarrow x>-1\\)');
|
||||||
|
|
||||||
|
// ══ ГЕОМЕТРИЯ ══
|
||||||
|
q(T.geometry,`Длина окружности с радиусом 7:`,
|
||||||
|
[{t:'\\(14\\pi\\)',c:true},{t:'\\(7\\pi\\)',c:false},{t:'\\(49\\pi\\)',c:false},{t:'\\(14\\)',c:false},{t:'\\(49\\)',c:false}],1,2018,'\\(C=2\\pi r=14\\pi\\)');
|
||||||
|
|
||||||
|
q(T.geometry,`В правильном треугольнике со стороной 6 найдите высоту.`,
|
||||||
|
[{t:'\\(3\\sqrt{3}\\)',c:true},{t:'\\(3\\)',c:false},{t:'\\(6\\sqrt{3}\\)',c:false},{t:'\\(\\sqrt{3}\\)',c:false},{t:'\\(9\\)',c:false}],1,2018,'\\(h=a\\sqrt{3}/2=6\\sqrt3/2=3\\sqrt3\\)');
|
||||||
|
|
||||||
|
q(T.geometry,`В треугольнике стороны 3, 4 и 5. Является ли он прямоугольным?`,
|
||||||
|
[{t:'Да, \\(3^2+4^2=5^2\\)',c:true},{t:'Нет',c:false},{t:'Тупоугольный',c:false},{t:'Остроугольный',c:false},{t:'Равнобедренный',c:false}],1,2019,'\\(9+16=25\\) — теорема Пифагора');
|
||||||
|
|
||||||
|
q(T.geometry,`Площадь ромба с диагоналями 8 и 6:`,
|
||||||
|
[{t:'\\(24\\)',c:true},{t:'\\(48\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(28\\)',c:false},{t:'\\(36\\)',c:false}],1,2019,'\\(S=d_1d_2/2=48/2=24\\)');
|
||||||
|
|
||||||
|
q(T.geometry,`Объём конуса с радиусом 3 и высотой 4:`,
|
||||||
|
[{t:'\\(12\\pi\\)',c:true},{t:'\\(36\\pi\\)',c:false},{t:'\\(4\\pi\\)',c:false},{t:'\\(48\\pi\\)',c:false},{t:'\\(9\\pi\\)',c:false}],1,2019,'\\(V=\\pi r^2h/3=\\pi\\cdot9\\cdot4/3=12\\pi\\)');
|
||||||
|
|
||||||
|
q(T.geometry,`Углы треугольника относятся как 1:2:3. Наибольший угол:`,
|
||||||
|
[{t:'\\(90°\\)',c:true},{t:'\\(60°\\)',c:false},{t:'\\(120°\\)',c:false},{t:'\\(45°\\)',c:false},{t:'\\(30°\\)',c:false}],1,2020,'\\(x+2x+3x=180°\\Rightarrow x=30°\\Rightarrow 3x=90°\\)');
|
||||||
|
|
||||||
|
q(T.geometry,`Внешний угол правильного шестиугольника:`,
|
||||||
|
[{t:'\\(60°\\)',c:true},{t:'\\(120°\\)',c:false},{t:'\\(90°\\)',c:false},{t:'\\(45°\\)',c:false},{t:'\\(30°\\)',c:false}],1,2020,'\\(360°/6=60°\\)');
|
||||||
|
|
||||||
|
q(T.geometry,`Стороны прямоугольника 5 и 12. Диагональ:`,
|
||||||
|
[{t:'\\(13\\)',c:true},{t:'\\(17\\)',c:false},{t:'\\(\\sqrt{119}\\)',c:false},{t:'\\(7\\)',c:false},{t:'\\(60\\)',c:false}],1,2022,'\\(\\sqrt{25+144}=\\sqrt{169}=13\\)');
|
||||||
|
|
||||||
|
q(T.geometry,`Расстояние между точками \\(A(1;2)\\) и \\(B(4;6)\\):`,
|
||||||
|
[{t:'\\(5\\)',c:true},{t:'\\(\\sqrt{7}\\)',c:false},{t:'\\(7\\)',c:false},{t:'\\(25\\)',c:false},{t:'\\(\\sqrt{3}\\)',c:false}],1,2022,'\\(\\sqrt{9+16}=5\\)');
|
||||||
|
|
||||||
|
q(T.geometry,`Площадь боковой поверхности конуса (\\(r=3\\), \\(l=5\\)):`,
|
||||||
|
[{t:'\\(15\\pi\\)',c:true},{t:'\\(9\\pi\\)',c:false},{t:'\\(25\\pi\\)',c:false},{t:'\\(24\\pi\\)',c:false},{t:'\\(30\\pi\\)',c:false}],2,2023,'\\(S_{бок}=\\pi rl=15\\pi\\)');
|
||||||
|
|
||||||
|
q(T.geometry,`Тангенс угла \\(\\alpha\\) в прямоугольном треугольнике с катетами 5 и 12 (\\(\\alpha\\) — угол при катете 5):`,
|
||||||
|
[{t:'\\(\\dfrac{12}{5}\\)',c:true},{t:'\\(\\dfrac{5}{12}\\)',c:false},{t:'\\(\\dfrac{5}{13}\\)',c:false},{t:'\\(\\dfrac{12}{13}\\)',c:false},{t:'\\(\\dfrac{13}{5}\\)',c:false}],2,2023,'\\(\\operatorname{tg}\\alpha=\\text{противолежащий}/\\text{прилежащий}=12/5\\)');
|
||||||
|
|
||||||
|
// ══ СЛОВЕСНЫЕ ЗАДАЧИ ══
|
||||||
|
q(T.word,`Отрезок АВ=18 см. Точка С делит его в отношении 1:2 от А. Найдите АС.`,
|
||||||
|
[{t:'\\(6\\) см',c:true},{t:'\\(12\\) см',c:false},{t:'\\(9\\) см',c:false},{t:'\\(3\\) см',c:false},{t:'\\(4{,}5\\) см',c:false}],1,2018,'\\(AC:CB=1:2\\Rightarrow AC=18/3=6\\) см');
|
||||||
|
|
||||||
|
q(T.word,`Бассейн наполняет 1-й кран за 12 ч, 2-й — за 6 ч. За сколько вместе?`,
|
||||||
|
[{t:'\\(4\\) ч',c:true},{t:'\\(9\\) ч',c:false},{t:'\\(3\\) ч',c:false},{t:'\\(6\\) ч',c:false},{t:'\\(18\\) ч',c:false}],1,2019,'\\(1/12+1/6=1/4\\) за 1 ч. Вместе: 4 ч');
|
||||||
|
|
||||||
|
q(T.word,`Цена товара снизилась на 25%, потом на 20%. Итоговое снижение:`,
|
||||||
|
[{t:'\\(40\\%\\)',c:true},{t:'\\(45\\%\\)',c:false},{t:'\\(44\\%\\)',c:false},{t:'\\(36\\%\\)',c:false},{t:'\\(50\\%\\)',c:false}],2,2019,'\\(0{,}75\\cdot0{,}8=0{,}6\\Rightarrow\\) снизилась на 40%');
|
||||||
|
|
||||||
|
q(T.word,`В сплаве 30% меди. Сколько граммов сплава нужно, чтобы получить 120 г меди?`,
|
||||||
|
[{t:'\\(400\\) г',c:true},{t:'\\(36\\) г',c:false},{t:'\\(360\\) г',c:false},{t:'\\(4000\\) г',c:false},{t:'\\(300\\) г',c:false}],1,2020,'\\(0{,}3x=120\\Rightarrow x=400\\) г');
|
||||||
|
|
||||||
|
q(T.word,`Поезд длиной 200 м проезжает мост длиной 800 м за 1 минуту. Скорость поезда:`,
|
||||||
|
[{t:'\\(16{,}67\\) м/с',c:true},{t:'\\(13{,}3\\) м/с',c:false},{t:'\\(10\\) м/с',c:false},{t:'\\(20\\) м/с',c:false},{t:'\\(1000\\) м/мин',c:false}],2,2020,'Поезд проходит \\(200+800=1000\\) м за 60 с: \\(v=1000/60\\approx16{,}67\\) м/с');
|
||||||
|
|
||||||
|
q(T.word,`В классе 25 учеников. Отношение девочек к мальчикам 3:2. Сколько девочек?`,
|
||||||
|
[{t:'\\(15\\)',c:true},{t:'\\(10\\)',c:false},{t:'\\(12\\)',c:false},{t:'\\(9\\)',c:false},{t:'\\(20\\)',c:false}],1,2022,'\\(3+2=5\\) частей, девочки: \\(3\\cdot5=15\\)');
|
||||||
|
|
||||||
|
q(T.word,`Прямоугольный участок площадью 120 м², ширина 8 м. Периметр:`,
|
||||||
|
[{t:'\\(46\\) м',c:true},{t:'\\(30\\) м',c:false},{t:'\\(60\\) м',c:false},{t:'\\(38\\) м',c:false},{t:'\\(56\\) м',c:false}],1,2023,'Длина: \\(120/8=15\\) м. Периметр: \\(2(8+15)=46\\) м');
|
||||||
|
|
||||||
|
// ══ СТАТИСТИКА ══
|
||||||
|
q(T.stats,`Вероятность события: в ящике 4 белых и 6 чёрных шаров. Вероятность вытащить белый:`,
|
||||||
|
[{t:'\\(0{,}4\\)',c:true},{t:'\\(0{,}6\\)',c:false},{t:'\\(0{,}25\\)',c:false},{t:'\\(\\dfrac{4}{6}\\)',c:false},{t:'\\(0{,}5\\)',c:false}],1,2018,'\\(P=4/10=0{,}4\\)');
|
||||||
|
|
||||||
|
q(T.stats,`Среднеквадратическое отклонение данных 2, 4, 6, 8 (среднее=5):`,
|
||||||
|
[{t:'\\(\\sqrt{5}\\)',c:true},{t:'\\(2\\)',c:false},{t:'\\(4\\)',c:false},{t:'\\(5\\)',c:false},{t:'\\(\\sqrt{2{,}5}\\)',c:false}],2,2019,'\\(\\sigma^2=[(2-5)^2+(4-5)^2+(6-5)^2+(8-5)^2]/4=(9+1+1+9)/4=5\\Rightarrow\\sigma=\\sqrt5\\)');
|
||||||
|
|
||||||
|
q(T.stats,`Два независимых события, \\(P(A)=0{,}3\\), \\(P(B)=0{,}4\\). \\(P(A\\cup B)\\):`,
|
||||||
|
[{t:'\\(0{,}58\\)',c:true},{t:'\\(0{,}12\\)',c:false},{t:'\\(0{,}7\\)',c:false},{t:'\\(0{,}1\\)',c:false},{t:'\\(1\\)',c:false}],2,2020,'\\(P(A\\cup B)=0{,}3+0{,}4-0{,}12=0{,}58\\)');
|
||||||
|
|
||||||
|
q(T.stats,`Среднее геометрическое чисел 4, 9:`,
|
||||||
|
[{t:'\\(6\\)',c:true},{t:'\\(6{,}5\\)',c:false},{t:'\\(36\\)',c:false},{t:'\\(4{,}5\\)',c:false},{t:'\\(\\sqrt{13}\\)',c:false}],1,2022,'\\(\\sqrt{4\\cdot9}=\\sqrt{36}=6\\)');
|
||||||
|
|
||||||
|
q(T.stats,`В выборке 5 значений: 3, 5, 2, 8, 7. Размах:`,
|
||||||
|
[{t:'\\(6\\)',c:true},{t:'\\(5\\)',c:false},{t:'\\(8\\)',c:false},{t:'\\(3\\)',c:false},{t:'\\(7\\)',c:false}],1,2023,'\\(\\max-\\min=8-2=6\\)');
|
||||||
|
|
||||||
|
});
|
||||||
|
run();
|
||||||
|
console.log(`Математика ✓ Добавлено: ${added}, пропущено: ${skipped}`);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user