diff --git a/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md b/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md index 77f147b..6984a83 100644 --- a/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md +++ b/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md @@ -3,14 +3,31 @@ - Краткое описание: минимальный UI-прототип для сабсервера на базе `LVGL + subserver touch`, с Wi-Fi flow, серверными адресами и общим экраном редактирования текста. - Что проверять: - стартует экран `HOME`; - - на `HOME` видны реальное значение сабсервера или `subserver not set`, реальное значение логина или `login not set`, при отсутствии секрета строка `secret not set`, а также `STATUS`, верхний правый блок с процентом батареи, иконкой батареи и индикатором Wi-Fi, кнопка `SETTINGS` и нижняя подпись `SHiNE subserver (v.0.18)`; + - на `HOME` видны реальное значение сабсервера или `subserver not set`, реальное значение логина или `login not set`, при отсутствии секрета строка `secret not set`, а также `STATUS`, верхний правый блок с процентом батареи, иконкой батареи и индикатором Wi-Fi, кнопка баланса, строка `SHiNE: ...`, кнопка `SETTINGS` уменьшенной ширины у правого края и нижняя подпись `SHiNE subserver (v.0.18)`; + - справа от строки логина виден индикатор статуса Solana-аккаунта: + - зелёный, если ключи совпали; + - красный, если mismatch; + - белый контур, если пользователь не найден; + - если статус не зелёный, рядом выводится краткое текстовое пояснение; - строка Wi-Fi на `HOME` корректно показывает одно из состояний: - `Wi-Fi (not configured) not configured` - `Wi-Fi () disconnected` - `Wi-Fi () connected` + - строка `SHiNE:` корректно показывает одно из состояний: + - `connected` + - `account not configured` + - `unavailable` - пока открыт `HOME`, статус сам обновляется без перехода на другие экраны; + - баланс обновляется кнопкой по нажатию; + - если логин зарегистрирован и секрет/сабсервер заданы, устройство: + - читает `user_pda` через Solana RPC; + - сверяет `root`, `blockchain`, `device` и `subserver` session type `100`; + - поднимает WebSocket-сессию с сервером SHiNE; + - шлёт `Ping` раз в минуту; - кнопка `SETTINGS` открывает `SETTINGS_MENU`; - свайп влево на `HOME` открывает `SETTINGS_MENU`; + - если пользователь не найден в Solana PDA, слева снизу появляется `REGISTER ACCOUNT`; + - `REGISTER ACCOUNT` открывает экран-заглушку; - в `SETTINGS_MENU` сначала видны только `Wi-Fi` и `Server`; - обе видимые карточки меню одного цвета; - свайп вверх показывает `Server` и `Account`; @@ -20,6 +37,9 @@ - `SELECT NETWORK` запускает скан; - после скана показывается список доступных SSID; - выбор SSID открывает общий экран редактирования текста для пароля; + - если для этого SSID пароль уже сохранялся раньше, он автоматически подставляется в редактор; + - если затем ввести пароль для другого SSID, пароль первой сети не теряется; + - одновременно хранится до `8` паролей для разных SSID; - на этом экране видно старое значение, курсор стоит в конце; - две верхние служебные строки над полем ввода отсутствуют; - при вводе пароля Wi-Fi текст показывается открыто, без точек; @@ -40,6 +60,7 @@ - медленный свайп по экрану не должен превращаться в случайное нажатие кнопки; - `ABC/123`, `SHIFT`, `DEL`, `SAVE`, `CANCEL` работают; - при успехе SSID и пароль сохраняются, а `HOME` показывает `Wi-Fi connected`; + - если после подключения ко второй сети снова выбрать первую, её старый пароль уже подставлен и достаточно нажать `SAVE`; - при ошибке показывается `Connection failed`; - `CLEAR SAVED WI-FI` очищает сохранённые настройки; - если сеть была ранее успешно сохранена, после потери связи устройство автоматически пытается переподключиться; @@ -60,8 +81,18 @@ - `Subserver` открывает промежуточный экран с `USE SUBSERVER1` и `EDIT MANUALLY`; - `USE SUBSERVER1` возвращает стандартное значение `subserver1`; - `EDIT MANUALLY` открывает общий экран редактирования и сохраняет значение в NVS; - - `Secret` открывает экран-заглушку, где сказано, что настройка ещё не реализована; - `Secret` теперь открывает меню секрета с показом секрета, ручным вводом и генерацией; + - в `SHOW SECRET` показывается прокручиваемый список всех ключей: + - `Secret (base58)` + - `Root key (base58)` + - `Root key priv (base58)` + - `Blockchain key (base58)` + - `Blockchain key priv (base58)` + - `Device key (base58)` + - `Device key priv (base58)` + - `Subserver key (base58)` + - `Subserver key priv (base58)` + - значения ключей показываются полными строками увеличенным шрифтом; - при смене `login` сохранённый секрет сбрасывается в `not set`; - во время генерации секрета есть `CANCEL` и подтверждение остановки; - при отмене генерации старый секрет, если он был, не должен теряться; @@ -69,4 +100,5 @@ - свайп вправо из `ACCOUNT_SUBSERVER_SCREEN` и `ACCOUNT_SECRET_SCREEN` возвращает в `ACCOUNT_SCREEN`; - если во время реального свайпа палец проходит по кнопке, это не должно открывать кнопку как обычный `click`. - Ожидаемый результат: новый скетч даёт чистый навигационный каркас и уже умеет настраивать Wi-Fi и серверные адреса на самой ESP32. +- Дополнительно ожидается: `HOME` уже показывает реальный Solana/WS-статус сабсервера, а отсутствие пользователя в Solana заметно сразу без перехода в настройки. - Статус: pending diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md index 9e1198f..5261000 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md @@ -7,19 +7,17 @@ Этот прототип проверяет базовую механику экранов, крупных кнопок, свайпов, первичную настройку Wi-Fi и настройку серверных адресов через общий экран редактирования текста. На этом этапе отсутствуют: -- логика серверной проверки доступности; - логин/пароль учётной записи SHiNE; - PIN; - кошелёк; - QR; -- баланс; - регистрация; -- PDA и транзакции; +- PDA update/create транзакции; - входящие запросы. ## Экраны -Прототип содержит 8 экранов: +Прототип содержит 10 экранов: - `HOME` - `SETTINGS_MENU` - `WIFI_SCREEN` @@ -27,13 +25,21 @@ - `ACCOUNT_SCREEN` - `ACCOUNT_SUBSERVER_SCREEN` - `ACCOUNT_SECRET_SCREEN` +- `SECRET_SHOW_SCREEN` +- `SECRET_GENERATE_*` - `TEXT_EDIT_SCREEN` +- `REGISTER_ACCOUNT_PLACEHOLDER` ## HOME Показывает: - сверху слева значение сабсервера или `subserver not set`; - ниже значение логина или `login not set`; +- справа от строки логина индикатор статуса Solana-аккаунта: + - зелёный — все ключи совпадают; + - красный — есть mismatch; + - белый контур — пользователь не найден в Solana PDA; +- рядом с индикатором краткий текст ошибки, если статус не зелёный; - третьей строкой `secret not set`, если секрет ещё не помечен как установленный; - сверху справа один ряд индикаторов: - процент батареи; @@ -41,7 +47,10 @@ - индикатор Wi-Fi уровня сигнала; - по центру крупный текст `STATUS`; - одна строка Wi-Fi вида `Wi-Fi () connected/disconnected`; -- снизу большую кнопку `SETTINGS`. +- кнопка баланса вида `Balance: ` или `Balance: failed to load`, по нажатию выполняет повторный запрос; +- строка `SHiNE: connected/account not configured/unavailable`; +- при отсутствии пользователя в Solana PDA слева снизу появляется кнопка `REGISTER ACCOUNT`; +- снизу кнопку `SETTINGS`, уменьшенную примерно до половины ширины экрана и сдвинутую к правому краю. - внизу на тёмной полосе подпись `SHiNE subserver (v.0.18)`. Строка Wi-Fi на `HOME`: @@ -50,9 +59,20 @@ - `Wi-Fi () connected` Переходы: +- кнопка `REGISTER ACCOUNT` -> `REGISTER_ACCOUNT_PLACEHOLDER`, только если пользователь не найден; - кнопка `SETTINGS` -> `SETTINGS_MENU`; - свайп влево -> `SETTINGS_MENU`. +Фоновая логика: +- пока открыт `HOME`, экран сам обновляется примерно раз в секунду; +- при наличии `login + secret + subserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC; +- сравниваются `root key`, `blockchain key`, `device key` и `subserver` session-запись типа `100`; +- для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE: + - авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`; + - session key = публичный `subserver key`; + - подтверждение создания сессии подписывается `device key`; + - heartbeat выполняется `Ping` раз в минуту. + ## SETTINGS_MENU Показывает вертикальное меню из 3 пунктов: @@ -90,7 +110,7 @@ Показывает: - текущий Wi-Fi статус; -- сохранённый SSID; +- сохранённый SSID и число известных сетей; - статусное сообщение; - кнопку `SELECT NETWORK`; - кнопку `CLEAR SAVED WI-FI`; @@ -109,6 +129,13 @@ Нажатие на SSID открывает `TEXT_EDIT_SCREEN` для ввода пароля. +Поведение: +- если для выбранного SSID пароль уже был сохранён раньше, он сразу подставляется в поле ввода; +- если пароль меняют для другого SSID, старые сохранённые пароли других сетей не теряются; +- после успешного подключения выбранная сеть становится текущей `wifi_ssid/wifi_pass`; +- одновременно хранится до `8` известных сетей `SSID -> password`; +- `CLEAR SAVED WI-FI` очищает все сохранённые сети и текущую сеть. + Переходы: - свайп вправо из любого режима `WIFI_SCREEN` -> `SETTINGS_MENU` - кнопка `BACK` -> `SETTINGS_MENU` @@ -162,16 +189,34 @@ ## ACCOUNT_SECRET_SCREEN -Пока это заглушка. - Показывает: -- текущий статус секрета `set/not set`; -- сообщение, что настройка секрета пока не реализована; -- кнопку `BACK`. +- кнопку `SHOW SECRET` или серую `SECRET NOT SET`, если секрета ещё нет; +- кнопку `ENTER SECRET MANUALLY (NOT RECOMMENDED)`; +- кнопку `GENERATE SECRET`. Переходы: - свайп вправо -> `ACCOUNT_SCREEN` -- `BACK` -> `ACCOUNT_SCREEN` +- `SHOW SECRET` -> экран просмотра секрета +- `ENTER SECRET MANUALLY (NOT RECOMMENDED)` -> `TEXT_EDIT_SCREEN` +- `GENERATE SECRET` -> экран подтверждения генерации + +## SECRET_SHOW_SCREEN + +Показывает: +- заголовок `SECRET`; +- вертикально прокручиваемый список ключей; +- `Secret (base58)`; +- `Root key (base58)`; +- `Root key priv (base58)`; +- `Blockchain key (base58)`; +- `Blockchain key priv (base58)`; +- `Device key (base58)`; +- `Device key priv (base58)`; +- `Subserver key (base58)`; +- `Subserver key priv (base58)`; +- для каждого поля показывается формула derivation; +- значения ключей показываются полными строками увеличенным шрифтом; +- кнопку `BACK`. ## TEXT_EDIT_SCREEN @@ -191,6 +236,15 @@ - кнопки `SAVE`, `CANCEL`, `DEL`, `CLR`; - большую экранную клавиатуру. +## REGISTER_ACCOUNT_PLACEHOLDER + +Временный экран-заглушка регистрации. + +Показывает: +- заголовок `REGISTER ACCOUNT`; +- сообщение, что регистрационный flow пока не реализован; +- кнопку `BACK`. + ## Клавиатура Клавиатура единая для всех текстовых вводов. @@ -211,8 +265,13 @@ - `wifi_ssid` - `wifi_pass` - `wifi_known_good` +- до `8` сохранённых пар `SSID -> password` -При старте устройства, если сохранён SSID, выполняется попытка подключения к сохранённой сети. +При старте устройства, если сохранён SSID, выполняется попытка подключения к текущей сохранённой сети. + +Если пользователь уже вводил пароль для сети раньше: +- при повторном выборе этого SSID старый пароль сразу подставляется в editor screen; +- сохранение пароля для другой сети не удаляет уже сохранённые пароли остальных сетей. Если сеть раньше уже была успешно подключена и помечена как валидная: - после потери связи устройство автоматически пытается переподключиться; diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md index f5126ca..9ce964a 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md @@ -1,6 +1,10 @@ # Test Device Скрипт заливает официальные Arduino-примеры для быстрой проверки платы. +`burn.sh` теперь: +- сам пытается найти USB-порт ESP32; +- сначала делает быструю инкрементальную сборку; +- если быстрая сборка не удалась, автоматически повторяет полную `clean`-сборку. Для режимов `widgets`, `audio` и `hello` рядом должен лежать локальный checkout `official-demo/` из официального репозитория Waveshare. В основной git он не добавляется, потому что это большой внешний набор примеров, библиотек, прошивок и артефактов. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh index 8ea2156..4783e44 100755 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh @@ -5,11 +5,29 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BOARD_DIR="$(cd "${ROOT_DIR}/.." && pwd)" DEMO_BASE="${BOARD_DIR}/official-demo/examples/Arduino-v3.3.5" MODE="${1:-widgets}" -PORT="${PORT:-/dev/ttyACM0}" +PORT="${PORT:-}" FQBN="${FQBN:-esp32:esp32:esp32s3:USBMode=hwcdc,CDCOnBoot=cdc,UploadSpeed=921600,CPUFreq=240,FlashMode=dio,FlashSize=16M,PartitionScheme=app3M_fat9M_16MB,PSRAM=opi}" BUILD_DIR="${BUILD_DIR:-${ROOT_DIR}/.arduino-build/build-${MODE}}" OUT_DIR="${OUT_DIR:-${ROOT_DIR}/.arduino-build/out-${MODE}}" +detect_port() { + local detected + detected="$(arduino-cli board list 2>/dev/null | awk '/\/dev\/tty(ACM|USB)/ {print $1; exit}')" + if [[ -n "${detected}" ]]; then + echo "${detected}" + return 0 + fi + + for candidate in /dev/ttyACM* /dev/ttyUSB*; do + if [[ -e "${candidate}" ]]; then + echo "${candidate}" + return 0 + fi + done + + return 1 +} + case "${MODE}" in hello) SKETCH_DIR="${DEMO_BASE}/examples/01_HelloWorld" ;; widgets) SKETCH_DIR="${DEMO_BASE}/examples/05_LVGL_Widgets" ;; @@ -34,6 +52,13 @@ case "${MODE}" in ;; esac +if [[ -z "${PORT}" ]]; then + if ! PORT="$(detect_port)"; then + echo "Failed to auto-detect ESP32 port. Set PORT=/dev/ttyACM0 ./burn.sh ${MODE}" >&2 + exit 3 + fi +fi + echo "== Mode: ${MODE}" echo "== Sketch: ${SKETCH_DIR}" echo "== Port: ${PORT}" @@ -41,17 +66,23 @@ echo "== FQBN: ${FQBN}" mkdir -p "${BUILD_DIR}" "${OUT_DIR}" -arduino-cli compile \ - --clean \ - --fqbn "${FQBN}" \ - --build-path "${BUILD_DIR}" \ - --output-dir "${OUT_DIR}" \ - --library "${DEMO_BASE}/libraries/GFX_Library_for_Arduino" \ - --library "${DEMO_BASE}/libraries/SensorLib" \ - --library "${DEMO_BASE}/libraries/XPowersLib" \ - --library "${DEMO_BASE}/libraries/lvgl" \ - --library "${DEMO_BASE}/libraries/Mylibrary" \ +compile_args=( + --fqbn "${FQBN}" + --build-path "${BUILD_DIR}" + --output-dir "${OUT_DIR}" + --library "${DEMO_BASE}/libraries/GFX_Library_for_Arduino" + --library "${DEMO_BASE}/libraries/SensorLib" + --library "${DEMO_BASE}/libraries/XPowersLib" + --library "${DEMO_BASE}/libraries/lvgl" + --library "${DEMO_BASE}/libraries/Mylibrary" "${SKETCH_DIR}" +) + +echo "== Compile: fast incremental build" +if ! arduino-cli compile "${compile_args[@]}"; then + echo "== Compile: fast build failed, retrying clean build" + arduino-cli compile --clean "${compile_args[@]}" +fi arduino-cli upload \ -p "${PORT}" \ diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino index 4e30667..1640db1 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino @@ -1,10 +1,17 @@ #include #include #include +#include +#include #include #include #include #include +#include +#include +#include +#include +#include #define XPOWERS_CHIP_AXP2101 #include "XPowersLib.h" #include "shine_secret_generation.h" @@ -27,16 +34,34 @@ #define SWIPE_THRESHOLD 48 #define TAP_CANCEL_THRESHOLD 18 #define MAX_SCAN_RESULTS 8 +#define MAX_SAVED_WIFI_NETWORKS 8 #define WIFI_CONNECT_TIMEOUT_MS 12000 #define WIFI_RECONNECT_FAST_MS 10000 #define WIFI_RECONNECT_SLOW_MS 30000 #define WIFI_RECONNECT_SLOW_AFTER_MS 90000 +#define HOME_REFRESH_MS 1000 +#define ACCOUNT_CHECK_RETRY_MS 15000 +#define SHINE_PING_INTERVAL_MS 60000 +#define SHINE_RECONNECT_MS 10000 +#define SHINE_RPC_TIMEOUT_MS 9000 #define TEXT_EDIT_PANEL_X 10 #define TEXT_EDIT_PANEL_Y 112 #define TEXT_EDIT_PANEL_W 460 #define TEXT_EDIT_PANEL_H 330 #define TEST_VERSION "SHiNE subserver (v.0.18)" +static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"; +static const char *kShineUsersUserPdaSeedPrefix = "user_login="; +static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress"; +static const uint8_t kBlockTypeRootKey = 1; +static const uint8_t kBlockTypeDeviceKey = 2; +static const uint8_t kBlockTypeBlockchainRegistry = 3; +static const uint8_t kBlockTypeServerProfile = 30; +static const uint8_t kBlockTypeAccessServers = 40; +static const uint8_t kBlockTypeSessions = 50; +static const uint8_t kBlockTypeTrustedState = 70; +static const uint8_t kSessionTypeSubserver = 100; + enum Screen { SCREEN_HOME, SCREEN_SETTINGS_MENU, @@ -50,6 +75,7 @@ enum Screen { SCREEN_SECRET_GENERATE_RUNNING, SCREEN_SECRET_GENERATE_CANCEL_CONFIRM, SCREEN_TEXT_EDIT, + SCREEN_REGISTER_ACCOUNT_PLACEHOLDER, }; enum SwipeDirection { @@ -86,6 +112,8 @@ enum ActionId { ACTION_SECRET_GENERATE_CANCEL_NO, ACTION_BACK_SECRET_MENU, ACTION_BACK_ACCOUNT, + ACTION_REFRESH_BALANCE, + ACTION_REGISTER_ACCOUNT, ACTION_EDITOR_SAVE, ACTION_EDITOR_CANCEL, }; @@ -111,6 +139,39 @@ enum KeyboardMode { KEYBOARD_MODE_SYMBOLS, }; +enum AccountPdaStatus { + ACCOUNT_PDA_UNKNOWN, + ACCOUNT_PDA_OK, + ACCOUNT_PDA_NOT_FOUND, + ACCOUNT_PDA_MISMATCH, +}; + +struct ShinePdaSessionRecord { + uint8_t sessionType = 0; + uint8_t sessionVersion = 0; + String sessionName; + uint8_t sessionPubKey32[32] = {}; +}; + +struct ShinePdaUserState { + bool found = false; + String login; + bool isServer = false; + String serverAddress; + uint8_t rootKey32[32] = {}; + uint8_t deviceKey32[32] = {}; + uint8_t blockchainKey32[32] = {}; + std::vector sessions; +}; + +struct SimpleWebSocketClient { + WiFiClientSecure client; + bool connected = false; + String host; + String path; + uint16_t port = 0; +}; + static const char *kMenuItems[] = {"Wi-Fi", "Server", "Account"}; static const size_t kMenuCount = sizeof(kMenuItems) / sizeof(kMenuItems[0]); @@ -144,6 +205,8 @@ static uint32_t gLastHandledTouchSequence = 0; static String gWifiSavedSsid; static String gWifiSavedPassword; static String gWifiSelectedSsid; +static String gKnownWifiSsids[MAX_SAVED_WIFI_NETWORKS]; +static String gKnownWifiPasswords[MAX_SAVED_WIFI_NETWORKS]; static String gWifiStatusMessage = "No Wi-Fi configured"; static String gScanResults[MAX_SCAN_RESULTS]; static int gScanResultCount = 0; @@ -158,6 +221,7 @@ static bool gSecretConfigured = false; static String gSecretBase58; static uint8_t gSecretBytes[32] = {}; static String gAccountStatusMessage = "Edit account fields"; +static String gBalanceStatusMessage = "Balance: tap to load"; static bool gWifiKnownGood = false; static bool gWifiReconnectEnabled = false; static bool gWifiOperationBusy = false; @@ -165,6 +229,36 @@ static unsigned long gWifiDisconnectedSinceMs = 0; static unsigned long gWifiLastReconnectAttemptMs = 0; static wl_status_t gLastWifiStatus = WL_IDLE_STATUS; static bool gPowerReady = false; +static AccountPdaStatus gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; +static String gAccountPdaStatusMessage = "Account not checked"; +static bool gAccountCheckPending = true; +static unsigned long gLastAccountCheckMs = 0; +static bool gShowRegisterAccountButton = false; +static String gShineStatusLine = "SHiNE: account not configured"; +static String gShineSessionId; +static String gShineSessionKey; +static String gShineStoragePwd; +static bool gShineAuthenticated = false; +static unsigned long gLastShineAttemptMs = 0; +static unsigned long gLastShinePingMs = 0; +static uint32_t gWsRequestCounter = 1; +static int64_t gShineServerTimeOffsetMs = 0; +static SimpleWebSocketClient gShineWs; + +struct DerivedKeyInfo { + String title; + String formula; + String value; +}; + +static String gRootPubB58; +static String gRootPrivB58; +static String gBlockchainPubB58; +static String gBlockchainPrivB58; +static String gDevicePubB58; +static String gDevicePrivB58; +static String gSubserverPubB58; +static String gSubserverPrivB58; static EditContext gEditContext = EDIT_CONTEXT_NONE; static Screen gEditReturnScreen = SCREEN_HOME; @@ -182,6 +276,11 @@ static void handleSwipe(SwipeDirection swipe); static void loadPrefs(); static void saveWifiPrefs(); static void clearWifiPrefs(); +static void clearSavedWifiList(); +static int findKnownWifiIndex(const String &ssid); +static String savedPasswordForSsid(const String &ssid); +static void upsertKnownWifi(const String &ssid, const String &password); +static bool refreshWalletBalance(String &messageOut); static void saveServerPrefs(); static void saveAccountPrefs(); static void beginSavedWifi(); @@ -203,6 +302,8 @@ static bool isTextEditKeyboardSwipeArea(int16_t x, int16_t y); static void syncEditValueFromTextarea(); static void keepCursorAtEnd(); static void restoreTextareaFromEditValue(); +static void refreshDerivedKeys(); +static void clearDerivedKeys(); static String loginDisplayValue(); static String subserverDisplayValue(); static String homeSecretStatus(); @@ -213,6 +314,36 @@ static void initPowerManagement(); static int batteryPercentValue(); static int wifiSignalLevel(); static void drawTopStatusIndicators(); +static void markAccountStateDirty(); +static void clearShineSessionState(bool clearStoredSession); +static void saveShineSessionPrefs(); +static String normalizeLoginValue(const String &value); +static bool base58ToFixed32(const String &value, uint8_t out[32]); +static bool base64DecodeStd(const String &value, std::vector &out); +static String bytesToBase64String(const uint8_t *data, size_t len); +static String jsonEscape(const String &value); +static bool jsonStringField(const String &json, const String &field, String &valueOut); +static bool jsonBoolField(const String &json, const String &field, bool &valueOut); +static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut); +static String shineWsUrl(); +static String shineHomeLine(); +static String balanceHomeLine(); +static uint64_t shineNowMs(); +static String buildSessionKeyStringFromPublicBase64(const String &pubB64); +static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); +static bool deriveSeedKeypairFromBase58(const String &seedB58, uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); +static bool findProgramAddress(const std::vector> &seeds, const char *programIdB58, uint8_t out32[32]); +static bool readShineUserPda(const String &login, ShinePdaUserState &outState, String &errorOut); +static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUserState &outState, String &errorOut); +static void refreshAccountPdaStatus(); +static void manageAccountPdaRefresh(); +static bool ensureWebSocketConnected(SimpleWebSocketClient &ws, const String &url, String &errorOut); +static void closeWebSocket(SimpleWebSocketClient &ws); +static bool wsSendText(SimpleWebSocketClient &ws, const String &payload); +static bool wsReadTextFrame(SimpleWebSocketClient &ws, String &messageOut, uint32_t timeoutMs); +static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const String &payloadJson, String &responseOut, uint32_t timeoutMs = SHINE_RPC_TIMEOUT_MS); +static bool ensureShineSessionAuthenticated(String &errorOut); +static void manageShineConnection(); static void lvglFlushCb(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *colorP) { uint32_t w = area->x2 - area->x1 + 1; @@ -334,10 +465,1340 @@ static void showMessageAt(const String &text, lv_coord_t y) { lv_obj_align(label, LV_ALIGN_TOP_MID, 0, y); } +static void clearSavedWifiList() { + for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) { + gKnownWifiSsids[i] = ""; + gKnownWifiPasswords[i] = ""; + } +} + +static int findKnownWifiIndex(const String &ssid) { + if (ssid.isEmpty()) { + return -1; + } + for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) { + if (gKnownWifiSsids[i] == ssid) { + return i; + } + } + return -1; +} + +static String savedPasswordForSsid(const String &ssid) { + int index = findKnownWifiIndex(ssid); + return index >= 0 ? gKnownWifiPasswords[index] : ""; +} + +static String splitFixedWidth(const String &value, size_t chunkSize) { + if (chunkSize == 0 || value.isEmpty()) { + return value; + } + + String out; + for (size_t i = 0; i < value.length(); ++i) { + if (i > 0 && (i % chunkSize) == 0) { + out += '\n'; + } + out += value.charAt(i); + } + return out; +} + +static void sha256calc(const uint8_t *in, size_t len, uint8_t *out32) { + mbedtls_sha256_context ctx; + mbedtls_sha256_init(&ctx); + mbedtls_sha256_starts(&ctx, 0); + mbedtls_sha256_update(&ctx, in, len); + mbedtls_sha256_finish(&ctx, out32); + mbedtls_sha256_free(&ctx); +} + +static String base64Std(const uint8_t *data, size_t len) { + char out[96] = {}; + size_t outLen = 0; + if (mbedtls_base64_encode(reinterpret_cast(out), sizeof(out), &outLen, data, len) != 0) { + return ""; + } + out[min(outLen, sizeof(out) - 1)] = '\0'; + return String(out); +} + +static String base58From32(const uint8_t *data32) { + char out[64] = {}; + shineSecretBase58Encode(data32, 32, out, sizeof(out)); + return String(out); +} + +static bool base58ToFixed32(const String &value, uint8_t out[32]) { + size_t outLen = 32; + String error; + if (!shineSecretBase58Decode(value.c_str(), out, &outLen, 32, error)) { + return false; + } + return outLen == 32; +} + +static bool base64DecodeStd(const String &value, std::vector &out) { + out.clear(); + String normalized = value; + normalized.trim(); + if (normalized.isEmpty()) { + return false; + } + size_t maxLen = ((normalized.length() + 3) / 4) * 3 + 4; + out.resize(maxLen); + size_t outLen = 0; + if (mbedtls_base64_decode(out.data(), out.size(), &outLen, + reinterpret_cast(normalized.c_str()), + normalized.length()) != 0) { + out.clear(); + return false; + } + out.resize(outLen); + return true; +} + +static String bytesToBase64String(const uint8_t *data, size_t len) { + return base64Std(data, len); +} + +static String normalizeLoginValue(const String &value) { + String out = value; + out.trim(); + out.toLowerCase(); + return out; +} + +static String jsonEscape(const String &value) { + String out; + out.reserve(value.length() + 8); + for (size_t i = 0; i < value.length(); ++i) { + char ch = value.charAt(i); + switch (ch) { + case '\\': out += "\\\\"; break; + case '"': out += "\\\""; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: out += ch; break; + } + } + return out; +} + +static bool jsonStringField(const String &json, const String &field, String &valueOut) { + String needle = "\"" + field + "\""; + int keyPos = json.indexOf(needle); + if (keyPos < 0) { + return false; + } + int colon = json.indexOf(':', keyPos + needle.length()); + if (colon < 0) { + return false; + } + int quote = json.indexOf('"', colon + 1); + if (quote < 0) { + return false; + } + String out; + bool escape = false; + for (int i = quote + 1; i < (int)json.length(); ++i) { + char ch = json.charAt(i); + if (escape) { + switch (ch) { + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + default: out += ch; break; + } + escape = false; + continue; + } + if (ch == '\\') { + escape = true; + continue; + } + if (ch == '"') { + valueOut = out; + return true; + } + out += ch; + } + return false; +} + +static bool jsonBoolField(const String &json, const String &field, bool &valueOut) { + String needle = "\"" + field + "\""; + int keyPos = json.indexOf(needle); + if (keyPos < 0) { + return false; + } + int colon = json.indexOf(':', keyPos + needle.length()); + if (colon < 0) { + return false; + } + int pos = colon + 1; + while (pos < (int)json.length() && isspace((unsigned char)json[pos])) { + pos++; + } + if (json.startsWith("true", pos)) { + valueOut = true; + return true; + } + if (json.startsWith("false", pos)) { + valueOut = false; + return true; + } + return false; +} + +static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut) { + hostOut = ""; + pathOut = "/"; + portOut = 0; + secureOut = false; + + int schemeEnd = url.indexOf("://"); + if (schemeEnd <= 0) { + return false; + } + String scheme = url.substring(0, schemeEnd); + secureOut = (scheme == "https" || scheme == "wss"); + int hostStart = schemeEnd + 3; + int slash = url.indexOf('/', hostStart); + String hostPort = slash >= 0 ? url.substring(hostStart, slash) : url.substring(hostStart); + pathOut = slash >= 0 ? url.substring(slash) : "/"; + int atPos = hostPort.lastIndexOf('@'); + if (atPos >= 0) { + hostPort = hostPort.substring(atPos + 1); + } + int colon = hostPort.lastIndexOf(':'); + if (colon > 0 && hostPort.indexOf(']') < 0) { + hostOut = hostPort.substring(0, colon); + portOut = (uint16_t)hostPort.substring(colon + 1).toInt(); + } else { + hostOut = hostPort; + portOut = secureOut ? 443 : 80; + } + hostOut.trim(); + if (pathOut.isEmpty()) { + pathOut = "/"; + } + return hostOut.length() > 0 && portOut > 0; +} + +static String subserverKeySuffix() { + String name = gSubserverValue; + name.trim(); + if (name.isEmpty()) { + name = "subserver1"; + } + return String("subserver.key:") + name; +} + +static void deriveKeyPairFromSecretSuffix(const uint8_t *secret32, const String &suffix, String &pubB58, String &privB58) { + pubB58 = ""; + privB58 = ""; + if (!secret32) { + return; + } + String material = base64Std(secret32, 32) + "|" + suffix; + uint8_t seed[32] = {}; + uint8_t pub[32] = {}; + sha256calc(reinterpret_cast(material.c_str()), material.length(), seed); + Ed25519::derivePublicKey(pub, seed); + privB58 = base58From32(seed); + pubB58 = base58From32(pub); +} + +static void clearDerivedKeys() { + gRootPubB58 = ""; + gRootPrivB58 = ""; + gBlockchainPubB58 = ""; + gBlockchainPrivB58 = ""; + gDevicePubB58 = ""; + gDevicePrivB58 = ""; + gSubserverPubB58 = ""; + gSubserverPrivB58 = ""; +} + +static void refreshDerivedKeys() { + clearDerivedKeys(); + if (!gSecretConfigured) { + return; + } + deriveKeyPairFromSecretSuffix(gSecretBytes, "root.key", gRootPubB58, gRootPrivB58); + deriveKeyPairFromSecretSuffix(gSecretBytes, "bch.key", gBlockchainPubB58, gBlockchainPrivB58); + deriveKeyPairFromSecretSuffix(gSecretBytes, "dev.key", gDevicePubB58, gDevicePrivB58); + deriveKeyPairFromSecretSuffix(gSecretBytes, subserverKeySuffix(), gSubserverPubB58, gSubserverPrivB58); +} + +static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) { + return crypto_sign_seed_keypair(pub32, sec64, seed32) == 0; +} + +static bool deriveSeedKeypairFromBase58(const String &seedB58, uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) { + if (!base58ToFixed32(seedB58, seed32)) { + return false; + } + return deriveKeypairFromSeed32(seed32, pub32, sec64); +} + +static void saveShineSessionPrefs() { + if (gShineSessionId.isEmpty() || gShineSessionKey.isEmpty()) { + gPrefs.remove("shine_sess_id"); + gPrefs.remove("shine_sess_key"); + gPrefs.remove("shine_store_pwd"); + return; + } + gPrefs.putString("shine_sess_id", gShineSessionId); + gPrefs.putString("shine_sess_key", gShineSessionKey); + gPrefs.putString("shine_store_pwd", gShineStoragePwd); +} + +static void closeWebSocket(SimpleWebSocketClient &ws) { + if (ws.client.connected()) { + ws.client.stop(); + } + ws.connected = false; +} + +static void clearShineSessionState(bool clearStoredSession) { + closeWebSocket(gShineWs); + gShineAuthenticated = false; + gLastShinePingMs = 0; + if (clearStoredSession) { + gShineSessionId = ""; + gShineSessionKey = ""; + gShineStoragePwd = ""; + saveShineSessionPrefs(); + } +} + +static void markAccountStateDirty() { + gAccountCheckPending = true; + gLastAccountCheckMs = 0; + gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; + gAccountPdaStatusMessage = "Account not checked"; + gShowRegisterAccountButton = false; + clearShineSessionState(true); + gShineStatusLine = "SHiNE: account not configured"; +} + +static bool findProgramAddress(const std::vector> &seeds, const char *programIdB58, uint8_t out32[32]) { + uint8_t programId[32] = {}; + if (!base58ToFixed32(String(programIdB58), programId)) { + return false; + } + for (int bump = 255; bump >= 0; --bump) { + crypto_hash_sha256_state st; + crypto_hash_sha256_init(&st); + for (const auto &seed : seeds) { + crypto_hash_sha256_update(&st, seed.data(), seed.size()); + } + uint8_t bumpByte = (uint8_t)bump; + crypto_hash_sha256_update(&st, &bumpByte, 1); + crypto_hash_sha256_update(&st, programId, 32); + crypto_hash_sha256_update(&st, + reinterpret_cast(kProgramDerivedAddressMarker), + strlen(kProgramDerivedAddressMarker)); + crypto_hash_sha256_final(&st, out32); + if (crypto_core_ed25519_is_valid_point(out32) == 0) { + return true; + } + } + return false; +} + +static bool isHttpUrl(const String &url) { + return url.startsWith("http://") || url.startsWith("https://"); +} + +static bool httpPostJson(const String &url, const String &body, int &statusCode, String &payload) { + statusCode = -1; + payload = ""; + if (!isHttpUrl(url)) { + return false; + } + + HTTPClient http; + WiFiClientSecure secureClient; + WiFiClient plainClient; + bool secure = url.startsWith("https://"); + if (secure) { + secureClient.setInsecure(); + if (!http.begin(secureClient, url)) { + return false; + } + } else { + if (!http.begin(plainClient, url)) { + return false; + } + } + + http.setTimeout(7000); + http.addHeader("Content-Type", "application/json"); + statusCode = http.POST(body); + if (statusCode > 0) { + payload = http.getString(); + } + http.end(); + return statusCode > 0; +} + +static bool jsonInt64Field(const String &json, const String &field, uint64_t &valueOut) { + String needle = "\"" + field + "\""; + int keyPos = json.indexOf(needle); + if (keyPos < 0) { + return false; + } + int colon = json.indexOf(':', keyPos + needle.length()); + if (colon < 0) { + return false; + } + int pos = colon + 1; + while (pos < (int)json.length() && (json[pos] == ' ' || json[pos] == '\n' || json[pos] == '\r' || json[pos] == '\t')) { + pos++; + } + int start = pos; + while (pos < (int)json.length() && isDigit((unsigned char)json[pos])) { + pos++; + } + if (pos == start) { + return false; + } + valueOut = strtoull(json.substring(start, pos).c_str(), nullptr, 10); + return true; +} + +static String formatSolValue(uint64_t lamports) { + uint64_t whole = lamports / 1000000000ULL; + uint64_t frac = (lamports % 1000000000ULL) / 1000000ULL; + char out[48]; + snprintf(out, sizeof(out), "Balance: %llu.%03llu SOL", + (unsigned long long)whole, + (unsigned long long)frac); + return String(out); +} + +static bool refreshWalletBalance(String &messageOut) { + messageOut = ""; + if (WiFi.status() != WL_CONNECTED) { + gBalanceStatusMessage = "Balance: Wi-Fi disconnected"; + messageOut = gBalanceStatusMessage; + return false; + } + if (gDevicePubB58.isEmpty()) { + gBalanceStatusMessage = "Balance: secret not set"; + messageOut = gBalanceStatusMessage; + return false; + } + + int code = -1; + String payload; + String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\",\"params\":[\"" + gDevicePubB58 + "\",{\"commitment\":\"confirmed\"}]}"; + if (!httpPostJson(gSolanaRpcUrl, req, code, payload)) { + gBalanceStatusMessage = "Balance: failed to load"; + messageOut = gBalanceStatusMessage; + return false; + } + + uint64_t lamports = 0; + if (!jsonInt64Field(payload, "value", lamports)) { + gBalanceStatusMessage = "Balance: failed to load"; + messageOut = gBalanceStatusMessage; + return false; + } + + gBalanceStatusMessage = formatSolValue(lamports); + messageOut = gBalanceStatusMessage; + return true; +} + +static String balanceHomeLine() { + return gBalanceStatusMessage; +} + +static String buildSessionKeyStringFromPublicBase64(const String &pubB64) { + return String("ed25519/") + pubB64; +} + +static String shineWsUrl() { + String raw = gShineServerUrl; + raw.trim(); + if (raw.isEmpty()) { + return ""; + } + if (raw.startsWith("ws://") || raw.startsWith("wss://")) { + return raw; + } + if (raw.startsWith("http://")) { + String tail = raw.substring(strlen("http://")); + if (tail.indexOf('/') < 0) { + tail += "/ws"; + } else if (tail.endsWith("/")) { + tail += "ws"; + } + return "ws://" + tail; + } + if (raw.startsWith("https://")) { + String tail = raw.substring(strlen("https://")); + if (tail.indexOf('/') < 0) { + tail += "/ws"; + } else if (tail.endsWith("/")) { + tail += "ws"; + } + return "wss://" + tail; + } + if (raw.indexOf('/') < 0) { + return "wss://" + raw + "/ws"; + } + return "wss://" + raw; +} + +static String shineHomeLine() { + return gShineStatusLine; +} + +static uint64_t shineNowMs() { + int64_t value = (int64_t)millis() + gShineServerTimeOffsetMs; + return value > 0 ? (uint64_t)value : (uint64_t)millis(); +} + +static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUserState &outState, String &errorOut) { + outState = ShinePdaUserState{}; + errorOut = ""; + if (bytes.size() < 64) { + errorOut = "PDA too short"; + return false; + } + + size_t offset = 0; + auto ensure = [&](size_t need) -> bool { + return offset + need <= bytes.size(); + }; + auto readU8 = [&](uint8_t &value) -> bool { + if (!ensure(1)) return false; + value = bytes[offset++]; + return true; + }; + auto readU16 = [&](uint16_t &value) -> bool { + if (!ensure(2)) return false; + value = (uint16_t)bytes[offset] | ((uint16_t)bytes[offset + 1] << 8); + offset += 2; + return true; + }; + auto readU32 = [&](uint32_t &value) -> bool { + if (!ensure(4)) return false; + value = (uint32_t)bytes[offset] + | ((uint32_t)bytes[offset + 1] << 8) + | ((uint32_t)bytes[offset + 2] << 16) + | ((uint32_t)bytes[offset + 3] << 24); + offset += 4; + return true; + }; + auto readU64 = [&](uint64_t &value) -> bool { + if (!ensure(8)) return false; + value = 0; + for (int i = 0; i < 8; ++i) { + value |= ((uint64_t)bytes[offset + i]) << (8 * i); + } + offset += 8; + return true; + }; + auto readBytes = [&](uint8_t *dst, size_t len) -> bool { + if (!ensure(len)) return false; + memcpy(dst, bytes.data() + offset, len); + offset += len; + return true; + }; + auto readStringU8 = [&](String &value) -> bool { + uint8_t len = 0; + if (!readU8(len) || !ensure(len)) return false; + value = ""; + value.reserve(len); + for (uint8_t i = 0; i < len; ++i) { + value += (char)bytes[offset + i]; + } + offset += len; + return true; + }; + + if (!ensure(5) || memcmp(bytes.data(), "SHiNE", 5) != 0) { + errorOut = "Bad PDA magic"; + return false; + } + offset += 5; + uint8_t version = 0; + uint8_t flags = 0; + uint16_t recordLen = 0; + uint64_t ignoreU64 = 0; + uint32_t ignoreU32 = 0; + if (!readU8(version) || !readU8(flags) || !readU16(recordLen)) { + errorOut = "Bad PDA header"; + return false; + } + if (recordLen < 73 || recordLen > bytes.size()) { + errorOut = "Bad PDA record len"; + return false; + } + offset = 9; + if (!readU64(ignoreU64) || !readU64(ignoreU64) || !readU32(ignoreU32)) { + errorOut = "Bad PDA fixed fields"; + return false; + } + offset += 32; // prev hash + if (!readStringU8(outState.login)) { + errorOut = "Bad PDA login"; + return false; + } + uint8_t blocksCount = 0; + if (!readU8(blocksCount)) { + errorOut = "Bad PDA blocks count"; + return false; + } + + for (uint8_t i = 0; i < blocksCount; ++i) { + uint8_t blockType = 0; + uint8_t blockVersion = 0; + if (!readU8(blockType) || !readU8(blockVersion)) { + errorOut = "Bad PDA block header"; + return false; + } + if (blockType == kBlockTypeRootKey) { + if (!readBytes(outState.rootKey32, 32)) { + errorOut = "Bad root key block"; + return false; + } + continue; + } + if (blockType == kBlockTypeDeviceKey) { + if (!readBytes(outState.deviceKey32, 32)) { + errorOut = "Bad device key block"; + return false; + } + continue; + } + if (blockType == kBlockTypeBlockchainRegistry) { + uint8_t count = 0; + if (!readU8(count)) { + errorOut = "Bad blockchain count"; + return false; + } + for (uint8_t j = 0; j < count; ++j) { + uint8_t blockchainType = 0; + String blockchainName; + uint8_t blockchainKey[32] = {}; + uint64_t paidLimit = 0; + uint64_t usedBytes = 0; + uint32_t lastBlockNumber = 0; + uint8_t lastBlockHash[32] = {}; + uint8_t lastBlockSig[64] = {}; + uint8_t arweavePresent = 0; + String arweaveTxId; + if (!readU8(blockchainType) + || !readStringU8(blockchainName) + || !readBytes(blockchainKey, 32) + || !readU64(paidLimit) + || !readU64(usedBytes) + || !readU32(lastBlockNumber) + || !readBytes(lastBlockHash, 32) + || !readBytes(lastBlockSig, 64) + || !readU8(arweavePresent)) { + errorOut = "Bad blockchain block"; + return false; + } + if (arweavePresent == 1 && !readStringU8(arweaveTxId)) { + errorOut = "Bad arweave field"; + return false; + } + if (j == 0) { + memcpy(outState.blockchainKey32, blockchainKey, 32); + } + } + continue; + } + if (blockType == kBlockTypeServerProfile) { + uint8_t isServer = 0; + if (!readU8(isServer)) { + errorOut = "Bad server profile"; + return false; + } + outState.isServer = isServer == 1; + if (outState.isServer) { + uint8_t addressFormatType = 0; + uint8_t addressFormatVersion = 0; + uint8_t syncCount = 0; + if (!readU8(addressFormatType) + || !readU8(addressFormatVersion) + || !readStringU8(outState.serverAddress) + || !readU8(syncCount)) { + errorOut = "Bad server address"; + return false; + } + for (uint8_t j = 0; j < syncCount; ++j) { + String ignoreSync; + if (!readStringU8(ignoreSync)) { + errorOut = "Bad sync_servers"; + return false; + } + } + } + continue; + } + if (blockType == kBlockTypeAccessServers) { + uint8_t accessCount = 0; + if (!readU8(accessCount)) { + errorOut = "Bad access servers"; + return false; + } + for (uint8_t j = 0; j < accessCount; ++j) { + String ignoreAccess; + if (!readStringU8(ignoreAccess)) { + errorOut = "Bad access server item"; + return false; + } + } + continue; + } + if (blockType == kBlockTypeSessions) { + uint8_t sessionsMode = 0; + uint8_t sessionsCount = 0; + if (!readU8(sessionsMode) || !readU8(sessionsCount)) { + errorOut = "Bad sessions block"; + return false; + } + outState.sessions.clear(); + for (uint8_t j = 0; j < sessionsCount; ++j) { + ShinePdaSessionRecord rec; + if (!readU8(rec.sessionType) + || !readU8(rec.sessionVersion) + || !readStringU8(rec.sessionName) + || !readBytes(rec.sessionPubKey32, 32)) { + errorOut = "Bad session item"; + return false; + } + outState.sessions.push_back(rec); + } + continue; + } + if (blockType == kBlockTypeTrustedState) { + uint8_t trustedCount = 0; + if (!readU8(trustedCount)) { + errorOut = "Bad trusted block"; + return false; + } + continue; + } + errorOut = "Unknown PDA block"; + return false; + } + + outState.found = true; + return true; +} + +static bool readShineUserPda(const String &login, ShinePdaUserState &outState, String &errorOut) { + outState = ShinePdaUserState{}; + errorOut = ""; + String cleanLogin = normalizeLoginValue(login); + if (cleanLogin.isEmpty()) { + errorOut = "Login not set"; + return false; + } + if (gSolanaRpcUrl.isEmpty()) { + errorOut = "Solana RPC not set"; + return false; + } + + uint8_t userPda[32] = {}; + std::vector> seeds = { + std::vector((const uint8_t *)kShineUsersUserPdaSeedPrefix, + (const uint8_t *)kShineUsersUserPdaSeedPrefix + strlen(kShineUsersUserPdaSeedPrefix)), + std::vector((const uint8_t *)cleanLogin.c_str(), + (const uint8_t *)cleanLogin.c_str() + cleanLogin.length()), + }; + if (!findProgramAddress(seeds, kShineUsersProgramId, userPda)) { + errorOut = "Cannot derive user PDA"; + return false; + } + + String pdaB58 = base58From32(userPda); + int code = -1; + String payload; + String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getAccountInfo\",\"params\":[\"" + pdaB58 + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]}"; + if (!httpPostJson(gSolanaRpcUrl, req, code, payload)) { + errorOut = "Solana RPC unavailable"; + return false; + } + if (payload.indexOf("\"value\":null") >= 0) { + outState.found = false; + return true; + } + String dataB64; + if (!jsonStringField(payload, "data", dataB64)) { + errorOut = "PDA base64 missing"; + return false; + } + std::vector raw; + if (!base64DecodeStd(dataB64, raw)) { + errorOut = "PDA base64 decode failed"; + return false; + } + return parseShineUserPdaBytes(raw, outState, errorOut); +} + +static void refreshAccountPdaStatus() { + gAccountCheckPending = false; + gShowRegisterAccountButton = false; + + if (gLoginValue.isEmpty() || !gSecretConfigured) { + gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; + gAccountPdaStatusMessage = "account not configured"; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; + clearShineSessionState(false); + return; + } + + ShinePdaUserState pdaState; + String error; + if (!readShineUserPda(gLoginValue, pdaState, error)) { + gAccountPdaStatus = ACCOUNT_PDA_MISMATCH; + gAccountPdaStatusMessage = error.isEmpty() ? "solana check failed" : error; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " unavailable"; + clearShineSessionState(false); + if (error == "Solana RPC unavailable") { + gAccountCheckPending = true; + } + return; + } + + if (!pdaState.found) { + gAccountPdaStatus = ACCOUNT_PDA_NOT_FOUND; + gAccountPdaStatusMessage = "user not found"; + gShowRegisterAccountButton = true; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; + clearShineSessionState(false); + return; + } + + uint8_t rootPub[32] = {}; + uint8_t devicePub[32] = {}; + uint8_t blockchainPub[32] = {}; + if (!base58ToFixed32(gRootPubB58, rootPub) + || !base58ToFixed32(gDevicePubB58, devicePub) + || !base58ToFixed32(gBlockchainPubB58, blockchainPub)) { + gAccountPdaStatus = ACCOUNT_PDA_MISMATCH; + gAccountPdaStatusMessage = "local keys invalid"; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; + clearShineSessionState(false); + return; + } + + String mismatch; + if (memcmp(rootPub, pdaState.rootKey32, 32) != 0) { + mismatch = "root key mismatch"; + } else if (memcmp(blockchainPub, pdaState.blockchainKey32, 32) != 0) { + mismatch = "blockchain key mismatch"; + } else if (memcmp(devicePub, pdaState.deviceKey32, 32) != 0) { + mismatch = "device key mismatch"; + } else if (gSubserverValue.isEmpty()) { + mismatch = "subserver not set"; + } else { + bool foundSession = false; + bool sessionMismatch = false; + for (const auto &session : pdaState.sessions) { + if (session.sessionType == kSessionTypeSubserver && session.sessionName == gSubserverValue) { + foundSession = true; + if (gSubserverPubB58.isEmpty()) { + sessionMismatch = true; + } else { + uint8_t subserverPub[32] = {}; + if (!base58ToFixed32(gSubserverPubB58, subserverPub) + || memcmp(subserverPub, session.sessionPubKey32, 32) != 0) { + sessionMismatch = true; + } + } + break; + } + } + if (!foundSession) { + mismatch = "subserver not in PDA"; + } else if (sessionMismatch) { + mismatch = "subserver key mismatch"; + } + } + + if (!mismatch.isEmpty()) { + gAccountPdaStatus = ACCOUNT_PDA_MISMATCH; + gAccountPdaStatusMessage = mismatch; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; + clearShineSessionState(false); + return; + } + + gAccountPdaStatus = ACCOUNT_PDA_OK; + gAccountPdaStatusMessage = "ok"; +} + +static void manageAccountPdaRefresh() { + if (!gAccountCheckPending) { + return; + } + if (WiFi.status() != WL_CONNECTED) { + return; + } + unsigned long now = millis(); + if (gLastAccountCheckMs != 0 && now - gLastAccountCheckMs < ACCOUNT_CHECK_RETRY_MS) { + return; + } + gLastAccountCheckMs = now; + refreshAccountPdaStatus(); +} + +static bool waitForClientBytes(WiFiClientSecure &client, size_t need, uint32_t timeoutMs) { + unsigned long start = millis(); + while (client.connected() && millis() - start < timeoutMs) { + if ((size_t)client.available() >= need) { + return true; + } + delay(2); + } + return false; +} + +static bool ensureWebSocketConnected(SimpleWebSocketClient &ws, const String &url, String &errorOut) { + errorOut = ""; + if (ws.connected && ws.client.connected()) { + return true; + } + + closeWebSocket(ws); + + bool secure = false; + if (!parseUrlHostPortPath(url, ws.host, ws.port, ws.path, secure)) { + errorOut = "bad WS url"; + return false; + } + + ws.client.setInsecure(); + ws.client.setTimeout(5); + if (!ws.client.connect(ws.host.c_str(), ws.port)) { + errorOut = "WS connect failed"; + return false; + } + + uint8_t keyRaw[16]; + for (size_t i = 0; i < sizeof(keyRaw); ++i) { + keyRaw[i] = (uint8_t)esp_random(); + } + String secKey = bytesToBase64String(keyRaw, sizeof(keyRaw)); + String request = + String("GET ") + ws.path + " HTTP/1.1\r\n" + + "Host: " + ws.host + ":" + String(ws.port) + "\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: " + secKey + "\r\n" + + "User-Agent: SHiNE-ESP32\r\n\r\n"; + ws.client.print(request); + + String statusLine = ws.client.readStringUntil('\n'); + if (statusLine.indexOf("101") < 0) { + errorOut = "WS handshake failed"; + closeWebSocket(ws); + return false; + } + + while (ws.client.connected()) { + String headerLine = ws.client.readStringUntil('\n'); + headerLine.trim(); + if (headerLine.isEmpty()) { + break; + } + } + + ws.connected = true; + return true; +} + +static bool wsSendFrame(SimpleWebSocketClient &ws, uint8_t opcode, const uint8_t *data, size_t len) { + if (!ws.connected || !ws.client.connected()) { + return false; + } + uint8_t header[10]; + size_t headerLen = 0; + header[headerLen++] = 0x80 | (opcode & 0x0F); + if (len < 126) { + header[headerLen++] = 0x80 | (uint8_t)len; + } else { + header[headerLen++] = 0x80 | 126; + header[headerLen++] = (uint8_t)((len >> 8) & 0xFF); + header[headerLen++] = (uint8_t)(len & 0xFF); + } + uint8_t mask[4]; + for (int i = 0; i < 4; ++i) { + mask[i] = (uint8_t)esp_random(); + header[headerLen++] = mask[i]; + } + if (ws.client.write(header, headerLen) != (int)headerLen) { + closeWebSocket(ws); + return false; + } + for (size_t i = 0; i < len; ++i) { + uint8_t byte = data ? (data[i] ^ mask[i % 4]) : mask[i % 4]; + if (ws.client.write(&byte, 1) != 1) { + closeWebSocket(ws); + return false; + } + } + return true; +} + +static bool wsSendText(SimpleWebSocketClient &ws, const String &payload) { + return wsSendFrame(ws, 0x1, reinterpret_cast(payload.c_str()), payload.length()); +} + +static bool wsReadTextFrame(SimpleWebSocketClient &ws, String &messageOut, uint32_t timeoutMs) { + messageOut = ""; + while (ws.connected && ws.client.connected()) { + if (!waitForClientBytes(ws.client, 2, timeoutMs)) { + closeWebSocket(ws); + return false; + } + uint8_t hdr[2]; + if (ws.client.read(hdr, 2) != 2) { + closeWebSocket(ws); + return false; + } + uint8_t opcode = hdr[0] & 0x0F; + bool masked = (hdr[1] & 0x80) != 0; + uint64_t payloadLen = hdr[1] & 0x7F; + if (payloadLen == 126) { + if (!waitForClientBytes(ws.client, 2, timeoutMs)) { + closeWebSocket(ws); + return false; + } + uint8_t ext[2]; + if (ws.client.read(ext, 2) != 2) { + closeWebSocket(ws); + return false; + } + payloadLen = ((uint16_t)ext[0] << 8) | ext[1]; + } else if (payloadLen == 127) { + closeWebSocket(ws); + return false; + } + uint8_t mask[4] = {}; + if (masked) { + if (!waitForClientBytes(ws.client, 4, timeoutMs) || ws.client.read(mask, 4) != 4) { + closeWebSocket(ws); + return false; + } + } + if (payloadLen > 4096) { + closeWebSocket(ws); + return false; + } + std::vector payload(payloadLen); + if (payloadLen > 0) { + if (!waitForClientBytes(ws.client, payloadLen, timeoutMs) + || ws.client.read(payload.data(), payloadLen) != (int)payloadLen) { + closeWebSocket(ws); + return false; + } + if (masked) { + for (size_t i = 0; i < payloadLen; ++i) { + payload[i] ^= mask[i % 4]; + } + } + } + + if (opcode == 0x8) { + closeWebSocket(ws); + return false; + } + if (opcode == 0x9) { + wsSendFrame(ws, 0xA, payload.data(), payload.size()); + continue; + } + if (opcode == 0xA) { + continue; + } + if (opcode == 0x1) { + messageOut = ""; + messageOut.reserve(payload.size()); + for (uint8_t byte : payload) { + messageOut += (char)byte; + } + return true; + } + } + closeWebSocket(ws); + return false; +} + +static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const String &payloadJson, String &responseOut, uint32_t timeoutMs) { + responseOut = ""; + String requestId = String("esp32-") + String(gWsRequestCounter++); + String request = String("{\"op\":\"") + op + "\",\"requestId\":\"" + requestId + "\",\"payload\":" + payloadJson + "}"; + if (!wsSendText(ws, request)) { + return false; + } + unsigned long start = millis(); + while (millis() - start < timeoutMs) { + String frame; + if (!wsReadTextFrame(ws, frame, timeoutMs)) { + return false; + } + if (frame.indexOf("\"requestId\":\"" + requestId + "\"") >= 0 && frame.indexOf("\"op\":\"" + op + "\"") >= 0) { + responseOut = frame; + return true; + } + } + return false; +} + +static bool ensureShineSessionAuthenticated(String &errorOut) { + errorOut = ""; + if (WiFi.status() != WL_CONNECTED) { + errorOut = "Wi-Fi disconnected"; + return false; + } + if (gLoginValue.isEmpty() || !gSecretConfigured || gSubserverValue.isEmpty()) { + errorOut = "account not configured"; + return false; + } + if (gAccountPdaStatus != ACCOUNT_PDA_OK) { + errorOut = "account not ready"; + return false; + } + + String wsUrl = shineWsUrl(); + if (wsUrl.isEmpty()) { + errorOut = "shine server not set"; + return false; + } + if (!ensureWebSocketConnected(gShineWs, wsUrl, errorOut)) { + return false; + } + + { + String pingResp; + if (!shineWsRequest(gShineWs, "Ping", "{\"ts\":0}", pingResp, SHINE_RPC_TIMEOUT_MS)) { + errorOut = "Ping failed"; + return false; + } + uint64_t pingStatus = 0; + uint64_t serverTs = 0; + if (!jsonInt64Field(pingResp, "status", pingStatus) || pingStatus != 200 || !jsonInt64Field(pingResp, "ts", serverTs)) { + errorOut = "Ping rejected"; + return false; + } + gShineServerTimeOffsetMs = (int64_t)serverTs - (int64_t)millis(); + } + + uint8_t deviceSeed[32] = {}; + uint8_t devicePub[32] = {}; + uint8_t deviceSec[64] = {}; + uint8_t subSeed[32] = {}; + uint8_t subPub[32] = {}; + uint8_t subSec[64] = {}; + if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec) + || !deriveSeedKeypairFromBase58(gSubserverPrivB58, subSeed, subPub, subSec)) { + errorOut = "local key derive failed"; + return false; + } + + String sessionKey = buildSessionKeyStringFromPublicBase64(bytesToBase64String(subPub, 32)); + if (!gShineSessionKey.isEmpty() && gShineSessionKey != sessionKey) { + clearShineSessionState(true); + } + + if (!gShineSessionId.isEmpty()) { + String response; + if (shineWsRequest(gShineWs, + "SessionChallenge", + String("{\"sessionId\":\"") + jsonEscape(gShineSessionId) + "\"}", + response)) { + uint64_t statusCode = 0; + String nonce; + if (jsonInt64Field(response, "status", statusCode) && statusCode == 200 && jsonStringField(response, "nonce", nonce)) { + uint64_t timeMs = shineNowMs(); + String preimage = String("SESSION_LOGIN:") + gShineSessionId + ":" + String((unsigned long long)timeMs) + ":" + nonce; + uint8_t signature[64] = {}; + crypto_sign_ed25519_detached(signature, nullptr, + reinterpret_cast(preimage.c_str()), + preimage.length(), subSec); + String loginReq = String("{\"sessionId\":\"") + jsonEscape(gShineSessionId) + + "\",\"sessionKey\":\"" + jsonEscape(sessionKey) + + "\",\"timeMs\":" + String((unsigned long long)timeMs) + + ",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + + "\",\"clientInfo\":\"ESP32 subserver\"}"; + String loginResp; + if (shineWsRequest(gShineWs, "SessionLogin", loginReq, loginResp)) { + if (jsonInt64Field(loginResp, "status", statusCode) && statusCode == 200) { + String storagePwd; + if (jsonStringField(loginResp, "storagePwd", storagePwd)) { + gShineStoragePwd = storagePwd; + } + gShineSessionKey = sessionKey; + gShineAuthenticated = true; + saveShineSessionPrefs(); + return true; + } + } + } + } + clearShineSessionState(true); + if (!ensureWebSocketConnected(gShineWs, wsUrl, errorOut)) { + return false; + } + } + + if (gShineStoragePwd.isEmpty()) { + uint8_t storageRaw[32]; + for (size_t i = 0; i < sizeof(storageRaw); ++i) { + storageRaw[i] = (uint8_t)esp_random(); + } + gShineStoragePwd = bytesToBase64String(storageRaw, sizeof(storageRaw)); + } + + String authResp; + if (!shineWsRequest(gShineWs, "AuthChallenge", + String("{\"login\":\"") + jsonEscape(gLoginValue) + "\"}", + authResp)) { + errorOut = "AuthChallenge failed"; + return false; + } + uint64_t statusCode = 0; + String authNonce; + if (!jsonInt64Field(authResp, "status", statusCode) || statusCode != 200 || !jsonStringField(authResp, "authNonce", authNonce)) { + errorOut = "AuthChallenge rejected"; + return false; + } + + uint64_t timeMs = shineNowMs(); + String preimage = String("AUTH_CREATE_SESSION:") + gLoginValue + ":" + sessionKey + ":" + gShineStoragePwd + ":" + String((unsigned long long)timeMs) + ":" + authNonce; + uint8_t signature[64] = {}; + crypto_sign_ed25519_detached(signature, nullptr, + reinterpret_cast(preimage.c_str()), + preimage.length(), deviceSec); + String createReq = String("{\"login\":\"") + jsonEscape(gLoginValue) + + "\",\"sessionKey\":\"" + jsonEscape(sessionKey) + + "\",\"storagePwd\":\"" + jsonEscape(gShineStoragePwd) + + "\",\"timeMs\":" + String((unsigned long long)timeMs) + + ",\"authNonce\":\"" + jsonEscape(authNonce) + + "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32)) + + "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + + "\",\"clientInfo\":\"ESP32 subserver\"}"; + String createResp; + if (!shineWsRequest(gShineWs, "CreateAuthSession", createReq, createResp)) { + errorOut = "CreateAuthSession failed"; + return false; + } + if (!jsonInt64Field(createResp, "status", statusCode) || statusCode != 200 || !jsonStringField(createResp, "sessionId", gShineSessionId)) { + errorOut = "CreateAuthSession rejected"; + return false; + } + + gShineSessionKey = sessionKey; + gShineAuthenticated = true; + saveShineSessionPrefs(); + return true; +} + +static void manageShineConnection() { + String serverLabel = gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl; + if (gLoginValue.isEmpty() || !gSecretConfigured || gSubserverValue.isEmpty()) { + gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured"; + clearShineSessionState(false); + return; + } + if (gAccountPdaStatus != ACCOUNT_PDA_OK) { + gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured"; + clearShineSessionState(false); + return; + } + if (WiFi.status() != WL_CONNECTED) { + gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable"; + clearShineSessionState(false); + return; + } + + unsigned long now = millis(); + if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { + if (now - gLastShineAttemptMs < SHINE_RECONNECT_MS) { + return; + } + gLastShineAttemptMs = now; + String error; + if (ensureShineSessionAuthenticated(error)) { + gShineStatusLine = String("SHiNE: ") + serverLabel + " connected"; + gLastShinePingMs = now; + } else { + gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable"; + clearShineSessionState(false); + } + return; + } + + if (now - gLastShinePingMs >= SHINE_PING_INTERVAL_MS) { + String pingResp; + if (shineWsRequest(gShineWs, "Ping", "{\"ts\":0}", pingResp, SHINE_RPC_TIMEOUT_MS)) { + uint64_t statusCode = 0; + if (jsonInt64Field(pingResp, "status", statusCode) && statusCode == 200) { + gLastShinePingMs = now; + gShineStatusLine = String("SHiNE: ") + serverLabel + " connected"; + return; + } + } + gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable"; + clearShineSessionState(false); + } +} + +static void upsertKnownWifi(const String &ssid, const String &password) { + if (ssid.isEmpty()) { + return; + } + + int existing = findKnownWifiIndex(ssid); + if (existing >= 0) { + gKnownWifiPasswords[existing] = password; + return; + } + + for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) { + if (gKnownWifiSsids[i].isEmpty()) { + gKnownWifiSsids[i] = ssid; + gKnownWifiPasswords[i] = password; + return; + } + } + + for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS - 1; ++i) { + gKnownWifiSsids[i] = gKnownWifiSsids[i + 1]; + gKnownWifiPasswords[i] = gKnownWifiPasswords[i + 1]; + } + gKnownWifiSsids[MAX_SAVED_WIFI_NETWORKS - 1] = ssid; + gKnownWifiPasswords[MAX_SAVED_WIFI_NETWORKS - 1] = password; +} + static void loadPrefs() { + clearSavedWifiList(); gWifiSavedSsid = gPrefs.getString("wifi_ssid", ""); gWifiSavedPassword = gPrefs.getString("wifi_pass", ""); gWifiKnownGood = gPrefs.getBool("wifi_known_good", false); + for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) { + String ssidKey = String("wifi_ssid_") + i; + String passKey = String("wifi_pass_") + i; + gKnownWifiSsids[i] = gPrefs.getString(ssidKey.c_str(), ""); + gKnownWifiPasswords[i] = gPrefs.getString(passKey.c_str(), ""); + } + if (!gWifiSavedSsid.isEmpty() && findKnownWifiIndex(gWifiSavedSsid) < 0) { + upsertKnownWifi(gWifiSavedSsid, gWifiSavedPassword); + } gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.devnet.solana.com"); gShineServerUrl = gPrefs.getString("shine_server", "https://shineup.me"); gLoginValue = gPrefs.getString("login", ""); @@ -352,12 +1813,34 @@ static void loadPrefs() { gSecretBase58 = ""; } } + refreshDerivedKeys(); + gShineSessionId = gPrefs.getString("shine_sess_id", ""); + gShineSessionKey = gPrefs.getString("shine_sess_key", ""); + gShineStoragePwd = gPrefs.getString("shine_store_pwd", ""); + gBalanceStatusMessage = gDevicePubB58.isEmpty() ? "Balance: secret not set" : "Balance: tap to load"; + gAccountCheckPending = true; + gLastAccountCheckMs = 0; + gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; + gAccountPdaStatusMessage = "Account not checked"; + gShowRegisterAccountButton = false; + gShineStatusLine = "SHiNE: account not configured"; } static void saveWifiPrefs() { gPrefs.putString("wifi_ssid", gWifiSavedSsid); gPrefs.putString("wifi_pass", gWifiSavedPassword); gPrefs.putBool("wifi_known_good", gWifiKnownGood); + for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) { + String ssidKey = String("wifi_ssid_") + i; + String passKey = String("wifi_pass_") + i; + if (gKnownWifiSsids[i].isEmpty()) { + gPrefs.remove(ssidKey.c_str()); + gPrefs.remove(passKey.c_str()); + } else { + gPrefs.putString(ssidKey.c_str(), gKnownWifiSsids[i]); + gPrefs.putString(passKey.c_str(), gKnownWifiPasswords[i]); + } + } } static void saveServerPrefs() { @@ -382,6 +1865,7 @@ static void clearWifiPrefs() { gWifiSavedSsid = ""; gWifiSavedPassword = ""; gWifiSelectedSsid = ""; + clearSavedWifiList(); gWifiKnownGood = false; gWifiReconnectEnabled = false; gWifiDisconnectedSinceMs = 0; @@ -418,7 +1902,13 @@ static String wifiSavedLabel() { if (gWifiSavedSsid.isEmpty()) { return "Saved: none"; } - return String("Saved: ") + gWifiSavedSsid; + int knownCount = 0; + for (int i = 0; i < MAX_SAVED_WIFI_NETWORKS; ++i) { + if (!gKnownWifiSsids[i].isEmpty()) { + knownCount++; + } + } + return String("Saved: ") + gWifiSavedSsid + " (" + knownCount + ")"; } static String wifiHomeSummary() { @@ -546,7 +2036,10 @@ static void clearSecretValue() { gSecretConfigured = false; gSecretBase58 = ""; memset(gSecretBytes, 0, sizeof(gSecretBytes)); + refreshDerivedKeys(); + gBalanceStatusMessage = "Balance: secret not set"; saveAccountPrefs(); + markAccountStateDirty(); } static void setSecretValue(const uint8_t *bytes32) { @@ -555,7 +2048,10 @@ static void setSecretValue(const uint8_t *bytes32) { shineSecretBase58Encode(bytes32, 32, b58, sizeof(b58)); gSecretBase58 = b58; gSecretConfigured = true; + refreshDerivedKeys(); + gBalanceStatusMessage = "Balance: tap to load"; saveAccountPrefs(); + markAccountStateDirty(); } static void updateWifiReconnectState() { @@ -652,6 +2148,7 @@ static bool connectWifiNow(const String &ssid, const String &password) { if (WiFi.status() == WL_CONNECTED) { gWifiSavedSsid = ssid; gWifiSavedPassword = password; + upsertKnownWifi(ssid, password); gWifiKnownGood = true; gWifiReconnectEnabled = true; gWifiDisconnectedSinceMs = 0; @@ -707,6 +2204,7 @@ static void applyEditorValue() { gSolanaRpcUrl = value; saveServerPrefs(); gServerStatusMessage = "Solana RPC saved"; + markAccountStateDirty(); showScreen(SCREEN_SERVER); return; } @@ -715,6 +2213,8 @@ static void applyEditorValue() { gShineServerUrl = value; saveServerPrefs(); gServerStatusMessage = "Shine server saved"; + clearShineSessionState(false); + gShineStatusLine = "SHiNE: reconnect pending"; showScreen(SCREEN_SERVER); return; } @@ -725,6 +2225,8 @@ static void applyEditorValue() { gLoginValue = value; if (gLoginValue != oldLogin) { clearSecretValue(); + } else { + markAccountStateDirty(); } saveAccountPrefs(); gAccountStatusMessage = gLoginValue.isEmpty() ? "Login cleared" : "Login saved"; @@ -735,7 +2237,9 @@ static void applyEditorValue() { if (gEditContext == EDIT_CONTEXT_SUBSERVER) { value.trim(); gSubserverValue = value; + refreshDerivedKeys(); saveAccountPrefs(); + markAccountStateDirty(); gAccountStatusMessage = gSubserverValue.isEmpty() ? "Subserver cleared" : "Subserver saved"; showScreen(SCREEN_ACCOUNT); return; @@ -830,7 +2334,7 @@ static void networkSelectCb(lv_event_t *event) { SCREEN_WIFI, "ENTER PASSWORD", String("SSID: ") + gWifiSelectedSsid, - "", + savedPasswordForSsid(gWifiSelectedSsid), true); } @@ -905,6 +2409,9 @@ static void actionButtonCb(lv_event_t *event) { case ACTION_OPEN_SETTINGS: showScreen(SCREEN_SETTINGS_MENU); break; + case ACTION_REGISTER_ACCOUNT: + showScreen(SCREEN_REGISTER_ACCOUNT_PLACEHOLDER); + break; case ACTION_OPEN_WIFI: gWifiViewMode = WIFI_VIEW_OVERVIEW; showScreen(SCREEN_WIFI); @@ -1001,7 +2508,9 @@ static void actionButtonCb(lv_event_t *event) { break; case ACTION_ACCOUNT_SUBSERVER_USE_DEFAULT: gSubserverValue = "subserver1"; + refreshDerivedKeys(); saveAccountPrefs(); + markAccountStateDirty(); gAccountStatusMessage = "Subserver set to subserver1"; showScreen(SCREEN_ACCOUNT); break; @@ -1016,6 +2525,17 @@ static void actionButtonCb(lv_event_t *event) { case ACTION_BACK_SECRET_MENU: showScreen(SCREEN_ACCOUNT_SECRET); break; + case ACTION_REFRESH_BALANCE: { + String message; + gBalanceStatusMessage = "Balance: loading..."; + rebuildScreen(); + lv_timer_handler(); + refreshWalletBalance(message); + if (gCurrentScreen == SCREEN_HOME) { + rebuildScreen(); + } + break; + } case ACTION_EDITOR_SAVE: applyEditorValue(); break; @@ -1108,6 +2628,41 @@ static void drawHome() { lv_obj_set_style_text_color(login, lv_color_hex(0xD5DEE7), 0); lv_obj_align_to(login, subserver, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6); + lv_obj_t *accountDot = lv_obj_create(gRoot); + lv_obj_set_size(accountDot, 14, 14); + lv_obj_set_pos(accountDot, 250, 52); + lv_obj_set_style_radius(accountDot, 7, 0); + lv_obj_set_style_border_width(accountDot, 2, 0); + lv_obj_set_style_bg_opa(accountDot, LV_OPA_COVER, 0); + lv_obj_clear_flag(accountDot, LV_OBJ_FLAG_SCROLLABLE); + + uint32_t dotColor = 0x8AA2B7; + uint32_t dotBg = 0x08111B; + uint32_t statusColor = 0xAFC0D1; + if (gAccountPdaStatus == ACCOUNT_PDA_OK) { + dotColor = 0x38B26D; + dotBg = 0x38B26D; + statusColor = 0x38B26D; + } else if (gAccountPdaStatus == ACCOUNT_PDA_MISMATCH) { + dotColor = 0xD26969; + dotBg = 0xD26969; + statusColor = 0xD26969; + } else if (gAccountPdaStatus == ACCOUNT_PDA_NOT_FOUND) { + dotColor = 0xF0F4F8; + dotBg = 0x08111B; + statusColor = 0xF0F4F8; + } + lv_obj_set_style_border_color(accountDot, lv_color_hex(dotColor), 0); + lv_obj_set_style_bg_color(accountDot, lv_color_hex(dotBg), 0); + + if (gAccountPdaStatus != ACCOUNT_PDA_OK) { + lv_obj_t *accountStatus = lv_label_create(gRoot); + lv_label_set_text(accountStatus, gAccountPdaStatusMessage.c_str()); + lv_obj_set_style_text_font(accountStatus, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(accountStatus, lv_color_hex(statusColor), 0); + lv_obj_set_pos(accountStatus, 272, 48); + } + if (!gSecretConfigured) { lv_obj_t *secret = lv_label_create(gRoot); lv_label_set_text(secret, homeSecretStatus().c_str()); @@ -1117,10 +2672,14 @@ static void drawHome() { } drawTopStatusIndicators(); - makeTitle("STATUS", 150, &lv_font_montserrat_28); + makeTitle("STATUS", 138, &lv_font_montserrat_28); showMessageAt(wifiHomeSummary(), 214); - makeBody("Swipe left or tap the button below.", 274, 360); - makeButton("SETTINGS", 22, 360, 436, 78, 0x2A6F97, ACTION_OPEN_SETTINGS, &lv_font_montserrat_24); + makeButton(balanceHomeLine().c_str(), 22, 254, 436, 56, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); + showMessageAt(shineHomeLine(), 322); + if (gShowRegisterAccountButton) { + makeButton("REGISTER ACCOUNT", 22, 360, 220, 78, 0x2A9D8F, ACTION_REGISTER_ACCOUNT, &lv_font_montserrat_20); + } + makeButton("SETTINGS", 238, 360, 220, 78, 0x2A6F97, ACTION_OPEN_SETTINGS, &lv_font_montserrat_24); makeVersionTag(); } @@ -1274,18 +2833,55 @@ static void drawSecretShowScreen() { setRootStyle(); makeTitle("SECRET", 18, &lv_font_montserrat_24); if (gSecretConfigured) { - showMessageAt("Current secret in base58:", 56); - lv_obj_t *label = lv_label_create(gRoot); - lv_label_set_text(label, gSecretBase58.c_str()); - lv_obj_set_width(label, 420); - lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); - lv_obj_set_style_text_font(label, &lv_font_montserrat_16, 0); - lv_obj_set_style_text_color(label, lv_color_hex(0xD9E1EA), 0); - lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 112); + lv_obj_t *panel = lv_obj_create(gRoot); + lv_obj_set_size(panel, 440, 320); + lv_obj_set_pos(panel, 20, 60); + lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0); + lv_obj_set_style_radius(panel, 14, 0); + lv_obj_set_style_pad_all(panel, 14, 0); + lv_obj_set_style_pad_row(panel, 8, 0); + lv_obj_set_scroll_dir(panel, LV_DIR_VER); + lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + auto addKeyBlock = [&](const String &title, const String &formula, const String &value) { + lv_obj_t *titleLabel = lv_label_create(panel); + lv_label_set_text(titleLabel, title.c_str()); + lv_obj_set_width(titleLabel, 400); + lv_obj_set_style_text_font(titleLabel, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(titleLabel, lv_color_hex(0xFFFFFF), 0); + + lv_obj_t *formulaLabel = lv_label_create(panel); + lv_label_set_text(formulaLabel, formula.c_str()); + lv_obj_set_width(formulaLabel, 400); + lv_obj_set_style_text_font(formulaLabel, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(formulaLabel, lv_color_hex(0x8FA4B8), 0); + + lv_obj_t *valueLabel = lv_label_create(panel); + lv_label_set_text(valueLabel, value.c_str()); + lv_obj_set_width(valueLabel, 400); + lv_label_set_long_mode(valueLabel, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(valueLabel, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(valueLabel, lv_color_hex(0xD9E1EA), 0); + }; + + addKeyBlock("Secret (base58)", "master secret", gSecretBase58); + addKeyBlock("Root key (base58)", "pub from sha256(base64(secret)|root.key)", gRootPubB58); + addKeyBlock("Root key priv (base58)", "sha256(base64(secret)|root.key)", gRootPrivB58); + addKeyBlock("Blockchain key (base58)", "pub from sha256(base64(secret)|bch.key)", gBlockchainPubB58); + addKeyBlock("Blockchain key priv (base58)", "sha256(base64(secret)|bch.key)", gBlockchainPrivB58); + addKeyBlock("Device key (base58)", "pub from sha256(base64(secret)|dev.key)", gDevicePubB58); + addKeyBlock("Device key priv (base58)", "sha256(base64(secret)|dev.key)", gDevicePrivB58); + addKeyBlock("Subserver key (base58)", String("pub from sha256(base64(secret)|") + subserverKeySuffix() + ")", gSubserverPubB58); + addKeyBlock("Subserver key priv (base58)", String("sha256(base64(secret)|") + subserverKeySuffix() + ")", gSubserverPrivB58); } else { showMessageAt("Secret not set", 96); } - makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_BACK_SECRET_MENU, &lv_font_montserrat_22); + makeButton("BACK", 140, 392, 200, 56, 0x5A6570, ACTION_BACK_SECRET_MENU, &lv_font_montserrat_20); makeVersionTag(); } @@ -1340,6 +2936,15 @@ static void drawSecretGenerateCancelConfirmScreen() { makeVersionTag(); } +static void drawRegisterAccountPlaceholderScreen() { + setRootStyle(); + makeTitle("REGISTER ACCOUNT", 22, &lv_font_montserrat_24); + makeBody("Registration flow is not implemented yet.", 112, 420); + makeBody("This button is shown when login is not found in Solana PDA.", 156, 420); + makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_22); + makeVersionTag(); +} + static void drawKeyRow(const char *const *tokens, int count, lv_coord_t x, @@ -1500,6 +3105,9 @@ static void rebuildScreen() { case SCREEN_TEXT_EDIT: drawTextEditScreen(); break; + case SCREEN_REGISTER_ACCOUNT_PLACEHOLDER: + drawRegisterAccountPlaceholderScreen(); + break; } } @@ -1624,11 +3232,15 @@ static void handleSwipe(SwipeDirection swipe) { case SCREEN_TEXT_EDIT: handleTextEditSwipe(swipe); break; + case SCREEN_REGISTER_ACCOUNT_PLACEHOLDER: + handleHomeSwipe(swipe); + break; } } void setup() { Serial.begin(115200); + sodium_init(); Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); initPowerManagement(); @@ -1685,9 +3297,11 @@ void setup() { void loop() { lv_timer_handler(); manageWifiReconnect(); + manageAccountPdaRefresh(); + manageShineConnection(); static unsigned long lastHomeRefreshMs = 0; - if (gCurrentScreen == SCREEN_HOME && !gTouchDown && millis() - lastHomeRefreshMs >= 1000) { + if (gCurrentScreen == SCREEN_HOME && !gTouchDown && millis() - lastHomeRefreshMs >= HOME_REFRESH_MS) { lastHomeRefreshMs = millis(); rebuildScreen(); } diff --git a/VERSION.properties b/VERSION.properties index 08a46c7..bf06daa 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.151 -server.version=1.2.143 +client.version=1.2.155 +server.version=1.2.147 diff --git a/shine-UI/Dev_Docs/features/interactive-network-graph.md b/shine-UI/Dev_Docs/features/interactive-network-graph.md index 6acb118..41d2717 100644 --- a/shine-UI/Dev_Docs/features/interactive-network-graph.md +++ b/shine-UI/Dev_Docs/features/interactive-network-graph.md @@ -45,12 +45,10 @@ - **Обычные линии:** SVG ` Q` (квадратичные Безье) — тонкие матовые дуги (`stroke-width ~1.0–1.2`), градиент с глубоким уходом в прозрачность: неон-центр `0.42` → цвет роли `0.07` у аватарки (растворяются в фоне, не спорят с сияющими). Изгиб реагирует на скорость. -- **Сияющие связи (плазменный композитинг):** один центральный S-путь (cubic Bézier) и три наложенных - слоя с одинаковым `d`: `.fg-plasma-flare` (16px, `#00bfff`, blur6), `.fg-plasma-tube` (6px, `#00e5ff`, - blur2) и `.fg-plasma-core` (2px, `#dffaff`). Поле и трубка идут в `mix-blend-mode: screen`, поэтому - свечение складывается аддитивно с тёмным фоном и ярче проявляется в пересечениях у центра. -- **Обычные связи:** теперь это мягкое цветное свечение по типу связи (семья/друзья/бизнес/контакт) — - широкая полупрозрачная подложка плюс тонкое ядро, без SVG-blur. +- **Сияющие связи (двухслойный «световод», Neon Layering):** два пути на одну связь — широкий размытый + GLOW (`stroke-width 4`, неон, `filter: blur(2px)`, `opacity 0.4`) + тонкий чёткий CORE (`1.5px`, + `#e0f7fc`). Линия остаётся изящной, но обретает объёмное OLED-свечение; растут оба синхронно (общий + dashoffset). Никаких бегущих импульсов. - **Жесты:** свайп-pan с инерцией (новое касание прерывает); короткий тап — центрирование + нижний сниппет; долгий тап — контекстное меню (в `#modal-root`, позиция по `getBoundingClientRect`); тап по центру — профиль. Нажатия на чипы фильтров гасят `pointerdown` (stopPropagation), чтобы сцена не @@ -63,7 +61,7 @@ (`hue-rotate`). На компоновщике (GPU), не будит rAF; подписи имён остаются контрастными. - **Стеклянные чипы фильтров (frosted glass):** `background: rgba(255,255,255,0.03)`, `backdrop-filter: blur(12px)`, граница `0.5px solid rgba(255,255,255,0.1)`; активный — подсвечен сине-голубым. -- **Поллиш:** «прицел» в центре (+пульс при захвате фокуса); «дыхание» фокуса (бесконечная CSS-анимация +- **Поллиш:** «дыхание» фокуса (бесконечная CSS-анимация размера, GPU, не будит rAF); свечение «сияющих» узлов — мягкая медленная пульсация (3.6с) многослойной `box-shadow` + размытый ореол через SVG-фильтр `#fg-shine-glow`; тестовые фото-аватарки (`NETWORK_PHOTOS`); хард-лимит ~90 DOM-аватарок (остальное — SVG-точки). @@ -90,6 +88,94 @@ тап по узлам переключает сети. - Реальный путь (`/network-view`) требует живого `wss://shineup.me/ws` (локально — `ws_open_error`, это норма). +## Режим «Интерактивная паутина» (ветка `pixel-web`, эксперимент, только лаборатория) +Включается чипом «🌌 Вселенная». Дальние уровни (2-3) по умолчанию скрыты и раскрываются локально: +- **Hover-превью (наведение):** навёл мышь/палец на узел — его ветка временно выплывает; убрал — + втягивается. Реализация: `pointerover/out` (мышь) и `pointerdown/up` (палец) → `onNodeHover` → + `graph.setHover(node|null)`; узел получает флаг `hovered`. +- **Фиксация кликом (pin):** тап/клик по узлу → `graph.toggleExpand` ставит флаг `pinned` — ветка + остаётся раскрытой и после ухода курсора. Повторный клик по раскрытому узлу **сворачивает** его + (надёжный toggle: `isOpen = pinned || expandP>0.5` → сброс `pinned`+`hovered`). +- Эффективное раскрытие = `pinned || hovered` (см. `expandTargetOf`), прогресс `expandP` (~400мс). +- **Spotlight-затемнение:** пока есть закреплённая ветка, остальные тускнеют до `SPOTLIGHT_DIM=0.25` + (узлы и их линии), фокус и закреплённая/наведённая ветка — 100%. Плавно через `spotCur` (lerp). +- **Узлы 2-го уровня — полноценные аватарки:** фото-лицо (pravatar) + имя, `DEEP2_SCALE=0.62` + (≈radius 16px), `DEEP2_OPACITY=0.85`. Не «пустые кружки», а видимые друзья друзей. +- **Глобальный сброс:** тап по корню (Иван) → `collapseAll()` снимает `pinned`/`hovered` → 100% яркость. +- **Адаптивное расталкивание (collision):** раскрытая ветка усиливает отталкивание соседних узлов + 1-го уровня пропорционально `expandP` (`EXPAND_REPULSION=2.4`) — кластеры разъезжаются, не накладываясь. +- **Камера-доводчик:** при фиксации ветки, если её «веер» упирается в край экрана, камера мягко + дотягивается (`glideCameraTo` → `camTargetX/Y`, lerp `CAM_GLIDE_K` в tick). Любой жест отменяет доводчик. +- **Свободный зум:** колесо мыши (`onWheel`) и щипок двумя пальцами (`activePointers`/`pinching`) — + масштаб `zoom` (0.55–2.6), «к точке» под курсором/центром щипка; мир масштабируется CSS-`scale`, + линии (отдельный SVG) пересчитываются в экранных координатах (× `zoom`). +- **Синхро-пульс линий:** сияющие/трековые «световоды» (`.fg-edge-glow`/`.fg-edge-core`) «дышат» + толщиной/размытием 3.6с — в такт ободку сияющего узла (в покое SVG не перерисовывается → синхронно). +- Мерцающие микрозвёзды 3-го уровня (`fg-star-twinkle`), хаптика (`navigator.vibrate`) на нажатие/раскрытие/натяжение. + +### Умный фокус (Smart Zoom / «аквариум») — ветка `pixel-aquarium` +**Наведение** (hover/палец) на узел — лёгкое превью ветки (раскрытие на месте, без камеры). +**Клик/тап по ЛЮБОМУ узлу** — **погружение (dive)** с кинематографичным наездом: +- **Камера-полёт + зум** (`diveTo` → `diveTargetId`/`diveZoom=1.7`, лёт в `tick` с `DIVE_FLY_K` ≈600мс): + узел плавно центрируется (offset ~0) и **вырастает до единого видимого размера** `HERO_VISUAL=1.4` + независимо от уровня (`depthScale = HERO_VISUAL / baseScaleOf`); его прямые дети — до `DIVE_CHILD_VISUAL`. +- **Адаптивный радиус орбиты (фикс слипания):** дети раскладываются на кольце + `ringR = max(baseR + радиус_родителя, число_детей × 13)` — НЕ лезут на (увеличенный зумом) родитель + и друг на друга (проверено: мин. дистанция 125px, 0 наложений). Радиус растёт вместе с зумом родителя. +- **Глубина «аквариума»** (`contextTargetOf` → `depthScale`/`depthBlur`/`spotCur`, лерп): Иван и боковые + ветки **уменьшаются** (root ×0.55, фон ×0.55) + уходят в **blur 3px** + тускнеют до 0.25 → задний план. +- **Железный Spotlight (единый активный путь):** `diveTo` сначала гасит ВСЕ прежние pin/hover, затем + раскрывает только путь к новой цели. Открыто → путь Иван→…→узел = 1.0, остальное = 0.25; переключение + веток сбрасывает прежнюю; **выход/`exitDive`/тап по Ивану → ВСЕ узлы гарантированно 1.0 + камера отъезжает**. +- **Нить-крошка**: путь (`divePathSet`/`onPath`) горит ярким «световодом» — виден путь назад к Ивану. +- **Pinch-to-Zoom + LOD**: щипок/колесо меняют `zoom`; при `zoom ≥ LOD_ZOOM (1.55)` видимые точки 3-го + уровня **дорисовываются как аватарки** (`updateLod`/`setNodeLod`), при отдалении — обратно в точки. +- Глубина — фейк-3D через масштаб + CSS-`blur` (GPU), без WebGL. + +**Полиш (партия 1):** веер детей раскрывается **полукругом «наружу»** (от пути назад, `DEEP_FAN`, +по `sibIndex`) — не перекрывает нить-крошку; **LOD с гистерезисом** (`LOD_ZOOM_UP=1.6`/`DOWN=1.4` — без +мигания у порога); **двойной тап по фону** и **сильный pinch-out на мин. зуме** = быстрый выход; +**префетч аватарок** детей при наведении/нырке. + +**Фишки (партия 2, лаборатория):** +- **Поиск + телепорт** — строка `.fg-search`; Enter → `graph.findNode(имя)` → камера летит к узлу (dive в + «Вселенной», иначе перецентр). +- **Хлебные крошки** — `.fg-breadcrumb` «Иван › Нина › Ада» (движок шлёт `onDiveChange(path)`, + API `getDivePath()`); клик по корню — полный сброс, по предку — навигация на тот уровень. +- **Бейдж числа связей** — `.fg-node-badge` (число из `degreeById`, обновляется в `updateBadges`). +- **Цветовые кластеры** — мягкая аура узла по типу связи (CSS `is-family/friend/business/contact`). + +**Линии-«жгуты» (партия 4, по референсу — плазменный композитинг):** +- **Сияющие** — ОДИН центральный S-путь (cubic Bézier) + ТРИ наложенных слоя с ОДИНАКОВЫМ `d` (объём + из толщины+размытия, НЕ из геометрии — никаких расходящихся линий): + Настоящий НЕОН (видимый ореол вокруг яркого ядра; поле/трубка в `mix-blend-mode: screen` — свет + складывается аддитивно с тёмным фоном, а в пересечениях у центра ярче — энергохаб): + - `.fg-plasma-flare` — плазменное облако: 16px, `#00bfff`, opacity 0.42, **`feGaussianBlur` stdDev=6**, screen (+ «дыхание» 3.6с); + - `.fg-plasma-tube` — направляющий свет: 6px, `#00e5ff`, opacity 0.85, **`feGaussianBlur` stdDev=2**, screen; + - `.fg-plasma-core` — ядро: 2px, `#dffaff` (светло-голубо-белое), opacity 1, без размытия. + Толщина/насыщенность подогнаны под референс (толстая яркая голубая плазма, гладкие края). + S-волна спокойная/изящная (amp до 13px). Размытие — именно SVG-фильтры (`#fg-plasma-blur6/2`), т.к. + CSS-`filter` на `` в части мобильных WebView не применяется (отсюда был «плоский»/«канатный» вид). + ⚠️ Это НЕ Canvas-движок (не библиотека force-graph): связи — реальные SVG ``, фильтры применяются. + Прозрачность слоёв inline (× spotlight/глубину). Тяжёлый blur только у сияющих (их мало) — перф. +- **Не-сияущие** — мягкое свечение **в цвете связи** (семья/друзья/бизнес/контакт): широкая + полупрозрачная подложка + тонкое ядро, без SVG-blur (дёшево). «Похоже, но тише». + +**Фишки (партия 3, лаборатория):** +- **Общие связи** — среди друзей человека один помечен как «общий» (он и твой друг тоже): золотой + ободок + ★ (CSS `is-common`; в лаб-генерации `addDeepLevels` подставляет узнаваемого друга Ивана). +- **Доступность** — визуально скрытый (`sr-only`) текстовый список графа `.fg-a11y` (центр + связи + 1-го уровня) для скринридеров; обновляется в `updateA11y` при перестроении. Полезно и для реального пути. + +**Автопроверки (`?fgtest`):** `js/pages/network/selftest.js` автозапускается в лаборатории при `?fgtest`, +прогоняет 17 ассертов (центровка/collision/полукруг/spotlight/переключение/LOD/поиск/крошки/бейдж/выход) через +детерминированные dev-хелперы движка `graph.debugState()` и `graph.pumpForTest()` (синхронно докручивают кадры +до покоя — не зависят от троттлинга rAF). Результат → консоль и `window.__fgTestResults`. В обычной работе не активны. + +> ⚠️ Эксперименты на ветках `pixel-web` (паутина) и `pixel-aquarium` (Smart Zoom) — для отката. +> Реальный путь `/network-view` не затронут: deep-код под `tier ≥ 2` / `hasDeep`, dive — только tier≥2 +> (в реальном пути их нет), depthScale/Blur по умолчанию нейтральны, `updateLod` выходит при `!hasDeep`. + ## Ограничения / на будущее - Многоуровневая глубина (друзья друзей мельче, 3-й уровень — точки), кластеры, «общие связи» упираются в API (отдаёт только прямые связи) — требуют доработки сервера. diff --git a/shine-UI/js/pages/network/force-graph.js b/shine-UI/js/pages/network/force-graph.js index cbd9bda..0f4dada 100644 --- a/shine-UI/js/pages/network/force-graph.js +++ b/shine-UI/js/pages/network/force-graph.js @@ -42,6 +42,44 @@ const PAN_THRESHOLD = 8; // порог смещения (px), после const LONGPRESS_MS = 480; // порог долгого нажатия const MAX_FULL_NODES = 90; // хард-лимит полных DOM-аватарок; остальное — лёгкие SVG-подобные точки +// «Мегамасштаб» — глубина 2-3 уровней (только лаборатория, фейковые данные). Узлы tier≥2 не +// участвуют в физике: позиционируются детерминированно вокруг своего родителя (layoutDeep). +const DEEP2_SCALE = 0.62; // узел 2-го уровня — ~вдвое меньше (radius ~16px), но с читаемым лицом/именем +const DEEP2_OPACITY = 0.85; // почти непрозрачный — это полноценная аватарка, а не «дырка» +const DEEP3_OPACITY = 0.9; // 3-й уровень — светящаяся микрозвезда (точка) +const DEEP_R2 = 70; // радиус орбиты 2-го уровня вокруг родителя 1-го уровня, px +const DEEP_R3 = 38; // радиус орбиты 3-го уровня вокруг родителя 2-го уровня, px + +// Зум камеры (свободное масштабирование колесом мыши / щипком двумя пальцами): мир целиком +// масштабируется CSS-scale (GPU), линии (отдельный SVG-слой) пересчитываются в экранных координатах. +const ZOOM_MIN = 0.55; // максимальное отдаление +const ZOOM_MAX = 2.6; // максимальное приближение +const ZOOM_WHEEL = 0.0016; // чувствительность колеса мыши +// Адаптивное расталкивание раскрытых веток (collision): пока ветка раскрыта (expandP→1), её узел +// сильнее отталкивает соседей — кластеры «разъезжаются», как магниты, и не накладываются (паутина). +const EXPAND_REPULSION = 2.4; // во сколько раз усиливается charge у полностью раскрытого узла +const SPOTLIGHT_DIM = 0.25; // прозрачность «затемнённых» веток, когда какая-то ветка закреплена кликом +// Камера-доводчик: мягкая дотяжка камеры, чтобы раскрытый кластер целиком попал в кадр (без рывков). +const CAM_GLIDE_K = 0.12; // скорость дотяжки (lerp за кадр) +const CAM_GLIDE_MARGIN = 18; // зазор от края экрана при дотяжке, px + +// Умный фокус (Smart Zoom / «аквариум»): клик по узлу 2-го уровня — камера летит и зумит к нему, +// он вырастает в центр; Иван и боковые ветки уменьшаются + расфокус (blur) на задний план; +// нить-крошка обратно к Ивану остаётся яркой. При сильном зуме точки 3-го уровня → аватарки (LOD). +const DIVE_ZOOM = 1.7; // зум камеры при погружении (наезд ~600мс) +const DIVE_FLY_K = 0.13; // скорость «полёта» камеры/зума к узлу (lerp за кадр) ≈ 600мс до цели +const HERO_VISUAL = 1.4; // желаемый ВИДИМЫЙ масштаб нырнутого узла — одинаков для tier-1/2/3 (читаемо) +const DIVE_CHILD_VISUAL = 0.95; // желаемый видимый масштаб его прямых детей (крупно/читаемо) +const DIVE_PATH_MUL = 0.72; // предки на пути назад — чуть мельче (видимая «цепочка крошек») +const DIVE_ROOT_MUL = 0.55; // корень (Иван) уходит вглубь сильнее всех +const DIVE_OFFPATH_MUL = 0.55; // боковые ветки (вне пути) — уменьшаются на задний план +const DIVE_BLUR = 3; // размытие фоновых (вне пути) узлов — эффект расфокуса/глубины, px +// LOD с гистерезисом (без «мигания» у порога): апгрейд точка→аватарка на UP, откат на DOWN (зазор). +const LOD_ZOOM_UP = 1.6; // зум, на котором точки 3-го уровня превращаются в аватарки +const LOD_ZOOM_DOWN = 1.4; // зум, ниже которого аватарки сворачиваются обратно в точки +const DEEP_FAN = Math.PI * 1.1; // ширина веера детей: полукруг «наружу» от пути назад (~198°) +const DOUBLE_TAP_MS = 320; // окно двойного тапа по фону (быстрый сброс погружения) + const RELATION_COLORS = { family: 'rgba(255, 159, 94, 0.92)', friend: 'rgba(120, 179, 255, 0.9)', @@ -71,6 +109,8 @@ function ensureShineFilter() { svg.setAttribute('width', '0'); svg.setAttribute('height', '0'); svg.style.position = 'absolute'; + // + SVG-фильтры размытия для плазменных линий (feGaussianBlur надёжнее CSS-filter на в WebView). + // Широкий регион фильтра (userSpaceOnUse-подобный запас в %), чтобы размытие не срезалось по bbox пути. svg.innerHTML = '' + '' + '' @@ -108,22 +148,26 @@ function hash01(str) { * @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void * @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }} */ -export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress } = {}) { +export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeHover, onDiveChange } = {}) { // Слои DOM const edgesSvg = document.createElementNS(SVGNS, 'svg'); edgesSvg.setAttribute('class', 'fg-edges'); const world = document.createElement('div'); world.className = 'fg-world'; - // «Прицел» в центре экрана: сжимается, когда под центром никого нет, и расширяется, - // когда под него попадает узел (визуальная зона фокуса при свободном панорамировании). - const reticle = document.createElement('div'); - reticle.className = 'fg-reticle'; - stage.append(edgesSvg, world, reticle); + // Доступность: визуально скрытый текстовый список графа для скринридеров (граф сам по себе им не читается). + const a11y = document.createElement('div'); + a11y.className = 'fg-a11y'; + a11y.setAttribute('role', 'region'); + a11y.setAttribute('aria-label', 'Карта связей — список'); + stage.append(edgesSvg, world, a11y); ensureShineFilter(); // SVG-фильтр свечения «сияющих» (нужен до первой отрисовки узлов) - // Состояние камеры (панорамирование) + // Состояние камеры (панорамирование + зум) let camX = 0; let camY = 0; + let zoom = 1; // масштаб камеры (1 = базовый); меняется колесом мыши / щипком + let camTargetX = null; // цель дотяжки камеры-доводчика (null = доводчик выключен) + let camTargetY = null; let viewW = stage.clientWidth || window.innerWidth; let viewH = stage.clientHeight || window.innerHeight; let centerX = viewW / 2; @@ -132,6 +176,109 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // Узлы движка: { id, login, name, ..., x, y, vx, vy, targetR, angle, scale, scaleFrom, scaleTo, el, dotRadius } let nodes = []; let focusId = ''; + let nodeById = new Map(); // id → node (для поиска родителя у глубоких уровней) + let hasDeep = false; // есть ли в графе узлы tier≥2 (включает deep-ветки рендера/раскладки) + let spotActive = false; // активен ли «spotlight» (есть закреплённая ветка → остальные тускнеют) + let diveTargetId = null; // id «нырнутого» узла (Smart Zoom); null — мы на верхнем уровне (Иван) + let diveZoom = 1; // целевой зум активного погружения + let surfacing = false; // идёт «всплытие» назад (камера/зум возвращаются к корню) + let childCountByParent = new Map(); // parentId → число детей (для адаптивного радиуса орбиты, без слипания) + let degreeById = new Map(); // id → число связей узла (для бейджа-счётчика на аватарке) + const rebuildIndex = () => { + nodeById = new Map(nodes.map((n) => [String(n.id), n])); + hasDeep = nodes.some((n) => n.tier >= 2); + // число детей у родителя + порядковый индекс ребёнка среди братьев (для веера «полукругом наружу») + childCountByParent = new Map(); + degreeById = new Map(); + let tier1count = 0; + for (const n of nodes) { + if (n.tier >= 2 && n.parentId) { + const i = childCountByParent.get(n.parentId) || 0; + n.sibIndex = i; + childCountByParent.set(n.parentId, i + 1); + degreeById.set(n.parentId, (degreeById.get(n.parentId) || 0) + 1); // у родителя +1 связь + } else if (n.tier === 1 && String(n.id) !== focusId) { + tier1count += 1; + } + } + degreeById.set(focusId, tier1count); // у центра — число связей 1-го уровня + }; + + // Spotlight: при закреплённой кликом ветке остальной граф тускнеет до SPOTLIGHT_DIM (0.25), чтобы + // взгляд держался на раскрытом кластере. Узел «в свете», если он фокус, либо его корневая ветка + // 1-го уровня закреплена (pinned) или временно наведена (hovered). Возвращает множитель прозрачности. + function rootTier1(n) { + let r = n; let guard = 0; + while (r && r.tier >= 2 && guard++ < 8) r = nodeById.get(r.parentId) || null; + return r; + } + function spotTargetOf(n) { + if (!spotActive || n.isFocus) return 1; + const r = rootTier1(n); + if (!r) return SPOTLIGHT_DIM; + return (r.pinned || r.hovered) ? 1 : SPOTLIGHT_DIM; + } + + // Путь-«крошки» от корня (Иван) до нырнутого узла включительно — для подсветки нити назад и глубины. + function divePathSet() { + const set = new Set(); + if (!diveTargetId) return set; + let cur = nodeById.get(diveTargetId); let guard = 0; + while (cur && guard++ < 16) { set.add(String(cur.id)); if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; } + set.add(String(focusId)); + return set; + } + let _pathSet = new Set(); + let _pathSetKey = ''; + function ensurePathSet() { + const k = diveTargetId || ''; + if (k !== _pathSetKey) { _pathSetKey = k; _pathSet = divePathSet(); } + return _pathSet; + } + // Хлебные крошки: упорядоченный путь focus → … → нырнутый узел (для UI-навигации). Пусто = верхний уровень. + function divePathNodes() { + const out = []; + if (!diveTargetId) return out; + let cur = nodeById.get(diveTargetId); let guard = 0; + while (cur && guard++ < 16) { out.push(cur); if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; } + const f = nodeById.get(focusId); + if (f && out[out.length - 1] !== f) out.push(f); + return out.reverse(); // от корня (Иван) к цели + } + function emitDiveChange() { + if (typeof onDiveChange !== 'function') return; + onDiveChange(divePathNodes().map((n) => ({ id: String(n.id), name: n.name || n.login || String(n.id), isFocus: !!n.isFocus }))); + } + // Поиск узла по имени/логину (строка поиска): точное совпадение приоритетнее подстроки. + function findNode(query) { + const q = String(query || '').trim().toLowerCase(); + if (!q) return null; + let hit = nodes.find((n) => String(n.name || '').toLowerCase() === q || String(n.login || '').toLowerCase() === q); + if (!hit) hit = nodes.find((n) => String(n.name || '').toLowerCase().includes(q) || String(n.login || '').toLowerCase().includes(q)); + return hit ? { id: String(hit.id), name: hit.name || hit.login || String(hit.id), tier: hit.tier } : null; + } + // Базовый масштаб узла по его роли/уровню (как в makeNodeState) — чтобы привести героя и его детей + // к ОДИНАКОВОМУ видимому размеру независимо от tier (depthScale = желаемый_видимый / базовый). + function baseScaleOf(n) { + if (n.isFocus) return FOCUS_SCALE; + if (n.tier >= 3) return n.lod === 'full' ? 0.42 : 1; + if (n.tier === 2) return DEEP2_SCALE; + return PRIMARY_SCALE; + } + // Контекст узла: целевые { прозрачность, множитель масштаба, размытие } для эффекта «аквариума»/spotlight. + // Погружение (dive) имеет приоритет: нырнутый узел крупный/чёткий, путь назад — чёткий, фон — мелкий/blur. + function contextTargetOf(n) { + if (diveTargetId) { + const ps = ensurePathSet(); + const id = String(n.id); + // герой и его прямые дети — до фиксированного ВИДИМОГО масштаба (множитель = желаемый/базовый) + if (id === diveTargetId) return { op: 1, scale: Math.max(0.8, Math.min(3.8, HERO_VISUAL / baseScaleOf(n))), blur: 0 }; + if (String(n.parentId) === diveTargetId) return { op: 1, scale: Math.max(0.8, Math.min(3.2, DIVE_CHILD_VISUAL / baseScaleOf(n))), blur: 0 }; + if (ps.has(id)) return { op: 1, scale: n.isFocus ? DIVE_ROOT_MUL : DIVE_PATH_MUL, blur: 0 }; // путь назад + return { op: SPOTLIGHT_DIM, scale: DIVE_OFFPATH_MUL, blur: DIVE_BLUR }; // фон (расфокус) + } + return { op: spotTargetOf(n), scale: 1, blur: 0 }; // без погружения — обычный spotlight + } // Управление циклом rAF let rafId = 0; @@ -158,23 +305,38 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let panVelX = 0; let panVelY = 0; + // Упругий «изгиб от свайпа»: сглаженный вектор, который догоняет скорость пальца и плавно + // возвращается к нулю при отпускании (lerp). Им смещаем контрольные точки Безье — нити тянутся. + let panBendX = 0; + let panBendY = 0; + let panTwang = false; // флаг «гитарной струны»: один вибро-щелчок при сильном натяжении нитей + function advancePanBend() { + panBendX += (panVelX - panBendX) * 0.3; + panBendY += (panVelY - panBendY) * 0.3; + } + // --- Построение модели ----------------------------------------------------- // Вычисляем «спецификации» узлов нового графа (без создания DOM) — для диффинга: // фокус + периферия, отсортированная по силе связи; сверх лимита — лёгкие точки. function computeSpecs(srcModel) { const list = Array.isArray(srcModel?.nodes) ? srcModel.nodes : []; const fId = String(srcModel?.focusId || (list[0] && list[0].id) || ''); - const peers = list - .filter((n) => String(n.id) !== fId) + // 1-й уровень держит орбиту вокруг центра (сортируем по силе, как раньше); узлы 2/3 уровня + // («друзья друзей» и микрозвёзды) орбиту не используют — их раскладывает layoutDeep вокруг родителя. + const tier1 = list + .filter((n) => String(n.id) !== fId && (Number(n.tier) || 1) === 1) .sort((a, b) => (Number(b.strength) || 0) - (Number(a.strength) || 0)); - const dotCount = Math.max(0, peers.length - MAX_FULL_NODES); + const deep = list.filter((n) => String(n.id) !== fId && (Number(n.tier) || 1) >= 2); + const dotCount = Math.max(0, tier1.length - MAX_FULL_NODES); if (dotCount > 0) { - console.info(`[force-graph] связей ${peers.length}: полных аватарок ${MAX_FULL_NODES}, точек ${dotCount}`); + console.info(`[force-graph] связей ${tier1.length}: полных аватарок ${MAX_FULL_NODES}, точек ${dotCount}`); } const specs = []; const focusSrc = list.find((n) => String(n.id) === fId) || list[0]; if (focusSrc) specs.push({ src: focusSrc, id: String(focusSrc.id), isFocus: true, index: 0, total: 1, dotOnly: false }); - peers.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: peers.length, dotOnly: i >= MAX_FULL_NODES })); + tier1.forEach((p, i) => specs.push({ src: p, id: String(p.id), isFocus: false, index: i, total: tier1.length, dotOnly: i >= MAX_FULL_NODES })); + // 3-й уровень рисуем точками (микрозвёзды), 2-й — маленькими аватарками + deep.forEach((p) => specs.push({ src: p, id: String(p.id), isFocus: false, index: 0, total: 1, dotOnly: (Number(p.tier) || 2) >= 3 })); return { focusId: fId, specs }; } @@ -192,7 +354,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const ja = (hash01(`${src.id}~a`) - 0.5) * 0.4; const targetR = isFocus ? 0 : ORBIT_MIN + (1 - strength) * (ORBIT_MAX - ORBIT_MIN) + jr; const angle = isFocus ? 0 : spreadAngle(index, total) + ja; - const scale = isFocus ? FOCUS_SCALE : (tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE); + // масштаб/прозрачность по уровню глубины: 2-й — вдвое меньше и полупрозрачный, 3-й — микрозвезда. + const scale = isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); + const op = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1); // целевая точка на орбите (равномерно по углу) и стартовая позиция ближе к центру — // узлы «выезжают» наружу при появлении (демонстрация физики), потом пружина их фиксирует. const tx = isFocus ? 0 : Math.cos(angle) * targetR; @@ -203,6 +367,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL ...src, isFocus, tier, + parentId: String(src.parentId || ''), // у tier≥2 — id родителя; пусто → центр (фокус) + deepAngle: Number(src.deepAngle) || hash01(`${src.id}~d`) * Math.PI * 2, + track: Boolean(src.track), // «трек прохождения» — линия к этому узлу горит ярко + pinned: false, // зафиксировано кликом/тапом — ветка раскрыта «намертво» + hovered: false, // временно раскрыто наведением (мышь/палец) — пропадёт при уходе + expandP: 0, // текущий прогресс раскрытия (0 скрыто → 1 выплыло), 400мс dotOnly, strength, targetR, @@ -216,12 +386,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL scale, targetScale: scale, hidden: false, - opacity: 1, - targetOpacity: 1, + opacity: tier >= 2 ? op : 1, + targetOpacity: op, + spotCur: 1, // текущий множитель spotlight-затемнения (1 = полный свет) + depthScale: 1, // множитель масштаба «глубины» (dive: цель крупно, фон мелко) + depthBlur: 0, // размытие «глубины» (dive: фон уходит в расфокус), px + lod: (dotOnly && tier >= 3) ? 'dot' : 'full', // уровень детализации tier-3: точка ↔ аватарка (по зуму) bloom: false, edgeGrow: 1, // прогресс «вырастания» линии этого узла 0→1 (для нового узла стартует с 0) el, - dotRadius: isFocus ? 32 : (dotOnly ? 7 : (tier >= 2 ? 18 : 26)), + dotRadius: isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (dotOnly ? 7 : 26))), }; } @@ -252,11 +426,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL if (dotOnly) { el.className = [ 'fg-node', 'fg-dot', + tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка) src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, ].filter(Boolean).join(' '); el.dataset.nodeId = String(src.id); el.title = src.name || src.login || ''; + // десинхронизируем мерцание звёзд (отрицательная задержка) → живое «созвездие», не «моргание в такт» + if (tier >= 3) el.style.animationDelay = `${(-hash01(`${src.id}~t`) * 3.4).toFixed(2)}s`; return el; } el.className = [ @@ -264,7 +441,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, + tier === 2 ? 'is-tier2' : '', // друг друзей (вдвое меньше, полупрозрачный) tier >= 2 ? 'is-secondary' : '', + src.common ? 'is-common' : '', // «общая связь» — этот человек и твой друг тоже (золотой ободок ★) ].filter(Boolean).join(' '); el.dataset.nodeId = String(src.id); @@ -280,6 +459,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL }); el.append(avatar); + // Бейдж-счётчик числа связей (заполняется в updateBadges по degreeById). Скрыт, пока 0. + const badge = document.createElement('span'); + badge.className = 'fg-node-badge'; + badge.hidden = true; + el.append(badge); + const label = document.createElement('span'); label.className = 'fg-node-label'; label.textContent = src.name || src.login || ''; @@ -287,6 +472,27 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return el; } + // Заполняет бейджи-счётчики связей (число детей/связей узла). Вызывается после rebuildIndex. + function updateBadges() { + for (const n of nodes) { + const badge = n.el.querySelector('.fg-node-badge'); + if (!badge) continue; // у точек (dotOnly) бейджа нет + const deg = degreeById.get(String(n.id)) || 0; + if (deg > 0) { badge.textContent = deg > 99 ? '99+' : String(deg); badge.hidden = false; } + else { badge.hidden = true; } + } + } + + // Доступность: текстовое представление графа для скринридеров (центр + связи 1-го уровня списком). + function updateA11y() { + const focus = nodes.find((n) => n.isFocus); + const tier1 = nodes.filter((n) => n.tier === 1 && !n.isFocus); + const rel = { family: 'семья', friend: 'друг', business: 'бизнес', contact: 'контакт' }; + const items = tier1.map((n) => `
  • ${escapeHtml(n.name || n.login || String(n.id))}${n.shining ? ' — сияющий' : ''} (${rel[n.relationType] || 'связь'})
  • `).join(''); + a11y.innerHTML = `

    Центр: ${escapeHtml(focus ? (focus.name || focus.login || '') : '')}. Связей 1-го уровня: ${tier1.length}.

      ${items}
    `; + } + function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } + // Переиспользование узла при диффинге: сохраняем x/y/скорость, задаём новую роль и цель. function updateNodeRole(node, spec) { const src = spec.src; @@ -294,6 +500,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const tier = Number(src.tier) || 1; node.isFocus = spec.isFocus; node.tier = tier; + node.parentId = String(src.parentId || ''); + node.deepAngle = Number(src.deepAngle) || node.deepAngle || hash01(`${src.id}~d`) * Math.PI * 2; + node.track = Boolean(src.track); + node.pinned = false; node.hovered = false; node.expandP = 0; node.spotCur = 1; // при перестроении глубина схлопывается + node.depthScale = 1; node.depthBlur = 0; node.lod = (spec.dotOnly && tier >= 3) ? 'dot' : 'full'; node.dotOnly = spec.dotOnly; node.strength = strength; node.relationType = src.relationType; @@ -304,44 +515,183 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL node.angle = spec.isFocus ? 0 : spreadAngle(spec.index, spec.total) + ja; node.tx = Math.cos(node.angle) * node.targetR; node.ty = Math.sin(node.angle) * node.targetR; - node.targetScale = spec.isFocus ? FOCUS_SCALE : (spec.dotOnly ? 1 : (tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE)); - node.targetOpacity = 1; + node.targetScale = spec.isFocus ? FOCUS_SCALE : (tier === 2 ? DEEP2_SCALE : (tier >= 3 ? 1 : PRIMARY_SCALE)); + node.targetOpacity = tier === 2 ? DEEP2_OPACITY : (tier >= 3 ? DEEP3_OPACITY : 1); node.hidden = false; node.bloom = false; - node.dotRadius = spec.isFocus ? 32 : (spec.dotOnly ? 7 : (tier >= 2 ? 18 : 26)); - // обновляем классы элемента (роль/тип/свечение) — без пересоздания DOM + node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26))); + // обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM node.el.className = spec.dotOnly - ? ['fg-node', 'fg-dot', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ') - : ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier >= 2 ? 'is-secondary' : ''].filter(Boolean).join(' '); + ? ['fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ') + : ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' '); } // --- Рендер ---------------------------------------------------------------- function applyWorldTransform() { - world.style.transform = `translate3d(${camX}px, ${camY}px, 0)`; + world.style.transform = `translate3d(${camX}px, ${camY}px, 0) scale(${zoom})`; + } + + // Камера-доводчик: мягко подвести раскрываемый кластер целиком в кадр, НЕ теряя центр (Иван остаётся + // в графе, просто сдвигается). Считаем экранную позицию узла и его «веера» (DEEP_R2) и, если он + // упирается в край, задаём цель дотяжки (плавный lerp в tick). Любой жест пользователя её отменяет. + function glideCameraTo(n) { + if (!n) return; + const ring = (DEEP_R2 + 64) * zoom; // радиус раскрытого веера + запас (экранные px) + const sx = centerX + camX + n.x * zoom; + const sy = centerY + camY + n.y * zoom; + let tx = camX; + let ty = camY; + const m = CAM_GLIDE_MARGIN; + if (sx - ring < m) tx += (m - (sx - ring)); + else if (sx + ring > viewW - m) tx -= ((sx + ring) - (viewW - m)); + if (sy - ring < m) ty += (m - (sy - ring)); + else if (sy + ring > viewH - m) ty -= ((sy + ring) - (viewH - m)); + if (Math.abs(tx - camX) > 1 || Math.abs(ty - camY) > 1) { camTargetX = tx; camTargetY = ty; } + } + + // --- Зум камеры (колесо мыши / щипок двумя пальцами) ----------------------- + // Масштабируем «к точке» (sx,sy): мировая точка под курсором/центром щипка остаётся на месте. + function setZoom(nextZoom, sx, sy) { + const z0 = zoom; + const z1 = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, nextZoom)); + if (Math.abs(z1 - z0) < 0.0001) return; + const wx = (sx - centerX - camX) / z0; + const wy = (sy - centerY - camY) / z0; + zoom = z1; + camX = sx - centerX - wx * z1; + camY = sy - centerY - wy * z1; + camTargetX = null; camTargetY = null; // ручной зум отменяет доводчик + applyWorldTransform(); + renderEdges(); + } + function onWheel(ev) { + ev.preventDefault(); + const rect = stage.getBoundingClientRect(); + setZoom(zoom * Math.exp(-ev.deltaY * ZOOM_WHEEL), ev.clientX - rect.left, ev.clientY - rect.top); + wake(); } function renderNodes() { for (const n of nodes) { + const ds = n.depthScale ?? 1; n.el.style.transform = - `translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale})`; - n.el.style.opacity = String(n.opacity); + `translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale * ds})`; + n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight/глубина: затемнённые тусклее + // расфокус глубины (dive): фоновые узлы уходят в blur; чёткие — без фильтра (дёшево) + n.el.style.filter = (n.depthBlur > 0.15 && n.opacity > 0.02) ? `blur(${n.depthBlur.toFixed(1)}px)` : ''; n.el.style.pointerEvents = n.hidden && n.opacity <= 0.01 ? 'none' : ''; } } + // Глубина 2-3 уровней (только лаборатория): узлы tier≥2 не участвуют в физике. По умолчанию + // СКРЫТЫ (схлопнуты в родителя, opacity 0) — и «выплывают» вокруг родителя по мере его раскрытия + // (parent.expandP: 0 скрыто → 1 выплыло). Так нет «каши»: видно только то, с чем взаимодействуешь. + function layoutDeep() { + if (!hasDeep) return; + for (const tier of [2, 3]) { + for (const n of nodes) { + if (n.tier !== tier) continue; + const p = nodeById.get(n.parentId); + if (!p) { n.opacity = 0; continue; } + const e = p.expandP || 0; // насколько раскрыт родитель + // АДАПТИВНЫЙ радиус орбиты (фикс слипания): дети не лезут на (возможно увеличенный зумом) родитель + // и не накладываются друг на друга. Клиренс = радиус родителя + базовый отступ; место = по числу детей. + const baseR = tier === 2 ? DEEP_R2 : DEEP_R3; + const pr = 26 * (p.scale || 1) * (p.depthScale || 1); // визуальный радиус родителя (world-единицы) + const cnt = childCountByParent.get(n.parentId) || 1; + const ringR = Math.max(baseR + pr, cnt * 13); // расталкивание: дети без наложений (клиренс + место) + const r = ringR * e; // при e=0 — в центре родителя, при 1 — на полной орбите + // ВЕЕР «полукругом наружу»: раскрываем детей в сторону ОТ пути назад (от деда к родителю), + // чтобы они не перекрывали нить-крошку и родителя. Равномерно по индексу среди братьев. + const gp = nodeById.get(p.parentId); + const ox = p.x - (gp ? gp.x : 0); + const oy = p.y - (gp ? gp.y : 0); + const outward = (ox || oy) ? Math.atan2(oy, ox) : n.deepAngle; // направление наружу (фолбэк — старый угол) + const t = cnt > 1 ? ((n.sibIndex || 0) / (cnt - 1) - 0.5) : 0; // -0.5..0.5 по веера + const ang = outward + t * DEEP_FAN; + n.x = p.x + Math.cos(ang) * r; + n.y = p.y + Math.sin(ang) * r; + const baseOp = tier === 2 ? DEEP2_OPACITY : DEEP3_OPACITY; + // tier-3: точка-звезда (scale ~1, CSS фиксирует 9px) ИЛИ аватарка при LOD-апгрейде (мельче, 52px*0.42) + const baseSc = tier === 2 ? DEEP2_SCALE : (n.lod === 'full' ? 0.42 : 1); + // вложенность: tier-3 виден только когда виден его tier-2 родитель (он сам — глубокий) + const parentVis = p.tier >= 2 ? ((p.opacity || 0) > 0.04 ? 1 : 0) : 1; + n.opacity = baseOp * e * parentVis; + n.scale = baseSc * (0.3 + 0.7 * e); // лёгкий «поп» при выплывании + n.targetOpacity = n.opacity; n.targetScale = n.scale; + } + } + } + + // Плавная анимация раскрытия (expandP → expandTarget, ~400мс). Возвращает true, пока что-то едет. + // Эффективная цель раскрытия узла: раскрыт, если зафиксирован кликом (pinned) ИЛИ временно наведён (hovered). + function expandTargetOf(n) { return (n.pinned || n.hovered) ? 1 : 0; } + + function advanceExpand() { + let moving = false; + for (const n of nodes) { + const t = expandTargetOf(n); + const cur = n.expandP || 0; + if (Math.abs(cur - t) > 0.001) { + let next = cur + (t - cur) * 0.18; + if (Math.abs(next - t) < 0.012) next = t; else moving = true; + n.expandP = next; + } + } + return moving; + } + + // Во время CSS-bloom (когда renderNodes не вызывается — узлы 1-го уровня анимирует компоновщик) + // отдельно позиционируем глубокие узлы из JS, чтобы они следовали за родителями. + function renderDeepNodes() { + if (!hasDeep) return; + for (const n of nodes) { + if (n.tier < 2) continue; + const ds = n.depthScale ?? 1; + n.el.style.transform = `translate(calc(${n.x}px - 50%), calc(${n.y}px - 50%)) scale(${n.scale * ds})`; + n.el.style.opacity = String(n.opacity * (n.spotCur ?? 1)); // spotlight/глубина: затемнённые тусклее + n.el.style.filter = (n.depthBlur > 0.15 && n.opacity > 0.02) ? `blur(${n.depthBlur.toFixed(1)}px)` : ''; + } + } + + // LOD (level-of-detail): при сильном зуме (pinch/dive) видимые точки 3-го уровня дорисовываются как + // маленькие аватарки (лицо+имя), при отдалении — сворачиваются обратно в светящиеся точки. + // Пере-рендер DOM только при пересечении порога (редкое событие); ежекадровая проверка дешёвая. + function updateLod() { + if (!hasDeep) return; + for (const n of nodes) { + if (n.tier < 3) continue; // LOD касается только микрозвёзд 3-го уровня + const isFull = n.lod === 'full'; + // гистерезис: апгрейд на UP, откат на DOWN — у промежуточного зума состояние «залипает» (без мигания) + const threshold = isFull ? LOD_ZOOM_DOWN : LOD_ZOOM_UP; + const wantFull = zoom >= threshold && (n.opacity > 0.04); // апгрейдим только видимые + if (wantFull === isFull) continue; + setNodeLod(n, wantFull); + } + } + function setNodeLod(n, full) { + const newEl = buildNodeElement(n, false, n.tier, !full); // n несёт src-поля (photo/name/...) через spread + newEl.style.transform = n.el.style.transform; // без скачка к (0,0) на один кадр + newEl.style.opacity = n.el.style.opacity; + world.replaceChild(newEl, n.el); + n.el = newEl; + n.lod = full ? 'full' : 'dot'; + n.dotOnly = !full; + n.dotRadius = full ? 12 : 5; // радиус для расчёта концов линий связей + if (full) { const b = newEl.querySelector('.fg-node-badge'); const deg = degreeById.get(String(n.id)) || 0; if (b && deg > 0) { b.textContent = deg > 99 ? '99+' : String(deg); b.hidden = false; } } + } + function renderEdges() { const focus = nodes.find((n) => n.id === focusId); if (!focus) { edgesSvg.innerHTML = ''; return; } - // концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость - const tx = (n) => centerX + camX + n.x; - const ty = (n) => centerY + camY + n.y; - - const fx = tx(focus); - const fy = ty(focus); - const fr = focus.dotRadius * focus.scale + 4; + // концы линий — строго по центрам узлов (намертво привязаны), изгиб даёт скорость. + // Z — зум камеры: SVG-слой отдельный (не масштабируется), поэтому координаты узлов умножаем на zoom. + const Z = zoom; + const tx = (n) => centerX + camX + n.x * Z; + const ty = (n) => centerY + camY + n.y * Z; const focusLogin = String(focus.login || '').toLowerCase(); const parts = []; @@ -353,6 +703,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const nodeOpacity = (typeof n.opacity === 'number') ? n.opacity : 1; if (n.hidden && nodeOpacity <= 0.02) continue; if (focusLogin && String(n.login || '').toLowerCase() === focusLogin) continue; + // начало связи = РОДИТЕЛЬ узла: tier-1 → фокус (поведение как раньше), tier-2/3 → их узел-родитель + const parent = (n.parentId && nodeById.get(n.parentId)) || focus; + const fx = centerX + camX + parent.x * Z; + const fy = centerY + camY + parent.y * Z; + const fr = parent.dotRadius * parent.scale * (parent.depthScale ?? 1) * Z + 4; const nx = tx(n); const ny = ty(n); if ((nx < -80 && fx < -80) || (nx > viewW + 80 && fx > viewW + 80)) continue; @@ -362,14 +717,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // и раскрываем dashoffset'ом синхронно с разлётом (кончик трекает аватарку). Переезжающие/ // общие узлы и покой — линия идёт к ТЕКУЩЕЙ точке (просто следует за узлом, без dash). const growing = cssBloom && cssBloomKind === 'bloom' && !n.isFocus && n.edgeGrow < 1; - const ex = growing ? (centerX + camX + n.bfx) : nx; - const ey = growing ? (centerY + camY + n.bfy) : ny; + const ex = growing ? (centerX + camX + n.bfx * Z) : nx; + const ey = growing ? (centerY + camY + n.bfy * Z) : ny; const dx = ex - fx; const dy = ey - fy; const len = Math.hypot(dx, dy) || 1; const ux = dx / len; const uy = dy / len; - const nr = n.dotRadius * n.scale + 4; + const nr = n.dotRadius * n.scale * (n.depthScale ?? 1) * Z + 4; // концы линии — у краёв кружков const x1 = fx + ux * fr; const y1 = fy + uy * fr; @@ -385,15 +740,25 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const lag = Math.min(30, speed * 1.8); // отставание ∝ скорости const invX = speed > 0.01 ? -n.vx / speed : 0; // направление против движения const invY = speed > 0.01 ? -n.vy / speed : 0; - // желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости - const desX = mx + (-uy) * baseBow + invX * lag; - const desY = my + ux * baseBow + invY * lag; + // ПАН-СТРЕТЧ (резиновые нити): при свайпе контрольную точку тянем ПРОТИВ направления пальца, + // сильнее у дальних узлов (инерция периферии). panBend сглажен и сам затухает → нить пружинит назад. + const farK = Math.min(1.3, Math.max(0.35, segLen0 / 200)); + let panBx = -panBendX * 0.55 * farK; + let panBy = -panBendY * 0.55 * farK; + const panBmag = Math.hypot(panBx, panBy); + if (panBmag > 40) { panBx = (panBx / panBmag) * 40; panBy = (panBy / panBmag) * 40; } + // желаемая середина кривой = M + перпендикулярная дуга + прогиб против скорости узла + пан-стретч + const desX = mx + (-uy) * baseBow + invX * lag + panBx; + const desY = my + ux * baseBow + invY * lag + panBy; const cpx = 2 * desX - mx; // CP так, чтобы середина Q-кривой попала в desired const cpy = 2 * desY - my; // Связь рисуем по статусу узла: - // • обычная — мягкое цветное свечение по типу связи; - // • сияющая — плазма из трёх слоёв на одном S-пути (flare/tube/core). + // • обычная — одна тонкая (1.0–1.2px) матовая дуга, градиент с ГЛУБОКИМ уходом в прозрачность; + // • СИЯЮЩАЯ — двухслойный неоновый «световод»: широкий размытый glow (под) + тонкий чёткий + // core 1.5px (над) → изящно, но с объёмным OLED-свечением (см. .fg-edge-glow / .fg-edge-core). const shine = Boolean(n.shining) && !n.hidden; + const sp = (n.spotCur ?? 1); // spotlight/глубина: линия тускнеет вместе со своим узлом + const onPath = Boolean(diveTargetId) && ensurePathSet().has(String(n.id)) && !n.isFocus; // нить-крошка пути const d = `M${x1.toFixed(1)} ${y1.toFixed(1)} Q${cpx.toFixed(1)} ${cpy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; // ПРОРАСТАНИЕ из центра: dasharray = длина пути, dashoffset от длины к 0 по мере разлёта узла // (growP = доля пройденного узлом пути центр→орбита) → линия «вытягивается» из центра. @@ -405,25 +770,39 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const L = (Math.hypot(cpx - x1, cpy - y1) + Math.hypot(x2 - cpx, y2 - cpy) + Math.hypot(x2 - x1, y2 - y1)) / 2; dashAttr = ` stroke-dasharray="${L.toFixed(1)}" stroke-dashoffset="${(L * (1 - growP)).toFixed(1)}"`; } - if (shine) { + const pe = parent.expandP || 0; // насколько раскрыт родитель (глубокие лучи видны вместе с детьми) + if (n.tier >= 3) { + // 3-й уровень: микрозвезда — еле заметная космическая ниточка, видна только при раскрытии + if (pe > 0.02) parts.push(``); + } else if (n.tier === 2) { + // 2-й уровень: матовая паутинка, проявляется по мере раскрытия родителя (локальный bloom) + if (pe > 0.02) parts.push(``); + } else if (shine || n.track || onPath) { + // СИЯЮЩАЯ связь — плазменный композитинг: ОДИН центральный S-путь (cubic) + ТРИ наложенных слоя + // с ОДИНАКОВЫМ d (объём из толщины+blur, не из геометрии). Слои: широкое поле / трубка / ядро. const pnx = -uy; - const pny = ux; - const amp = Math.min(13, 5 + segLen0 * 0.05); + const pny = ux; // перпендикуляр к хорде + const amp = Math.min(13, 5 + segLen0 * 0.05); // амплитуда S — спокойная изящная волна (∝ длине) const bowX = desX - mx; - const bowY = desY - my; - const c1x = x1 + (x2 - x1) / 3 + bowX + pnx * amp; - const c1y = y1 + (y2 - y1) / 3 + bowY + pny * amp; - const c2x = x1 + 2 * (x2 - x1) / 3 + bowX - pnx * amp; - const c2y = y1 + 2 * (y2 - y1) / 3 + bowY - pny * amp; + const bowY = desY - my; // вектор изящной дуги + пан-стретч — сохраняем + // единственная S-кривая: контрольная точка на 1/3 смещена +amp, на 2/3 — −amp (плавная волна) + const c1x = x1 + (x2 - x1) / 3 + bowX + pnx * amp; const c1y = y1 + (y2 - y1) / 3 + bowY + pny * amp; + const c2x = x1 + 2 * (x2 - x1) / 3 + bowX - pnx * amp; const c2y = y1 + 2 * (y2 - y1) / 3 + bowY - pny * amp; const dS = `M${x1.toFixed(1)} ${y1.toFixed(1)} C${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`; - parts.push(``); - parts.push(``); - parts.push(``); + const base = nodeOpacity * sp; // затемнение от spotlight/глубины + // 3 слоя на ОДНОМ пути dS. Видимый НЕОН: яркое ядро + чёткий голубой ореол (поле+трубка в режиме + // screen — свет складывается аддитивно с фоном). Прозрачности подняты, чтобы не было «белого каната». + parts.push(``); + parts.push(``); + parts.push(``); } else { + // ОБЫЧНАЯ связь — мягкий светящийся жгут В ЦВЕТЕ СВЯЗИ (семья тёплый / друзья синий / + // бизнес фиолет): тоньше и тише сияющих. Без SVG-blur (дёшево): широкая подложка + тонкое ядро. const col = relationColor(n.relationType); - const haloOp = (0.22 * nodeOpacity).toFixed(2); - const coreOp = (0.7 * nodeOpacity).toFixed(2); - const sw = (2.6 + n.strength * 1.4).toFixed(2); + const opVal = nodeOpacity * sp; + const haloOp = (0.22 * opVal).toFixed(2); + const coreOp = (0.7 * opVal).toFixed(2); + const sw = (2.6 + n.strength * 1.4).toFixed(2); // мягкая подложка-«свечение» в цвете связи parts.push(``); parts.push(``); } @@ -431,21 +810,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL edgesSvg.innerHTML = `${defs.join('')}${parts.join('')}`; } - function updateReticle() { - // ближайший видимый узел к центру экрана (центр = camX/camY смещение от мировой точки 0,0) - let best = Infinity; - for (const n of nodes) { - if (n.hidden) continue; - const d = Math.hypot(camX + n.x, camY + n.y); - if (d < best) best = d; - } - reticle.classList.toggle('is-locked', best < 46); - } - function renderAll() { renderNodes(); renderEdges(); - updateReticle(); } // --- Физика (пружины + отталкивание) --------------------------------------- @@ -456,6 +823,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let totalV = 0; for (const n of nodes) { if (n.hidden) { n.vx = 0; n.vy = 0; continue; } // скрытые фильтром узлы физика не двигает + if (n.tier >= 2) { n.vx = 0; n.vy = 0; continue; } // глубокие уровни — раскладывает layoutDeep let ax = 0; let ay = 0; @@ -471,15 +839,19 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const fr = K_RADIAL * (n.targetR - d); ax += fr * ux; ay += fr * uy; - // отталкивание от всех остальных видимых узлов (включая фокус — чтобы не залипали в центре) + // отталкивание от всех остальных видимых узлов (включая фокус — чтобы не залипали в центре); + // глубокие уровни (tier≥2) в отталкивании не участвуют (их много — берегём перф) for (const m of nodes) { - if (m === n || m.hidden) continue; + if (m === n || m.hidden || m.tier >= 2) continue; const dx = n.x - m.x; const dy = n.y - m.y; let dist2 = dx * dx + dy * dy; if (dist2 < MIN_DIST * MIN_DIST) dist2 = MIN_DIST * MIN_DIST; const dist = Math.sqrt(dist2); - const f = chargeNow / dist2; + // адаптивное расталкивание (collision): раскрытая ветка «толще» — усиливаем отталкивание + // пропорционально прогрессу раскрытия любого из пары, чтобы кластеры разъезжались как магниты. + const ex = Math.max(n.expandP || 0, m.expandP || 0); + const f = chargeNow * (1 + (EXPAND_REPULSION - 1) * ex) / dist2; ax += (dx / dist) * f; ay += (dy / dist) * f; } @@ -494,20 +866,28 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL return totalV; } - // Плавное приближение масштаба/прозрачности к целям + рост линии («прорастание»). + // Плавное приближение масштаба/прозрачности к целям + рост линии + spotlight + глубина (dive). function advanceVisual() { + spotActive = nodes.some((n) => n.pinned); // есть закреплённая ветка → остальные тускнеют for (const n of nodes) { n.scale += (n.targetScale - n.scale) * 0.2; n.opacity += (n.targetOpacity - n.opacity) * 0.2; + const c = contextTargetOf(n); // {op, scale, blur} — spotlight или глубина dive + n.spotCur += (c.op - n.spotCur) * 0.2; // затемнение/прояснение + n.depthScale += (c.scale - n.depthScale) * 0.2; // dive: цель крупно / фон мелко + n.depthBlur += (c.blur - n.depthBlur) * 0.2; // dive: фон уходит в расфокус // линия растёт только когда узел уже «выпущен» из центра (не скрыт) — догоняет его как нить if (!n.hidden && n.edgeGrow < 1) n.edgeGrow = Math.min(1, n.edgeGrow + 0.08); } } - // Не «успокоились» ли ещё визуальные параметры/рост линий (для условия заморозки). + // Не «успокоились» ли ещё визуальные параметры/рост линий/глубина (для условия заморозки). function visualSettling() { for (const n of nodes) { - if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01 || n.edgeGrow < 1) return true; + const c = contextTargetOf(n); + if (Math.abs(n.scale - n.targetScale) > 0.01 || Math.abs(n.opacity - n.targetOpacity) > 0.01 + || Math.abs(n.spotCur - c.op) > 0.01 || Math.abs(n.depthScale - c.scale) > 0.01 + || Math.abs(n.depthBlur - c.blur) > 0.4 || n.edgeGrow < 1) return true; } return false; } @@ -671,6 +1051,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL n.scale = n.targetScale; n.opacity = n.targetOpacity; } + panBendX = 0; panBendY = 0; // нити в покое — ровные базовые дуги + layoutDeep(); // глубокие уровни — на орбитах родителей перед финальным кадром renderAll(); // финальный кадр на целых координатах } @@ -678,11 +1060,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL function tick(ts) { rafId = 0; - // режим CSS-bloom: узлы анимирует компоновщик — мы лишь ведём лучи за ними (без физики) + // режим CSS-bloom: узлы 1-го уровня анимирует компоновщик — мы лишь ведём лучи за ними (без физики); + // глубокие уровни (tier≥2) не в CSS-bloom — позиционируем их из JS, чтобы следовали за родителями if (cssBloom) { syncPositionsFromDOM(); + advanceExpand(); + layoutDeep(); + renderDeepNodes(); renderEdges(); - updateReticle(); schedule(); return; } @@ -695,10 +1080,57 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL panVelX *= PAN_FRICTION; panVelY *= PAN_FRICTION; applyWorldTransform(); + } else if (dragging) { + // палец удерживается без движения → скорость (и изгиб нитей) мягко расслабляется, + // но onPointerMove перезапишет её свежим дельтой при следующем движении + panVelX *= 0.7; + panVelY *= 0.7; } else { panVelX = 0; panVelY = 0; } + advancePanBend(); // сглаженный изгиб нитей догоняет скорость пальца и пружинит к нулю + // «гитарная струна»: один короткий вибро-щелчок при сильном натяжении нитей свайпом + const pbMag = Math.abs(panBendX) + Math.abs(panBendY); + if (pbMag > 26 && !panTwang) { panTwang = true; haptic(4); } + else if (pbMag < 12) panTwang = false; + + // камера-доводчик: плавно дотягиваем камеру к цели (после раскрытия ветки). Жест уже обнулил цель. + let camGliding = false; + if (camTargetX !== null && !dragging && !panActive) { + camX += (camTargetX - camX) * CAM_GLIDE_K; + camY += (camTargetY - camY) * CAM_GLIDE_K; + if (Math.abs(camTargetX - camX) < 0.5 && Math.abs(camTargetY - camY) < 0.5) { + camX = camTargetX; camY = camTargetY; camTargetX = null; camTargetY = null; + } else { camGliding = true; } + applyWorldTransform(); + } + + // Smart Zoom: dive-камера летит и зумит к нырнутому узлу (аквариумный наезд), центрируя его; + // всплытие (surfacing) — плавный возврат камеры/зума к корню. Любой жест (drag/pan/pinch) приостанавливает. + let diveCamActive = false; + if (!dragging && !panActive && !pinching) { + if (diveTargetId) { + const t = nodeById.get(diveTargetId); + if (t) { + zoom += (diveZoom - zoom) * DIVE_FLY_K; + const desX = -t.x * zoom; // узел → центр экрана (screen = center + cam + x*zoom = center) + const desY = -t.y * zoom; + camX += (desX - camX) * DIVE_FLY_K; + camY += (desY - camY) * DIVE_FLY_K; + applyWorldTransform(); + if (Math.abs(zoom - diveZoom) > 0.004 || Math.abs(desX - camX) > 0.4 || Math.abs(desY - camY) > 0.4) diveCamActive = true; + } + } else if (surfacing) { + zoom += (1 - zoom) * DIVE_FLY_K; + camX += (0 - camX) * DIVE_FLY_K; + camY += (0 - camY) * DIVE_FLY_K; + applyWorldTransform(); + if (Math.abs(zoom - 1) < 0.004 && Math.abs(camX) < 0.4 && Math.abs(camY) < 0.4) { zoom = 1; camX = 0; camY = 0; surfacing = false; } + else diveCamActive = true; + } + } + updateLod(); // LOD: точки 3-го уровня ↔ аватарки по текущему зуму // динамическая вязкость: первые ~700мс после перестроения трение завышено (0.94→0.80), // отталкивание ослаблено — гасим «взрыв» из центра, узлы выходят на орбиту в «геле» @@ -714,9 +1146,12 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL advanceVisual(); // bloom/смена роли вне твина — через целевые scale/opacity } + const expanding = advanceExpand(); // раскрытие/схлопывание глубоких уровней (по клику/ховеру) + layoutDeep(); // глубокие уровни следуют за родителями (после шага физики 1-го уровня) renderAll(); - if (tween || dragging || panActive || boost > 0 || totalV > SLEEP_V || visualSettling()) { + const bendSettling = Math.abs(panBendX) + Math.abs(panBendY) > 0.2; // ждём, пока нити спружинят назад + if (tween || dragging || panActive || camGliding || diveCamActive || boost > 0 || totalV > SLEEP_V || bendSettling || expanding || visualSettling()) { schedule(); } else { freezeGraph(); // система успокоилась — замираем @@ -741,6 +1176,96 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL let downNodeEl = null; let longTimer = 0; let longFired = false; + const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами + let pinching = false; // активен щипок (pan/tap на это время заморожены) + let pinchDist0 = 0; // базовая дистанция между пальцами (px) для расчёта масштаба + let hoverNode = null; // узел под курсором мыши (для ховер-раскрытия ветки) + let lastBgTapTs = 0; // время последнего тапа по пустому фону (для двойного тапа = сброс) + // Виброотклик (мобильные): не критичен — на десктопе navigator.vibrate просто отсутствует. + const haptic = (pattern) => { try { if (navigator.vibrate) navigator.vibrate(pattern); } catch { /* нет API */ } }; + + // Префетч аватарок детей при наведении/нырке — чтобы при раскрытии лица уже были в кэше браузера. + const prefetched = new Set(); + function prefetchChildren(node) { + if (!node) return; + const pid = String(node.id); + for (const m of nodes) { + if (String(m.parentId) === pid && m.photo && !prefetched.has(m.photo)) { + prefetched.add(m.photo); + try { const im = new Image(); im.decoding = 'async'; im.src = m.photo; } catch { /* нет Image — не критично */ } + } + } + } + + // Режим «Интерактивная паутина» (раскрытие веток НА МЕСТЕ, без смены центра; анимация expandP, см. + // advanceExpand/layoutDeep). Состояние раскрытия = pinned (клик) ИЛИ hovered (наведение). + // Тап/клик по узлу — ФИКСИРУЕТ раскрытие ветки (pinned). Повторный тап снимает фиксацию. Ветка остаётся + // раскрытой, даже когда убрали палец/мышь (в отличие от ховера). + камера-доводчик подводит кластер в кадр. + function toggleExpand(node) { + const n = node && (nodeById.get(String(node.id)) || node); + if (!n) return; + // надёжный toggle: клик по УЖЕ раскрытому узлу сворачивает его (даже если он был раскрыт ховером). + const isOpen = n.pinned || (n.expandP || 0) > 0.5; + if (isOpen) { + n.pinned = false; n.hovered = false; // свернуть полностью + haptic(8); + } else { + n.pinned = true; haptic([10, 25, 6, 35, 3]); glideCameraTo(n); // раскрыть + дотяжка камеры + } + wake(); + } + // Ховер (наведение мышью / касание пальцем) — ВРЕМЕННОЕ раскрытие: ветка выплывает, пока курсор/палец + // над узлом, и втягивается при уходе (если узел не закреплён кликом). node=null — снять ховер со всех. + function setHover(node) { + const target = node && (nodeById.get(String(node.id)) || node); + let changed = false; + for (const n of nodes) { + const want = n === target; + if (Boolean(n.hovered) !== want) { n.hovered = want; changed = true; } + } + if (target) prefetchChildren(target); // подгружаем лица детей заранее (до клика) + if (changed) wake(); + } + // Глобальный сброс: снимаем фиксацию И ховер со ВСЕХ веток + всплываем из погружения (тап по Ивану). + function collapseAll() { + let any = false; + for (const n of nodes) { if (n.pinned || n.hovered) { n.pinned = false; n.hovered = false; any = true; } } + if (diveTargetId) { diveTargetId = null; surfacing = true; any = true; } // всплыть наверх + if (any) haptic(14); + emitDiveChange(); // крошки → верхний уровень + wake(); + } + + // Умный фокус (Smart Zoom): погружение в узел 2-го+ уровня. Камера летит/зумит к нему (в tick), + // узел вырастает в центр, путь назад к Ивану остаётся ярким, фон уходит в расфокус (см. contextTargetOf). + function diveTo(node) { + const n = node && (nodeById.get(String(node.id)) || node); + if (!n || n.isFocus) return; // в сам фокус (Иван) не ныряем + if (diveTargetId === String(n.id)) { exitDive(); return; } // повтор по цели — всплыть назад (полный сброс) + // ЕДИНЫЙ активный путь (железный spotlight): гасим ВСЕ прежние фиксации/ховеры, затем раскрываем + // только путь к новой цели (предки до Ивана) — чтобы цель и её дети были видимы, прочее не «копилось». + for (const m of nodes) { m.pinned = false; m.hovered = false; } + let cur = n; let guard = 0; + while (cur && guard++ < 16) { if (!cur.isFocus) cur.pinned = true; if (cur.isFocus) break; const p = nodeById.get(cur.parentId); if (!p) break; cur = p; } + diveTargetId = String(n.id); + diveZoom = DIVE_ZOOM; + surfacing = false; + camTargetX = null; camTargetY = null; // dive-камера центрирует сама + prefetchChildren(n); // подгружаем лица детей заранее + haptic([12, 30, 8, 40]); // «погружение» — нарастающий импульс + emitDiveChange(); // обновляем хлебные крошки (Иван › … › цель) + wake(); + } + // Всплытие/закрытие ветки: ПОЛНЫЙ сброс — снимаем все фиксации/ховеры (дети втягиваются), + // камера и зум плавно возвращаются на весь граф, ВСЕ узлы гарантированно вернут opacity 1. + function exitDive() { + for (const m of nodes) { m.pinned = false; m.hovered = false; } + diveTargetId = null; + surfacing = true; + haptic(10); + emitDiveChange(); // крошки → верхний уровень + wake(); + } function nodeFromEvent(ev) { const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null; @@ -750,10 +1275,23 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } function onPointerDown(ev) { + activePointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); + // второй палец → режим щипкового зума: прерываем pan/tap/longpress, фиксируем базовую дистанцию + if (activePointers.size === 2) { + pinching = true; + camTargetX = null; camTargetY = null; + if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; } + if (downNodeEl) { downNodeEl.classList.remove('is-pressed'); } + dragging = false; + const pts = [...activePointers.values()]; + pinchDist0 = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1; + return; + } if (pointerId !== null) return; pointerId = ev.pointerId; panVelX = 0; // новое касание мгновенно прерывает инерцию panVelY = 0; + camTargetX = null; camTargetY = null; // касание отменяет доводчик камеры try { stage.setPointerCapture(ev.pointerId); } catch { /* pointer уже неактивен — не критично */ } downX = ev.clientX; downY = ev.clientY; @@ -762,7 +1300,10 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL moved = false; longFired = false; downNodeEl = ev.target instanceof Element ? ev.target.closest('.fg-node') : null; + if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания const downNode = nodeFromEvent(ev); + // касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out + if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true); if (downNode && typeof onNodeLongPress === 'function') { longTimer = window.setTimeout(() => { if (moved) return; @@ -774,13 +1315,47 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } } + // Ховер мышью: наведение на узел → превью ветки; уход с узла → сворачивание (если не закреплён кликом). + function onPointerOver(ev) { + if (ev.pointerType !== 'mouse' || typeof onNodeHover !== 'function') return; + const el = ev.target instanceof Element ? ev.target.closest('.fg-node') : null; + const node = el ? nodes.find((n) => String(n.id) === String(el.dataset.nodeId)) : null; + if (node && node !== hoverNode) { hoverNode = node; onNodeHover(node, true); } + } + function onPointerOut(ev) { + if (ev.pointerType !== 'mouse' || typeof onNodeHover !== 'function') return; + const toEl = ev.relatedTarget instanceof Element ? ev.relatedTarget.closest('.fg-node') : null; + if (toEl && hoverNode && String(toEl.dataset.nodeId) === String(hoverNode.id)) return; // ещё внутри узла + if (hoverNode) { hoverNode = null; onNodeHover(null, false); } + } + function onPointerMove(ev) { + if (activePointers.has(ev.pointerId)) activePointers.set(ev.pointerId, { x: ev.clientX, y: ev.clientY }); + // щипок двумя пальцами: масштабируем относительно центра между пальцами (зум «к точке») + if (pinching && activePointers.size >= 2) { + const pts = [...activePointers.values()]; + const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) || 1; + const rect = stage.getBoundingClientRect(); + const mx = (pts[0].x + pts[1].x) / 2 - rect.left; + const my = (pts[0].y + pts[1].y) / 2 - rect.top; + const prevDist = pinchDist0; + setZoom(zoom * (dist / pinchDist0), mx, my); + pinchDist0 = dist; + // сильный pinch-out (пальцы сходятся) на минимальном зуме во время погружения = всплыть назад + if (diveTargetId && dist < prevDist && zoom <= ZOOM_MIN + 0.02) exitDive(); + wake(); + return; + } if (ev.pointerId !== pointerId) return; const dx = ev.clientX - downX; const dy = ev.clientY - downY; if (!moved && Math.hypot(dx, dy) > PAN_THRESHOLD) { moved = true; if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; } + if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это свайп, а не нажатие + // палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был + if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false); + camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста) cancelTween(); // жест прерывает анимацию центрирования dragging = true; } @@ -792,19 +1367,29 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL camX = newCamX; camY = newCamY; applyWorldTransform(); + advancePanBend(); // упругий изгиб нитей догоняет палец во время свайпа renderEdges(); // рёбра следуют за камерой синхронно (дёшево) - updateReticle(); } } function onPointerUp(ev) { + activePointers.delete(ev.pointerId); + // выход из щипка: пока пальцев <2 — щипок завершён; остаток НЕ превращаем в pan/tap (избегаем рывка) + if (pinching) { + if (activePointers.size < 2) { pinching = false; pinchDist0 = 0; } + if (ev.pointerId === pointerId) { pointerId = null; dragging = false; } + return; + } if (ev.pointerId !== pointerId) return; if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; } + if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // отпустили — «кнопка» возвращается try { stage.releasePointerCapture(ev.pointerId); } catch { /* не было захвата — ок */ } const wasMoved = moved; const wasLong = longFired; pointerId = null; dragging = false; + // касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap) + if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false); if (wasMoved || wasLong) { // после pan даём физике чуть устаканиться и уснуть @@ -815,7 +1400,13 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL const tapNode = downNodeEl ? nodes.find((n) => String(n.id) === String(downNodeEl.dataset.nodeId)) : null; - if (!tapNode) return; + if (!tapNode) { + // тап по пустому фону: двойной быстрый тап = сброс погружения/раскрытия (всплыть на весь граф) + const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0; + if (now && now - lastBgTapTs < DOUBLE_TAP_MS) { lastBgTapTs = 0; if (diveTargetId || spotActive) collapseAll(); } + else lastBgTapTs = now; + return; + } if (tapNode.isFocus) { if (typeof onCenterTap === 'function') onCenterTap(tapNode); return; @@ -857,14 +1448,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL window.setTimeout(() => ghost.remove(), 1000); // удаление строго через 1000мс } - // Импульс центрального кольца — подтверждение «захвата» нового фокуса. - function pulseReticle() { - reticle.classList.remove('is-pulse'); - void reticle.offsetWidth; - reticle.classList.add('is-pulse'); - window.setTimeout(() => reticle.classList.remove('is-pulse'), 620); - } - // Во время CSS-bloom узлы двигает компоновщик — читаем их фактические позиции из DOM, // чтобы лучи (рисуются в JS) точно следовали за анимируемыми аватарками. function syncPositionsFromDOM() { @@ -872,8 +1455,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL for (const n of nodes) { const dot = n.el.querySelector('.node-dot') || n.el; const r = dot.getBoundingClientRect(); - n.x = (r.left + r.width / 2) - sr.left - centerX - camX; - n.y = (r.top + r.height / 2) - sr.top - centerY - camY; + n.x = ((r.left + r.width / 2) - sr.left - centerX - camX) / zoom; + n.y = ((r.top + r.height / 2) - sr.top - centerY - camY) / zoom; // живая прозрачность из CSS-перехода — чтобы лучи гасли/проявлялись вместе с аватаркой const o = parseFloat(getComputedStyle(n.el).opacity); if (Number.isFinite(o)) n.opacity = o; @@ -893,6 +1476,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL n.scale = n.bfs; n.targetScale = n.bfs; n.opacity = fo; n.targetOpacity = fo; n.vx = 0; n.vy = 0; n.edgeGrow = 1; } + layoutDeep(); // глубокие уровни ставим на орбиты родителей (их bfx — орбита центра, не годится) if (cssBloomKind === 'filter') { // ФИЛЬТР: узлы уже стоят на равномерных углах (их поставил CSS-переход) — фиксируемся // строго на этих позициях БЕЗ физики: ноль тряски и идеальный мгновенный sleep. @@ -946,7 +1530,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL // финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty) const finalX = node.isFocus ? 0 : node.tx; const finalY = node.isFocus ? 0 : node.ty; - const finalScale = node.isFocus ? FOCUS_SCALE : (node.dotOnly ? 1 : (node.tier >= 2 ? SECONDARY_SCALE : PRIMARY_SCALE)); + const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка) + const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1) // стартовая точка разлёта let fx; let fy; let fs; let fo; let delay = 0; @@ -963,19 +1548,29 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL } // финал запоминаем для покоя; стартовое состояние держим в n.x/n.y (для первой отрисовки лучей) - node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = 1; + node.bfx = finalX; node.bfy = finalY; node.bfs = finalScale; node.bfo = finalOp; node.x = fx; node.y = fy; - node.scale = finalScale; node.opacity = 1; node.targetScale = finalScale; node.targetOpacity = 1; + node.scale = finalScale; node.opacity = finalOp; node.targetScale = finalScale; node.targetOpacity = finalOp; node.hidden = false; // НОВЫЙ узел (разлетается из центра) — помечаем для эффекта прорастания линии (edgeGrow=0); // переезжающий/общий узел — линия просто следует за ним (edgeGrow=1, без dash-раскрытия). node.edgeGrow = (isNew && !node.isFocus) ? 0 : 1; - maxDelay = Math.max(maxDelay, delay); - blooms.push({ el: node.el, start: tf(fx, fy, fs), final: tf(finalX, finalY, finalScale), fo, delay }); + // глубокие уровни (tier≥2) НЕ участвуют в CSS-bloom — их позиционирует layoutDeep/renderDeepNodes + if (node.tier < 2) { + maxDelay = Math.max(maxDelay, delay); + blooms.push({ el: node.el, start: tf(fx, fy, fs), final: tf(finalX, finalY, finalScale), fo, delay }); + } return node; }); pendingFocusOrigin = null; + diveTargetId = null; surfacing = false; zoom = 1; // перестроение графа сбрасывает погружение и зум + rebuildIndex(); // обновляем nodeById/hasDeep под новый набор узлов + updateBadges(); // бейджи-счётчики связей под новый набор + updateA11y(); // текстовый список графа для скринридеров + layoutDeep(); // сразу ставим глубокие уровни на орбиты вокруг родителей + renderDeepNodes(); // и показываем их (CSS-bloom их элементы не трогает) + emitDiveChange(); // сбрасываем хлебные крошки (новый граф = верхний уровень) camX = 0; camY = 0; applyWorldTransform(); @@ -993,7 +1588,6 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL cssBloom = true; // физика выключена; цикл лишь ведёт лучи за CSS-узлами cssBloomKind = 'bloom'; // после каскада — лёгкое упругое до-покачивание (boost) renderEdges(); - pulseReticle(); cssBloomTimer = window.setTimeout(endCssBloom, BLOOM_MS + maxDelay + 80); // завершение гарантировано wake(); } @@ -1002,6 +1596,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL stage.addEventListener('pointermove', onPointerMove); stage.addEventListener('pointerup', onPointerUp); stage.addEventListener('pointercancel', onPointerUp); + stage.addEventListener('pointerover', onPointerOver); // ховер мышью → превью ветки + stage.addEventListener('pointerout', onPointerOut); + stage.addEventListener('wheel', onWheel, { passive: false }); // зум колесом мыши window.addEventListener('resize', onResize); let ro = null; @@ -1016,7 +1613,28 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL recenter: (id) => startRecenterTween(id), setModel, setFilter, + toggleExpand, // mind-map: ЗАФИКСИРОВАТЬ/снять раскрытие ветки кликом (pinned) + setHover, // mind-map: ВРЕМЕННОЕ раскрытие ветки наведением (node | null) + diveTo, // Smart Zoom: погрузиться в узел 2-го+ уровня (наезд камеры, «аквариум») + exitDive, // Smart Zoom: всплыть из погружения на уровень назад + collapseAll, // mind-map: свернуть всё + всплыть наверх (тап по корню) + findNode, // поиск узла по имени/логину → { id, name, tier } | null (для строки поиска) + getDivePath: () => divePathNodes().map((n) => ({ id: String(n.id), name: n.name || n.login || String(n.id), isFocus: !!n.isFocus })), // хлебные крошки getFocusNode: () => nodes.find((n) => n.isFocus) || null, + // --- Dev/тест-хелперы (для автопроверок; не вызываются в обычной работе) ------------------- + // Снимок состояния (только чтение): позиции/масштаб/прозрачность/уровень узлов + камера. + debugState: () => ({ + zoom: +zoom.toFixed(3), camX: Math.round(camX), camY: Math.round(camY), diveTargetId, surfacing, spotActive, focusId, + nodes: nodes.map((n) => ({ id: String(n.id), tier: n.tier, lod: n.lod, pinned: !!n.pinned, hovered: !!n.hovered, sibIndex: n.sibIndex, expandP: +(n.expandP || 0).toFixed(3), x: Math.round(n.x), y: Math.round(n.y), scale: +(n.scale || 0).toFixed(3), depthScale: +(n.depthScale || 1).toFixed(3), depthBlur: +(n.depthBlur || 0).toFixed(2), opacity: +(n.opacity || 0).toFixed(3), spotCur: +(n.spotCur || 1).toFixed(3) })), + }), + // Детерминированно докрутить анимацию до покоя (обходит троттлинг rAF в фоновых вкладках/тестах). + pumpForTest: (maxFrames = 1200) => { + let ts = (typeof performance !== 'undefined' && performance.now) ? performance.now() : 0; + let i = 0; + for (; i < maxFrames; i += 1) { ts += 16; tick(ts); if (!rafId) break; } // tick заморозился → rafId=0 + if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } + return i + 1; + }, destroy() { if (rafId) cancelAnimationFrame(rafId); rafId = 0; @@ -1025,11 +1643,14 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL stage.removeEventListener('pointermove', onPointerMove); stage.removeEventListener('pointerup', onPointerUp); stage.removeEventListener('pointercancel', onPointerUp); + stage.removeEventListener('pointerover', onPointerOver); + stage.removeEventListener('pointerout', onPointerOut); + stage.removeEventListener('wheel', onWheel); window.removeEventListener('resize', onResize); if (ro) ro.disconnect(); edgesSvg.remove(); world.remove(); - reticle.remove(); + a11y.remove(); }, }; } diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index e9133c4..5350608 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -60,34 +60,39 @@ transition: opacity 420ms ease; /* плавное появление линий при перестройке */ } -/* Сияющая связь = плазменный композитинг (3 слоя на одном S-пути, см. renderEdges). */ -.fg-plasma-flare { +/* Сияющая связь = плазменный композитинг (3 слоя на ОДНОМ S-пути, см. renderEdges). + Настоящий НЕОН: яркое светлое ядро + ВИДИМЫЙ голубой ореол вокруг. Слои поля/трубки идут в режиме + mix-blend-mode: screen → свет складывается аддитивно с тёмным фоном (как реальное свечение), а в точках + пересечения нитей у центра — ярче (энергетический хаб). Прозрачность слоёв — inline (×spotlight/глубину). */ +.fg-plasma-flare { /* нижний: широкое насыщенное голубое плазменное свечение (по референсу) */ fill: none; - stroke: #00bfff; + stroke: #00bfff; /* глубокий голубой */ stroke-width: 16; stroke-linecap: round; - filter: url(#fg-plasma-blur6); - mix-blend-mode: screen; + filter: url(#fg-plasma-blur6); /* мягкое объёмное свечение (гладкие края — как на референсе) */ + mix-blend-mode: screen; /* аддитивное свечение поверх тёмного фона */ + /* синхро-«дыхание» поля толщиной в такт ободку сияющего узла (3.6с); прозрачность не трогаем (inline) */ animation: fg-plasma-breath 3.6s ease-in-out infinite; } -.fg-plasma-tube { +.fg-plasma-tube { /* средний: яркая толстая неоновая трубка */ fill: none; - stroke: #00e5ff; + stroke: #00e5ff; /* яркий циан */ stroke-width: 6; stroke-linecap: round; - filter: url(#fg-plasma-blur2); - mix-blend-mode: screen; + filter: url(#fg-plasma-blur2); /* SVG feGaussianBlur stdDeviation=2 */ + mix-blend-mode: screen; /* аддитивное свечение */ } -.fg-plasma-core { +.fg-plasma-core { /* верхний: яркое чёткое ядро (светло-голубо-белое) */ fill: none; - stroke: #dffaff; + stroke: #dffaff; /* светло-голубо-белое — «жидкое» ядро */ stroke-width: 2; stroke-linecap: round; } +/* мягкое «дыхание» плазменного облака толщиной, синхронно с пульсом сияющего ободка (3.6с) */ @keyframes fg-plasma-breath { 0%, 100% { stroke-width: 14; } - 50% { stroke-width: 19; } + 50% { stroke-width: 19; } } @media (prefers-reduced-motion: reduce) { @@ -120,7 +125,7 @@ height: 52px; margin: 0; font-size: 16px; - transition: box-shadow 160ms ease, border-color 160ms ease; + transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease; } .fg-node.is-family .node-dot { @@ -150,10 +155,18 @@ box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.45), 0 10px 22px rgba(4, 8, 15, 0.45); } +/* Тактильный отклик «нажатия вглубь»: аватарка слегка вдавливается (scale 0.92), а неоновое кольцо + вспыхивает заметно ярче (~1.5×). Срабатывает при наведении, фокусе и зажатии (.is-pressed). */ .fg-node:focus-visible .node-dot, -.fg-node:hover .node-dot { - border-color: rgba(166, 218, 255, 0.95); - box-shadow: 0 0 0 3px rgba(77, 160, 255, 0.22), 0 8px 16px rgba(4, 8, 15, 0.35); +.fg-node:hover .node-dot, +.fg-node.is-pressed .node-dot { + transform: scale(0.92); + border-color: rgba(160, 240, 255, 0.95); + box-shadow: 0 0 0 2px rgba(150, 238, 255, 0.6), 0 0 22px rgba(120, 230, 255, 0.85); +} + +@media (prefers-reduced-motion: reduce) { + .fg-node.is-pressed .node-dot { transform: none; } } /* «Сияние» — мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи. @@ -283,28 +296,50 @@ .fg-dot.is-contact { background: #36435c; } .fg-dot.is-shine { box-shadow: 0 0 9px rgba(130, 235, 255, 0.75); } -/* «Прицел» в центре экрана (зона фокуса) — позади узлов */ -.fg-reticle { - position: absolute; - left: 50%; - top: 50%; - width: 64px; - height: 64px; - margin: -32px 0 0 -32px; - border-radius: 50%; - border: 2px dashed rgba(150, 190, 255, 0.3); - pointer-events: none; - z-index: 0; - opacity: 0.45; - transition: width 200ms ease, height 200ms ease, margin 200ms ease, border-color 200ms ease, opacity 200ms ease; +/* === Глубина 2-3 уровней (прототип «Вселенная», только лаборатория) ============== */ +/* 2-й уровень — «друзья друзей»: полноценная аватарка (лицо + имя), просто мельче основных. + Масштаб/прозрачность задаёт движок; здесь — читаемый ободок и подпись (не «дырка»). */ +.fg-node.is-tier2 .node-dot { + border-color: rgba(170, 200, 240, 0.65); + box-shadow: 0 2px 8px rgba(4, 8, 15, 0.4); +} +.fg-node.is-tier2 .fg-node-label { + font-size: 9px; + opacity: 0.9; + top: calc(100% + 1px); } -.fg-reticle.is-locked { - width: 94px; - height: 94px; - margin: -47px 0 0 -47px; - border-color: rgba(130, 235, 255, 0.65); - opacity: 0.85; +/* 3-й уровень — микрозвезда: светящаяся точка без картинки (эффект далёкого созвездия). */ +.fg-dot.is-tier3 { + width: 9px; + height: 9px; + border: 0; + background: radial-gradient(circle, #e6f6ff 0%, rgba(150, 215, 255, 0.95) 38%, rgba(120, 200, 255, 0) 75%); + box-shadow: 0 0 6px rgba(150, 220, 255, 0.9), 0 0 13px rgba(115, 200, 255, 0.5); + /* медленное мерцание «звезды» — по box-shadow/яркости (НЕ opacity/scale: ими управляет движок при + раскрытии). У каждой звезды своя задержка (inline animation-delay) → живое созвездие. */ + animation: fg-star-twinkle 3.4s ease-in-out infinite; +} +.fg-dot.is-tier3.is-shine { + background: radial-gradient(circle, #ffffff 0%, rgba(160, 240, 255, 1) 38%, rgba(120, 230, 255, 0) 75%); + box-shadow: 0 0 8px rgba(160, 240, 255, 1), 0 0 16px rgba(115, 220, 255, 0.7); +} + +@keyframes fg-star-twinkle { + 0%, 100% { box-shadow: 0 0 3px rgba(150, 220, 255, 0.45), 0 0 7px rgba(115, 200, 255, 0.25); filter: brightness(0.78); } + 50% { box-shadow: 0 0 7px rgba(165, 235, 255, 0.95), 0 0 15px rgba(120, 210, 255, 0.6); filter: brightness(1.3); } +} + +@media (prefers-reduced-motion: reduce) { + .fg-dot.is-tier3 { animation: none; } +} + +/* Чип-переключатель «Вселенная» — активное состояние наследует стиль .fg-filter-chip.is-active */ +.fg-deep-chip.is-active { + background: rgba(150, 130, 255, 0.18); + border-color: rgba(190, 170, 255, 0.6); + box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(150, 120, 255, 0.3); + color: #efeaff; } /* «Призрак» старой карты при Z-переходе (эффект погружения) */ @@ -319,17 +354,6 @@ transition: transform 1000ms cubic-bezier(0.16, 1, 0.3, 1), opacity 1000ms ease; } -/* Импульс центрального кольца при захвате нового фокуса */ -.fg-reticle.is-pulse { - animation: fg-reticle-pulse 0.6s ease; -} - -@keyframes fg-reticle-pulse { - 0% { transform: scale(1); } - 40% { transform: scale(1.22); border-color: rgba(130, 235, 255, 0.9); } - 100% { transform: scale(1); } -} - /* Панель фильтров слоёв (оверлей под шапкой) */ .fg-filter-bar { position: absolute; @@ -514,3 +538,141 @@ .fg-sheet-actions > button { flex: 1; } + +/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */ + +/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */ +.fg-node-badge { + position: absolute; + top: -2px; + right: -2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: rgba(16, 24, 40, 0.92); + border: 1px solid rgba(150, 200, 255, 0.5); + color: #d9ecff; + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; + pointer-events: none; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); +} +.fg-node.is-focus .fg-node-badge { + background: rgba(61, 196, 223, 0.95); + border-color: rgba(220, 245, 255, 0.8); + color: #06131c; +} +.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); } + +/* Цветовые кластеры: мягкая аура узла по типу связи (визуально группирует «семью/друзей/бизнес») */ +.fg-node.is-family .node-dot { box-shadow: 0 0 16px rgba(255, 159, 94, 0.20); } +.fg-node.is-friend .node-dot { box-shadow: 0 0 16px rgba(120, 179, 255, 0.20); } +.fg-node.is-business .node-dot { box-shadow: 0 0 16px rgba(190, 150, 255, 0.20); } +.fg-node.is-contact .node-dot { box-shadow: 0 0 16px rgba(170, 190, 220, 0.16); } +/* сияющие/фокус — свой эффект свечения (см. выше), ауру кластера не навязываем */ +.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; } + +/* Строка поиска (оверлей вверху, под панелью фильтров) */ +.fg-search { + position: absolute; + top: max(92px, calc(env(safe-area-inset-top) + 88px)); + left: 50%; + transform: translateX(-50%); + z-index: 12; + width: min(280px, 70vw); + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + border: 0.5px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.08); +} +.fg-search input { + flex: 1; + border: 0; + background: transparent; + color: #eaf2ff; + font-size: 13px; + outline: none; +} +.fg-search input::placeholder { color: #7d8aa6; } +.fg-search .fg-search-ico { color: #9fc0ff; font-size: 13px; } + +/* Хлебные крошки навигации (стек погружений: Иван › Нина › Ада) */ +.fg-breadcrumb { + position: absolute; + top: max(132px, calc(env(safe-area-inset-top) + 128px)); + left: 0; + right: 0; + z-index: 12; + display: none; + justify-content: center; + flex-wrap: wrap; + gap: 4px; + padding: 0 12px; + pointer-events: none; +} +.fg-breadcrumb.is-open { display: flex; } +.fg-crumb { + pointer-events: auto; + border: 0; + background: rgba(16, 24, 40, 0.7); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: #cfe0ff; + font-size: 11px; + font-weight: 600; + line-height: 1; + padding: 5px 10px; + border-radius: 999px; + cursor: pointer; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.fg-crumb.is-last { + background: rgba(125, 215, 255, 0.18); + color: #eaf7ff; + cursor: default; +} +.fg-crumb-sep { color: #5f7196; font-size: 11px; align-self: center; pointer-events: none; } + +/* Доступность: визуально скрытый список графа для скринридеров (sr-only, читается ассистивными технологиями) */ +.fg-a11y { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* «Общая связь» (этот человек — и твой друг тоже): золотой ободок + ★ под аватаркой */ +.fg-node.is-common .node-dot { + border-color: rgba(255, 214, 120, 0.95); + box-shadow: 0 0 14px rgba(255, 200, 90, 0.4); +} +.fg-node.is-common .node-dot::after { + content: '★'; + position: absolute; + bottom: -5px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + line-height: 1; + color: #ffd678; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); + pointer-events: none; +} diff --git a/test/avatars/SOURCES.md b/test/avatars/SOURCES.md new file mode 100644 index 0000000..cdaba52 --- /dev/null +++ b/test/avatars/SOURCES.md @@ -0,0 +1,5 @@ +# Источники тестовых аватаров + +- `avatar_001.png` ... `avatar_050.png`: портреты с `randomuser.me` +- `avatar_051.png` ... `avatar_100.png`: мультяшные аватары с `dicebear.com` +- Все файлы приведены к размеру `512x512`. diff --git a/test/avatars/avatar_001.png b/test/avatars/avatar_001.png new file mode 100644 index 0000000..e5327c9 Binary files /dev/null and b/test/avatars/avatar_001.png differ diff --git a/test/avatars/avatar_002.png b/test/avatars/avatar_002.png new file mode 100644 index 0000000..9de22c6 Binary files /dev/null and b/test/avatars/avatar_002.png differ diff --git a/test/avatars/avatar_003.png b/test/avatars/avatar_003.png new file mode 100644 index 0000000..94afe1a Binary files /dev/null and b/test/avatars/avatar_003.png differ diff --git a/test/avatars/avatar_004.png b/test/avatars/avatar_004.png new file mode 100644 index 0000000..82a34dd Binary files /dev/null and b/test/avatars/avatar_004.png differ diff --git a/test/avatars/avatar_005.png b/test/avatars/avatar_005.png new file mode 100644 index 0000000..2019c3c Binary files /dev/null and b/test/avatars/avatar_005.png differ diff --git a/test/avatars/avatar_006.png b/test/avatars/avatar_006.png new file mode 100644 index 0000000..3f756ad Binary files /dev/null and b/test/avatars/avatar_006.png differ diff --git a/test/avatars/avatar_007.png b/test/avatars/avatar_007.png new file mode 100644 index 0000000..6808342 Binary files /dev/null and b/test/avatars/avatar_007.png differ diff --git a/test/avatars/avatar_008.png b/test/avatars/avatar_008.png new file mode 100644 index 0000000..c3d7487 Binary files /dev/null and b/test/avatars/avatar_008.png differ diff --git a/test/avatars/avatar_009.png b/test/avatars/avatar_009.png new file mode 100644 index 0000000..3608b44 Binary files /dev/null and b/test/avatars/avatar_009.png differ diff --git a/test/avatars/avatar_010.png b/test/avatars/avatar_010.png new file mode 100644 index 0000000..66f1a43 Binary files /dev/null and b/test/avatars/avatar_010.png differ diff --git a/test/avatars/avatar_011.png b/test/avatars/avatar_011.png new file mode 100644 index 0000000..bf9d985 Binary files /dev/null and b/test/avatars/avatar_011.png differ diff --git a/test/avatars/avatar_012.png b/test/avatars/avatar_012.png new file mode 100644 index 0000000..c4c1155 Binary files /dev/null and b/test/avatars/avatar_012.png differ diff --git a/test/avatars/avatar_013.png b/test/avatars/avatar_013.png new file mode 100644 index 0000000..7d41512 Binary files /dev/null and b/test/avatars/avatar_013.png differ diff --git a/test/avatars/avatar_014.png b/test/avatars/avatar_014.png new file mode 100644 index 0000000..0345d58 Binary files /dev/null and b/test/avatars/avatar_014.png differ diff --git a/test/avatars/avatar_015.png b/test/avatars/avatar_015.png new file mode 100644 index 0000000..b94ad43 Binary files /dev/null and b/test/avatars/avatar_015.png differ diff --git a/test/avatars/avatar_016.png b/test/avatars/avatar_016.png new file mode 100644 index 0000000..ae165cb Binary files /dev/null and b/test/avatars/avatar_016.png differ diff --git a/test/avatars/avatar_017.png b/test/avatars/avatar_017.png new file mode 100644 index 0000000..b8568f5 Binary files /dev/null and b/test/avatars/avatar_017.png differ diff --git a/test/avatars/avatar_018.png b/test/avatars/avatar_018.png new file mode 100644 index 0000000..9cefdf0 Binary files /dev/null and b/test/avatars/avatar_018.png differ diff --git a/test/avatars/avatar_019.png b/test/avatars/avatar_019.png new file mode 100644 index 0000000..9e8e3d8 Binary files /dev/null and b/test/avatars/avatar_019.png differ diff --git a/test/avatars/avatar_020.png b/test/avatars/avatar_020.png new file mode 100644 index 0000000..af11a01 Binary files /dev/null and b/test/avatars/avatar_020.png differ diff --git a/test/avatars/avatar_021.png b/test/avatars/avatar_021.png new file mode 100644 index 0000000..c11748a Binary files /dev/null and b/test/avatars/avatar_021.png differ diff --git a/test/avatars/avatar_022.png b/test/avatars/avatar_022.png new file mode 100644 index 0000000..04bd058 Binary files /dev/null and b/test/avatars/avatar_022.png differ diff --git a/test/avatars/avatar_023.png b/test/avatars/avatar_023.png new file mode 100644 index 0000000..bd367d0 Binary files /dev/null and b/test/avatars/avatar_023.png differ diff --git a/test/avatars/avatar_024.png b/test/avatars/avatar_024.png new file mode 100644 index 0000000..6618a27 Binary files /dev/null and b/test/avatars/avatar_024.png differ diff --git a/test/avatars/avatar_025.png b/test/avatars/avatar_025.png new file mode 100644 index 0000000..8c6bd82 Binary files /dev/null and b/test/avatars/avatar_025.png differ diff --git a/test/avatars/avatar_026.png b/test/avatars/avatar_026.png new file mode 100644 index 0000000..8d7bd6b Binary files /dev/null and b/test/avatars/avatar_026.png differ diff --git a/test/avatars/avatar_027.png b/test/avatars/avatar_027.png new file mode 100644 index 0000000..6fd2666 Binary files /dev/null and b/test/avatars/avatar_027.png differ diff --git a/test/avatars/avatar_028.png b/test/avatars/avatar_028.png new file mode 100644 index 0000000..6abdf64 Binary files /dev/null and b/test/avatars/avatar_028.png differ diff --git a/test/avatars/avatar_029.png b/test/avatars/avatar_029.png new file mode 100644 index 0000000..4df4201 Binary files /dev/null and b/test/avatars/avatar_029.png differ diff --git a/test/avatars/avatar_030.png b/test/avatars/avatar_030.png new file mode 100644 index 0000000..e780453 Binary files /dev/null and b/test/avatars/avatar_030.png differ diff --git a/test/avatars/avatar_031.png b/test/avatars/avatar_031.png new file mode 100644 index 0000000..ef4769e Binary files /dev/null and b/test/avatars/avatar_031.png differ diff --git a/test/avatars/avatar_032.png b/test/avatars/avatar_032.png new file mode 100644 index 0000000..3f04b41 Binary files /dev/null and b/test/avatars/avatar_032.png differ diff --git a/test/avatars/avatar_033.png b/test/avatars/avatar_033.png new file mode 100644 index 0000000..76a939b Binary files /dev/null and b/test/avatars/avatar_033.png differ diff --git a/test/avatars/avatar_034.png b/test/avatars/avatar_034.png new file mode 100644 index 0000000..f5151ec Binary files /dev/null and b/test/avatars/avatar_034.png differ diff --git a/test/avatars/avatar_035.png b/test/avatars/avatar_035.png new file mode 100644 index 0000000..c89eac7 Binary files /dev/null and b/test/avatars/avatar_035.png differ diff --git a/test/avatars/avatar_036.png b/test/avatars/avatar_036.png new file mode 100644 index 0000000..88e9f3c Binary files /dev/null and b/test/avatars/avatar_036.png differ diff --git a/test/avatars/avatar_037.png b/test/avatars/avatar_037.png new file mode 100644 index 0000000..36950ef Binary files /dev/null and b/test/avatars/avatar_037.png differ diff --git a/test/avatars/avatar_038.png b/test/avatars/avatar_038.png new file mode 100644 index 0000000..d903551 Binary files /dev/null and b/test/avatars/avatar_038.png differ diff --git a/test/avatars/avatar_039.png b/test/avatars/avatar_039.png new file mode 100644 index 0000000..3f22f80 Binary files /dev/null and b/test/avatars/avatar_039.png differ diff --git a/test/avatars/avatar_040.png b/test/avatars/avatar_040.png new file mode 100644 index 0000000..4919adb Binary files /dev/null and b/test/avatars/avatar_040.png differ diff --git a/test/avatars/avatar_041.png b/test/avatars/avatar_041.png new file mode 100644 index 0000000..1679866 Binary files /dev/null and b/test/avatars/avatar_041.png differ diff --git a/test/avatars/avatar_042.png b/test/avatars/avatar_042.png new file mode 100644 index 0000000..a77e4ee Binary files /dev/null and b/test/avatars/avatar_042.png differ diff --git a/test/avatars/avatar_043.png b/test/avatars/avatar_043.png new file mode 100644 index 0000000..7688d3a Binary files /dev/null and b/test/avatars/avatar_043.png differ diff --git a/test/avatars/avatar_044.png b/test/avatars/avatar_044.png new file mode 100644 index 0000000..498392c Binary files /dev/null and b/test/avatars/avatar_044.png differ diff --git a/test/avatars/avatar_045.png b/test/avatars/avatar_045.png new file mode 100644 index 0000000..f88ac89 Binary files /dev/null and b/test/avatars/avatar_045.png differ diff --git a/test/avatars/avatar_046.png b/test/avatars/avatar_046.png new file mode 100644 index 0000000..837d6eb Binary files /dev/null and b/test/avatars/avatar_046.png differ diff --git a/test/avatars/avatar_047.png b/test/avatars/avatar_047.png new file mode 100644 index 0000000..c21c2aa Binary files /dev/null and b/test/avatars/avatar_047.png differ diff --git a/test/avatars/avatar_048.png b/test/avatars/avatar_048.png new file mode 100644 index 0000000..64abc73 Binary files /dev/null and b/test/avatars/avatar_048.png differ diff --git a/test/avatars/avatar_049.png b/test/avatars/avatar_049.png new file mode 100644 index 0000000..f5a8f43 Binary files /dev/null and b/test/avatars/avatar_049.png differ